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";
72const TAG_SEVER: &str = "sever";
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum RekeyScope {
82 Channel(ChannelId),
84 Root,
87}
88
89impl RekeyScope {
90 pub fn id32(&self) -> [u8; 32] {
92 match self {
93 RekeyScope::Channel(c) => c.0,
94 RekeyScope::Root => [0u8; 32],
95 }
96 }
97
98 fn to_hex(self) -> String {
99 crate::simd::hex::bytes_to_hex_32(&self.id32())
100 }
101
102 fn from_hex(hex: &str) -> Option<RekeyScope> {
103 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
104 return None;
105 }
106 let bytes = crate::simd::hex::hex_to_bytes_32(hex);
107 Some(if bytes == [0u8; 32] {
108 RekeyScope::Root
109 } else {
110 RekeyScope::Channel(ChannelId(bytes))
111 })
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct RekeyBlob {
122 pub locator: String,
123 pub wrapped: String,
124}
125
126#[derive(Debug)]
128pub enum RekeyError {
129 Stream(StreamError),
130 Crypto(String),
131 BadBlobLength(usize),
133 BadBaseBlobWidth(usize),
136 ControlPairMismatch,
140 BadControlWrapLength(usize),
142 ScopeSplice,
144 EpochSplice,
146 NotARekey(u16),
148 BadTag(&'static str),
150 NonMonotonicEpoch,
152 BadChunkIndex,
154 TooManyBlobs(usize),
156}
157
158impl std::fmt::Display for RekeyError {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 match self {
161 RekeyError::Stream(e) => write!(f, "stream: {e}"),
162 RekeyError::Crypto(e) => write!(f, "crypto: {e}"),
163 RekeyError::BadBlobLength(n) => write!(f, "rekey blob plaintext is {n} bytes, expected 72"),
164 RekeyError::BadBaseBlobWidth(n) => write!(f, "base rekey blob plaintext is {n} bytes, expected 72, 104 or 136"),
165 RekeyError::ControlPairMismatch => write!(f, "base rekey blob control_root does not derive to its control_pk"),
166 RekeyError::BadControlWrapLength(n) => write!(f, "control_wrap plaintext is {n} bytes, expected 40"),
167 RekeyError::ScopeSplice => write!(f, "rekey blob scope binding mismatch (splice)"),
168 RekeyError::EpochSplice => write!(f, "rekey blob epoch binding mismatch (splice)"),
169 RekeyError::NotARekey(k) => write!(f, "rumor kind {k} is not a rekey"),
170 RekeyError::BadTag(t) => write!(f, "missing/duplicate/malformed rekey tag: {t}"),
171 RekeyError::NonMonotonicEpoch => write!(f, "rekey new_epoch must exceed prev_epoch"),
172 RekeyError::BadChunkIndex => write!(f, "rekey chunk index out of range"),
173 RekeyError::TooManyBlobs(n) => write!(f, "rekey carries {n} blobs, over the cap"),
174 }
175 }
176}
177
178impl std::error::Error for RekeyError {}
179
180impl From<StreamError> for RekeyError {
181 fn from(e: StreamError) -> Self {
182 RekeyError::Stream(e)
183 }
184}
185
186fn bound_plaintext(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> [u8; 72] {
191 let mut pt = [0u8; 72];
192 pt[..32].copy_from_slice(&scope.id32());
193 pt[32..40].copy_from_slice(&epoch.0.to_be_bytes());
194 pt[40..].copy_from_slice(new_key);
195 pt
196}
197
198pub fn bound_plaintext_b64(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> String {
201 base64_simd::STANDARD.encode_to_string(bound_plaintext(scope, epoch, new_key))
202}
203
204pub fn parse_bound_plaintext(pt: &[u8], scope: RekeyScope, epoch: Epoch) -> Result<[u8; 32], RekeyError> {
208 if pt.len() != 72 {
209 return Err(RekeyError::BadBlobLength(pt.len()));
210 }
211 if pt[..32] != scope.id32() {
212 return Err(RekeyError::ScopeSplice);
213 }
214 let mut epoch_be = [0u8; 8];
215 epoch_be.copy_from_slice(&pt[32..40]);
216 if u64::from_be_bytes(epoch_be) != epoch.0 {
217 return Err(RekeyError::EpochSplice);
218 }
219 let mut new_key = [0u8; 32];
220 new_key.copy_from_slice(&pt[40..72]);
221 Ok(new_key)
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct BaseKeyDelivery {
236 pub new_root: [u8; 32],
237 pub control_pk: Option<[u8; 32]>,
241 pub control_root: Option<[u8; 32]>,
244}
245
246fn bound_base_plaintext(epoch: Epoch, new_root: &[u8; 32], control_pk: &[u8; 32], control_root: Option<&[u8; 32]>) -> Vec<u8> {
247 let mut pt = Vec::with_capacity(if control_root.is_some() { 136 } else { 104 });
248 pt.extend_from_slice(&bound_plaintext(RekeyScope::Root, epoch, new_root));
249 pt.extend_from_slice(control_pk);
250 if let Some(cr) = control_root {
251 pt.extend_from_slice(cr);
252 }
253 pt
254}
255
256pub fn bound_base_plaintext_b64(epoch: Epoch, new_root: &[u8; 32], control_pk: &[u8; 32], control_root: Option<&[u8; 32]>) -> String {
259 base64_simd::STANDARD.encode_to_string(bound_base_plaintext(epoch, new_root, control_pk, control_root))
260}
261
262pub fn parse_bound_base_plaintext(pt: &[u8], community_id: &CommunityId, epoch: Epoch) -> Result<BaseKeyDelivery, RekeyError> {
279 if !matches!(pt.len(), 72 | 104 | 136) && pt.len() < 137 {
280 return Err(RekeyError::BadBaseBlobWidth(pt.len()));
281 }
282 let new_root = parse_bound_plaintext(&pt[..72], RekeyScope::Root, epoch)?;
283 if pt.len() == 72 {
284 return Ok(BaseKeyDelivery { new_root, control_pk: None, control_root: None });
285 }
286 let mut control_pk = [0u8; 32];
287 control_pk.copy_from_slice(&pt[72..104]);
288 if pt.len() == 104 {
289 return Ok(BaseKeyDelivery { new_root, control_pk: Some(control_pk), control_root: None });
290 }
291 let mut control_root = [0u8; 32];
292 control_root.copy_from_slice(&pt[104..136]);
293 if control_signer_group_key(&control_root, community_id, epoch).pk().to_bytes() != control_pk {
294 if pt.len() == 136 {
295 return Err(RekeyError::ControlPairMismatch);
296 }
297 return Ok(BaseKeyDelivery { new_root, control_pk: Some(control_pk), control_root: None });
300 }
301 Ok(BaseKeyDelivery { new_root, control_pk: Some(control_pk), control_root: Some(control_root) })
302}
303
304pub async fn build_base_blob<S: crate::signer::VectorSigner + ?Sized>(
308 signer: &S,
309 rotator_xonly: &[u8; 32],
310 recipient_pk: &PublicKey,
311 epoch: Epoch,
312 new_root: &[u8; 32],
313 control_pk: &[u8; 32],
314 control_root: Option<&[u8; 32]>,
315) -> Result<RekeyBlob, RekeyError> {
316 let inner_b64 = Zeroizing::new(bound_base_plaintext_b64(epoch, new_root, control_pk, control_root));
317 let wrapped = signer
318 .nip44_encrypt_async(recipient_pk, inner_b64.as_str())
319 .await
320 .map_err(|e| RekeyError::Crypto(e.to_string()))?;
321 Ok(RekeyBlob {
322 locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), RekeyScope::Root, epoch),
323 wrapped,
324 })
325}
326
327pub async fn open_base_blob<S: crate::signer::VectorSigner + ?Sized>(
331 signer: &S,
332 rotator_pk: &PublicKey,
333 community_id: &CommunityId,
334 epoch: Epoch,
335 blob: &RekeyBlob,
336) -> Result<BaseKeyDelivery, RekeyError> {
337 let inner_b64 = Zeroizing::new(
338 signer
339 .nip44_decrypt_async(rotator_pk, &blob.wrapped)
340 .await
341 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
342 );
343 let pt = Zeroizing::new(
344 base64_simd::STANDARD
345 .decode_to_vec(inner_b64.as_bytes())
346 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
347 );
348 parse_bound_base_plaintext(&pt, community_id, epoch)
349}
350
351pub fn encode_control_wrap(epoch: Epoch, control_root: &[u8; 32]) -> [u8; 40] {
357 let mut pt = [0u8; 40];
358 pt[..8].copy_from_slice(&epoch.0.to_be_bytes());
359 pt[8..].copy_from_slice(control_root);
360 pt
361}
362
363pub fn control_wrap_b64(epoch: Epoch, control_root: &[u8; 32]) -> String {
366 base64_simd::STANDARD.encode_to_string(encode_control_wrap(epoch, control_root))
367}
368
369pub fn parse_control_wrap(pt: &[u8]) -> Result<(Epoch, [u8; 32]), RekeyError> {
373 if pt.len() != 40 {
374 return Err(RekeyError::BadControlWrapLength(pt.len()));
375 }
376 let mut epoch_be = [0u8; 8];
377 epoch_be.copy_from_slice(&pt[..8]);
378 let mut control_root = [0u8; 32];
379 control_root.copy_from_slice(&pt[8..]);
380 Ok((Epoch(u64::from_be_bytes(epoch_be)), control_root))
381}
382
383pub fn blob_locator(rotator_xonly: &[u8; 32], recipient_xonly: &[u8; 32], scope: RekeyScope, epoch: Epoch) -> String {
386 crate::simd::hex::bytes_to_hex_32(&recipient_locator(rotator_xonly, recipient_xonly, &scope.id32(), epoch))
387}
388
389pub fn build_blob_local(
394 rotator_sk: &SecretKey,
395 rotator_xonly: &[u8; 32],
396 recipient_pk: &PublicKey,
397 scope: RekeyScope,
398 epoch: Epoch,
399 new_key: &[u8; 32],
400) -> Result<RekeyBlob, RekeyError> {
401 let inner_b64 = Zeroizing::new(bound_plaintext_b64(scope, epoch, new_key));
402 let ck = ConversationKey::derive(rotator_sk, recipient_pk).map_err(|e| RekeyError::Crypto(e.to_string()))?;
403 let payload = crate::community::cipher::encrypt_with_random_nonce(&ck, inner_b64.as_bytes()).map_err(|e| RekeyError::Crypto(e.to_string()))?;
404 Ok(RekeyBlob {
405 locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), scope, epoch),
406 wrapped: base64_simd::STANDARD.encode_to_string(&payload),
407 })
408}
409
410pub fn open_blob_local(
416 my_sk: &SecretKey,
417 rotator_pk: &PublicKey,
418 scope: RekeyScope,
419 epoch: Epoch,
420 blob: &RekeyBlob,
421) -> Result<[u8; 32], RekeyError> {
422 let ck = ConversationKey::derive(my_sk, rotator_pk).map_err(|e| RekeyError::Crypto(e.to_string()))?;
423 let payload = base64_simd::STANDARD
424 .decode_to_vec(blob.wrapped.as_bytes())
425 .map_err(|e| RekeyError::Crypto(e.to_string()))?;
426 let inner_b64 = Zeroizing::new(decrypt_to_bytes(&ck, &payload).map_err(|e| RekeyError::Crypto(e.to_string()))?);
427 let pt = Zeroizing::new(
428 base64_simd::STANDARD
429 .decode_to_vec(inner_b64.as_slice())
430 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
431 );
432 parse_bound_plaintext(&pt, scope, epoch)
433}
434
435pub async fn build_blob<S: crate::signer::VectorSigner + ?Sized>(
441 signer: &S,
442 rotator_xonly: &[u8; 32],
443 recipient_pk: &PublicKey,
444 scope: RekeyScope,
445 epoch: Epoch,
446 new_key: &[u8; 32],
447) -> Result<RekeyBlob, RekeyError> {
448 let inner_b64 = Zeroizing::new(bound_plaintext_b64(scope, epoch, new_key));
449 let wrapped = signer
450 .nip44_encrypt_async(recipient_pk, inner_b64.as_str())
451 .await
452 .map_err(|e| RekeyError::Crypto(e.to_string()))?;
453 Ok(RekeyBlob {
454 locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), scope, epoch),
455 wrapped,
456 })
457}
458
459pub async fn open_blob<S: crate::signer::VectorSigner + ?Sized>(
463 signer: &S,
464 rotator_pk: &PublicKey,
465 scope: RekeyScope,
466 epoch: Epoch,
467 blob: &RekeyBlob,
468) -> Result<[u8; 32], RekeyError> {
469 let inner_b64 = Zeroizing::new(
470 signer
471 .nip44_decrypt_async(rotator_pk, &blob.wrapped)
472 .await
473 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
474 );
475 let pt = Zeroizing::new(
476 base64_simd::STANDARD
477 .decode_to_vec(inner_b64.as_bytes())
478 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
479 );
480 parse_bound_plaintext(&pt, scope, epoch)
481}
482
483pub fn find_my_blob<'a>(
487 blobs: &'a [RekeyBlob],
488 rotator_xonly: &[u8; 32],
489 my_xonly: &[u8; 32],
490 scope: RekeyScope,
491 epoch: Epoch,
492) -> Option<&'a RekeyBlob> {
493 find_my_blobs(blobs, rotator_xonly, my_xonly, scope, epoch).next()
494}
495
496pub fn find_my_blobs<'a>(
502 blobs: &'a [RekeyBlob],
503 rotator_xonly: &[u8; 32],
504 my_xonly: &[u8; 32],
505 scope: RekeyScope,
506 epoch: Epoch,
507) -> impl Iterator<Item = &'a RekeyBlob> {
508 let want = blob_locator(rotator_xonly, my_xonly, scope, epoch);
509 blobs.iter().filter(move |b| b.locator == want)
510}
511
512#[derive(Debug, Clone)]
517pub struct RekeyChunk {
518 pub rotator: PublicKey,
519 pub scope: RekeyScope,
520 pub new_epoch: Epoch,
521 pub prev_epoch: Epoch,
522 pub prev_commit: [u8; 32],
523 pub chunk: (u32, u32),
525 pub blobs: Vec<RekeyBlob>,
526 pub citation: Option<crate::community::edition::AuthorityCitation>,
529 pub severed: bool,
531}
532
533pub type RotationKey = ([u8; 32], [u8; 32], u64, [u8; 32]);
537
538impl RekeyChunk {
539 pub fn correlation(&self) -> RotationKey {
541 (self.rotator.to_bytes(), self.scope.id32(), self.new_epoch.0, self.prev_commit)
542 }
543}
544
545#[allow(clippy::too_many_arguments)]
548pub fn build_rekey_rumor(
549 rotator: PublicKey,
550 scope: RekeyScope,
551 new_epoch: Epoch,
552 prev_epoch: Epoch,
553 prev_commit: &[u8; 32],
554 blobs: &[RekeyBlob],
555 chunk_i: u32,
556 chunk_n: u32,
557 at_secs: u64,
558 citation: Option<&crate::community::edition::AuthorityCitation>,
559 severed: bool,
560) -> Result<UnsignedEvent, RekeyError> {
561 if new_epoch.0 <= prev_epoch.0 {
562 return Err(RekeyError::NonMonotonicEpoch);
563 }
564 if chunk_n < 1 || chunk_i < 1 || chunk_i > chunk_n {
565 return Err(RekeyError::BadChunkIndex);
566 }
567 if blobs.len() > MAX_REKEY_BLOBS_PER_EVENT {
568 return Err(RekeyError::TooManyBlobs(blobs.len()));
569 }
570 let content = serde_json::to_string(blobs).map_err(|e| RekeyError::Crypto(e.to_string()))?;
571 let mut tags = vec![
572 Tag::custom(TAG_SCOPE, [scope.to_hex()]),
573 Tag::custom(TAG_NEW_EPOCH, [new_epoch.0.to_string()]),
574 Tag::custom(TAG_PREV_EPOCH, [prev_epoch.0.to_string()]),
575 Tag::custom(TAG_PREV_COMMIT, [crate::simd::hex::bytes_to_hex_32(prev_commit)]),
576 Tag::custom(TAG_CHUNK, [chunk_i.to_string(), chunk_n.to_string()]),
577 ];
578 if let Some(c) = citation {
582 tags.push(c.to_tag());
583 }
584 if severed {
585 tags.push(Tag::custom(TAG_SEVER, ["1"]));
586 }
587 Ok(stream::build_rumor_secs(super::kind::REKEY, rotator, &content, tags, at_secs))
589}
590
591pub fn channel_rekey_group(addressing_root: &[u8; 32], channel_id: &ChannelId, new_epoch: Epoch) -> GroupKey {
598 channel_rekey_group_key(addressing_root, channel_id, new_epoch)
599}
600
601pub fn base_rekey_group(prior_root: &[u8; 32], community_id: &CommunityId, new_epoch: Epoch) -> GroupKey {
604 base_rekey_group_key(prior_root, community_id, new_epoch)
605}
606
607pub fn seal_rekey_chunk(
611 rumor: &UnsignedEvent,
612 rekey_group: &GroupKey,
613 rotator_keys: &Keys,
614 wrap_at: Timestamp,
615) -> Result<(Event, Keys), RekeyError> {
616 let seal = stream::build_seal(rumor, SealForm::Encrypted, rekey_group, rotator_keys)?;
617 Ok(stream::wrap_seal(&seal, rekey_group, stream::KIND_WRAP, wrap_at)?)
618}
619
620#[allow(clippy::too_many_arguments)]
624pub fn build_rekey_chunks_local(
625 rotator_keys: &Keys,
626 rekey_group: &GroupKey,
627 scope: RekeyScope,
628 new_epoch: Epoch,
629 prev_epoch: Epoch,
630 prev_commit: &[u8; 32],
631 blobs: &[RekeyBlob],
632 at_secs: u64,
633 citation: Option<&crate::community::edition::AuthorityCitation>,
634 severed: bool,
635) -> Result<Vec<Event>, RekeyError> {
636 let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
637 vec![&[]]
638 } else {
639 blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
640 };
641 let n = groups.len() as u32;
642 let mut out = Vec::with_capacity(groups.len());
643 for (idx, group_blobs) in groups.iter().enumerate() {
644 let rumor = build_rekey_rumor(
645 rotator_keys.public_key(),
646 scope,
647 new_epoch,
648 prev_epoch,
649 prev_commit,
650 group_blobs,
651 idx as u32 + 1,
652 n,
653 at_secs,
654 citation,
655 severed,
656 )?;
657 let (wrap, _) = seal_rekey_chunk(&rumor, rekey_group, rotator_keys, Timestamp::from_secs(at_secs))?;
658 out.push(wrap);
659 }
660 Ok(out)
661}
662
663#[allow(clippy::too_many_arguments)]
667pub async fn build_rekey_chunks<S: crate::signer::VectorSigner + ?Sized>(
668 signer: &S,
669 rotator_pk: PublicKey,
670 rekey_group: &GroupKey,
671 scope: RekeyScope,
672 new_epoch: Epoch,
673 prev_epoch: Epoch,
674 prev_commit: &[u8; 32],
675 blobs: &[RekeyBlob],
676 at_secs: u64,
677 citation: Option<&crate::community::edition::AuthorityCitation>,
678 severed: bool,
679) -> Result<Vec<Event>, RekeyError> {
680 let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
681 vec![&[]]
682 } else {
683 blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
684 };
685 let n = groups.len() as u32;
686 let mut out = Vec::with_capacity(groups.len());
687 for (idx, group_blobs) in groups.iter().enumerate() {
688 let rumor = build_rekey_rumor(rotator_pk, scope, new_epoch, prev_epoch, prev_commit, group_blobs, idx as u32 + 1, n, at_secs, citation, severed)?;
689 let (wrap, _) = stream::seal_and_wrap_signed(signer, rotator_pk, &rumor, SealForm::Encrypted, rekey_group, stream::KIND_WRAP, Timestamp::from_secs(at_secs), &[]).await?;
690 out.push(wrap);
691 }
692 Ok(out)
693}
694
695pub async fn build_rekey_chunk_extension<S: crate::signer::VectorSigner + ?Sized>(
706 signer: &S,
707 rotator_pk: PublicKey,
708 rekey_group: &GroupKey,
709 scope: RekeyScope,
710 new_epoch: Epoch,
711 prev_epoch: Epoch,
712 prev_commit: &[u8; 32],
713 blobs: &[RekeyBlob],
714 at_secs: u64,
715 citation: Option<&crate::community::edition::AuthorityCitation>,
716 severed: bool,
717 start_after: u32,
718) -> Result<Vec<Event>, RekeyError> {
719 let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
720 vec![&[]]
721 } else {
722 blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
723 };
724 let n = start_after + groups.len() as u32;
725 let mut out = Vec::with_capacity(groups.len());
726 for (idx, group_blobs) in groups.iter().enumerate() {
727 let i = start_after + idx as u32 + 1;
728 let rumor = build_rekey_rumor(rotator_pk, scope, new_epoch, prev_epoch, prev_commit, group_blobs, i, n, at_secs, citation, severed)?;
729 let (wrap, _) = stream::seal_and_wrap_signed(signer, rotator_pk, &rumor, SealForm::Encrypted, rekey_group, stream::KIND_WRAP, Timestamp::from_secs(at_secs), &[]).await?;
730 out.push(wrap);
731 }
732 Ok(out)
733}
734
735pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError> {
739 if opened.seal_form != SealForm::Encrypted {
740 return Err(RekeyError::Stream(StreamError::BadSealKind(stream::KIND_SEAL_PLAINTEXT)));
742 }
743 let rumor = &opened.rumor;
744 if rumor.kind.as_u16() != super::kind::REKEY {
745 return Err(RekeyError::NotARekey(rumor.kind.as_u16()));
746 }
747 let scope = RekeyScope::from_hex(&unique_tag(rumor, TAG_SCOPE)?.ok_or(RekeyError::BadTag(TAG_SCOPE))?)
748 .ok_or(RekeyError::BadTag(TAG_SCOPE))?;
749 let new_epoch = Epoch(parse_u64(rumor, TAG_NEW_EPOCH)?);
750 let prev_epoch = Epoch(parse_u64(rumor, TAG_PREV_EPOCH)?);
751 if new_epoch.0 <= prev_epoch.0 {
752 return Err(RekeyError::NonMonotonicEpoch);
753 }
754 let prev_hex = unique_tag(rumor, TAG_PREV_COMMIT)?.ok_or(RekeyError::BadTag(TAG_PREV_COMMIT))?;
755 if prev_hex.len() != 64 || !prev_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
756 return Err(RekeyError::BadTag(TAG_PREV_COMMIT));
757 }
758 let prev_commit = crate::simd::hex::hex_to_bytes_32(&prev_hex);
759
760 let (chunk_i, chunk_n) = parse_chunk(rumor)?;
761
762 let blobs: Vec<RekeyBlob> = serde_json::from_str(&rumor.content).map_err(|_| RekeyError::BadTag("blobs"))?;
763 if blobs.len() > MAX_REKEY_BLOBS_RECEIVED {
764 return Err(RekeyError::TooManyBlobs(blobs.len()));
765 }
766
767 let severed = match unique_tag(rumor, TAG_SEVER)?.as_deref() {
769 None => false,
770 Some("1") => true,
771 Some(_) => return Err(RekeyError::BadTag(TAG_SEVER)),
772 };
773
774 Ok(RekeyChunk {
775 rotator: opened.author,
776 scope,
777 new_epoch,
778 prev_epoch,
779 prev_commit,
780 chunk: (chunk_i, chunk_n),
781 blobs,
782 citation: crate::community::edition::AuthorityCitation::from_tags(&rumor.tags),
783 severed,
784 })
785}
786
787#[derive(Debug, Clone, Copy, PartialEq, Eq)]
791pub enum Continuity {
792 Extends,
795 Gap,
798 Fork,
800}
801
802pub fn check_continuity(chunk: &RekeyChunk, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
806 if chunk.prev_epoch.0 == held_epoch.0 {
807 if epoch_key_commitment(held_epoch, held_key) == chunk.prev_commit {
808 Continuity::Extends
809 } else {
810 Continuity::Fork
811 }
812 } else if chunk.prev_epoch.0 > held_epoch.0 {
813 Continuity::Gap
814 } else {
815 Continuity::Fork
818 }
819}
820
821#[derive(Debug, Clone)]
824pub struct Rotation {
825 pub rotator: PublicKey,
826 pub scope: RekeyScope,
827 pub new_epoch: Epoch,
828 pub prev_epoch: Epoch,
829 pub prev_commit: [u8; 32],
830 pub blobs: Vec<RekeyBlob>,
832 pub declared_chunks: u32,
834 pub held_chunks: std::collections::BTreeSet<u32>,
836 pub citation: Option<crate::community::edition::AuthorityCitation>,
839 pub severed: bool,
842}
843
844impl Rotation {
845 pub fn is_complete(&self) -> bool {
849 self.declared_chunks >= 1 && (1..=self.declared_chunks).all(|i| self.held_chunks.contains(&i))
850 }
851
852 pub fn continuity(&self, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
856 if self.prev_epoch.0 == held_epoch.0 {
857 if epoch_key_commitment(held_epoch, held_key) == self.prev_commit {
858 Continuity::Extends
859 } else {
860 Continuity::Fork
861 }
862 } else if self.prev_epoch.0 > held_epoch.0 {
863 Continuity::Gap
864 } else {
865 Continuity::Fork
866 }
867 }
868}
869
870pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec<Rotation> {
875 use std::collections::BTreeMap;
876 let mut by_key: BTreeMap<RotationKey, Rotation> = BTreeMap::new();
877 for c in chunks {
878 let entry = by_key.entry(c.correlation()).or_insert_with(|| Rotation {
879 rotator: c.rotator,
880 scope: c.scope,
881 new_epoch: c.new_epoch,
882 prev_epoch: c.prev_epoch,
883 prev_commit: c.prev_commit,
884 blobs: Vec::new(),
885 declared_chunks: c.chunk.1,
886 held_chunks: std::collections::BTreeSet::new(),
887 citation: c.citation.clone(),
888 severed: c.severed,
889 });
890 entry.severed |= c.severed;
891 entry.held_chunks.insert(c.chunk.0);
900 for b in &c.blobs {
901 if !entry.blobs.iter().any(|held| held.locator == b.locator && held.wrapped == b.wrapped) {
902 entry.blobs.push(b.clone());
903 }
904 }
905 }
906 by_key.into_values().collect()
907}
908
909pub fn am_i_removed(rotation: &Rotation, my_xonly: &[u8; 32]) -> Option<bool> {
913 if !rotation.is_complete() {
914 return None;
915 }
916 let mine = find_my_blob(&rotation.blobs, &rotation.rotator.to_bytes(), my_xonly, rotation.scope, rotation.new_epoch);
917 Some(mine.is_none())
918}
919
920pub fn lowest_key_winner(candidate_keys: &[[u8; 32]]) -> Option<usize> {
926 candidate_keys
927 .iter()
928 .enumerate()
929 .min_by(|(_, a), (_, b)| a.cmp(b))
930 .map(|(i, _)| i)
931}
932
933fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, RekeyError> {
936 let mut found: Option<String> = None;
937 for t in rumor.tags.iter() {
938 let s = t.as_slice();
939 if s.len() >= 2 && s[0] == name {
940 if found.is_some() {
941 return Err(RekeyError::BadTag(name));
942 }
943 found = Some(s[1].clone());
944 }
945 }
946 Ok(found)
947}
948
949fn parse_u64(rumor: &UnsignedEvent, name: &'static str) -> Result<u64, RekeyError> {
950 let raw = unique_tag(rumor, name)?.ok_or(RekeyError::BadTag(name))?;
951 if !crate::community::edition::is_tag_decimal(&raw) {
954 return Err(RekeyError::BadTag(name));
955 }
956 raw.parse::<u64>().map_err(|_| RekeyError::BadTag(name))
957}
958
959fn parse_chunk(rumor: &UnsignedEvent) -> Result<(u32, u32), RekeyError> {
960 let mut found: Option<(u32, u32)> = None;
961 for t in rumor.tags.iter() {
962 let s = t.as_slice();
963 if s.len() >= 3 && s[0] == TAG_CHUNK {
964 if found.is_some() {
965 return Err(RekeyError::BadTag(TAG_CHUNK));
966 }
967 if !crate::community::edition::is_tag_decimal(&s[1]) || !crate::community::edition::is_tag_decimal(&s[2]) {
968 return Err(RekeyError::BadTag(TAG_CHUNK));
969 }
970 let i: u32 = s[1].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
971 let n: u32 = s[2].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
972 found = Some((i, n));
973 }
974 }
975 let (i, n) = found.ok_or(RekeyError::BadTag(TAG_CHUNK))?;
976 if n < 1 || i < 1 || i > n {
977 return Err(RekeyError::BadChunkIndex);
978 }
979 Ok((i, n))
980}
981
982#[cfg(test)]
983mod tests {
984
985 fn as_signer(k: &Keys) -> crate::signer::ActiveSigner {
989 crate::signer::ActiveSigner::Keys(k.clone())
990 }
991 use super::*;
992
993 fn keys(byte: u8) -> Keys {
994 Keys::new(SecretKey::from_slice(&[byte; 32]).unwrap())
995 }
996
997 fn xonly(k: &Keys) -> [u8; 32] {
998 k.public_key().to_bytes()
999 }
1000
1001 const CHAN: ChannelId = ChannelId([0x42u8; 32]);
1002
1003 #[test]
1006 fn bound_plaintext_layout_is_frozen() {
1007 let pt = bound_plaintext(RekeyScope::Root, Epoch(1), &[0xABu8; 32]);
1008 let expected = format!("{}{}{}", "00".repeat(32), "0000000000000001", "ab".repeat(32));
1009 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
1010 let pt2 = bound_plaintext(RekeyScope::Channel(ChannelId([0x11u8; 32])), Epoch(0x0102), &[0xCDu8; 32]);
1011 let expected2 = format!("{}{}{}", "11".repeat(32), "0000000000000102", "cd".repeat(32));
1012 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt2), expected2);
1013 }
1014
1015 #[test]
1016 fn blob_round_trips_both_scopes() {
1017 let rotator = keys(7);
1018 let recipient = keys(8);
1019 for (scope, epoch, key) in [
1020 (RekeyScope::Root, Epoch(1), [0xABu8; 32]),
1021 (RekeyScope::Channel(CHAN), Epoch(5), [0xCDu8; 32]),
1022 ] {
1023 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
1024 let got = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob).unwrap();
1025 assert_eq!(got, key, "the recipient recovers the fresh key");
1026 }
1027 }
1028
1029 #[tokio::test]
1030 async fn signer_blob_is_wire_compatible_with_local_both_directions() {
1031 let rotator = keys(7);
1035 let recipient = keys(8);
1036 let scope = RekeyScope::Root;
1037 let epoch = Epoch(3);
1038 let key = [0x5Au8; 32];
1039
1040 let blob_l = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
1042 let got_s = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_l).await.unwrap();
1043 assert_eq!(got_s, key, "signer opens a local-built blob");
1044
1045 let blob_s = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).await.unwrap();
1047 assert_eq!(blob_s.locator, blob_l.locator, "same public locator regardless of build path");
1048 let got_l = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob_s).unwrap();
1049 assert_eq!(got_l, key, "local opens a signer-built blob");
1050
1051 let got_ss = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_s).await.unwrap();
1053 assert_eq!(got_ss, key, "signer round-trips its own blob");
1054 }
1055
1056 #[tokio::test]
1057 async fn signer_blob_bound_check_still_gates_scope_epoch_splice() {
1058 let rotator = keys(7);
1059 let recipient = keys(8);
1060 let blob = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[9u8; 32]).await.unwrap();
1061 assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob).await.is_err(), "epoch splice rejected");
1062 assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob).await.is_err(), "scope splice rejected");
1063 }
1064
1065 #[test]
1066 fn locator_is_public_and_computable_from_pubkeys_alone() {
1067 let rotator = keys(7);
1071 let recipient = keys(8);
1072 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(2), &[1u8; 32]).unwrap();
1073 let recomputed = blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(2));
1074 assert_eq!(blob.locator, recomputed, "both sides compute the same public locator");
1075 }
1076
1077 #[test]
1078 fn a_non_recipient_cannot_open_even_holding_the_public_locator() {
1079 let rotator = keys(7);
1083 let recipient = keys(8);
1084 let outsider = keys(9);
1085 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[2u8; 32]).unwrap();
1086 assert_eq!(blob.locator, blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)));
1088 assert!(open_blob_local(outsider.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).is_err());
1091 }
1092
1093 #[test]
1094 fn a_relocated_blob_still_opens_by_decrypt_not_locator() {
1095 let rotator = keys(7);
1100 let recipient = keys(8);
1101 let mut blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[3u8; 32]).unwrap();
1102 blob.locator = "ff".repeat(32);
1103 assert_eq!(
1105 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).unwrap(),
1106 [3u8; 32]
1107 );
1108 assert!(find_my_blob(std::slice::from_ref(&blob), &xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)).is_none());
1110 }
1111
1112 #[test]
1113 fn scope_and_epoch_splices_are_rejected_on_open() {
1114 let rotator = keys(7);
1115 let recipient = keys(8);
1116 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[4u8; 32]).unwrap();
1117 assert!(matches!(
1119 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob),
1120 Err(RekeyError::ScopeSplice)
1121 ));
1122 assert!(matches!(
1124 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob),
1125 Err(RekeyError::EpochSplice)
1126 ));
1127 }
1128
1129 #[test]
1130 fn wrapped_carries_base64_of_the_72_bytes_for_bunker_parity() {
1131 let rotator = keys(7);
1135 let recipient = keys(8);
1136 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[5u8; 32]).unwrap();
1137 let ck = ConversationKey::derive(recipient.secret_key(), &rotator.public_key()).unwrap();
1138 let payload = base64_simd::STANDARD.decode_to_vec(blob.wrapped.as_bytes()).unwrap();
1139 let inner = decrypt_to_bytes(&ck, &payload).unwrap();
1140 assert_eq!(String::from_utf8(inner).unwrap(), bound_plaintext_b64(RekeyScope::Root, Epoch(1), &[5u8; 32]));
1141 }
1142
1143 #[test]
1144 fn distinct_recipients_and_scopes_get_distinct_locators() {
1145 let rotator = keys(7);
1146 let r1 = keys(8);
1147 let r2 = keys(9);
1148 assert_ne!(
1149 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
1150 blob_locator(&xonly(&rotator), &xonly(&r2), RekeyScope::Root, Epoch(1))
1151 );
1152 assert_ne!(
1154 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
1155 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Channel(CHAN), Epoch(1))
1156 );
1157 }
1158
1159 fn root() -> [u8; 32] {
1162 [0x55u8; 32]
1163 }
1164
1165 #[test]
1166 fn channel_rekey_round_trips_through_the_stream() {
1167 let rotator = keys(1);
1168 let recipient = keys(8);
1169 let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
1170 let key = [0xABu8; 32];
1171 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &key).unwrap();
1172 let commit = epoch_key_commitment(Epoch(0), &[0xEEu8; 32]);
1173 let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &commit, &[blob.clone()], 1, 1, 100, None, false).unwrap();
1174 let (wrap, _) = seal_rekey_chunk(&rumor, &group, &rotator, Timestamp::from_secs(100)).unwrap();
1175
1176 assert_ne!(wrap.pubkey, rotator.public_key());
1178 assert_eq!(wrap.pubkey, group.pk());
1179
1180 let opened = stream::open_wrap(&wrap, &group).unwrap();
1181 let chunk = parse_rekey_chunk(&opened).unwrap();
1182 assert_eq!(chunk.rotator, rotator.public_key(), "rotator recovered from the seal");
1183 assert!(matches!(chunk.scope, RekeyScope::Channel(c) if c.0 == CHAN.0));
1184 assert_eq!(chunk.new_epoch, Epoch(1));
1185 assert_eq!(chunk.prev_epoch, Epoch(0));
1186 assert_eq!(chunk.prev_commit, commit);
1187 assert_eq!(chunk.chunk, (1, 1));
1188 assert_eq!(chunk.blobs, vec![blob]);
1189 }
1190
1191 #[test]
1192 fn base_rekey_addresses_under_the_prior_root() {
1193 let rotator = keys(1);
1196 let recipient = keys(8);
1197 let prior_root = [0x66u8; 32];
1198 let community = CommunityId([0x77u8; 32]);
1199 let new_root = [0x99u8; 32];
1200 let group = base_rekey_group(&prior_root, &community, Epoch(1));
1201 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &new_root).unwrap();
1202 let commit = epoch_key_commitment(Epoch(0), &prior_root);
1203 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &commit, &[blob], 100, None, false).unwrap();
1204 assert_eq!(chunks.len(), 1);
1205
1206 let opened = stream::open_wrap(&chunks[0], &group).unwrap();
1207 let chunk = parse_rekey_chunk(&opened).unwrap();
1208 assert!(matches!(chunk.scope, RekeyScope::Root));
1209 let mine = find_my_blob(&chunk.blobs, &chunk.rotator.to_bytes(), &xonly(&recipient), chunk.scope, chunk.new_epoch).unwrap();
1211 assert_eq!(open_blob_local(recipient.secret_key(), &chunk.rotator, chunk.scope, chunk.new_epoch, mine).unwrap(), new_root);
1212
1213 let wrong = base_rekey_group(&[0u8; 32], &community, Epoch(1));
1215 assert!(stream::open_wrap(&chunks[0], &wrong).is_err());
1216 }
1217
1218 #[test]
1219 fn a_full_send_chunk_stays_under_the_relay_size_limit() {
1220 let rotator = keys(1);
1223 let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
1224 let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT)
1225 .map(|i| {
1226 let r = keys((i % 200 + 20) as u8);
1227 build_blob_local(rotator.secret_key(), &xonly(&rotator), &r.public_key(), RekeyScope::Root, Epoch(1), &[0xCDu8; 32]).unwrap()
1228 })
1229 .collect();
1230 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &blobs, 100, None, false).unwrap();
1231 assert_eq!(chunks.len(), 1, "a full send chunk is exactly one event");
1232 assert!(chunks[0].as_json().len() <= 65_536, "a full chunk must fit a 64KB relay event");
1233 }
1234
1235 #[test]
1236 fn oversize_recipient_set_splits_into_chunks() {
1237 let rotator = keys(1);
1238 let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
1239 let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT + 1)
1241 .map(|_| RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() })
1242 .collect();
1243 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(2), Epoch(1), &[0u8; 32], &blobs, 100, None, false).unwrap();
1244 assert_eq!(chunks.len(), 2);
1245 let parsed: Vec<RekeyChunk> = chunks.iter().map(|w| parse_rekey_chunk(&stream::open_wrap(w, &group).unwrap()).unwrap()).collect();
1246 assert_eq!(parsed[0].chunk, (1, 2));
1247 assert_eq!(parsed[1].chunk, (2, 2));
1248 assert_eq!(parsed[0].blobs.len(), MAX_REKEY_BLOBS_PER_EVENT);
1249 assert_eq!(parsed[1].blobs.len(), 1);
1250 }
1251
1252 #[test]
1253 fn plaintext_sealed_rekey_is_rejected() {
1254 let rotator = keys(1);
1255 let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
1256 let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[], 1, 1, 100, None, false).unwrap();
1257 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group, &rotator).unwrap();
1258 let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
1259 let opened = stream::open_wrap(&wrap, &group).unwrap();
1260 assert!(parse_rekey_chunk(&opened).is_err(), "the rekey plane must be encrypted-sealed");
1261 }
1262
1263 #[test]
1264 fn non_monotonic_epoch_is_refused_at_mint_and_on_parse() {
1265 let rotator = keys(1);
1266 assert!(matches!(
1267 build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(1), &[0u8; 32], &[], 1, 1, 100, None, false),
1268 Err(RekeyError::NonMonotonicEpoch)
1269 ));
1270 }
1271
1272 #[test]
1273 fn bad_chunk_indices_are_refused() {
1274 let rotator = keys(1);
1275 for (i, n) in [(0u32, 1u32), (2, 1), (1, 0)] {
1276 assert!(
1277 matches!(build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &[], i, n, 100, None, false), Err(RekeyError::BadChunkIndex)),
1278 "chunk ({i},{n}) must be rejected"
1279 );
1280 }
1281 }
1282
1283 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 {
1286 RekeyChunk {
1287 rotator: rotator.public_key(),
1288 scope,
1289 new_epoch: Epoch(new_epoch),
1290 prev_epoch: Epoch(prev_epoch),
1291 prev_commit: epoch_key_commitment(Epoch(prev_epoch), prev_key),
1292 chunk: (i, n),
1293 blobs,
1294 citation: None,
1295 severed: false,
1296 }
1297 }
1298
1299 #[test]
1300 fn a_colliding_chunk_index_must_not_swallow_a_recipients_blob() {
1301 let rotator = keys(1);
1311 let prev = [0x55u8; 32];
1312 let mine = RekeyBlob { locator: "aa".repeat(32), wrapped: "mine".into() };
1313 let other = RekeyBlob { locator: "bb".repeat(32), wrapped: "other".into() };
1314
1315 let original = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![mine.clone()], 1, 1);
1318 let reclaim = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![other.clone()], 1, 1);
1319
1320 for (order, chunks) in [
1321 ("original first", vec![original.clone(), reclaim.clone()]),
1322 ("reclaim first", vec![reclaim.clone(), original.clone()]),
1323 ] {
1324 let rotations = collect_rotations(&chunks);
1325 assert_eq!(rotations.len(), 1, "{order}: same correlation key");
1326 let has_mine = rotations[0].blobs.iter().any(|b| b.locator == mine.locator);
1327 assert!(
1328 has_mine,
1329 "{order}: my blob was dropped by an index collision — that reads as a removal \
1330 and removal deletes the community",
1331 );
1332 }
1333 }
1334
1335 #[test]
1336 fn a_severed_rotation_cannot_be_laundered_by_an_unmarked_chunk() {
1337 let rotator = keys(1);
1345 let prev = [0x44u8; 32];
1346 let mut marked = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![], 1, 2);
1347 marked.severed = true;
1348 let unmarked = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![], 2, 2);
1349
1350 for (order, chunks) in [
1351 ("marked first", vec![marked.clone(), unmarked.clone()]),
1352 ("unmarked first", vec![unmarked.clone(), marked.clone()]),
1353 ] {
1354 let rotations = collect_rotations(&chunks);
1355 assert_eq!(rotations.len(), 1, "{order}: one correlation key, one rotation");
1356 assert!(
1357 rotations[0].severed,
1358 "{order}: an unmarked sibling must never launder the containment away",
1359 );
1360 assert!(rotations[0].is_complete(), "{order}: both chunks held");
1361 }
1362 }
1363
1364 #[test]
1365 fn continuity_extends_gaps_and_forks() {
1366 let rotator = keys(1);
1367 let held = [0x33u8; 32];
1368 let good = chunk_at(&rotator, RekeyScope::Root, 3, 2, &held, vec![], 1, 1);
1370 assert_eq!(check_continuity(&good, Epoch(2), &held), Continuity::Extends);
1371 let ahead = chunk_at(&rotator, RekeyScope::Root, 5, 4, &held, vec![], 1, 1);
1373 assert_eq!(check_continuity(&ahead, Epoch(2), &held), Continuity::Gap);
1374 let fork = chunk_at(&rotator, RekeyScope::Root, 3, 2, &[0x99u8; 32], vec![], 1, 1);
1376 assert_eq!(check_continuity(&fork, Epoch(2), &held), Continuity::Fork);
1377 let stale = chunk_at(&rotator, RekeyScope::Root, 2, 1, &held, vec![], 1, 1);
1379 assert_eq!(check_continuity(&stale, Epoch(2), &held), Continuity::Fork);
1380 }
1381
1382 #[test]
1383 fn a_missing_chunk_is_never_a_removal() {
1384 let rotator = keys(1);
1387 let me = keys(8);
1388 let my_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &me.public_key(), RekeyScope::Root, Epoch(1), &[0xAAu8; 32]).unwrap();
1390 let c1 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![RekeyBlob { locator: "bb".repeat(32), wrapped: "x".into() }], 1, 2);
1391 let rots = collect_rotations(&[c1.clone()]);
1392 assert_eq!(rots.len(), 1);
1393 assert!(!rots[0].is_complete(), "one of two chunks held → incomplete");
1394 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), None, "incomplete → keep recovering, never conclude removal");
1395
1396 let c2 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![my_blob.clone()], 2, 2);
1398 let rots = collect_rotations(&[c1, c2]);
1399 assert!(rots[0].is_complete());
1400 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(false));
1401 let mine = find_my_blob(&rots[0].blobs, &rots[0].rotator.to_bytes(), &xonly(&me), RekeyScope::Root, Epoch(1)).unwrap();
1403 assert_eq!(open_blob_local(me.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), mine).unwrap(), [0xAAu8; 32]);
1404 }
1405
1406 #[test]
1407 fn a_complete_rotation_without_my_blob_is_a_removal() {
1408 let rotator = keys(1);
1409 let me = keys(8);
1410 let other = keys(9);
1411 let their_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &other.public_key(), RekeyScope::Root, Epoch(1), &[0xBBu8; 32]).unwrap();
1413 let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![their_blob], 1, 1);
1414 let rots = collect_rotations(&[c]);
1415 assert!(rots[0].is_complete());
1416 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(true), "complete rotation, no blob for me → removed");
1417 }
1418
1419 #[test]
1420 fn collect_rotations_separates_concurrent_rotators_and_scopes() {
1421 let rot_a = keys(1);
1424 let rot_b = keys(2);
1425 let ca = chunk_at(&rot_a, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1426 let cb = chunk_at(&rot_b, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1427 let cc = chunk_at(&rot_a, RekeyScope::Channel(CHAN), 2, 1, &[7u8; 32], vec![], 1, 1);
1428 let rots = collect_rotations(&[ca, cb, cc]);
1429 assert_eq!(rots.len(), 3, "different rotator or scope ⇒ different rotation");
1430 }
1431
1432 #[test]
1433 fn duplicate_chunk_delivery_is_idempotent() {
1434 let rotator = keys(1);
1435 let blob = RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() };
1436 let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![blob.clone()], 1, 1);
1437 let rots = collect_rotations(&[c.clone(), c]);
1438 assert_eq!(rots.len(), 1);
1439 assert_eq!(rots[0].blobs.len(), 1, "re-delivering a chunk must not double its blobs");
1440 }
1441
1442 #[test]
1445 fn base_blob_forms_are_width_declared() {
1446 let community = CommunityId([0x77u8; 32]);
1447 let new_root = [0xABu8; 32];
1448 let control_root = [0x5Cu8; 32];
1449 let epoch = Epoch(3);
1450 let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1451
1452 let member = bound_base_plaintext(epoch, &new_root, &control_pk, None);
1454 assert_eq!(member.len(), 104);
1455 let d = parse_bound_base_plaintext(&member, &community, epoch).unwrap();
1456 assert_eq!(d.new_root, new_root);
1457 assert_eq!(d.control_pk, Some(control_pk));
1458 assert_eq!(d.control_root, None);
1459
1460 let staff = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&control_root));
1462 assert_eq!(staff.len(), 136);
1463 let d = parse_bound_base_plaintext(&staff, &community, epoch).unwrap();
1464 assert_eq!(d.control_root, Some(control_root));
1465 assert_eq!(d.control_pk, Some(control_pk));
1466
1467 let legacy = bound_plaintext(RekeyScope::Root, epoch, &new_root);
1469 let d = parse_bound_base_plaintext(&legacy, &community, epoch).unwrap();
1470 assert_eq!(d.new_root, new_root);
1471 assert_eq!(d.control_pk, None);
1472 assert_eq!(d.control_root, None);
1473 }
1474
1475 #[test]
1476 fn base_blob_mismatched_control_pair_is_refused_whole() {
1477 let community = CommunityId([0x77u8; 32]);
1478 let control_pk = control_signer_group_key(&[0x5Cu8; 32], &community, Epoch(3)).pk().to_bytes();
1479 let bad = bound_base_plaintext(Epoch(3), &[0xABu8; 32], &control_pk, Some(&[0x11u8; 32]));
1482 assert!(matches!(
1483 parse_bound_base_plaintext(&bad, &community, Epoch(3)),
1484 Err(RekeyError::ControlPairMismatch)
1485 ));
1486 }
1487
1488 #[test]
1489 fn base_blob_rejects_any_other_width_and_splices() {
1490 let community = CommunityId([0x77u8; 32]);
1491 for n in [0usize, 71, 73, 100, 105, 135] {
1493 assert!(matches!(
1494 parse_bound_base_plaintext(&vec![0u8; n], &community, Epoch(1)),
1495 Err(RekeyError::BadBaseBlobWidth(m)) if m == n
1496 ));
1497 }
1498 let control_root = [0x5Cu8; 32];
1500 let pk = control_signer_group_key(&control_root, &community, Epoch(3)).pk().to_bytes();
1501 let pt = bound_base_plaintext(Epoch(3), &[0xABu8; 32], &pk, None);
1502 assert!(matches!(parse_bound_base_plaintext(&pt, &community, Epoch(4)), Err(RekeyError::EpochSplice)));
1503 let mut channel_scoped = pt.clone();
1504 channel_scoped[..32].copy_from_slice(&[0x42u8; 32]);
1505 assert!(matches!(parse_bound_base_plaintext(&channel_scoped, &community, Epoch(3)), Err(RekeyError::ScopeSplice)));
1506 }
1507
1508 #[tokio::test]
1509 async fn base_blob_round_trips_member_and_staff_forms() {
1510 let rotator = keys(7);
1511 let member = keys(8);
1512 let staffer = keys(9);
1513 let community = CommunityId([0x77u8; 32]);
1514 let epoch = Epoch(2);
1515 let new_root = [0xEEu8; 32];
1516 let control_root = [0xDDu8; 32];
1517 let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1518
1519 let mb = build_base_blob(&as_signer(&rotator), &xonly(&rotator), &member.public_key(), epoch, &new_root, &control_pk, None).await.unwrap();
1520 let d = open_base_blob(&as_signer(&member), &rotator.public_key(), &community, epoch, &mb).await.unwrap();
1521 assert_eq!(d.new_root, new_root);
1522 assert_eq!(d.control_pk, Some(control_pk));
1523 assert_eq!(d.control_root, None, "a member blob never carries the secret");
1524
1525 let sb = build_base_blob(&as_signer(&rotator), &xonly(&rotator), &staffer.public_key(), epoch, &new_root, &control_pk, Some(&control_root)).await.unwrap();
1526 let d = open_base_blob(&as_signer(&staffer), &rotator.public_key(), &community, epoch, &sb).await.unwrap();
1527 assert_eq!(d.control_root, Some(control_root));
1528
1529 assert_eq!(mb.locator, blob_locator(&xonly(&rotator), &xonly(&member), RekeyScope::Root, epoch));
1531 let legacy = build_blob(&as_signer(&rotator), &xonly(&rotator), &member.public_key(), RekeyScope::Root, epoch, &new_root).await.unwrap();
1533 let d = open_base_blob(&as_signer(&member), &rotator.public_key(), &community, epoch, &legacy).await.unwrap();
1534 assert_eq!(d.new_root, new_root);
1535 assert_eq!(d.control_pk, None);
1536 }
1537
1538 #[test]
1539 fn a_future_wider_base_blob_degrades_instead_of_forking() {
1540 let community = CommunityId([0x77u8; 32]);
1544 let new_root = [0xABu8; 32];
1545 let control_root = [0x5Cu8; 32];
1546 let epoch = Epoch(3);
1547 let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1548
1549 let mut future = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&control_root));
1551 future.extend_from_slice(&[0x99u8; 32]);
1552 let d = parse_bound_base_plaintext(&future, &community, epoch).unwrap();
1553 assert_eq!(d.new_root, new_root);
1554 assert_eq!(d.control_pk, Some(control_pk));
1555 assert_eq!(d.control_root, Some(control_root), "held offsets still verify → full function");
1556
1557 let mut reordered = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&[0x44u8; 32]));
1560 reordered.extend_from_slice(&[0x99u8; 32]);
1561 let d = parse_bound_base_plaintext(&reordered, &community, epoch).unwrap();
1562 assert_eq!(d.new_root, new_root);
1563 assert_eq!(d.control_pk, Some(control_pk));
1564 assert_eq!(d.control_root, None, "an unverifiable secret is dropped, not adopted");
1565
1566 assert!(parse_bound_base_plaintext(&future, &community, Epoch(4)).is_err());
1568 }
1569
1570 #[test]
1571 fn control_wrap_layout_is_frozen_and_round_trips() {
1572 let pt = encode_control_wrap(Epoch(7), &[0xCDu8; 32]);
1573 assert_eq!(pt.len(), 40);
1574 let expected = format!("{}{}", "0000000000000007", "cd".repeat(32));
1575 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
1576 let (epoch, root) = parse_control_wrap(&pt).unwrap();
1577 assert_eq!(epoch, Epoch(7));
1578 assert_eq!(root, [0xCDu8; 32]);
1579 for n in [0usize, 39, 41, 72] {
1580 assert!(matches!(parse_control_wrap(&vec![0u8; n]), Err(RekeyError::BadControlWrapLength(m)) if m == n));
1581 }
1582 }
1583
1584 #[test]
1585 fn lowest_key_fork_winner_is_deterministic() {
1586 let keys_a = [[0x03u8; 32], [0x01u8; 32], [0x02u8; 32]];
1589 assert_eq!(lowest_key_winner(&keys_a), Some(1));
1590 let keys_b = [[0x01u8; 32], [0x02u8; 32], [0x03u8; 32]];
1591 assert_eq!(lowest_key_winner(&keys_b), Some(0));
1592 assert_eq!(lowest_key_winner(&[]), None);
1593 }
1594}