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
695#[allow(clippy::too_many_arguments)]
706pub async fn build_rekey_chunk_extension<S: crate::signer::VectorSigner + ?Sized>(
707 signer: &S,
708 rotator_pk: PublicKey,
709 rekey_group: &GroupKey,
710 scope: RekeyScope,
711 new_epoch: Epoch,
712 prev_epoch: Epoch,
713 prev_commit: &[u8; 32],
714 blobs: &[RekeyBlob],
715 at_secs: u64,
716 citation: Option<&crate::community::edition::AuthorityCitation>,
717 severed: bool,
718 start_after: u32,
719) -> Result<Vec<Event>, RekeyError> {
720 let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
721 vec![&[]]
722 } else {
723 blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
724 };
725 let n = start_after + groups.len() as u32;
726 let mut out = Vec::with_capacity(groups.len());
727 for (idx, group_blobs) in groups.iter().enumerate() {
728 let i = start_after + idx as u32 + 1;
729 let rumor = build_rekey_rumor(rotator_pk, scope, new_epoch, prev_epoch, prev_commit, group_blobs, i, n, at_secs, citation, severed)?;
730 let (wrap, _) = stream::seal_and_wrap_signed(signer, rotator_pk, &rumor, SealForm::Encrypted, rekey_group, stream::KIND_WRAP, Timestamp::from_secs(at_secs), &[]).await?;
731 out.push(wrap);
732 }
733 Ok(out)
734}
735
736pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError> {
740 if opened.seal_form != SealForm::Encrypted {
741 return Err(RekeyError::Stream(StreamError::BadSealKind(stream::KIND_SEAL_PLAINTEXT)));
743 }
744 let rumor = &opened.rumor;
745 if rumor.kind.as_u16() != super::kind::REKEY {
746 return Err(RekeyError::NotARekey(rumor.kind.as_u16()));
747 }
748 let scope = RekeyScope::from_hex(&unique_tag(rumor, TAG_SCOPE)?.ok_or(RekeyError::BadTag(TAG_SCOPE))?)
749 .ok_or(RekeyError::BadTag(TAG_SCOPE))?;
750 let new_epoch = Epoch(parse_u64(rumor, TAG_NEW_EPOCH)?);
751 let prev_epoch = Epoch(parse_u64(rumor, TAG_PREV_EPOCH)?);
752 if new_epoch.0 <= prev_epoch.0 {
753 return Err(RekeyError::NonMonotonicEpoch);
754 }
755 let prev_hex = unique_tag(rumor, TAG_PREV_COMMIT)?.ok_or(RekeyError::BadTag(TAG_PREV_COMMIT))?;
756 if prev_hex.len() != 64 || !prev_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
757 return Err(RekeyError::BadTag(TAG_PREV_COMMIT));
758 }
759 let prev_commit = crate::simd::hex::hex_to_bytes_32(&prev_hex);
760
761 let (chunk_i, chunk_n) = parse_chunk(rumor)?;
762
763 let blobs: Vec<RekeyBlob> = serde_json::from_str(&rumor.content).map_err(|_| RekeyError::BadTag("blobs"))?;
764 if blobs.len() > MAX_REKEY_BLOBS_RECEIVED {
765 return Err(RekeyError::TooManyBlobs(blobs.len()));
766 }
767
768 let severed = match unique_tag(rumor, TAG_SEVER)?.as_deref() {
770 None => false,
771 Some("1") => true,
772 Some(_) => return Err(RekeyError::BadTag(TAG_SEVER)),
773 };
774
775 Ok(RekeyChunk {
776 rotator: opened.author,
777 scope,
778 new_epoch,
779 prev_epoch,
780 prev_commit,
781 chunk: (chunk_i, chunk_n),
782 blobs,
783 citation: crate::community::edition::AuthorityCitation::from_tags(&rumor.tags),
784 severed,
785 })
786}
787
788#[derive(Debug, Clone, Copy, PartialEq, Eq)]
792pub enum Continuity {
793 Extends,
796 Gap,
799 Fork,
801}
802
803pub fn check_continuity(chunk: &RekeyChunk, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
807 if chunk.prev_epoch.0 == held_epoch.0 {
808 if epoch_key_commitment(held_epoch, held_key) == chunk.prev_commit {
809 Continuity::Extends
810 } else {
811 Continuity::Fork
812 }
813 } else if chunk.prev_epoch.0 > held_epoch.0 {
814 Continuity::Gap
815 } else {
816 Continuity::Fork
819 }
820}
821
822#[derive(Debug, Clone)]
825pub struct Rotation {
826 pub rotator: PublicKey,
827 pub scope: RekeyScope,
828 pub new_epoch: Epoch,
829 pub prev_epoch: Epoch,
830 pub prev_commit: [u8; 32],
831 pub blobs: Vec<RekeyBlob>,
833 pub declared_chunks: u32,
835 pub held_chunks: std::collections::BTreeSet<u32>,
837 pub citation: Option<crate::community::edition::AuthorityCitation>,
840 pub severed: bool,
843}
844
845impl Rotation {
846 pub fn is_complete(&self) -> bool {
850 self.declared_chunks >= 1 && (1..=self.declared_chunks).all(|i| self.held_chunks.contains(&i))
851 }
852
853 pub fn continuity(&self, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
857 if self.prev_epoch.0 == held_epoch.0 {
858 if epoch_key_commitment(held_epoch, held_key) == self.prev_commit {
859 Continuity::Extends
860 } else {
861 Continuity::Fork
862 }
863 } else if self.prev_epoch.0 > held_epoch.0 {
864 Continuity::Gap
865 } else {
866 Continuity::Fork
867 }
868 }
869}
870
871pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec<Rotation> {
876 use std::collections::BTreeMap;
877 let mut by_key: BTreeMap<RotationKey, Rotation> = BTreeMap::new();
878 for c in chunks {
879 let entry = by_key.entry(c.correlation()).or_insert_with(|| Rotation {
880 rotator: c.rotator,
881 scope: c.scope,
882 new_epoch: c.new_epoch,
883 prev_epoch: c.prev_epoch,
884 prev_commit: c.prev_commit,
885 blobs: Vec::new(),
886 declared_chunks: c.chunk.1,
887 held_chunks: std::collections::BTreeSet::new(),
888 citation: c.citation.clone(),
889 severed: c.severed,
890 });
891 entry.severed |= c.severed;
892 entry.held_chunks.insert(c.chunk.0);
901 for b in &c.blobs {
902 if !entry.blobs.iter().any(|held| held.locator == b.locator && held.wrapped == b.wrapped) {
903 entry.blobs.push(b.clone());
904 }
905 }
906 }
907 by_key.into_values().collect()
908}
909
910pub fn am_i_removed(rotation: &Rotation, my_xonly: &[u8; 32]) -> Option<bool> {
914 if !rotation.is_complete() {
915 return None;
916 }
917 let mine = find_my_blob(&rotation.blobs, &rotation.rotator.to_bytes(), my_xonly, rotation.scope, rotation.new_epoch);
918 Some(mine.is_none())
919}
920
921pub fn lowest_key_winner(candidate_keys: &[[u8; 32]]) -> Option<usize> {
927 candidate_keys
928 .iter()
929 .enumerate()
930 .min_by(|(_, a), (_, b)| a.cmp(b))
931 .map(|(i, _)| i)
932}
933
934fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, RekeyError> {
937 let mut found: Option<String> = None;
938 for t in rumor.tags.iter() {
939 let s = t.as_slice();
940 if s.len() >= 2 && s[0] == name {
941 if found.is_some() {
942 return Err(RekeyError::BadTag(name));
943 }
944 found = Some(s[1].clone());
945 }
946 }
947 Ok(found)
948}
949
950fn parse_u64(rumor: &UnsignedEvent, name: &'static str) -> Result<u64, RekeyError> {
951 let raw = unique_tag(rumor, name)?.ok_or(RekeyError::BadTag(name))?;
952 if !crate::community::edition::is_tag_decimal(&raw) {
955 return Err(RekeyError::BadTag(name));
956 }
957 raw.parse::<u64>().map_err(|_| RekeyError::BadTag(name))
958}
959
960fn parse_chunk(rumor: &UnsignedEvent) -> Result<(u32, u32), RekeyError> {
961 let mut found: Option<(u32, u32)> = None;
962 for t in rumor.tags.iter() {
963 let s = t.as_slice();
964 if s.len() >= 3 && s[0] == TAG_CHUNK {
965 if found.is_some() {
966 return Err(RekeyError::BadTag(TAG_CHUNK));
967 }
968 if !crate::community::edition::is_tag_decimal(&s[1]) || !crate::community::edition::is_tag_decimal(&s[2]) {
969 return Err(RekeyError::BadTag(TAG_CHUNK));
970 }
971 let i: u32 = s[1].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
972 let n: u32 = s[2].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
973 found = Some((i, n));
974 }
975 }
976 let (i, n) = found.ok_or(RekeyError::BadTag(TAG_CHUNK))?;
977 if n < 1 || i < 1 || i > n {
978 return Err(RekeyError::BadChunkIndex);
979 }
980 Ok((i, n))
981}
982
983#[cfg(test)]
984mod tests {
985
986 fn as_signer(k: &Keys) -> crate::signer::ActiveSigner {
990 crate::signer::ActiveSigner::Keys(k.clone())
991 }
992 use super::*;
993
994 fn keys(byte: u8) -> Keys {
995 Keys::new(SecretKey::from_slice(&[byte; 32]).unwrap())
996 }
997
998 fn xonly(k: &Keys) -> [u8; 32] {
999 k.public_key().to_bytes()
1000 }
1001
1002 const CHAN: ChannelId = ChannelId([0x42u8; 32]);
1003
1004 #[test]
1007 fn bound_plaintext_layout_is_frozen() {
1008 let pt = bound_plaintext(RekeyScope::Root, Epoch(1), &[0xABu8; 32]);
1009 let expected = format!("{}{}{}", "00".repeat(32), "0000000000000001", "ab".repeat(32));
1010 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
1011 let pt2 = bound_plaintext(RekeyScope::Channel(ChannelId([0x11u8; 32])), Epoch(0x0102), &[0xCDu8; 32]);
1012 let expected2 = format!("{}{}{}", "11".repeat(32), "0000000000000102", "cd".repeat(32));
1013 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt2), expected2);
1014 }
1015
1016 #[test]
1017 fn blob_round_trips_both_scopes() {
1018 let rotator = keys(7);
1019 let recipient = keys(8);
1020 for (scope, epoch, key) in [
1021 (RekeyScope::Root, Epoch(1), [0xABu8; 32]),
1022 (RekeyScope::Channel(CHAN), Epoch(5), [0xCDu8; 32]),
1023 ] {
1024 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
1025 let got = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob).unwrap();
1026 assert_eq!(got, key, "the recipient recovers the fresh key");
1027 }
1028 }
1029
1030 #[tokio::test]
1031 async fn signer_blob_is_wire_compatible_with_local_both_directions() {
1032 let rotator = keys(7);
1036 let recipient = keys(8);
1037 let scope = RekeyScope::Root;
1038 let epoch = Epoch(3);
1039 let key = [0x5Au8; 32];
1040
1041 let blob_l = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
1043 let got_s = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_l).await.unwrap();
1044 assert_eq!(got_s, key, "signer opens a local-built blob");
1045
1046 let blob_s = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).await.unwrap();
1048 assert_eq!(blob_s.locator, blob_l.locator, "same public locator regardless of build path");
1049 let got_l = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob_s).unwrap();
1050 assert_eq!(got_l, key, "local opens a signer-built blob");
1051
1052 let got_ss = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_s).await.unwrap();
1054 assert_eq!(got_ss, key, "signer round-trips its own blob");
1055 }
1056
1057 #[tokio::test]
1058 async fn signer_blob_bound_check_still_gates_scope_epoch_splice() {
1059 let rotator = keys(7);
1060 let recipient = keys(8);
1061 let blob = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[9u8; 32]).await.unwrap();
1062 assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob).await.is_err(), "epoch splice rejected");
1063 assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob).await.is_err(), "scope splice rejected");
1064 }
1065
1066 #[test]
1067 fn locator_is_public_and_computable_from_pubkeys_alone() {
1068 let rotator = keys(7);
1072 let recipient = keys(8);
1073 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(2), &[1u8; 32]).unwrap();
1074 let recomputed = blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(2));
1075 assert_eq!(blob.locator, recomputed, "both sides compute the same public locator");
1076 }
1077
1078 #[test]
1079 fn a_non_recipient_cannot_open_even_holding_the_public_locator() {
1080 let rotator = keys(7);
1084 let recipient = keys(8);
1085 let outsider = keys(9);
1086 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[2u8; 32]).unwrap();
1087 assert_eq!(blob.locator, blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)));
1089 assert!(open_blob_local(outsider.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).is_err());
1092 }
1093
1094 #[test]
1095 fn a_relocated_blob_still_opens_by_decrypt_not_locator() {
1096 let rotator = keys(7);
1101 let recipient = keys(8);
1102 let mut blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[3u8; 32]).unwrap();
1103 blob.locator = "ff".repeat(32);
1104 assert_eq!(
1106 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).unwrap(),
1107 [3u8; 32]
1108 );
1109 assert!(find_my_blob(std::slice::from_ref(&blob), &xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)).is_none());
1111 }
1112
1113 #[test]
1114 fn scope_and_epoch_splices_are_rejected_on_open() {
1115 let rotator = keys(7);
1116 let recipient = keys(8);
1117 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[4u8; 32]).unwrap();
1118 assert!(matches!(
1120 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob),
1121 Err(RekeyError::ScopeSplice)
1122 ));
1123 assert!(matches!(
1125 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob),
1126 Err(RekeyError::EpochSplice)
1127 ));
1128 }
1129
1130 #[test]
1131 fn wrapped_carries_base64_of_the_72_bytes_for_bunker_parity() {
1132 let rotator = keys(7);
1136 let recipient = keys(8);
1137 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[5u8; 32]).unwrap();
1138 let ck = ConversationKey::derive(recipient.secret_key(), &rotator.public_key()).unwrap();
1139 let payload = base64_simd::STANDARD.decode_to_vec(blob.wrapped.as_bytes()).unwrap();
1140 let inner = decrypt_to_bytes(&ck, &payload).unwrap();
1141 assert_eq!(String::from_utf8(inner).unwrap(), bound_plaintext_b64(RekeyScope::Root, Epoch(1), &[5u8; 32]));
1142 }
1143
1144 #[test]
1145 fn distinct_recipients_and_scopes_get_distinct_locators() {
1146 let rotator = keys(7);
1147 let r1 = keys(8);
1148 let r2 = keys(9);
1149 assert_ne!(
1150 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
1151 blob_locator(&xonly(&rotator), &xonly(&r2), RekeyScope::Root, Epoch(1))
1152 );
1153 assert_ne!(
1155 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
1156 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Channel(CHAN), Epoch(1))
1157 );
1158 }
1159
1160 fn root() -> [u8; 32] {
1163 [0x55u8; 32]
1164 }
1165
1166 #[test]
1167 fn channel_rekey_round_trips_through_the_stream() {
1168 let rotator = keys(1);
1169 let recipient = keys(8);
1170 let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
1171 let key = [0xABu8; 32];
1172 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &key).unwrap();
1173 let commit = epoch_key_commitment(Epoch(0), &[0xEEu8; 32]);
1174 let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &commit, &[blob.clone()], 1, 1, 100, None, false).unwrap();
1175 let (wrap, _) = seal_rekey_chunk(&rumor, &group, &rotator, Timestamp::from_secs(100)).unwrap();
1176
1177 assert_ne!(wrap.pubkey, rotator.public_key());
1179 assert_eq!(wrap.pubkey, group.pk());
1180
1181 let opened = stream::open_wrap(&wrap, &group).unwrap();
1182 let chunk = parse_rekey_chunk(&opened).unwrap();
1183 assert_eq!(chunk.rotator, rotator.public_key(), "rotator recovered from the seal");
1184 assert!(matches!(chunk.scope, RekeyScope::Channel(c) if c.0 == CHAN.0));
1185 assert_eq!(chunk.new_epoch, Epoch(1));
1186 assert_eq!(chunk.prev_epoch, Epoch(0));
1187 assert_eq!(chunk.prev_commit, commit);
1188 assert_eq!(chunk.chunk, (1, 1));
1189 assert_eq!(chunk.blobs, vec![blob]);
1190 }
1191
1192 #[test]
1193 fn base_rekey_addresses_under_the_prior_root() {
1194 let rotator = keys(1);
1197 let recipient = keys(8);
1198 let prior_root = [0x66u8; 32];
1199 let community = CommunityId([0x77u8; 32]);
1200 let new_root = [0x99u8; 32];
1201 let group = base_rekey_group(&prior_root, &community, Epoch(1));
1202 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &new_root).unwrap();
1203 let commit = epoch_key_commitment(Epoch(0), &prior_root);
1204 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &commit, &[blob], 100, None, false).unwrap();
1205 assert_eq!(chunks.len(), 1);
1206
1207 let opened = stream::open_wrap(&chunks[0], &group).unwrap();
1208 let chunk = parse_rekey_chunk(&opened).unwrap();
1209 assert!(matches!(chunk.scope, RekeyScope::Root));
1210 let mine = find_my_blob(&chunk.blobs, &chunk.rotator.to_bytes(), &xonly(&recipient), chunk.scope, chunk.new_epoch).unwrap();
1212 assert_eq!(open_blob_local(recipient.secret_key(), &chunk.rotator, chunk.scope, chunk.new_epoch, mine).unwrap(), new_root);
1213
1214 let wrong = base_rekey_group(&[0u8; 32], &community, Epoch(1));
1216 assert!(stream::open_wrap(&chunks[0], &wrong).is_err());
1217 }
1218
1219 #[test]
1220 fn a_full_send_chunk_stays_under_the_relay_size_limit() {
1221 let rotator = keys(1);
1224 let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
1225 let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT)
1226 .map(|i| {
1227 let r = keys((i % 200 + 20) as u8);
1228 build_blob_local(rotator.secret_key(), &xonly(&rotator), &r.public_key(), RekeyScope::Root, Epoch(1), &[0xCDu8; 32]).unwrap()
1229 })
1230 .collect();
1231 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &blobs, 100, None, false).unwrap();
1232 assert_eq!(chunks.len(), 1, "a full send chunk is exactly one event");
1233 assert!(chunks[0].as_json().len() <= 65_536, "a full chunk must fit a 64KB relay event");
1234 }
1235
1236 #[test]
1237 fn oversize_recipient_set_splits_into_chunks() {
1238 let rotator = keys(1);
1239 let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
1240 let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT + 1)
1242 .map(|_| RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() })
1243 .collect();
1244 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(2), Epoch(1), &[0u8; 32], &blobs, 100, None, false).unwrap();
1245 assert_eq!(chunks.len(), 2);
1246 let parsed: Vec<RekeyChunk> = chunks.iter().map(|w| parse_rekey_chunk(&stream::open_wrap(w, &group).unwrap()).unwrap()).collect();
1247 assert_eq!(parsed[0].chunk, (1, 2));
1248 assert_eq!(parsed[1].chunk, (2, 2));
1249 assert_eq!(parsed[0].blobs.len(), MAX_REKEY_BLOBS_PER_EVENT);
1250 assert_eq!(parsed[1].blobs.len(), 1);
1251 }
1252
1253 #[test]
1254 fn plaintext_sealed_rekey_is_rejected() {
1255 let rotator = keys(1);
1256 let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
1257 let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[], 1, 1, 100, None, false).unwrap();
1258 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group, &rotator).unwrap();
1259 let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
1260 let opened = stream::open_wrap(&wrap, &group).unwrap();
1261 assert!(parse_rekey_chunk(&opened).is_err(), "the rekey plane must be encrypted-sealed");
1262 }
1263
1264 #[test]
1265 fn non_monotonic_epoch_is_refused_at_mint_and_on_parse() {
1266 let rotator = keys(1);
1267 assert!(matches!(
1268 build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(1), &[0u8; 32], &[], 1, 1, 100, None, false),
1269 Err(RekeyError::NonMonotonicEpoch)
1270 ));
1271 }
1272
1273 #[test]
1274 fn bad_chunk_indices_are_refused() {
1275 let rotator = keys(1);
1276 for (i, n) in [(0u32, 1u32), (2, 1), (1, 0)] {
1277 assert!(
1278 matches!(build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &[], i, n, 100, None, false), Err(RekeyError::BadChunkIndex)),
1279 "chunk ({i},{n}) must be rejected"
1280 );
1281 }
1282 }
1283
1284 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 {
1287 RekeyChunk {
1288 rotator: rotator.public_key(),
1289 scope,
1290 new_epoch: Epoch(new_epoch),
1291 prev_epoch: Epoch(prev_epoch),
1292 prev_commit: epoch_key_commitment(Epoch(prev_epoch), prev_key),
1293 chunk: (i, n),
1294 blobs,
1295 citation: None,
1296 severed: false,
1297 }
1298 }
1299
1300 #[test]
1301 fn a_colliding_chunk_index_must_not_swallow_a_recipients_blob() {
1302 let rotator = keys(1);
1312 let prev = [0x55u8; 32];
1313 let mine = RekeyBlob { locator: "aa".repeat(32), wrapped: "mine".into() };
1314 let other = RekeyBlob { locator: "bb".repeat(32), wrapped: "other".into() };
1315
1316 let original = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![mine.clone()], 1, 1);
1319 let reclaim = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![other.clone()], 1, 1);
1320
1321 for (order, chunks) in [
1322 ("original first", vec![original.clone(), reclaim.clone()]),
1323 ("reclaim first", vec![reclaim.clone(), original.clone()]),
1324 ] {
1325 let rotations = collect_rotations(&chunks);
1326 assert_eq!(rotations.len(), 1, "{order}: same correlation key");
1327 let has_mine = rotations[0].blobs.iter().any(|b| b.locator == mine.locator);
1328 assert!(
1329 has_mine,
1330 "{order}: my blob was dropped by an index collision — that reads as a removal \
1331 and removal deletes the community",
1332 );
1333 }
1334 }
1335
1336 #[test]
1337 fn a_severed_rotation_cannot_be_laundered_by_an_unmarked_chunk() {
1338 let rotator = keys(1);
1346 let prev = [0x44u8; 32];
1347 let mut marked = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![], 1, 2);
1348 marked.severed = true;
1349 let unmarked = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![], 2, 2);
1350
1351 for (order, chunks) in [
1352 ("marked first", vec![marked.clone(), unmarked.clone()]),
1353 ("unmarked first", vec![unmarked.clone(), marked.clone()]),
1354 ] {
1355 let rotations = collect_rotations(&chunks);
1356 assert_eq!(rotations.len(), 1, "{order}: one correlation key, one rotation");
1357 assert!(
1358 rotations[0].severed,
1359 "{order}: an unmarked sibling must never launder the containment away",
1360 );
1361 assert!(rotations[0].is_complete(), "{order}: both chunks held");
1362 }
1363 }
1364
1365 #[test]
1366 fn continuity_extends_gaps_and_forks() {
1367 let rotator = keys(1);
1368 let held = [0x33u8; 32];
1369 let good = chunk_at(&rotator, RekeyScope::Root, 3, 2, &held, vec![], 1, 1);
1371 assert_eq!(check_continuity(&good, Epoch(2), &held), Continuity::Extends);
1372 let ahead = chunk_at(&rotator, RekeyScope::Root, 5, 4, &held, vec![], 1, 1);
1374 assert_eq!(check_continuity(&ahead, Epoch(2), &held), Continuity::Gap);
1375 let fork = chunk_at(&rotator, RekeyScope::Root, 3, 2, &[0x99u8; 32], vec![], 1, 1);
1377 assert_eq!(check_continuity(&fork, Epoch(2), &held), Continuity::Fork);
1378 let stale = chunk_at(&rotator, RekeyScope::Root, 2, 1, &held, vec![], 1, 1);
1380 assert_eq!(check_continuity(&stale, Epoch(2), &held), Continuity::Fork);
1381 }
1382
1383 #[test]
1384 fn a_missing_chunk_is_never_a_removal() {
1385 let rotator = keys(1);
1388 let me = keys(8);
1389 let my_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &me.public_key(), RekeyScope::Root, Epoch(1), &[0xAAu8; 32]).unwrap();
1391 let c1 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![RekeyBlob { locator: "bb".repeat(32), wrapped: "x".into() }], 1, 2);
1392 let rots = collect_rotations(&[c1.clone()]);
1393 assert_eq!(rots.len(), 1);
1394 assert!(!rots[0].is_complete(), "one of two chunks held → incomplete");
1395 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), None, "incomplete → keep recovering, never conclude removal");
1396
1397 let c2 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![my_blob.clone()], 2, 2);
1399 let rots = collect_rotations(&[c1, c2]);
1400 assert!(rots[0].is_complete());
1401 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(false));
1402 let mine = find_my_blob(&rots[0].blobs, &rots[0].rotator.to_bytes(), &xonly(&me), RekeyScope::Root, Epoch(1)).unwrap();
1404 assert_eq!(open_blob_local(me.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), mine).unwrap(), [0xAAu8; 32]);
1405 }
1406
1407 #[test]
1408 fn a_complete_rotation_without_my_blob_is_a_removal() {
1409 let rotator = keys(1);
1410 let me = keys(8);
1411 let other = keys(9);
1412 let their_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &other.public_key(), RekeyScope::Root, Epoch(1), &[0xBBu8; 32]).unwrap();
1414 let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![their_blob], 1, 1);
1415 let rots = collect_rotations(&[c]);
1416 assert!(rots[0].is_complete());
1417 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(true), "complete rotation, no blob for me → removed");
1418 }
1419
1420 #[test]
1421 fn collect_rotations_separates_concurrent_rotators_and_scopes() {
1422 let rot_a = keys(1);
1425 let rot_b = keys(2);
1426 let ca = chunk_at(&rot_a, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1427 let cb = chunk_at(&rot_b, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1428 let cc = chunk_at(&rot_a, RekeyScope::Channel(CHAN), 2, 1, &[7u8; 32], vec![], 1, 1);
1429 let rots = collect_rotations(&[ca, cb, cc]);
1430 assert_eq!(rots.len(), 3, "different rotator or scope ⇒ different rotation");
1431 }
1432
1433 #[test]
1434 fn duplicate_chunk_delivery_is_idempotent() {
1435 let rotator = keys(1);
1436 let blob = RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() };
1437 let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![blob.clone()], 1, 1);
1438 let rots = collect_rotations(&[c.clone(), c]);
1439 assert_eq!(rots.len(), 1);
1440 assert_eq!(rots[0].blobs.len(), 1, "re-delivering a chunk must not double its blobs");
1441 }
1442
1443 #[test]
1446 fn base_blob_forms_are_width_declared() {
1447 let community = CommunityId([0x77u8; 32]);
1448 let new_root = [0xABu8; 32];
1449 let control_root = [0x5Cu8; 32];
1450 let epoch = Epoch(3);
1451 let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1452
1453 let member = bound_base_plaintext(epoch, &new_root, &control_pk, None);
1455 assert_eq!(member.len(), 104);
1456 let d = parse_bound_base_plaintext(&member, &community, epoch).unwrap();
1457 assert_eq!(d.new_root, new_root);
1458 assert_eq!(d.control_pk, Some(control_pk));
1459 assert_eq!(d.control_root, None);
1460
1461 let staff = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&control_root));
1463 assert_eq!(staff.len(), 136);
1464 let d = parse_bound_base_plaintext(&staff, &community, epoch).unwrap();
1465 assert_eq!(d.control_root, Some(control_root));
1466 assert_eq!(d.control_pk, Some(control_pk));
1467
1468 let legacy = bound_plaintext(RekeyScope::Root, epoch, &new_root);
1470 let d = parse_bound_base_plaintext(&legacy, &community, epoch).unwrap();
1471 assert_eq!(d.new_root, new_root);
1472 assert_eq!(d.control_pk, None);
1473 assert_eq!(d.control_root, None);
1474 }
1475
1476 #[test]
1477 fn base_blob_mismatched_control_pair_is_refused_whole() {
1478 let community = CommunityId([0x77u8; 32]);
1479 let control_pk = control_signer_group_key(&[0x5Cu8; 32], &community, Epoch(3)).pk().to_bytes();
1480 let bad = bound_base_plaintext(Epoch(3), &[0xABu8; 32], &control_pk, Some(&[0x11u8; 32]));
1483 assert!(matches!(
1484 parse_bound_base_plaintext(&bad, &community, Epoch(3)),
1485 Err(RekeyError::ControlPairMismatch)
1486 ));
1487 }
1488
1489 #[test]
1490 fn base_blob_rejects_any_other_width_and_splices() {
1491 let community = CommunityId([0x77u8; 32]);
1492 for n in [0usize, 71, 73, 100, 105, 135] {
1494 assert!(matches!(
1495 parse_bound_base_plaintext(&vec![0u8; n], &community, Epoch(1)),
1496 Err(RekeyError::BadBaseBlobWidth(m)) if m == n
1497 ));
1498 }
1499 let control_root = [0x5Cu8; 32];
1501 let pk = control_signer_group_key(&control_root, &community, Epoch(3)).pk().to_bytes();
1502 let pt = bound_base_plaintext(Epoch(3), &[0xABu8; 32], &pk, None);
1503 assert!(matches!(parse_bound_base_plaintext(&pt, &community, Epoch(4)), Err(RekeyError::EpochSplice)));
1504 let mut channel_scoped = pt.clone();
1505 channel_scoped[..32].copy_from_slice(&[0x42u8; 32]);
1506 assert!(matches!(parse_bound_base_plaintext(&channel_scoped, &community, Epoch(3)), Err(RekeyError::ScopeSplice)));
1507 }
1508
1509 #[tokio::test]
1510 async fn base_blob_round_trips_member_and_staff_forms() {
1511 let rotator = keys(7);
1512 let member = keys(8);
1513 let staffer = keys(9);
1514 let community = CommunityId([0x77u8; 32]);
1515 let epoch = Epoch(2);
1516 let new_root = [0xEEu8; 32];
1517 let control_root = [0xDDu8; 32];
1518 let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1519
1520 let mb = build_base_blob(&as_signer(&rotator), &xonly(&rotator), &member.public_key(), epoch, &new_root, &control_pk, None).await.unwrap();
1521 let d = open_base_blob(&as_signer(&member), &rotator.public_key(), &community, epoch, &mb).await.unwrap();
1522 assert_eq!(d.new_root, new_root);
1523 assert_eq!(d.control_pk, Some(control_pk));
1524 assert_eq!(d.control_root, None, "a member blob never carries the secret");
1525
1526 let sb = build_base_blob(&as_signer(&rotator), &xonly(&rotator), &staffer.public_key(), epoch, &new_root, &control_pk, Some(&control_root)).await.unwrap();
1527 let d = open_base_blob(&as_signer(&staffer), &rotator.public_key(), &community, epoch, &sb).await.unwrap();
1528 assert_eq!(d.control_root, Some(control_root));
1529
1530 assert_eq!(mb.locator, blob_locator(&xonly(&rotator), &xonly(&member), RekeyScope::Root, epoch));
1532 let legacy = build_blob(&as_signer(&rotator), &xonly(&rotator), &member.public_key(), RekeyScope::Root, epoch, &new_root).await.unwrap();
1534 let d = open_base_blob(&as_signer(&member), &rotator.public_key(), &community, epoch, &legacy).await.unwrap();
1535 assert_eq!(d.new_root, new_root);
1536 assert_eq!(d.control_pk, None);
1537 }
1538
1539 #[test]
1540 fn a_future_wider_base_blob_degrades_instead_of_forking() {
1541 let community = CommunityId([0x77u8; 32]);
1545 let new_root = [0xABu8; 32];
1546 let control_root = [0x5Cu8; 32];
1547 let epoch = Epoch(3);
1548 let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1549
1550 let mut future = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&control_root));
1552 future.extend_from_slice(&[0x99u8; 32]);
1553 let d = parse_bound_base_plaintext(&future, &community, epoch).unwrap();
1554 assert_eq!(d.new_root, new_root);
1555 assert_eq!(d.control_pk, Some(control_pk));
1556 assert_eq!(d.control_root, Some(control_root), "held offsets still verify → full function");
1557
1558 let mut reordered = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&[0x44u8; 32]));
1561 reordered.extend_from_slice(&[0x99u8; 32]);
1562 let d = parse_bound_base_plaintext(&reordered, &community, epoch).unwrap();
1563 assert_eq!(d.new_root, new_root);
1564 assert_eq!(d.control_pk, Some(control_pk));
1565 assert_eq!(d.control_root, None, "an unverifiable secret is dropped, not adopted");
1566
1567 assert!(parse_bound_base_plaintext(&future, &community, Epoch(4)).is_err());
1569 }
1570
1571 #[test]
1572 fn control_wrap_layout_is_frozen_and_round_trips() {
1573 let pt = encode_control_wrap(Epoch(7), &[0xCDu8; 32]);
1574 assert_eq!(pt.len(), 40);
1575 let expected = format!("{}{}", "0000000000000007", "cd".repeat(32));
1576 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
1577 let (epoch, root) = parse_control_wrap(&pt).unwrap();
1578 assert_eq!(epoch, Epoch(7));
1579 assert_eq!(root, [0xCDu8; 32]);
1580 for n in [0usize, 39, 41, 72] {
1581 assert!(matches!(parse_control_wrap(&vec![0u8; n]), Err(RekeyError::BadControlWrapLength(m)) if m == n));
1582 }
1583 }
1584
1585 #[test]
1586 fn lowest_key_fork_winner_is_deterministic() {
1587 let keys_a = [[0x03u8; 32], [0x01u8; 32], [0x02u8; 32]];
1590 assert_eq!(lowest_key_winner(&keys_a), Some(1));
1591 let keys_b = [[0x01u8; 32], [0x02u8; 32], [0x03u8; 32]];
1592 assert_eq!(lowest_key_winner(&keys_b), Some(0));
1593 assert_eq!(lowest_key_winner(&[]), None);
1594 }
1595}