1use nostr_sdk::prelude::nip44::v2::{decrypt_to_bytes, ConversationKey};
41use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, Timestamp, UnsignedEvent};
42use serde::{Deserialize, Serialize};
43use zeroize::Zeroizing;
44
45use super::super::{ChannelId, CommunityId, Epoch};
46use super::derive::{
47 base_rekey_group_key, channel_rekey_group_key, control_signer_group_key, epoch_key_commitment,
48 recipient_locator, GroupKey,
49};
50use super::stream::{self, OpenedStream, SealForm, StreamError};
51
52pub const MAX_REKEY_BLOBS_PER_EVENT: usize = 80;
60
61pub const MAX_REKEY_BLOBS_RECEIVED: usize = 120;
66
67const TAG_SCOPE: &str = "scope";
68const TAG_NEW_EPOCH: &str = "newepoch";
69const TAG_PREV_EPOCH: &str = "prevepoch";
70const TAG_PREV_COMMIT: &str = "prevcommit";
71const TAG_CHUNK: &str = "chunk";
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum RekeyScope {
77 Channel(ChannelId),
79 Root,
82}
83
84impl RekeyScope {
85 pub fn id32(&self) -> [u8; 32] {
87 match self {
88 RekeyScope::Channel(c) => c.0,
89 RekeyScope::Root => [0u8; 32],
90 }
91 }
92
93 fn to_hex(self) -> String {
94 crate::simd::hex::bytes_to_hex_32(&self.id32())
95 }
96
97 fn from_hex(hex: &str) -> Option<RekeyScope> {
98 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
99 return None;
100 }
101 let bytes = crate::simd::hex::hex_to_bytes_32(hex);
102 Some(if bytes == [0u8; 32] {
103 RekeyScope::Root
104 } else {
105 RekeyScope::Channel(ChannelId(bytes))
106 })
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct RekeyBlob {
117 pub locator: String,
118 pub wrapped: String,
119}
120
121#[derive(Debug)]
123pub enum RekeyError {
124 Stream(StreamError),
125 Crypto(String),
126 BadBlobLength(usize),
128 BadBaseBlobWidth(usize),
131 ControlPairMismatch,
135 BadControlWrapLength(usize),
137 ScopeSplice,
139 EpochSplice,
141 NotARekey(u16),
143 BadTag(&'static str),
145 NonMonotonicEpoch,
147 BadChunkIndex,
149 TooManyBlobs(usize),
151}
152
153impl std::fmt::Display for RekeyError {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 match self {
156 RekeyError::Stream(e) => write!(f, "stream: {e}"),
157 RekeyError::Crypto(e) => write!(f, "crypto: {e}"),
158 RekeyError::BadBlobLength(n) => write!(f, "rekey blob plaintext is {n} bytes, expected 72"),
159 RekeyError::BadBaseBlobWidth(n) => write!(f, "base rekey blob plaintext is {n} bytes, expected 72, 104 or 136"),
160 RekeyError::ControlPairMismatch => write!(f, "base rekey blob control_root does not derive to its control_pk"),
161 RekeyError::BadControlWrapLength(n) => write!(f, "control_wrap plaintext is {n} bytes, expected 40"),
162 RekeyError::ScopeSplice => write!(f, "rekey blob scope binding mismatch (splice)"),
163 RekeyError::EpochSplice => write!(f, "rekey blob epoch binding mismatch (splice)"),
164 RekeyError::NotARekey(k) => write!(f, "rumor kind {k} is not a rekey"),
165 RekeyError::BadTag(t) => write!(f, "missing/duplicate/malformed rekey tag: {t}"),
166 RekeyError::NonMonotonicEpoch => write!(f, "rekey new_epoch must exceed prev_epoch"),
167 RekeyError::BadChunkIndex => write!(f, "rekey chunk index out of range"),
168 RekeyError::TooManyBlobs(n) => write!(f, "rekey carries {n} blobs, over the cap"),
169 }
170 }
171}
172
173impl std::error::Error for RekeyError {}
174
175impl From<StreamError> for RekeyError {
176 fn from(e: StreamError) -> Self {
177 RekeyError::Stream(e)
178 }
179}
180
181fn bound_plaintext(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> [u8; 72] {
186 let mut pt = [0u8; 72];
187 pt[..32].copy_from_slice(&scope.id32());
188 pt[32..40].copy_from_slice(&epoch.0.to_be_bytes());
189 pt[40..].copy_from_slice(new_key);
190 pt
191}
192
193pub fn bound_plaintext_b64(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> String {
196 base64_simd::STANDARD.encode_to_string(bound_plaintext(scope, epoch, new_key))
197}
198
199pub fn parse_bound_plaintext(pt: &[u8], scope: RekeyScope, epoch: Epoch) -> Result<[u8; 32], RekeyError> {
203 if pt.len() != 72 {
204 return Err(RekeyError::BadBlobLength(pt.len()));
205 }
206 if pt[..32] != scope.id32() {
207 return Err(RekeyError::ScopeSplice);
208 }
209 let mut epoch_be = [0u8; 8];
210 epoch_be.copy_from_slice(&pt[32..40]);
211 if u64::from_be_bytes(epoch_be) != epoch.0 {
212 return Err(RekeyError::EpochSplice);
213 }
214 let mut new_key = [0u8; 32];
215 new_key.copy_from_slice(&pt[40..72]);
216 Ok(new_key)
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct BaseKeyDelivery {
231 pub new_root: [u8; 32],
232 pub control_pk: Option<[u8; 32]>,
236 pub control_root: Option<[u8; 32]>,
239}
240
241fn bound_base_plaintext(epoch: Epoch, new_root: &[u8; 32], control_pk: &[u8; 32], control_root: Option<&[u8; 32]>) -> Vec<u8> {
242 let mut pt = Vec::with_capacity(if control_root.is_some() { 136 } else { 104 });
243 pt.extend_from_slice(&bound_plaintext(RekeyScope::Root, epoch, new_root));
244 pt.extend_from_slice(control_pk);
245 if let Some(cr) = control_root {
246 pt.extend_from_slice(cr);
247 }
248 pt
249}
250
251pub fn bound_base_plaintext_b64(epoch: Epoch, new_root: &[u8; 32], control_pk: &[u8; 32], control_root: Option<&[u8; 32]>) -> String {
254 base64_simd::STANDARD.encode_to_string(bound_base_plaintext(epoch, new_root, control_pk, control_root))
255}
256
257pub fn parse_bound_base_plaintext(pt: &[u8], community_id: &CommunityId, epoch: Epoch) -> Result<BaseKeyDelivery, RekeyError> {
274 if !matches!(pt.len(), 72 | 104 | 136) && pt.len() < 137 {
275 return Err(RekeyError::BadBaseBlobWidth(pt.len()));
276 }
277 let new_root = parse_bound_plaintext(&pt[..72], RekeyScope::Root, epoch)?;
278 if pt.len() == 72 {
279 return Ok(BaseKeyDelivery { new_root, control_pk: None, control_root: None });
280 }
281 let mut control_pk = [0u8; 32];
282 control_pk.copy_from_slice(&pt[72..104]);
283 if pt.len() == 104 {
284 return Ok(BaseKeyDelivery { new_root, control_pk: Some(control_pk), control_root: None });
285 }
286 let mut control_root = [0u8; 32];
287 control_root.copy_from_slice(&pt[104..136]);
288 if control_signer_group_key(&control_root, community_id, epoch).pk().to_bytes() != control_pk {
289 if pt.len() == 136 {
290 return Err(RekeyError::ControlPairMismatch);
291 }
292 return Ok(BaseKeyDelivery { new_root, control_pk: Some(control_pk), control_root: None });
295 }
296 Ok(BaseKeyDelivery { new_root, control_pk: Some(control_pk), control_root: Some(control_root) })
297}
298
299pub async fn build_base_blob<S: crate::signer::VectorSigner + ?Sized>(
303 signer: &S,
304 rotator_xonly: &[u8; 32],
305 recipient_pk: &PublicKey,
306 epoch: Epoch,
307 new_root: &[u8; 32],
308 control_pk: &[u8; 32],
309 control_root: Option<&[u8; 32]>,
310) -> Result<RekeyBlob, RekeyError> {
311 let inner_b64 = Zeroizing::new(bound_base_plaintext_b64(epoch, new_root, control_pk, control_root));
312 let wrapped = signer
313 .nip44_encrypt_async(recipient_pk, inner_b64.as_str())
314 .await
315 .map_err(|e| RekeyError::Crypto(e.to_string()))?;
316 Ok(RekeyBlob {
317 locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), RekeyScope::Root, epoch),
318 wrapped,
319 })
320}
321
322pub async fn open_base_blob<S: crate::signer::VectorSigner + ?Sized>(
326 signer: &S,
327 rotator_pk: &PublicKey,
328 community_id: &CommunityId,
329 epoch: Epoch,
330 blob: &RekeyBlob,
331) -> Result<BaseKeyDelivery, RekeyError> {
332 let inner_b64 = Zeroizing::new(
333 signer
334 .nip44_decrypt_async(rotator_pk, &blob.wrapped)
335 .await
336 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
337 );
338 let pt = Zeroizing::new(
339 base64_simd::STANDARD
340 .decode_to_vec(inner_b64.as_bytes())
341 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
342 );
343 parse_bound_base_plaintext(&pt, community_id, epoch)
344}
345
346pub fn encode_control_wrap(epoch: Epoch, control_root: &[u8; 32]) -> [u8; 40] {
352 let mut pt = [0u8; 40];
353 pt[..8].copy_from_slice(&epoch.0.to_be_bytes());
354 pt[8..].copy_from_slice(control_root);
355 pt
356}
357
358pub fn control_wrap_b64(epoch: Epoch, control_root: &[u8; 32]) -> String {
361 base64_simd::STANDARD.encode_to_string(encode_control_wrap(epoch, control_root))
362}
363
364pub fn parse_control_wrap(pt: &[u8]) -> Result<(Epoch, [u8; 32]), RekeyError> {
368 if pt.len() != 40 {
369 return Err(RekeyError::BadControlWrapLength(pt.len()));
370 }
371 let mut epoch_be = [0u8; 8];
372 epoch_be.copy_from_slice(&pt[..8]);
373 let mut control_root = [0u8; 32];
374 control_root.copy_from_slice(&pt[8..]);
375 Ok((Epoch(u64::from_be_bytes(epoch_be)), control_root))
376}
377
378pub fn blob_locator(rotator_xonly: &[u8; 32], recipient_xonly: &[u8; 32], scope: RekeyScope, epoch: Epoch) -> String {
381 crate::simd::hex::bytes_to_hex_32(&recipient_locator(rotator_xonly, recipient_xonly, &scope.id32(), epoch))
382}
383
384pub fn build_blob_local(
389 rotator_sk: &SecretKey,
390 rotator_xonly: &[u8; 32],
391 recipient_pk: &PublicKey,
392 scope: RekeyScope,
393 epoch: Epoch,
394 new_key: &[u8; 32],
395) -> Result<RekeyBlob, RekeyError> {
396 let inner_b64 = Zeroizing::new(bound_plaintext_b64(scope, epoch, new_key));
397 let ck = ConversationKey::derive(rotator_sk, recipient_pk).map_err(|e| RekeyError::Crypto(e.to_string()))?;
398 let payload = crate::community::cipher::encrypt_with_random_nonce(&ck, inner_b64.as_bytes()).map_err(|e| RekeyError::Crypto(e.to_string()))?;
399 Ok(RekeyBlob {
400 locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), scope, epoch),
401 wrapped: base64_simd::STANDARD.encode_to_string(&payload),
402 })
403}
404
405pub fn open_blob_local(
411 my_sk: &SecretKey,
412 rotator_pk: &PublicKey,
413 scope: RekeyScope,
414 epoch: Epoch,
415 blob: &RekeyBlob,
416) -> Result<[u8; 32], RekeyError> {
417 let ck = ConversationKey::derive(my_sk, rotator_pk).map_err(|e| RekeyError::Crypto(e.to_string()))?;
418 let payload = base64_simd::STANDARD
419 .decode_to_vec(blob.wrapped.as_bytes())
420 .map_err(|e| RekeyError::Crypto(e.to_string()))?;
421 let inner_b64 = Zeroizing::new(decrypt_to_bytes(&ck, &payload).map_err(|e| RekeyError::Crypto(e.to_string()))?);
422 let pt = Zeroizing::new(
423 base64_simd::STANDARD
424 .decode_to_vec(inner_b64.as_slice())
425 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
426 );
427 parse_bound_plaintext(&pt, scope, epoch)
428}
429
430pub async fn build_blob<S: crate::signer::VectorSigner + ?Sized>(
436 signer: &S,
437 rotator_xonly: &[u8; 32],
438 recipient_pk: &PublicKey,
439 scope: RekeyScope,
440 epoch: Epoch,
441 new_key: &[u8; 32],
442) -> Result<RekeyBlob, RekeyError> {
443 let inner_b64 = Zeroizing::new(bound_plaintext_b64(scope, epoch, new_key));
444 let wrapped = signer
445 .nip44_encrypt_async(recipient_pk, inner_b64.as_str())
446 .await
447 .map_err(|e| RekeyError::Crypto(e.to_string()))?;
448 Ok(RekeyBlob {
449 locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), scope, epoch),
450 wrapped,
451 })
452}
453
454pub async fn open_blob<S: crate::signer::VectorSigner + ?Sized>(
458 signer: &S,
459 rotator_pk: &PublicKey,
460 scope: RekeyScope,
461 epoch: Epoch,
462 blob: &RekeyBlob,
463) -> Result<[u8; 32], RekeyError> {
464 let inner_b64 = Zeroizing::new(
465 signer
466 .nip44_decrypt_async(rotator_pk, &blob.wrapped)
467 .await
468 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
469 );
470 let pt = Zeroizing::new(
471 base64_simd::STANDARD
472 .decode_to_vec(inner_b64.as_bytes())
473 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
474 );
475 parse_bound_plaintext(&pt, scope, epoch)
476}
477
478pub fn find_my_blob<'a>(
482 blobs: &'a [RekeyBlob],
483 rotator_xonly: &[u8; 32],
484 my_xonly: &[u8; 32],
485 scope: RekeyScope,
486 epoch: Epoch,
487) -> Option<&'a RekeyBlob> {
488 let want = blob_locator(rotator_xonly, my_xonly, scope, epoch);
489 blobs.iter().find(|b| b.locator == want)
490}
491
492#[derive(Debug, Clone)]
497pub struct RekeyChunk {
498 pub rotator: PublicKey,
499 pub scope: RekeyScope,
500 pub new_epoch: Epoch,
501 pub prev_epoch: Epoch,
502 pub prev_commit: [u8; 32],
503 pub chunk: (u32, u32),
505 pub blobs: Vec<RekeyBlob>,
506 pub citation: Option<crate::community::edition::AuthorityCitation>,
509}
510
511pub type RotationKey = ([u8; 32], [u8; 32], u64, [u8; 32]);
515
516impl RekeyChunk {
517 pub fn correlation(&self) -> RotationKey {
519 (self.rotator.to_bytes(), self.scope.id32(), self.new_epoch.0, self.prev_commit)
520 }
521}
522
523#[allow(clippy::too_many_arguments)]
526pub fn build_rekey_rumor(
527 rotator: PublicKey,
528 scope: RekeyScope,
529 new_epoch: Epoch,
530 prev_epoch: Epoch,
531 prev_commit: &[u8; 32],
532 blobs: &[RekeyBlob],
533 chunk_i: u32,
534 chunk_n: u32,
535 at_secs: u64,
536 citation: Option<&crate::community::edition::AuthorityCitation>,
537) -> Result<UnsignedEvent, RekeyError> {
538 if new_epoch.0 <= prev_epoch.0 {
539 return Err(RekeyError::NonMonotonicEpoch);
540 }
541 if chunk_n < 1 || chunk_i < 1 || chunk_i > chunk_n {
542 return Err(RekeyError::BadChunkIndex);
543 }
544 if blobs.len() > MAX_REKEY_BLOBS_PER_EVENT {
545 return Err(RekeyError::TooManyBlobs(blobs.len()));
546 }
547 let content = serde_json::to_string(blobs).map_err(|e| RekeyError::Crypto(e.to_string()))?;
548 let mut tags = vec![
549 Tag::custom(TAG_SCOPE, [scope.to_hex()]),
550 Tag::custom(TAG_NEW_EPOCH, [new_epoch.0.to_string()]),
551 Tag::custom(TAG_PREV_EPOCH, [prev_epoch.0.to_string()]),
552 Tag::custom(TAG_PREV_COMMIT, [crate::simd::hex::bytes_to_hex_32(prev_commit)]),
553 Tag::custom(TAG_CHUNK, [chunk_i.to_string(), chunk_n.to_string()]),
554 ];
555 if let Some(c) = citation {
559 tags.push(c.to_tag());
560 }
561 Ok(stream::build_rumor_secs(super::kind::REKEY, rotator, &content, tags, at_secs))
563}
564
565pub fn channel_rekey_group(addressing_root: &[u8; 32], channel_id: &ChannelId, new_epoch: Epoch) -> GroupKey {
572 channel_rekey_group_key(addressing_root, channel_id, new_epoch)
573}
574
575pub fn base_rekey_group(prior_root: &[u8; 32], community_id: &CommunityId, new_epoch: Epoch) -> GroupKey {
578 base_rekey_group_key(prior_root, community_id, new_epoch)
579}
580
581pub fn seal_rekey_chunk(
585 rumor: &UnsignedEvent,
586 rekey_group: &GroupKey,
587 rotator_keys: &Keys,
588 wrap_at: Timestamp,
589) -> Result<(Event, Keys), RekeyError> {
590 let seal = stream::build_seal(rumor, SealForm::Encrypted, rekey_group, rotator_keys)?;
591 Ok(stream::wrap_seal(&seal, rekey_group, stream::KIND_WRAP, wrap_at)?)
592}
593
594#[allow(clippy::too_many_arguments)]
598pub fn build_rekey_chunks_local(
599 rotator_keys: &Keys,
600 rekey_group: &GroupKey,
601 scope: RekeyScope,
602 new_epoch: Epoch,
603 prev_epoch: Epoch,
604 prev_commit: &[u8; 32],
605 blobs: &[RekeyBlob],
606 at_secs: u64,
607 citation: Option<&crate::community::edition::AuthorityCitation>,
608) -> Result<Vec<Event>, RekeyError> {
609 let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
610 vec![&[]]
611 } else {
612 blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
613 };
614 let n = groups.len() as u32;
615 let mut out = Vec::with_capacity(groups.len());
616 for (idx, group_blobs) in groups.iter().enumerate() {
617 let rumor = build_rekey_rumor(
618 rotator_keys.public_key(),
619 scope,
620 new_epoch,
621 prev_epoch,
622 prev_commit,
623 group_blobs,
624 idx as u32 + 1,
625 n,
626 at_secs,
627 citation,
628 )?;
629 let (wrap, _) = seal_rekey_chunk(&rumor, rekey_group, rotator_keys, Timestamp::from_secs(at_secs))?;
630 out.push(wrap);
631 }
632 Ok(out)
633}
634
635#[allow(clippy::too_many_arguments)]
639pub async fn build_rekey_chunks<S: crate::signer::VectorSigner + ?Sized>(
640 signer: &S,
641 rotator_pk: PublicKey,
642 rekey_group: &GroupKey,
643 scope: RekeyScope,
644 new_epoch: Epoch,
645 prev_epoch: Epoch,
646 prev_commit: &[u8; 32],
647 blobs: &[RekeyBlob],
648 at_secs: u64,
649 citation: Option<&crate::community::edition::AuthorityCitation>,
650) -> Result<Vec<Event>, RekeyError> {
651 let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
652 vec![&[]]
653 } else {
654 blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
655 };
656 let n = groups.len() as u32;
657 let mut out = Vec::with_capacity(groups.len());
658 for (idx, group_blobs) in groups.iter().enumerate() {
659 let rumor = build_rekey_rumor(rotator_pk, scope, new_epoch, prev_epoch, prev_commit, group_blobs, idx as u32 + 1, n, at_secs, citation)?;
660 let (wrap, _) = stream::seal_and_wrap_signed(signer, rotator_pk, &rumor, SealForm::Encrypted, rekey_group, stream::KIND_WRAP, Timestamp::from_secs(at_secs), &[]).await?;
661 out.push(wrap);
662 }
663 Ok(out)
664}
665
666pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError> {
670 if opened.seal_form != SealForm::Encrypted {
671 return Err(RekeyError::Stream(StreamError::BadSealKind(stream::KIND_SEAL_PLAINTEXT)));
673 }
674 let rumor = &opened.rumor;
675 if rumor.kind.as_u16() != super::kind::REKEY {
676 return Err(RekeyError::NotARekey(rumor.kind.as_u16()));
677 }
678 let scope = RekeyScope::from_hex(&unique_tag(rumor, TAG_SCOPE)?.ok_or(RekeyError::BadTag(TAG_SCOPE))?)
679 .ok_or(RekeyError::BadTag(TAG_SCOPE))?;
680 let new_epoch = Epoch(parse_u64(rumor, TAG_NEW_EPOCH)?);
681 let prev_epoch = Epoch(parse_u64(rumor, TAG_PREV_EPOCH)?);
682 if new_epoch.0 <= prev_epoch.0 {
683 return Err(RekeyError::NonMonotonicEpoch);
684 }
685 let prev_hex = unique_tag(rumor, TAG_PREV_COMMIT)?.ok_or(RekeyError::BadTag(TAG_PREV_COMMIT))?;
686 if prev_hex.len() != 64 || !prev_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
687 return Err(RekeyError::BadTag(TAG_PREV_COMMIT));
688 }
689 let prev_commit = crate::simd::hex::hex_to_bytes_32(&prev_hex);
690
691 let (chunk_i, chunk_n) = parse_chunk(rumor)?;
692
693 let blobs: Vec<RekeyBlob> = serde_json::from_str(&rumor.content).map_err(|_| RekeyError::BadTag("blobs"))?;
694 if blobs.len() > MAX_REKEY_BLOBS_RECEIVED {
695 return Err(RekeyError::TooManyBlobs(blobs.len()));
696 }
697
698 Ok(RekeyChunk {
699 rotator: opened.author,
700 scope,
701 new_epoch,
702 prev_epoch,
703 prev_commit,
704 chunk: (chunk_i, chunk_n),
705 blobs,
706 citation: crate::community::edition::AuthorityCitation::from_tags(&rumor.tags),
707 })
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714pub enum Continuity {
715 Extends,
718 Gap,
721 Fork,
723}
724
725pub fn check_continuity(chunk: &RekeyChunk, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
729 if chunk.prev_epoch.0 == held_epoch.0 {
730 if epoch_key_commitment(held_epoch, held_key) == chunk.prev_commit {
731 Continuity::Extends
732 } else {
733 Continuity::Fork
734 }
735 } else if chunk.prev_epoch.0 > held_epoch.0 {
736 Continuity::Gap
737 } else {
738 Continuity::Fork
741 }
742}
743
744#[derive(Debug, Clone)]
747pub struct Rotation {
748 pub rotator: PublicKey,
749 pub scope: RekeyScope,
750 pub new_epoch: Epoch,
751 pub prev_epoch: Epoch,
752 pub prev_commit: [u8; 32],
753 pub blobs: Vec<RekeyBlob>,
755 pub declared_chunks: u32,
757 pub held_chunks: std::collections::BTreeSet<u32>,
759 pub citation: Option<crate::community::edition::AuthorityCitation>,
762}
763
764impl Rotation {
765 pub fn is_complete(&self) -> bool {
769 self.declared_chunks >= 1 && (1..=self.declared_chunks).all(|i| self.held_chunks.contains(&i))
770 }
771
772 pub fn continuity(&self, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
776 if self.prev_epoch.0 == held_epoch.0 {
777 if epoch_key_commitment(held_epoch, held_key) == self.prev_commit {
778 Continuity::Extends
779 } else {
780 Continuity::Fork
781 }
782 } else if self.prev_epoch.0 > held_epoch.0 {
783 Continuity::Gap
784 } else {
785 Continuity::Fork
786 }
787 }
788}
789
790pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec<Rotation> {
795 use std::collections::BTreeMap;
796 let mut by_key: BTreeMap<RotationKey, Rotation> = BTreeMap::new();
797 for c in chunks {
798 let entry = by_key.entry(c.correlation()).or_insert_with(|| Rotation {
799 rotator: c.rotator,
800 scope: c.scope,
801 new_epoch: c.new_epoch,
802 prev_epoch: c.prev_epoch,
803 prev_commit: c.prev_commit,
804 blobs: Vec::new(),
805 declared_chunks: c.chunk.1,
806 held_chunks: std::collections::BTreeSet::new(),
807 citation: c.citation.clone(),
808 });
809 if entry.held_chunks.insert(c.chunk.0) {
810 entry.blobs.extend(c.blobs.iter().cloned());
811 }
812 }
813 by_key.into_values().collect()
814}
815
816pub fn am_i_removed(rotation: &Rotation, my_xonly: &[u8; 32]) -> Option<bool> {
820 if !rotation.is_complete() {
821 return None;
822 }
823 let mine = find_my_blob(&rotation.blobs, &rotation.rotator.to_bytes(), my_xonly, rotation.scope, rotation.new_epoch);
824 Some(mine.is_none())
825}
826
827pub fn lowest_key_winner(candidate_keys: &[[u8; 32]]) -> Option<usize> {
833 candidate_keys
834 .iter()
835 .enumerate()
836 .min_by(|(_, a), (_, b)| a.cmp(b))
837 .map(|(i, _)| i)
838}
839
840fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, RekeyError> {
843 let mut found: Option<String> = None;
844 for t in rumor.tags.iter() {
845 let s = t.as_slice();
846 if s.len() >= 2 && s[0] == name {
847 if found.is_some() {
848 return Err(RekeyError::BadTag(name));
849 }
850 found = Some(s[1].clone());
851 }
852 }
853 Ok(found)
854}
855
856fn parse_u64(rumor: &UnsignedEvent, name: &'static str) -> Result<u64, RekeyError> {
857 let raw = unique_tag(rumor, name)?.ok_or(RekeyError::BadTag(name))?;
858 if !crate::community::edition::is_tag_decimal(&raw) {
861 return Err(RekeyError::BadTag(name));
862 }
863 raw.parse::<u64>().map_err(|_| RekeyError::BadTag(name))
864}
865
866fn parse_chunk(rumor: &UnsignedEvent) -> Result<(u32, u32), RekeyError> {
867 let mut found: Option<(u32, u32)> = None;
868 for t in rumor.tags.iter() {
869 let s = t.as_slice();
870 if s.len() >= 3 && s[0] == TAG_CHUNK {
871 if found.is_some() {
872 return Err(RekeyError::BadTag(TAG_CHUNK));
873 }
874 if !crate::community::edition::is_tag_decimal(&s[1]) || !crate::community::edition::is_tag_decimal(&s[2]) {
875 return Err(RekeyError::BadTag(TAG_CHUNK));
876 }
877 let i: u32 = s[1].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
878 let n: u32 = s[2].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
879 found = Some((i, n));
880 }
881 }
882 let (i, n) = found.ok_or(RekeyError::BadTag(TAG_CHUNK))?;
883 if n < 1 || i < 1 || i > n {
884 return Err(RekeyError::BadChunkIndex);
885 }
886 Ok((i, n))
887}
888
889#[cfg(test)]
890mod tests {
891
892 fn as_signer(k: &Keys) -> crate::signer::ActiveSigner {
896 crate::signer::ActiveSigner::Keys(k.clone())
897 }
898 use super::*;
899
900 fn keys(byte: u8) -> Keys {
901 Keys::new(SecretKey::from_slice(&[byte; 32]).unwrap())
902 }
903
904 fn xonly(k: &Keys) -> [u8; 32] {
905 k.public_key().to_bytes()
906 }
907
908 const CHAN: ChannelId = ChannelId([0x42u8; 32]);
909
910 #[test]
913 fn bound_plaintext_layout_is_frozen() {
914 let pt = bound_plaintext(RekeyScope::Root, Epoch(1), &[0xABu8; 32]);
915 let expected = format!("{}{}{}", "00".repeat(32), "0000000000000001", "ab".repeat(32));
916 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
917 let pt2 = bound_plaintext(RekeyScope::Channel(ChannelId([0x11u8; 32])), Epoch(0x0102), &[0xCDu8; 32]);
918 let expected2 = format!("{}{}{}", "11".repeat(32), "0000000000000102", "cd".repeat(32));
919 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt2), expected2);
920 }
921
922 #[test]
923 fn blob_round_trips_both_scopes() {
924 let rotator = keys(7);
925 let recipient = keys(8);
926 for (scope, epoch, key) in [
927 (RekeyScope::Root, Epoch(1), [0xABu8; 32]),
928 (RekeyScope::Channel(CHAN), Epoch(5), [0xCDu8; 32]),
929 ] {
930 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
931 let got = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob).unwrap();
932 assert_eq!(got, key, "the recipient recovers the fresh key");
933 }
934 }
935
936 #[tokio::test]
937 async fn signer_blob_is_wire_compatible_with_local_both_directions() {
938 let rotator = keys(7);
942 let recipient = keys(8);
943 let scope = RekeyScope::Root;
944 let epoch = Epoch(3);
945 let key = [0x5Au8; 32];
946
947 let blob_l = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
949 let got_s = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_l).await.unwrap();
950 assert_eq!(got_s, key, "signer opens a local-built blob");
951
952 let blob_s = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).await.unwrap();
954 assert_eq!(blob_s.locator, blob_l.locator, "same public locator regardless of build path");
955 let got_l = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob_s).unwrap();
956 assert_eq!(got_l, key, "local opens a signer-built blob");
957
958 let got_ss = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_s).await.unwrap();
960 assert_eq!(got_ss, key, "signer round-trips its own blob");
961 }
962
963 #[tokio::test]
964 async fn signer_blob_bound_check_still_gates_scope_epoch_splice() {
965 let rotator = keys(7);
966 let recipient = keys(8);
967 let blob = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[9u8; 32]).await.unwrap();
968 assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob).await.is_err(), "epoch splice rejected");
969 assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob).await.is_err(), "scope splice rejected");
970 }
971
972 #[test]
973 fn locator_is_public_and_computable_from_pubkeys_alone() {
974 let rotator = keys(7);
978 let recipient = keys(8);
979 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(2), &[1u8; 32]).unwrap();
980 let recomputed = blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(2));
981 assert_eq!(blob.locator, recomputed, "both sides compute the same public locator");
982 }
983
984 #[test]
985 fn a_non_recipient_cannot_open_even_holding_the_public_locator() {
986 let rotator = keys(7);
990 let recipient = keys(8);
991 let outsider = keys(9);
992 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[2u8; 32]).unwrap();
993 assert_eq!(blob.locator, blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)));
995 assert!(open_blob_local(outsider.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).is_err());
998 }
999
1000 #[test]
1001 fn a_relocated_blob_still_opens_by_decrypt_not_locator() {
1002 let rotator = keys(7);
1007 let recipient = keys(8);
1008 let mut blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[3u8; 32]).unwrap();
1009 blob.locator = "ff".repeat(32);
1010 assert_eq!(
1012 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).unwrap(),
1013 [3u8; 32]
1014 );
1015 assert!(find_my_blob(std::slice::from_ref(&blob), &xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)).is_none());
1017 }
1018
1019 #[test]
1020 fn scope_and_epoch_splices_are_rejected_on_open() {
1021 let rotator = keys(7);
1022 let recipient = keys(8);
1023 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[4u8; 32]).unwrap();
1024 assert!(matches!(
1026 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob),
1027 Err(RekeyError::ScopeSplice)
1028 ));
1029 assert!(matches!(
1031 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob),
1032 Err(RekeyError::EpochSplice)
1033 ));
1034 }
1035
1036 #[test]
1037 fn wrapped_carries_base64_of_the_72_bytes_for_bunker_parity() {
1038 let rotator = keys(7);
1042 let recipient = keys(8);
1043 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[5u8; 32]).unwrap();
1044 let ck = ConversationKey::derive(recipient.secret_key(), &rotator.public_key()).unwrap();
1045 let payload = base64_simd::STANDARD.decode_to_vec(blob.wrapped.as_bytes()).unwrap();
1046 let inner = decrypt_to_bytes(&ck, &payload).unwrap();
1047 assert_eq!(String::from_utf8(inner).unwrap(), bound_plaintext_b64(RekeyScope::Root, Epoch(1), &[5u8; 32]));
1048 }
1049
1050 #[test]
1051 fn distinct_recipients_and_scopes_get_distinct_locators() {
1052 let rotator = keys(7);
1053 let r1 = keys(8);
1054 let r2 = keys(9);
1055 assert_ne!(
1056 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
1057 blob_locator(&xonly(&rotator), &xonly(&r2), RekeyScope::Root, Epoch(1))
1058 );
1059 assert_ne!(
1061 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
1062 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Channel(CHAN), Epoch(1))
1063 );
1064 }
1065
1066 fn root() -> [u8; 32] {
1069 [0x55u8; 32]
1070 }
1071
1072 #[test]
1073 fn channel_rekey_round_trips_through_the_stream() {
1074 let rotator = keys(1);
1075 let recipient = keys(8);
1076 let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
1077 let key = [0xABu8; 32];
1078 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &key).unwrap();
1079 let commit = epoch_key_commitment(Epoch(0), &[0xEEu8; 32]);
1080 let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &commit, &[blob.clone()], 1, 1, 100, None).unwrap();
1081 let (wrap, _) = seal_rekey_chunk(&rumor, &group, &rotator, Timestamp::from_secs(100)).unwrap();
1082
1083 assert_ne!(wrap.pubkey, rotator.public_key());
1085 assert_eq!(wrap.pubkey, group.pk());
1086
1087 let opened = stream::open_wrap(&wrap, &group).unwrap();
1088 let chunk = parse_rekey_chunk(&opened).unwrap();
1089 assert_eq!(chunk.rotator, rotator.public_key(), "rotator recovered from the seal");
1090 assert!(matches!(chunk.scope, RekeyScope::Channel(c) if c.0 == CHAN.0));
1091 assert_eq!(chunk.new_epoch, Epoch(1));
1092 assert_eq!(chunk.prev_epoch, Epoch(0));
1093 assert_eq!(chunk.prev_commit, commit);
1094 assert_eq!(chunk.chunk, (1, 1));
1095 assert_eq!(chunk.blobs, vec![blob]);
1096 }
1097
1098 #[test]
1099 fn base_rekey_addresses_under_the_prior_root() {
1100 let rotator = keys(1);
1103 let recipient = keys(8);
1104 let prior_root = [0x66u8; 32];
1105 let community = CommunityId([0x77u8; 32]);
1106 let new_root = [0x99u8; 32];
1107 let group = base_rekey_group(&prior_root, &community, Epoch(1));
1108 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &new_root).unwrap();
1109 let commit = epoch_key_commitment(Epoch(0), &prior_root);
1110 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &commit, &[blob], 100, None).unwrap();
1111 assert_eq!(chunks.len(), 1);
1112
1113 let opened = stream::open_wrap(&chunks[0], &group).unwrap();
1114 let chunk = parse_rekey_chunk(&opened).unwrap();
1115 assert!(matches!(chunk.scope, RekeyScope::Root));
1116 let mine = find_my_blob(&chunk.blobs, &chunk.rotator.to_bytes(), &xonly(&recipient), chunk.scope, chunk.new_epoch).unwrap();
1118 assert_eq!(open_blob_local(recipient.secret_key(), &chunk.rotator, chunk.scope, chunk.new_epoch, mine).unwrap(), new_root);
1119
1120 let wrong = base_rekey_group(&[0u8; 32], &community, Epoch(1));
1122 assert!(stream::open_wrap(&chunks[0], &wrong).is_err());
1123 }
1124
1125 #[test]
1126 fn a_full_send_chunk_stays_under_the_relay_size_limit() {
1127 let rotator = keys(1);
1130 let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
1131 let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT)
1132 .map(|i| {
1133 let r = keys((i % 200 + 20) as u8);
1134 build_blob_local(rotator.secret_key(), &xonly(&rotator), &r.public_key(), RekeyScope::Root, Epoch(1), &[0xCDu8; 32]).unwrap()
1135 })
1136 .collect();
1137 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &blobs, 100, None).unwrap();
1138 assert_eq!(chunks.len(), 1, "a full send chunk is exactly one event");
1139 assert!(chunks[0].as_json().len() <= 65_536, "a full chunk must fit a 64KB relay event");
1140 }
1141
1142 #[test]
1143 fn oversize_recipient_set_splits_into_chunks() {
1144 let rotator = keys(1);
1145 let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
1146 let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT + 1)
1148 .map(|_| RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() })
1149 .collect();
1150 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(2), Epoch(1), &[0u8; 32], &blobs, 100, None).unwrap();
1151 assert_eq!(chunks.len(), 2);
1152 let parsed: Vec<RekeyChunk> = chunks.iter().map(|w| parse_rekey_chunk(&stream::open_wrap(w, &group).unwrap()).unwrap()).collect();
1153 assert_eq!(parsed[0].chunk, (1, 2));
1154 assert_eq!(parsed[1].chunk, (2, 2));
1155 assert_eq!(parsed[0].blobs.len(), MAX_REKEY_BLOBS_PER_EVENT);
1156 assert_eq!(parsed[1].blobs.len(), 1);
1157 }
1158
1159 #[test]
1160 fn plaintext_sealed_rekey_is_rejected() {
1161 let rotator = keys(1);
1162 let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
1163 let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[], 1, 1, 100, None).unwrap();
1164 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group, &rotator).unwrap();
1165 let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
1166 let opened = stream::open_wrap(&wrap, &group).unwrap();
1167 assert!(parse_rekey_chunk(&opened).is_err(), "the rekey plane must be encrypted-sealed");
1168 }
1169
1170 #[test]
1171 fn non_monotonic_epoch_is_refused_at_mint_and_on_parse() {
1172 let rotator = keys(1);
1173 assert!(matches!(
1174 build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(1), &[0u8; 32], &[], 1, 1, 100, None),
1175 Err(RekeyError::NonMonotonicEpoch)
1176 ));
1177 }
1178
1179 #[test]
1180 fn bad_chunk_indices_are_refused() {
1181 let rotator = keys(1);
1182 for (i, n) in [(0u32, 1u32), (2, 1), (1, 0)] {
1183 assert!(
1184 matches!(build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &[], i, n, 100, None), Err(RekeyError::BadChunkIndex)),
1185 "chunk ({i},{n}) must be rejected"
1186 );
1187 }
1188 }
1189
1190 fn chunk_at(rotator: &Keys, scope: RekeyScope, new_epoch: u64, prev_epoch: u64, prev_key: &[u8; 32], blobs: Vec<RekeyBlob>, i: u32, n: u32) -> RekeyChunk {
1193 RekeyChunk {
1194 rotator: rotator.public_key(),
1195 scope,
1196 new_epoch: Epoch(new_epoch),
1197 prev_epoch: Epoch(prev_epoch),
1198 prev_commit: epoch_key_commitment(Epoch(prev_epoch), prev_key),
1199 chunk: (i, n),
1200 blobs,
1201 citation: None,
1202 }
1203 }
1204
1205 #[test]
1206 fn continuity_extends_gaps_and_forks() {
1207 let rotator = keys(1);
1208 let held = [0x33u8; 32];
1209 let good = chunk_at(&rotator, RekeyScope::Root, 3, 2, &held, vec![], 1, 1);
1211 assert_eq!(check_continuity(&good, Epoch(2), &held), Continuity::Extends);
1212 let ahead = chunk_at(&rotator, RekeyScope::Root, 5, 4, &held, vec![], 1, 1);
1214 assert_eq!(check_continuity(&ahead, Epoch(2), &held), Continuity::Gap);
1215 let fork = chunk_at(&rotator, RekeyScope::Root, 3, 2, &[0x99u8; 32], vec![], 1, 1);
1217 assert_eq!(check_continuity(&fork, Epoch(2), &held), Continuity::Fork);
1218 let stale = chunk_at(&rotator, RekeyScope::Root, 2, 1, &held, vec![], 1, 1);
1220 assert_eq!(check_continuity(&stale, Epoch(2), &held), Continuity::Fork);
1221 }
1222
1223 #[test]
1224 fn a_missing_chunk_is_never_a_removal() {
1225 let rotator = keys(1);
1228 let me = keys(8);
1229 let my_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &me.public_key(), RekeyScope::Root, Epoch(1), &[0xAAu8; 32]).unwrap();
1231 let c1 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![RekeyBlob { locator: "bb".repeat(32), wrapped: "x".into() }], 1, 2);
1232 let rots = collect_rotations(&[c1.clone()]);
1233 assert_eq!(rots.len(), 1);
1234 assert!(!rots[0].is_complete(), "one of two chunks held → incomplete");
1235 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), None, "incomplete → keep recovering, never conclude removal");
1236
1237 let c2 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![my_blob.clone()], 2, 2);
1239 let rots = collect_rotations(&[c1, c2]);
1240 assert!(rots[0].is_complete());
1241 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(false));
1242 let mine = find_my_blob(&rots[0].blobs, &rots[0].rotator.to_bytes(), &xonly(&me), RekeyScope::Root, Epoch(1)).unwrap();
1244 assert_eq!(open_blob_local(me.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), mine).unwrap(), [0xAAu8; 32]);
1245 }
1246
1247 #[test]
1248 fn a_complete_rotation_without_my_blob_is_a_removal() {
1249 let rotator = keys(1);
1250 let me = keys(8);
1251 let other = keys(9);
1252 let their_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &other.public_key(), RekeyScope::Root, Epoch(1), &[0xBBu8; 32]).unwrap();
1254 let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![their_blob], 1, 1);
1255 let rots = collect_rotations(&[c]);
1256 assert!(rots[0].is_complete());
1257 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(true), "complete rotation, no blob for me → removed");
1258 }
1259
1260 #[test]
1261 fn collect_rotations_separates_concurrent_rotators_and_scopes() {
1262 let rot_a = keys(1);
1265 let rot_b = keys(2);
1266 let ca = chunk_at(&rot_a, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1267 let cb = chunk_at(&rot_b, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1268 let cc = chunk_at(&rot_a, RekeyScope::Channel(CHAN), 2, 1, &[7u8; 32], vec![], 1, 1);
1269 let rots = collect_rotations(&[ca, cb, cc]);
1270 assert_eq!(rots.len(), 3, "different rotator or scope ⇒ different rotation");
1271 }
1272
1273 #[test]
1274 fn duplicate_chunk_delivery_is_idempotent() {
1275 let rotator = keys(1);
1276 let blob = RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() };
1277 let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![blob.clone()], 1, 1);
1278 let rots = collect_rotations(&[c.clone(), c]);
1279 assert_eq!(rots.len(), 1);
1280 assert_eq!(rots[0].blobs.len(), 1, "re-delivering a chunk must not double its blobs");
1281 }
1282
1283 #[test]
1286 fn base_blob_forms_are_width_declared() {
1287 let community = CommunityId([0x77u8; 32]);
1288 let new_root = [0xABu8; 32];
1289 let control_root = [0x5Cu8; 32];
1290 let epoch = Epoch(3);
1291 let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1292
1293 let member = bound_base_plaintext(epoch, &new_root, &control_pk, None);
1295 assert_eq!(member.len(), 104);
1296 let d = parse_bound_base_plaintext(&member, &community, epoch).unwrap();
1297 assert_eq!(d.new_root, new_root);
1298 assert_eq!(d.control_pk, Some(control_pk));
1299 assert_eq!(d.control_root, None);
1300
1301 let staff = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&control_root));
1303 assert_eq!(staff.len(), 136);
1304 let d = parse_bound_base_plaintext(&staff, &community, epoch).unwrap();
1305 assert_eq!(d.control_root, Some(control_root));
1306 assert_eq!(d.control_pk, Some(control_pk));
1307
1308 let legacy = bound_plaintext(RekeyScope::Root, epoch, &new_root);
1310 let d = parse_bound_base_plaintext(&legacy, &community, epoch).unwrap();
1311 assert_eq!(d.new_root, new_root);
1312 assert_eq!(d.control_pk, None);
1313 assert_eq!(d.control_root, None);
1314 }
1315
1316 #[test]
1317 fn base_blob_mismatched_control_pair_is_refused_whole() {
1318 let community = CommunityId([0x77u8; 32]);
1319 let control_pk = control_signer_group_key(&[0x5Cu8; 32], &community, Epoch(3)).pk().to_bytes();
1320 let bad = bound_base_plaintext(Epoch(3), &[0xABu8; 32], &control_pk, Some(&[0x11u8; 32]));
1323 assert!(matches!(
1324 parse_bound_base_plaintext(&bad, &community, Epoch(3)),
1325 Err(RekeyError::ControlPairMismatch)
1326 ));
1327 }
1328
1329 #[test]
1330 fn base_blob_rejects_any_other_width_and_splices() {
1331 let community = CommunityId([0x77u8; 32]);
1332 for n in [0usize, 71, 73, 100, 105, 135] {
1334 assert!(matches!(
1335 parse_bound_base_plaintext(&vec![0u8; n], &community, Epoch(1)),
1336 Err(RekeyError::BadBaseBlobWidth(m)) if m == n
1337 ));
1338 }
1339 let control_root = [0x5Cu8; 32];
1341 let pk = control_signer_group_key(&control_root, &community, Epoch(3)).pk().to_bytes();
1342 let pt = bound_base_plaintext(Epoch(3), &[0xABu8; 32], &pk, None);
1343 assert!(matches!(parse_bound_base_plaintext(&pt, &community, Epoch(4)), Err(RekeyError::EpochSplice)));
1344 let mut channel_scoped = pt.clone();
1345 channel_scoped[..32].copy_from_slice(&[0x42u8; 32]);
1346 assert!(matches!(parse_bound_base_plaintext(&channel_scoped, &community, Epoch(3)), Err(RekeyError::ScopeSplice)));
1347 }
1348
1349 #[tokio::test]
1350 async fn base_blob_round_trips_member_and_staff_forms() {
1351 let rotator = keys(7);
1352 let member = keys(8);
1353 let staffer = keys(9);
1354 let community = CommunityId([0x77u8; 32]);
1355 let epoch = Epoch(2);
1356 let new_root = [0xEEu8; 32];
1357 let control_root = [0xDDu8; 32];
1358 let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1359
1360 let mb = build_base_blob(&as_signer(&rotator), &xonly(&rotator), &member.public_key(), epoch, &new_root, &control_pk, None).await.unwrap();
1361 let d = open_base_blob(&as_signer(&member), &rotator.public_key(), &community, epoch, &mb).await.unwrap();
1362 assert_eq!(d.new_root, new_root);
1363 assert_eq!(d.control_pk, Some(control_pk));
1364 assert_eq!(d.control_root, None, "a member blob never carries the secret");
1365
1366 let sb = build_base_blob(&as_signer(&rotator), &xonly(&rotator), &staffer.public_key(), epoch, &new_root, &control_pk, Some(&control_root)).await.unwrap();
1367 let d = open_base_blob(&as_signer(&staffer), &rotator.public_key(), &community, epoch, &sb).await.unwrap();
1368 assert_eq!(d.control_root, Some(control_root));
1369
1370 assert_eq!(mb.locator, blob_locator(&xonly(&rotator), &xonly(&member), RekeyScope::Root, epoch));
1372 let legacy = build_blob(&as_signer(&rotator), &xonly(&rotator), &member.public_key(), RekeyScope::Root, epoch, &new_root).await.unwrap();
1374 let d = open_base_blob(&as_signer(&member), &rotator.public_key(), &community, epoch, &legacy).await.unwrap();
1375 assert_eq!(d.new_root, new_root);
1376 assert_eq!(d.control_pk, None);
1377 }
1378
1379 #[test]
1380 fn a_future_wider_base_blob_degrades_instead_of_forking() {
1381 let community = CommunityId([0x77u8; 32]);
1385 let new_root = [0xABu8; 32];
1386 let control_root = [0x5Cu8; 32];
1387 let epoch = Epoch(3);
1388 let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1389
1390 let mut future = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&control_root));
1392 future.extend_from_slice(&[0x99u8; 32]);
1393 let d = parse_bound_base_plaintext(&future, &community, epoch).unwrap();
1394 assert_eq!(d.new_root, new_root);
1395 assert_eq!(d.control_pk, Some(control_pk));
1396 assert_eq!(d.control_root, Some(control_root), "held offsets still verify → full function");
1397
1398 let mut reordered = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&[0x44u8; 32]));
1401 reordered.extend_from_slice(&[0x99u8; 32]);
1402 let d = parse_bound_base_plaintext(&reordered, &community, epoch).unwrap();
1403 assert_eq!(d.new_root, new_root);
1404 assert_eq!(d.control_pk, Some(control_pk));
1405 assert_eq!(d.control_root, None, "an unverifiable secret is dropped, not adopted");
1406
1407 assert!(parse_bound_base_plaintext(&future, &community, Epoch(4)).is_err());
1409 }
1410
1411 #[test]
1412 fn control_wrap_layout_is_frozen_and_round_trips() {
1413 let pt = encode_control_wrap(Epoch(7), &[0xCDu8; 32]);
1414 assert_eq!(pt.len(), 40);
1415 let expected = format!("{}{}", "0000000000000007", "cd".repeat(32));
1416 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
1417 let (epoch, root) = parse_control_wrap(&pt).unwrap();
1418 assert_eq!(epoch, Epoch(7));
1419 assert_eq!(root, [0xCDu8; 32]);
1420 for n in [0usize, 39, 41, 72] {
1421 assert!(matches!(parse_control_wrap(&vec![0u8; n]), Err(RekeyError::BadControlWrapLength(m)) if m == n));
1422 }
1423 }
1424
1425 #[test]
1426 fn lowest_key_fork_winner_is_deterministic() {
1427 let keys_a = [[0x03u8; 32], [0x01u8; 32], [0x02u8; 32]];
1430 assert_eq!(lowest_key_winner(&keys_a), Some(1));
1431 let keys_b = [[0x01u8; 32], [0x02u8; 32], [0x03u8; 32]];
1432 assert_eq!(lowest_key_winner(&keys_b), Some(0));
1433 assert_eq!(lowest_key_winner(&[]), None);
1434 }
1435}