-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgroup_crypto.rs
More file actions
1432 lines (1303 loc) · 57.1 KB
/
Copy pathgroup_crypto.rs
File metadata and controls
1432 lines (1303 loc) · 57.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Group cryptography.
//!
//! This is a byte-for-byte port of `src/group/groupCrypto.js` in the web client.
//! The two implementations MUST agree exactly: a roster signed here is verified
//! by a browser, and every member of a group derives the same safety code from
//! the same bytes — so a single byte of disagreement is not a compatibility bug
//! that degrades, it is a group that cannot form.
//!
//! It lives in the core rather than in a platform's UI layer for the same reason
//! the key exchange does: desktop, mobile and any headless client need the same
//! signatures, and a reviewer needs one place to look.
//!
//! WHY A SEPARATE IDENTITY KEY EXISTS
//! ----------------------------------
//! The pairwise handshake generates a fresh ECDSA key pair per CONNECTION. That
//! is exactly right for 1:1 — there are no accounts, so nothing a long-term key
//! should outlive — but it means Alice presents a different identity key to Bob
//! than she presents to Carol. A group cannot be built on that: a membership
//! operation signed toward Bob would be unverifiable by Carol, and there would
//! be nothing stable to put in a group safety code.
//!
//! So a group gets its own ECDSA P-384 key pair, generated per group per device
//! and destroyed with the group. It never touches the pairwise handshake, and it
//! is published to the other members over the ALREADY VERIFIED pairwise
//! channels.
//!
//! WHY THE SAFETY CODE IS COMMIT-THEN-REVEAL
//! -----------------------------------------
//! The obvious construction — hash the sorted set of member key fingerprints and
//! show the digits, the way a Signal safety number works — is unsafe at the
//! length a group can actually read aloud.
//!
//! The attacker is a group member who introduces two others and sits in the
//! middle of the pair they could not reach directly. They present key K_b to Bob
//! and K_c to Carol. To go unnoticed they need Bob's digits and Carol's digits
//! to match, and both sets are under their control: they can generate candidate
//! key pairs until the two truncated hashes collide. That is a BIRTHDAY search,
//! not a preimage search — roughly 10^(d/2) work for d digits. A 7-digit code
//! falls in a few thousand tries.
//!
//! Commit-then-reveal removes the search instead of outrunning it. Every member
//! commits to a random nonce before any nonce is known, and the code is derived
//! from every member's key AND every member's nonce. By the time the attacker
//! learns the values that go into the digits, their own contribution is already
//! fixed. `GroupSasCeremony` is the only place that ordering is enforced, and
//! `reveal()` refusing to run early is the entire security argument for a code
//! short enough to read out loud.
//!
//! Everything here is a parser of hostile input: a group member is only as
//! trustworthy as the group makes them. Lengths, ranges and alphabets are
//! checked before a value is used.
use crate::error::CoreError;
use ecdsa::signature::hazmat::{PrehashSigner, PrehashVerifier};
use hkdf::Hkdf;
use p384::ecdsa::{Signature, SigningKey, VerifyingKey};
use p384::pkcs8::{DecodePublicKey, EncodePublicKey};
use rand::RngCore;
use sha2::{Digest, Sha256, Sha384};
use std::collections::BTreeMap;
use zeroize::Zeroize;
fn bad(m: impl Into<String>) -> CoreError {
CoreError::invalid_input(m.into())
}
// ---------------------------------------------------------------------------
// limits
// ---------------------------------------------------------------------------
/// Eight is a mesh limit, not a crypto limit: it is where N(N-1)/2 pairwise
/// connections and N-1 fan-out copies stop being comfortable on a phone.
pub const MAX_MEMBERS: usize = 8;
pub const MIN_MEMBERS: usize = 2;
pub const GROUP_ID_BYTES: usize = 16;
pub const NONCE_BYTES: usize = 32;
pub const COMMIT_BYTES: usize = 32;
pub const FINGERPRINT_BYTES: usize = 32;
/// Matches the pairwise SAS. Safe at this length only because of the
/// commit-reveal ordering — see the module header.
pub const SAS_DIGITS: u32 = 7;
/// Bytes, not characters. A 36-character Cyrillic name is 68 bytes, and a UI
/// that clamps by characters against a byte budget produces a name the user
/// typed and the roster refuses to sign.
pub const MAX_NAME_BYTES: usize = 128;
/// Epoch is a uint32 on the wire; a group that changes membership four billion
/// times has other problems.
pub const MAX_EPOCH: u64 = 0xffff_ffff;
pub const MAX_SPKI_BYTES: usize = 256;
pub const MIN_SPKI_BYTES: usize = 40;
pub const MAX_SIG_BYTES: usize = 160;
pub const MIN_SIG_BYTES: usize = 48;
/// Group frames travel as chat content, and that path truncates at 2000
/// characters. A frame that was truncated would no longer match the hash its
/// signature covers, so the body budget leaves room for the envelope.
pub const MAX_BODY_BYTES: usize = 1024;
pub const FRAME_BUDGET_CHARS: usize = 1800;
/// A mesh descriptor as it travels inside a group frame. SBQ2 caps a payload at
/// 512 bytes, which is "SB2:" plus 683 base64url characters at the worst.
pub const MAX_DESCRIPTOR_CHARS: usize = 768;
/// Binds an answer to the one dial attempt that asked for it.
pub const MESH_NONCE_BYTES: usize = 16;
/// A group call's identifier, in bytes.
///
/// Random rather than derived, and long enough that two members who press
/// "call" at the same instant cannot collide. Everything about a call is scoped
/// to it: a `join` for one call says nothing about another, and a `leave`
/// replayed from a finished call cannot end a later one.
pub const CALL_ID_BYTES: usize = 16;
/// A membership operation. The wire form is the lowercase word.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberOp {
Create,
Add,
Remove,
Rename,
}
impl MemberOp {
pub fn as_str(self) -> &'static str {
match self {
MemberOp::Create => "create",
MemberOp::Add => "add",
MemberOp::Remove => "remove",
MemberOp::Rename => "rename",
}
}
pub fn parse(value: &str) -> Result<Self, CoreError> {
match value {
"create" => Ok(MemberOp::Create),
"add" => Ok(MemberOp::Add),
"remove" => Ok(MemberOp::Remove),
"rename" => Ok(MemberOp::Rename),
_ => Err(bad("unknown membership operation")),
}
}
}
/// Which half of a mesh dial a signature covers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MeshKind {
Offer,
Answer,
}
impl MeshKind {
pub fn as_str(self) -> &'static str {
match self {
MeshKind::Offer => "moffer",
MeshKind::Answer => "manswer",
}
}
pub fn parse(value: &str) -> Result<Self, CoreError> {
match value {
"moffer" => Ok(MeshKind::Offer),
"manswer" => Ok(MeshKind::Answer),
_ => Err(bad("unknown mesh descriptor kind")),
}
}
}
/// What one member is telling the group about a call.
///
/// There is deliberately no "end the call for everyone": a call ends when the
/// last person in it leaves, which is a fact every member can observe from the
/// frames they already have. An explicit end would be a button one member could
/// press to hang up on the others, and nothing in a group without a server
/// makes that person more entitled to it than anybody else.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallAction {
/// I have opened a call and I am in it.
Start,
/// I am joining the call already open.
Join,
/// I have left; the call ends when nobody is left.
Leave,
}
impl CallAction {
pub fn as_str(self) -> &'static str {
match self {
CallAction::Start => "start",
CallAction::Join => "join",
CallAction::Leave => "leave",
}
}
pub fn parse(value: &str) -> Result<Self, CoreError> {
match value {
"start" => Ok(CallAction::Start),
"join" => Ok(CallAction::Join),
"leave" => Ok(CallAction::Leave),
_ => Err(bad("unknown call action")),
}
}
}
// ---------------------------------------------------------------------------
// canonical encoding
// ---------------------------------------------------------------------------
/// One component of a length-prefixed payload.
pub enum Part<'a> {
Bytes(&'a [u8]),
Text(&'a str),
}
/// Length-prefixed concatenation.
///
/// Everything signed or hashed in this module goes through here, so that no two
/// distinct field sets can ever produce the same bytes. Plain concatenation
/// would let ("ab","c") and ("a","bc") sign the same payload, which is precisely
/// how a membership operation gets reinterpreted as a different one.
///
/// The label is NUL-terminated and NOT length-prefixed, exactly as in the web
/// client — this is wire format, not a style choice.
fn lp(label: &str, parts: &[Part<'_>]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(label.as_bytes());
out.push(0);
for part in parts {
let bytes = match part {
Part::Bytes(b) => *b,
Part::Text(t) => t.as_bytes(),
};
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
out
}
fn u32_be(n: u64) -> Result<[u8; 4], CoreError> {
if n > MAX_EPOCH {
return Err(bad("value out of uint32 range"));
}
Ok((n as u32).to_be_bytes())
}
/// Constant-time byte comparison. Cheap, and keeps the habit uniform.
fn equal_bytes(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
// ---------------------------------------------------------------------------
// validation of attacker-supplied values
// ---------------------------------------------------------------------------
fn is_lower_hex(value: &str) -> bool {
value.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
pub fn assert_group_id(group_id: &str) -> Result<Vec<u8>, CoreError> {
if group_id.len() != GROUP_ID_BYTES * 2 || !is_lower_hex(group_id) {
return Err(bad("malformed group id"));
}
hex::decode(group_id).map_err(|_| bad("malformed group id"))
}
pub fn assert_fingerprint(fp: &str) -> Result<Vec<u8>, CoreError> {
if fp.len() != FINGERPRINT_BYTES * 2 || !is_lower_hex(fp) {
return Err(bad("malformed member fingerprint"));
}
hex::decode(fp).map_err(|_| bad("malformed member fingerprint"))
}
pub fn assert_epoch(epoch: u64) -> Result<u64, CoreError> {
if epoch > MAX_EPOCH {
return Err(bad("epoch out of range"));
}
Ok(epoch)
}
pub fn assert_name(name: &str) -> Result<(), CoreError> {
if name.as_bytes().len() > MAX_NAME_BYTES {
return Err(bad("group name too long"));
}
Ok(())
}
/// Canonical member ordering.
///
/// Sorting by fingerprint — never by join order, never by however the list
/// arrived — is what makes every device hash identical bytes. A set that two
/// members order differently produces two different safety codes and the group
/// fails to form for no visible reason.
pub fn canonical_fingerprints(fps: &[String]) -> Result<Vec<String>, CoreError> {
if fps.len() < MIN_MEMBERS {
return Err(bad("a group needs at least two members"));
}
if fps.len() > MAX_MEMBERS {
return Err(bad(format!("a group is limited to {} members", MAX_MEMBERS)));
}
let mut seen = std::collections::BTreeSet::new();
for fp in fps {
assert_fingerprint(fp)?;
if !seen.insert(fp.clone()) {
return Err(bad("duplicate member fingerprint"));
}
}
let mut ordered: Vec<String> = fps.to_vec();
ordered.sort();
Ok(ordered)
}
// ---------------------------------------------------------------------------
// group identity key
// ---------------------------------------------------------------------------
/// A group identity key pair for this device, in this group.
///
/// The private half signs and nothing else. It is zeroized on drop rather than
/// left in memory for the lifetime of the process: a group is torn down when the
/// user leaves it, and the key must go with it.
pub struct GroupIdentity {
signing: SigningKey,
pub spki: Vec<u8>,
pub fingerprint: String,
}
impl GroupIdentity {
/// A fresh identity for one group on this device.
pub fn generate() -> Result<Self, CoreError> {
let signing = SigningKey::random(&mut rand::rngs::OsRng);
let spki = signing
.verifying_key()
.to_public_key_der()
.map_err(|e| CoreError::crypto_failure(format!("group SPKI export failed: {}", e)))?
.as_bytes()
.to_vec();
let fingerprint = fingerprint_spki(&spki)?;
Ok(Self { signing, spki, fingerprint })
}
/// Restore an identity from a PKCS#8 private key. For tests and for a
/// platform that persists a group across a restart.
pub fn from_pkcs8_der(der: &[u8]) -> Result<Self, CoreError> {
use p384::pkcs8::DecodePrivateKey;
let signing = SigningKey::from_pkcs8_der(der)
.map_err(|_| bad("not a valid P-384 private key"))?;
let spki = signing
.verifying_key()
.to_public_key_der()
.map_err(|e| CoreError::crypto_failure(format!("group SPKI export failed: {}", e)))?
.as_bytes()
.to_vec();
let fingerprint = fingerprint_spki(&spki)?;
Ok(Self { signing, spki, fingerprint })
}
pub fn verifying_key(&self) -> VerifyingKey {
*self.signing.verifying_key()
}
/// Sign a payload the way WebCrypto's ECDSA/SHA-384 does: the digest is
/// signed and the signature is the raw r‖s pair, never DER. A DER signature
/// here would be refused by every browser member.
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, CoreError> {
let digest = Sha384::digest(payload);
let sig: Signature = self
.signing
.sign_prehash(&digest)
.map_err(|e| CoreError::crypto_failure(format!("group signing failed: {}", e)))?;
Ok(sig.to_bytes().to_vec())
}
}
impl Drop for GroupIdentity {
fn drop(&mut self) {
self.spki.zeroize();
}
}
/// SHA-256 over the SPKI, hex. The stable name of a member inside a group.
pub fn fingerprint_spki(spki: &[u8]) -> Result<String, CoreError> {
if spki.len() < MIN_SPKI_BYTES || spki.len() > MAX_SPKI_BYTES {
return Err(bad("SPKI length out of range"));
}
Ok(hex::encode(Sha256::digest(spki)))
}
/// Import a member's published verifying key.
///
/// Returns the key AND the fingerprint computed from the bytes we were actually
/// given, never one the sender asserted. A member is identified by what their
/// key hashes to; accepting a claimed fingerprint would let a member occupy
/// someone else's slot in the safety code.
pub fn import_member_identity(spki: &[u8]) -> Result<(VerifyingKey, String), CoreError> {
let fingerprint = fingerprint_spki(spki)?;
let key = VerifyingKey::from_public_key_der(spki)
.map_err(|_| bad("member identity key is not a valid P-384 public key"))?;
Ok((key, fingerprint))
}
/// Check one signature against a member's key.
///
/// Bounds first: a signature outside the plausible range is refused before it
/// reaches the curve implementation.
fn verify_with(key: &VerifyingKey, payload: &[u8], signature: &[u8]) -> bool {
if signature.len() < MIN_SIG_BYTES || signature.len() > MAX_SIG_BYTES {
return false;
}
let sig = match Signature::from_slice(signature) {
Ok(s) => s,
Err(_) => return false,
};
key.verify_prehash(&Sha384::digest(payload), &sig).is_ok()
}
// ---------------------------------------------------------------------------
// commit / reveal
// ---------------------------------------------------------------------------
/// Commitment to a member's nonce for one epoch.
///
/// The group id and epoch are inside the hash so a commitment cannot be replayed
/// into a different group or a later epoch, and the fingerprint is inside so one
/// member cannot claim another member's commitment as their own.
pub fn build_commitment(
group_id: &str,
epoch: u64,
fingerprint: &str,
nonce: &[u8],
) -> Result<[u8; 32], CoreError> {
let gid = assert_group_id(group_id)?;
let fp = assert_fingerprint(fingerprint)?;
let epoch_be = u32_be(assert_epoch(epoch)?)?;
if nonce.len() != NONCE_BYTES {
return Err(bad("nonce must be 32 bytes"));
}
let payload = lp(
"securebit/group/commit/v1",
&[
Part::Bytes(&gid),
Part::Bytes(&epoch_be),
Part::Bytes(&fp),
Part::Bytes(nonce),
],
);
Ok(Sha256::digest(payload).into())
}
pub fn verify_commitment(
commitment: &[u8],
group_id: &str,
epoch: u64,
fingerprint: &str,
nonce: &[u8],
) -> bool {
if commitment.len() != COMMIT_BYTES {
return false;
}
match build_commitment(group_id, epoch, fingerprint, nonce) {
Ok(expected) => equal_bytes(commitment, &expected),
Err(_) => false,
}
}
/// One member's contribution to the code.
#[derive(Debug, Clone)]
pub struct Contribution {
pub fingerprint: String,
pub nonce: Vec<u8>,
}
/// The digits every member reads aloud.
///
/// Inputs are the full member set with their revealed nonces, sorted by
/// fingerprint. Every member's key AND every member's nonce is covered, so a
/// substituted key or a substituted nonce anywhere in the group changes the code
/// for the members who received the substitution — and not for the others, which
/// is the mismatch the humans are there to notice.
pub fn compute_group_sas(
group_id: &str,
epoch: u64,
contributions: &[Contribution],
digits: u32,
) -> Result<String, CoreError> {
let gid = assert_group_id(group_id)?;
let epoch_be = u32_be(assert_epoch(epoch)?)?;
if !(4..=12).contains(&digits) {
return Err(bad("digit count out of range"));
}
let fps: Vec<String> = contributions.iter().map(|c| c.fingerprint.clone()).collect();
canonical_fingerprints(&fps)?;
let mut ordered: Vec<&Contribution> = contributions.iter().collect();
ordered.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
let mut decoded: Vec<(Vec<u8>, Vec<u8>)> = Vec::with_capacity(ordered.len());
for c in &ordered {
if c.nonce.len() != NONCE_BYTES {
return Err(bad("every member must contribute a 32-byte nonce"));
}
decoded.push((assert_fingerprint(&c.fingerprint)?, c.nonce.clone()));
}
let mut parts: Vec<Part<'_>> = Vec::with_capacity(2 + decoded.len() * 2);
parts.push(Part::Bytes(&gid));
parts.push(Part::Bytes(&epoch_be));
for (fp, nonce) in &decoded {
parts.push(Part::Bytes(fp));
parts.push(Part::Bytes(nonce));
}
let mut ikm = lp("securebit/group/sas/v1", &parts);
let salt = Sha256::digest(lp(
"securebit/group/sas-salt/v1",
&[Part::Bytes(&gid), Part::Bytes(&epoch_be)],
));
let hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
let mut bits = [0u8; 8];
hk.expand(b"securebit-group-sas-v1", &mut bits)
.map_err(|_| CoreError::crypto_failure("group SAS derivation failed"))?;
ikm.zeroize();
// 52 bits of entropy folded into the digits, exactly as the web client does
// it: the first word scaled by 2^20 plus the top 20 bits of the second. The
// modulo bias at 10^7 is ~1e-9.
let hi = u32::from_be_bytes([bits[0], bits[1], bits[2], bits[3]]) as u64;
let lo = u32::from_be_bytes([bits[4], bits[5], bits[6], bits[7]]) as u64;
let n = hi * (1u64 << 20) + (lo >> 12);
bits.zeroize();
let modulus = 10u64.pow(digits);
Ok(format!("{:0width$}", n % modulus, width = digits as usize))
}
/// The commit/reveal state machine.
///
/// This type exists so that the ordering rule has exactly one implementation.
/// `reveal()` fails until every expected commitment has arrived, and that
/// refusal is the entire security argument for a seven-digit group code.
pub struct GroupSasCeremony {
group_id: String,
epoch: u64,
self_fingerprint: String,
members: Vec<String>,
nonce: Vec<u8>,
commitments: BTreeMap<String, Vec<u8>>,
nonces: BTreeMap<String, Vec<u8>>,
pub revealed: bool,
pub code: Option<String>,
}
impl GroupSasCeremony {
pub fn new(
group_id: &str,
epoch: u64,
self_fingerprint: &str,
member_fingerprints: &[String],
) -> Result<Self, CoreError> {
assert_group_id(group_id)?;
assert_epoch(epoch)?;
assert_fingerprint(self_fingerprint)?;
let members = canonical_fingerprints(member_fingerprints)?;
if !members.iter().any(|fp| fp == self_fingerprint) {
return Err(bad("the local member is not in the member set"));
}
let mut nonce = vec![0u8; NONCE_BYTES];
rand::rngs::OsRng.fill_bytes(&mut nonce);
Ok(Self {
group_id: group_id.to_string(),
epoch,
self_fingerprint: self_fingerprint.to_string(),
members,
nonce,
commitments: BTreeMap::new(),
nonces: BTreeMap::new(),
revealed: false,
code: None,
})
}
/// Our own commitment, to be broadcast first.
pub fn own_commitment(&mut self) -> Result<[u8; 32], CoreError> {
let commitment = build_commitment(
&self.group_id,
self.epoch,
&self.self_fingerprint,
&self.nonce,
)?;
self.commitments
.insert(self.self_fingerprint.clone(), commitment.to_vec());
Ok(commitment)
}
/// Record a peer commitment. Rejects anyone outside the member set, and
/// refuses to overwrite one already recorded — a second, different
/// commitment from the same member is an attempt to move after seeing more
/// of the round.
pub fn accept_commitment(&mut self, fingerprint: &str, commitment: &[u8]) -> Result<bool, CoreError> {
assert_fingerprint(fingerprint)?;
if !self.members.iter().any(|fp| fp == fingerprint) {
return Err(bad("commitment from a non-member"));
}
if commitment.len() != COMMIT_BYTES {
return Err(bad("malformed commitment"));
}
if let Some(existing) = self.commitments.get(fingerprint) {
if !equal_bytes(existing, commitment) {
return Err(bad("member changed their commitment"));
}
return Ok(false);
}
self.commitments
.insert(fingerprint.to_string(), commitment.to_vec());
Ok(true)
}
pub fn commitments_complete(&self) -> bool {
self.members.iter().all(|fp| self.commitments.contains_key(fp))
}
pub fn has_commitment(&self, fingerprint: &str) -> bool {
self.commitments.contains_key(fingerprint)
}
/// Our nonce — available ONLY once every commitment is in.
///
/// This is the gate the whole construction rests on. Do not add a caller
/// that bypasses it, and do not relax it when a member is slow: a timeout
/// must fail the ceremony, never proceed without a commitment.
pub fn reveal(&mut self) -> Result<Vec<u8>, CoreError> {
if !self.commitments_complete() {
return Err(bad("cannot reveal before every member has committed"));
}
self.revealed = true;
self.nonces
.insert(self.self_fingerprint.clone(), self.nonce.clone());
Ok(self.nonce.clone())
}
/// Record a peer nonce, checking it against the commitment they are bound to.
pub fn accept_reveal(&mut self, fingerprint: &str, nonce: &[u8]) -> Result<(), CoreError> {
assert_fingerprint(fingerprint)?;
if !self.members.iter().any(|fp| fp == fingerprint) {
return Err(bad("reveal from a non-member"));
}
let commitment = self
.commitments
.get(fingerprint)
.ok_or_else(|| bad("reveal arrived before the commitment"))?
.clone();
if !verify_commitment(&commitment, &self.group_id, self.epoch, fingerprint, nonce) {
return Err(bad("revealed nonce does not match the commitment"));
}
self.nonces.insert(fingerprint.to_string(), nonce.to_vec());
Ok(())
}
pub fn reveals_complete(&self) -> bool {
self.members.iter().all(|fp| self.nonces.contains_key(fp))
}
/// The digits, once every nonce is in and verified.
pub fn finish(&mut self) -> Result<String, CoreError> {
if !self.reveals_complete() {
return Err(bad("not every member has revealed"));
}
let contributions: Vec<Contribution> = self
.members
.iter()
.map(|fp| Contribution {
fingerprint: fp.clone(),
nonce: self.nonces.get(fp).cloned().unwrap_or_default(),
})
.collect();
let code = compute_group_sas(&self.group_id, self.epoch, &contributions, SAS_DIGITS)?;
self.code = Some(code.clone());
Ok(code)
}
}
/// Wipe the nonce material once the code exists or the ceremony is abandoned.
impl Drop for GroupSasCeremony {
fn drop(&mut self) {
self.nonce.zeroize();
for nonce in self.nonces.values_mut() {
nonce.zeroize();
}
}
}
// ---------------------------------------------------------------------------
// membership operations
// ---------------------------------------------------------------------------
/// The bytes a membership change is signed over.
///
/// The resulting member set is signed in full rather than the delta, so a
/// recipient never has to reconstruct state from a sequence of operations it may
/// have received out of order or incompletely. The epoch is what orders them,
/// and accepting only a strictly greater epoch is what refuses both a replay and
/// a rollback to a set that used to be valid.
pub fn member_op_payload(
group_id: &str,
epoch: u64,
op: MemberOp,
member_fps: &[String],
name: &str,
) -> Result<Vec<u8>, CoreError> {
let gid = assert_group_id(group_id)?;
let epoch_be = u32_be(assert_epoch(epoch)?)?;
assert_name(name)?;
let ordered = canonical_fingerprints(member_fps)?;
let decoded: Vec<Vec<u8>> = ordered
.iter()
.map(|fp| assert_fingerprint(fp))
.collect::<Result<_, _>>()?;
let mut parts: Vec<Part<'_>> = vec![
Part::Bytes(&gid),
Part::Bytes(&epoch_be),
Part::Text(op.as_str()),
Part::Text(name),
];
for fp in &decoded {
parts.push(Part::Bytes(fp));
}
Ok(lp("securebit/group/member-op/v1", &parts))
}
pub fn sign_member_op(
identity: &GroupIdentity,
group_id: &str,
epoch: u64,
op: MemberOp,
member_fps: &[String],
name: &str,
) -> Result<Vec<u8>, CoreError> {
identity.sign(&member_op_payload(group_id, epoch, op, member_fps, name)?)
}
pub fn verify_member_op(
key: &VerifyingKey,
group_id: &str,
epoch: u64,
op: MemberOp,
member_fps: &[String],
name: &str,
signature: &[u8],
) -> bool {
match member_op_payload(group_id, epoch, op, member_fps, name) {
Ok(payload) => verify_with(key, &payload, signature),
Err(_) => false,
}
}
// ---------------------------------------------------------------------------
// group messages
// ---------------------------------------------------------------------------
pub fn hash_body(body: &[u8]) -> Result<[u8; 32], CoreError> {
if body.len() > MAX_BODY_BYTES {
return Err(bad("message body exceeds the group limit"));
}
Ok(Sha256::digest(body).into())
}
/// The bytes a group message is signed over.
///
/// Only the hash of the body is signed, not the body: it keeps the payload a
/// fixed size regardless of message length, and the hash is what a later
/// consistency comparison needs anyway.
pub fn group_message_payload(
group_id: &str,
epoch: u64,
seq: u64,
sender_fp: &str,
body_hash: &[u8],
) -> Result<Vec<u8>, CoreError> {
let gid = assert_group_id(group_id)?;
let epoch_be = u32_be(assert_epoch(epoch)?)?;
// Same uint32 range as the epoch; a per-sender counter.
let seq_be = u32_be(assert_epoch(seq)?)?;
let fp = assert_fingerprint(sender_fp)?;
if body_hash.len() != 32 {
return Err(bad("body hash must be 32 bytes"));
}
Ok(lp(
"securebit/group/message/v1",
&[
Part::Bytes(&gid),
Part::Bytes(&epoch_be),
Part::Bytes(&seq_be),
Part::Bytes(&fp),
Part::Bytes(body_hash),
],
))
}
pub fn sign_group_message(
identity: &GroupIdentity,
group_id: &str,
epoch: u64,
seq: u64,
sender_fp: &str,
body_hash: &[u8],
) -> Result<Vec<u8>, CoreError> {
identity.sign(&group_message_payload(group_id, epoch, seq, sender_fp, body_hash)?)
}
pub fn verify_group_message(
key: &VerifyingKey,
group_id: &str,
epoch: u64,
seq: u64,
sender_fp: &str,
body_hash: &[u8],
signature: &[u8],
) -> bool {
match group_message_payload(group_id, epoch, seq, sender_fp, body_hash) {
Ok(payload) => verify_with(key, &payload, signature),
Err(_) => false,
}
}
// ---------------------------------------------------------------------------
// mesh links
// ---------------------------------------------------------------------------
//
// WHY A MESH DESCRIPTOR IS SIGNED WITH THE GROUP IDENTITY KEY
// -----------------------------------------------------------
// Two members who have never met have no pairwise channel to introduce
// themselves over, so their WebRTC descriptors have to travel through a member
// who CAN reach both — in practice the admin. That relay is not trusted with the
// content of the group, and it must not become trusted with the shape of the
// group's transport either: a relay that could swap a descriptor for its own
// would sit in the middle of the very link that was built to route around it.
//
// The descriptor is therefore signed with the sender's group identity key — the
// same key whose fingerprint the signed roster names and whose presence the
// humans confirmed when they compared the group code. A relay can drop a dial or
// delay it, which costs availability and nothing else. It cannot substitute one.
//
// The signature covers the direction, BOTH fingerprints and a per-attempt nonce
// as well as the descriptor bytes:
// - the direction stops an offer being replayed back as an answer;
// - both fingerprints stop a descriptor addressed to one member being
// re-aimed at another;
// - the nonce binds an answer to the one dial that asked for it.
pub fn mesh_descriptor_payload(
group_id: &str,
epoch: u64,
kind: MeshKind,
from_fp: &str,
to_fp: &str,
descriptor: &str,
nonce: &[u8],
) -> Result<Vec<u8>, CoreError> {
let gid = assert_group_id(group_id)?;
let epoch_be = u32_be(assert_epoch(epoch)?)?;
let from = assert_fingerprint(from_fp)?;
let to = assert_fingerprint(to_fp)?;
if from_fp == to_fp {
return Err(bad("a member cannot dial itself"));
}
if descriptor.is_empty() || descriptor.len() > MAX_DESCRIPTOR_CHARS {
return Err(bad("mesh descriptor is missing or oversized"));
}
if nonce.len() != MESH_NONCE_BYTES {
return Err(bad("mesh nonce must be 16 bytes"));
}
Ok(lp(
"securebit/group/mesh-descriptor/v1",
&[
Part::Bytes(&gid),
Part::Bytes(&epoch_be),
Part::Text(kind.as_str()),
Part::Bytes(&from),
Part::Bytes(&to),
Part::Text(descriptor),
Part::Bytes(nonce),
],
))
}
pub fn sign_mesh_descriptor(
identity: &GroupIdentity,
group_id: &str,
epoch: u64,
kind: MeshKind,
from_fp: &str,
to_fp: &str,
descriptor: &str,
nonce: &[u8],
) -> Result<Vec<u8>, CoreError> {
identity.sign(&mesh_descriptor_payload(
group_id, epoch, kind, from_fp, to_fp, descriptor, nonce,
)?)
}
#[allow(clippy::too_many_arguments)]
pub fn verify_mesh_descriptor(
key: &VerifyingKey,
group_id: &str,
epoch: u64,
kind: MeshKind,
from_fp: &str,
to_fp: &str,
descriptor: &str,
nonce: &[u8],
signature: &[u8],
) -> bool {
match mesh_descriptor_payload(group_id, epoch, kind, from_fp, to_fp, descriptor, nonce) {
Ok(payload) => verify_with(key, &payload, signature),
Err(_) => false,
}
}
/// The bytes a link probe is signed over.
///
/// A probe is how a member says "the pairwise chat you are reading this on is
/// me, member <fp>". It exists because two members can perfectly well already
/// hold a verified 1:1 chat with each other before the group was formed, and
/// dialling a second connection between them would be pure waste.
///
/// The claim has to be authenticated TO THIS SESSION. A bare signed claim would
/// be replayable: any member could capture one and present it on their own link
/// to impersonate its author, and group traffic meant for that member would then
/// be encrypted to the impersonator's pairwise session — a plaintext disclosure,
/// not merely a routing mistake.
///
/// `link_fp` is what closes that. It is the pairwise session's own key
/// fingerprint, derived from the ECDH shared secret, so it is known to exactly
/// the two endpoints of that session and to nobody else. The receiver checks it
/// against the fingerprint IT holds for the session the probe arrived on, never
/// against a value inside the frame.
pub fn link_probe_payload(
group_id: &str,
epoch: u64,
fp: &str,
link_fp: &str,
) -> Result<Vec<u8>, CoreError> {
let gid = assert_group_id(group_id)?;
let epoch_be = u32_be(assert_epoch(epoch)?)?;
let member = assert_fingerprint(fp)?;
if link_fp.is_empty() || link_fp.len() > 256 {
return Err(bad("link fingerprint is missing or oversized"));
}
Ok(lp(
"securebit/group/link-probe/v1",
&[
Part::Bytes(&gid),
Part::Bytes(&epoch_be),
Part::Bytes(&member),
Part::Text(link_fp),
],
))
}
pub fn sign_link_probe(
identity: &GroupIdentity,
group_id: &str,
epoch: u64,
fp: &str,
link_fp: &str,
) -> Result<Vec<u8>, CoreError> {
identity.sign(&link_probe_payload(group_id, epoch, fp, link_fp)?)
}
pub fn verify_link_probe(
key: &VerifyingKey,
group_id: &str,
epoch: u64,
fp: &str,
link_fp: &str,
signature: &[u8],
) -> bool {
match link_probe_payload(group_id, epoch, fp, link_fp) {
Ok(payload) => verify_with(key, &payload, signature),
Err(_) => false,
}
}
pub fn assert_call_id(call_id: &str) -> Result<Vec<u8>, CoreError> {
if call_id.len() != CALL_ID_BYTES * 2 || !is_lower_hex(call_id) {
return Err(bad("malformed call id"));
}
hex::decode(call_id).map_err(|_| bad("malformed call id"))
}
/// The bytes a call-control frame is signed over.
///
/// Call control is membership-visible metadata, not content, but it is signed
/// with the same group identity key for the same reason a message is: a