1use nostr_sdk::nips::nip44::v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey};
41use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, TagKind, 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, epoch_key_commitment, recipient_locator, GroupKey,
48};
49use super::stream::{self, OpenedStream, SealForm, StreamError};
50
51pub const MAX_REKEY_BLOBS_PER_EVENT: usize = 80;
59
60pub const MAX_REKEY_BLOBS_RECEIVED: usize = 120;
65
66const TAG_SCOPE: &str = "scope";
67const TAG_NEW_EPOCH: &str = "newepoch";
68const TAG_PREV_EPOCH: &str = "prevepoch";
69const TAG_PREV_COMMIT: &str = "prevcommit";
70const TAG_CHUNK: &str = "chunk";
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum RekeyScope {
76 Channel(ChannelId),
78 Root,
81}
82
83impl RekeyScope {
84 pub fn id32(&self) -> [u8; 32] {
86 match self {
87 RekeyScope::Channel(c) => c.0,
88 RekeyScope::Root => [0u8; 32],
89 }
90 }
91
92 fn to_hex(self) -> String {
93 crate::simd::hex::bytes_to_hex_32(&self.id32())
94 }
95
96 fn from_hex(hex: &str) -> Option<RekeyScope> {
97 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
98 return None;
99 }
100 let bytes = crate::simd::hex::hex_to_bytes_32(hex);
101 Some(if bytes == [0u8; 32] {
102 RekeyScope::Root
103 } else {
104 RekeyScope::Channel(ChannelId(bytes))
105 })
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct RekeyBlob {
116 pub locator: String,
117 pub wrapped: String,
118}
119
120#[derive(Debug)]
122pub enum RekeyError {
123 Stream(StreamError),
124 Crypto(String),
125 BadBlobLength(usize),
127 ScopeSplice,
129 EpochSplice,
131 NotARekey(u16),
133 BadTag(&'static str),
135 NonMonotonicEpoch,
137 BadChunkIndex,
139 TooManyBlobs(usize),
141}
142
143impl std::fmt::Display for RekeyError {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 RekeyError::Stream(e) => write!(f, "stream: {e}"),
147 RekeyError::Crypto(e) => write!(f, "crypto: {e}"),
148 RekeyError::BadBlobLength(n) => write!(f, "rekey blob plaintext is {n} bytes, expected 72"),
149 RekeyError::ScopeSplice => write!(f, "rekey blob scope binding mismatch (splice)"),
150 RekeyError::EpochSplice => write!(f, "rekey blob epoch binding mismatch (splice)"),
151 RekeyError::NotARekey(k) => write!(f, "rumor kind {k} is not a rekey"),
152 RekeyError::BadTag(t) => write!(f, "missing/duplicate/malformed rekey tag: {t}"),
153 RekeyError::NonMonotonicEpoch => write!(f, "rekey new_epoch must exceed prev_epoch"),
154 RekeyError::BadChunkIndex => write!(f, "rekey chunk index out of range"),
155 RekeyError::TooManyBlobs(n) => write!(f, "rekey carries {n} blobs, over the cap"),
156 }
157 }
158}
159
160impl std::error::Error for RekeyError {}
161
162impl From<StreamError> for RekeyError {
163 fn from(e: StreamError) -> Self {
164 RekeyError::Stream(e)
165 }
166}
167
168fn bound_plaintext(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> [u8; 72] {
173 let mut pt = [0u8; 72];
174 pt[..32].copy_from_slice(&scope.id32());
175 pt[32..40].copy_from_slice(&epoch.0.to_be_bytes());
176 pt[40..].copy_from_slice(new_key);
177 pt
178}
179
180pub fn bound_plaintext_b64(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> String {
183 base64_simd::STANDARD.encode_to_string(bound_plaintext(scope, epoch, new_key))
184}
185
186pub fn parse_bound_plaintext(pt: &[u8], scope: RekeyScope, epoch: Epoch) -> Result<[u8; 32], RekeyError> {
190 if pt.len() != 72 {
191 return Err(RekeyError::BadBlobLength(pt.len()));
192 }
193 if pt[..32] != scope.id32() {
194 return Err(RekeyError::ScopeSplice);
195 }
196 let mut epoch_be = [0u8; 8];
197 epoch_be.copy_from_slice(&pt[32..40]);
198 if u64::from_be_bytes(epoch_be) != epoch.0 {
199 return Err(RekeyError::EpochSplice);
200 }
201 let mut new_key = [0u8; 32];
202 new_key.copy_from_slice(&pt[40..72]);
203 Ok(new_key)
204}
205
206pub fn blob_locator(rotator_xonly: &[u8; 32], recipient_xonly: &[u8; 32], scope: RekeyScope, epoch: Epoch) -> String {
209 crate::simd::hex::bytes_to_hex_32(&recipient_locator(rotator_xonly, recipient_xonly, &scope.id32(), epoch))
210}
211
212pub fn build_blob_local(
217 rotator_sk: &SecretKey,
218 rotator_xonly: &[u8; 32],
219 recipient_pk: &PublicKey,
220 scope: RekeyScope,
221 epoch: Epoch,
222 new_key: &[u8; 32],
223) -> Result<RekeyBlob, RekeyError> {
224 let inner_b64 = Zeroizing::new(bound_plaintext_b64(scope, epoch, new_key));
225 let ck = ConversationKey::derive(rotator_sk, recipient_pk).map_err(|e| RekeyError::Crypto(e.to_string()))?;
226 let payload = encrypt_to_bytes(&ck, inner_b64.as_bytes()).map_err(|e| RekeyError::Crypto(e.to_string()))?;
227 Ok(RekeyBlob {
228 locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), scope, epoch),
229 wrapped: base64_simd::STANDARD.encode_to_string(&payload),
230 })
231}
232
233pub fn open_blob_local(
239 my_sk: &SecretKey,
240 rotator_pk: &PublicKey,
241 scope: RekeyScope,
242 epoch: Epoch,
243 blob: &RekeyBlob,
244) -> Result<[u8; 32], RekeyError> {
245 let ck = ConversationKey::derive(my_sk, rotator_pk).map_err(|e| RekeyError::Crypto(e.to_string()))?;
246 let payload = base64_simd::STANDARD
247 .decode_to_vec(blob.wrapped.as_bytes())
248 .map_err(|e| RekeyError::Crypto(e.to_string()))?;
249 let inner_b64 = Zeroizing::new(decrypt_to_bytes(&ck, &payload).map_err(|e| RekeyError::Crypto(e.to_string()))?);
250 let pt = Zeroizing::new(
251 base64_simd::STANDARD
252 .decode_to_vec(inner_b64.as_slice())
253 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
254 );
255 parse_bound_plaintext(&pt, scope, epoch)
256}
257
258pub fn find_my_blob<'a>(
262 blobs: &'a [RekeyBlob],
263 rotator_xonly: &[u8; 32],
264 my_xonly: &[u8; 32],
265 scope: RekeyScope,
266 epoch: Epoch,
267) -> Option<&'a RekeyBlob> {
268 let want = blob_locator(rotator_xonly, my_xonly, scope, epoch);
269 blobs.iter().find(|b| b.locator == want)
270}
271
272#[derive(Debug, Clone)]
277pub struct RekeyChunk {
278 pub rotator: PublicKey,
279 pub scope: RekeyScope,
280 pub new_epoch: Epoch,
281 pub prev_epoch: Epoch,
282 pub prev_commit: [u8; 32],
283 pub chunk: (u32, u32),
285 pub blobs: Vec<RekeyBlob>,
286}
287
288pub type RotationKey = ([u8; 32], [u8; 32], u64, [u8; 32]);
292
293impl RekeyChunk {
294 pub fn correlation(&self) -> RotationKey {
296 (self.rotator.to_bytes(), self.scope.id32(), self.new_epoch.0, self.prev_commit)
297 }
298}
299
300#[allow(clippy::too_many_arguments)]
303pub fn build_rekey_rumor(
304 rotator: PublicKey,
305 scope: RekeyScope,
306 new_epoch: Epoch,
307 prev_epoch: Epoch,
308 prev_commit: &[u8; 32],
309 blobs: &[RekeyBlob],
310 chunk_i: u32,
311 chunk_n: u32,
312 at_secs: u64,
313) -> Result<UnsignedEvent, RekeyError> {
314 if new_epoch.0 <= prev_epoch.0 {
315 return Err(RekeyError::NonMonotonicEpoch);
316 }
317 if chunk_n < 1 || chunk_i < 1 || chunk_i > chunk_n {
318 return Err(RekeyError::BadChunkIndex);
319 }
320 if blobs.len() > MAX_REKEY_BLOBS_PER_EVENT {
321 return Err(RekeyError::TooManyBlobs(blobs.len()));
322 }
323 let content = serde_json::to_string(blobs).map_err(|e| RekeyError::Crypto(e.to_string()))?;
324 let tags = vec![
325 Tag::custom(TagKind::Custom(TAG_SCOPE.into()), [scope.to_hex()]),
326 Tag::custom(TagKind::Custom(TAG_NEW_EPOCH.into()), [new_epoch.0.to_string()]),
327 Tag::custom(TagKind::Custom(TAG_PREV_EPOCH.into()), [prev_epoch.0.to_string()]),
328 Tag::custom(TagKind::Custom(TAG_PREV_COMMIT.into()), [crate::simd::hex::bytes_to_hex_32(prev_commit)]),
329 Tag::custom(TagKind::Custom(TAG_CHUNK.into()), [chunk_i.to_string(), chunk_n.to_string()]),
330 ];
331 Ok(stream::build_rumor_secs(super::kind::REKEY, rotator, &content, tags, at_secs))
333}
334
335pub fn channel_rekey_group(addressing_root: &[u8; 32], channel_id: &ChannelId, new_epoch: Epoch) -> GroupKey {
342 channel_rekey_group_key(addressing_root, channel_id, new_epoch)
343}
344
345pub fn base_rekey_group(prior_root: &[u8; 32], community_id: &CommunityId, new_epoch: Epoch) -> GroupKey {
348 base_rekey_group_key(prior_root, community_id, new_epoch)
349}
350
351pub fn seal_rekey_chunk(
355 rumor: &UnsignedEvent,
356 rekey_group: &GroupKey,
357 rotator_keys: &Keys,
358 wrap_at: Timestamp,
359) -> Result<(Event, Keys), RekeyError> {
360 let seal = stream::build_seal(rumor, SealForm::Encrypted, rekey_group, rotator_keys)?;
361 Ok(stream::wrap_seal(&seal, rekey_group, stream::KIND_WRAP, wrap_at)?)
362}
363
364#[allow(clippy::too_many_arguments)]
368pub fn build_rekey_chunks_local(
369 rotator_keys: &Keys,
370 rekey_group: &GroupKey,
371 scope: RekeyScope,
372 new_epoch: Epoch,
373 prev_epoch: Epoch,
374 prev_commit: &[u8; 32],
375 blobs: &[RekeyBlob],
376 at_secs: u64,
377) -> Result<Vec<Event>, RekeyError> {
378 let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
379 vec![&[]]
380 } else {
381 blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
382 };
383 let n = groups.len() as u32;
384 let mut out = Vec::with_capacity(groups.len());
385 for (idx, group_blobs) in groups.iter().enumerate() {
386 let rumor = build_rekey_rumor(
387 rotator_keys.public_key(),
388 scope,
389 new_epoch,
390 prev_epoch,
391 prev_commit,
392 group_blobs,
393 idx as u32 + 1,
394 n,
395 at_secs,
396 )?;
397 let (wrap, _) = seal_rekey_chunk(&rumor, rekey_group, rotator_keys, Timestamp::from_secs(at_secs))?;
398 out.push(wrap);
399 }
400 Ok(out)
401}
402
403pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError> {
407 if opened.seal_form != SealForm::Encrypted {
408 return Err(RekeyError::Stream(StreamError::BadSealKind(stream::KIND_SEAL_PLAINTEXT)));
410 }
411 let rumor = &opened.rumor;
412 if rumor.kind.as_u16() != super::kind::REKEY {
413 return Err(RekeyError::NotARekey(rumor.kind.as_u16()));
414 }
415 let scope = RekeyScope::from_hex(&unique_tag(rumor, TAG_SCOPE)?.ok_or(RekeyError::BadTag(TAG_SCOPE))?)
416 .ok_or(RekeyError::BadTag(TAG_SCOPE))?;
417 let new_epoch = Epoch(parse_u64(rumor, TAG_NEW_EPOCH)?);
418 let prev_epoch = Epoch(parse_u64(rumor, TAG_PREV_EPOCH)?);
419 if new_epoch.0 <= prev_epoch.0 {
420 return Err(RekeyError::NonMonotonicEpoch);
421 }
422 let prev_hex = unique_tag(rumor, TAG_PREV_COMMIT)?.ok_or(RekeyError::BadTag(TAG_PREV_COMMIT))?;
423 if prev_hex.len() != 64 || !prev_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
424 return Err(RekeyError::BadTag(TAG_PREV_COMMIT));
425 }
426 let prev_commit = crate::simd::hex::hex_to_bytes_32(&prev_hex);
427
428 let (chunk_i, chunk_n) = parse_chunk(rumor)?;
429
430 let blobs: Vec<RekeyBlob> = serde_json::from_str(&rumor.content).map_err(|_| RekeyError::BadTag("blobs"))?;
431 if blobs.len() > MAX_REKEY_BLOBS_RECEIVED {
432 return Err(RekeyError::TooManyBlobs(blobs.len()));
433 }
434
435 Ok(RekeyChunk {
436 rotator: opened.author,
437 scope,
438 new_epoch,
439 prev_epoch,
440 prev_commit,
441 chunk: (chunk_i, chunk_n),
442 blobs,
443 })
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum Continuity {
451 Extends,
454 Gap,
457 Fork,
459}
460
461pub fn check_continuity(chunk: &RekeyChunk, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
465 if chunk.prev_epoch.0 == held_epoch.0 {
466 if epoch_key_commitment(held_epoch, held_key) == chunk.prev_commit {
467 Continuity::Extends
468 } else {
469 Continuity::Fork
470 }
471 } else if chunk.prev_epoch.0 > held_epoch.0 {
472 Continuity::Gap
473 } else {
474 Continuity::Fork
477 }
478}
479
480#[derive(Debug, Clone)]
483pub struct Rotation {
484 pub rotator: PublicKey,
485 pub scope: RekeyScope,
486 pub new_epoch: Epoch,
487 pub prev_epoch: Epoch,
488 pub prev_commit: [u8; 32],
489 pub blobs: Vec<RekeyBlob>,
491 pub declared_chunks: u32,
493 pub held_chunks: std::collections::BTreeSet<u32>,
495}
496
497impl Rotation {
498 pub fn is_complete(&self) -> bool {
502 self.declared_chunks >= 1 && (1..=self.declared_chunks).all(|i| self.held_chunks.contains(&i))
503 }
504
505 pub fn continuity(&self, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
509 if self.prev_epoch.0 == held_epoch.0 {
510 if epoch_key_commitment(held_epoch, held_key) == self.prev_commit {
511 Continuity::Extends
512 } else {
513 Continuity::Fork
514 }
515 } else if self.prev_epoch.0 > held_epoch.0 {
516 Continuity::Gap
517 } else {
518 Continuity::Fork
519 }
520 }
521}
522
523pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec<Rotation> {
528 use std::collections::BTreeMap;
529 let mut by_key: BTreeMap<RotationKey, Rotation> = BTreeMap::new();
530 for c in chunks {
531 let entry = by_key.entry(c.correlation()).or_insert_with(|| Rotation {
532 rotator: c.rotator,
533 scope: c.scope,
534 new_epoch: c.new_epoch,
535 prev_epoch: c.prev_epoch,
536 prev_commit: c.prev_commit,
537 blobs: Vec::new(),
538 declared_chunks: c.chunk.1,
539 held_chunks: std::collections::BTreeSet::new(),
540 });
541 if entry.held_chunks.insert(c.chunk.0) {
542 entry.blobs.extend(c.blobs.iter().cloned());
543 }
544 }
545 by_key.into_values().collect()
546}
547
548pub fn am_i_removed(rotation: &Rotation, my_xonly: &[u8; 32]) -> Option<bool> {
552 if !rotation.is_complete() {
553 return None;
554 }
555 let mine = find_my_blob(&rotation.blobs, &rotation.rotator.to_bytes(), my_xonly, rotation.scope, rotation.new_epoch);
556 Some(mine.is_none())
557}
558
559pub fn lowest_key_winner(candidate_keys: &[[u8; 32]]) -> Option<usize> {
565 candidate_keys
566 .iter()
567 .enumerate()
568 .min_by(|(_, a), (_, b)| a.cmp(b))
569 .map(|(i, _)| i)
570}
571
572fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, RekeyError> {
575 let mut found: Option<String> = None;
576 for t in rumor.tags.iter() {
577 let s = t.as_slice();
578 if s.len() >= 2 && s[0] == name {
579 if found.is_some() {
580 return Err(RekeyError::BadTag(name));
581 }
582 found = Some(s[1].clone());
583 }
584 }
585 Ok(found)
586}
587
588fn parse_u64(rumor: &UnsignedEvent, name: &'static str) -> Result<u64, RekeyError> {
589 unique_tag(rumor, name)?
590 .ok_or(RekeyError::BadTag(name))?
591 .parse::<u64>()
592 .map_err(|_| RekeyError::BadTag(name))
593}
594
595fn parse_chunk(rumor: &UnsignedEvent) -> Result<(u32, u32), RekeyError> {
596 let mut found: Option<(u32, u32)> = None;
597 for t in rumor.tags.iter() {
598 let s = t.as_slice();
599 if s.len() >= 3 && s[0] == TAG_CHUNK {
600 if found.is_some() {
601 return Err(RekeyError::BadTag(TAG_CHUNK));
602 }
603 let i: u32 = s[1].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
604 let n: u32 = s[2].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
605 found = Some((i, n));
606 }
607 }
608 let (i, n) = found.ok_or(RekeyError::BadTag(TAG_CHUNK))?;
609 if n < 1 || i < 1 || i > n {
610 return Err(RekeyError::BadChunkIndex);
611 }
612 Ok((i, n))
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use nostr_sdk::prelude::JsonUtil;
619
620 fn keys(byte: u8) -> Keys {
621 Keys::new(SecretKey::from_slice(&[byte; 32]).unwrap())
622 }
623
624 fn xonly(k: &Keys) -> [u8; 32] {
625 k.public_key().to_bytes()
626 }
627
628 const CHAN: ChannelId = ChannelId([0x42u8; 32]);
629
630 #[test]
633 fn bound_plaintext_layout_is_frozen() {
634 let pt = bound_plaintext(RekeyScope::Root, Epoch(1), &[0xABu8; 32]);
635 let expected = format!("{}{}{}", "00".repeat(32), "0000000000000001", "ab".repeat(32));
636 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
637 let pt2 = bound_plaintext(RekeyScope::Channel(ChannelId([0x11u8; 32])), Epoch(0x0102), &[0xCDu8; 32]);
638 let expected2 = format!("{}{}{}", "11".repeat(32), "0000000000000102", "cd".repeat(32));
639 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt2), expected2);
640 }
641
642 #[test]
643 fn blob_round_trips_both_scopes() {
644 let rotator = keys(7);
645 let recipient = keys(8);
646 for (scope, epoch, key) in [
647 (RekeyScope::Root, Epoch(1), [0xABu8; 32]),
648 (RekeyScope::Channel(CHAN), Epoch(5), [0xCDu8; 32]),
649 ] {
650 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
651 let got = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob).unwrap();
652 assert_eq!(got, key, "the recipient recovers the fresh key");
653 }
654 }
655
656 #[test]
657 fn locator_is_public_and_computable_from_pubkeys_alone() {
658 let rotator = keys(7);
662 let recipient = keys(8);
663 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(2), &[1u8; 32]).unwrap();
664 let recomputed = blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(2));
665 assert_eq!(blob.locator, recomputed, "both sides compute the same public locator");
666 }
667
668 #[test]
669 fn a_non_recipient_cannot_open_even_holding_the_public_locator() {
670 let rotator = keys(7);
674 let recipient = keys(8);
675 let outsider = keys(9);
676 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[2u8; 32]).unwrap();
677 assert_eq!(blob.locator, blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)));
679 assert!(open_blob_local(outsider.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).is_err());
682 }
683
684 #[test]
685 fn a_relocated_blob_still_opens_by_decrypt_not_locator() {
686 let rotator = keys(7);
691 let recipient = keys(8);
692 let mut blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[3u8; 32]).unwrap();
693 blob.locator = "ff".repeat(32);
694 assert_eq!(
696 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).unwrap(),
697 [3u8; 32]
698 );
699 assert!(find_my_blob(std::slice::from_ref(&blob), &xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)).is_none());
701 }
702
703 #[test]
704 fn scope_and_epoch_splices_are_rejected_on_open() {
705 let rotator = keys(7);
706 let recipient = keys(8);
707 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[4u8; 32]).unwrap();
708 assert!(matches!(
710 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob),
711 Err(RekeyError::ScopeSplice)
712 ));
713 assert!(matches!(
715 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob),
716 Err(RekeyError::EpochSplice)
717 ));
718 }
719
720 #[test]
721 fn wrapped_carries_base64_of_the_72_bytes_for_bunker_parity() {
722 let rotator = keys(7);
726 let recipient = keys(8);
727 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[5u8; 32]).unwrap();
728 let ck = ConversationKey::derive(recipient.secret_key(), &rotator.public_key()).unwrap();
729 let payload = base64_simd::STANDARD.decode_to_vec(blob.wrapped.as_bytes()).unwrap();
730 let inner = decrypt_to_bytes(&ck, &payload).unwrap();
731 assert_eq!(String::from_utf8(inner).unwrap(), bound_plaintext_b64(RekeyScope::Root, Epoch(1), &[5u8; 32]));
732 }
733
734 #[test]
735 fn distinct_recipients_and_scopes_get_distinct_locators() {
736 let rotator = keys(7);
737 let r1 = keys(8);
738 let r2 = keys(9);
739 assert_ne!(
740 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
741 blob_locator(&xonly(&rotator), &xonly(&r2), RekeyScope::Root, Epoch(1))
742 );
743 assert_ne!(
745 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
746 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Channel(CHAN), Epoch(1))
747 );
748 }
749
750 fn root() -> [u8; 32] {
753 [0x55u8; 32]
754 }
755
756 #[test]
757 fn channel_rekey_round_trips_through_the_stream() {
758 let rotator = keys(1);
759 let recipient = keys(8);
760 let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
761 let key = [0xABu8; 32];
762 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &key).unwrap();
763 let commit = epoch_key_commitment(Epoch(0), &[0xEEu8; 32]);
764 let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &commit, &[blob.clone()], 1, 1, 100).unwrap();
765 let (wrap, _) = seal_rekey_chunk(&rumor, &group, &rotator, Timestamp::from_secs(100)).unwrap();
766
767 assert_ne!(wrap.pubkey, rotator.public_key());
769 assert_eq!(wrap.pubkey, group.pk());
770
771 let opened = stream::open_wrap(&wrap, &group).unwrap();
772 let chunk = parse_rekey_chunk(&opened).unwrap();
773 assert_eq!(chunk.rotator, rotator.public_key(), "rotator recovered from the seal");
774 assert!(matches!(chunk.scope, RekeyScope::Channel(c) if c.0 == CHAN.0));
775 assert_eq!(chunk.new_epoch, Epoch(1));
776 assert_eq!(chunk.prev_epoch, Epoch(0));
777 assert_eq!(chunk.prev_commit, commit);
778 assert_eq!(chunk.chunk, (1, 1));
779 assert_eq!(chunk.blobs, vec![blob]);
780 }
781
782 #[test]
783 fn base_rekey_addresses_under_the_prior_root() {
784 let rotator = keys(1);
787 let recipient = keys(8);
788 let prior_root = [0x66u8; 32];
789 let community = CommunityId([0x77u8; 32]);
790 let new_root = [0x99u8; 32];
791 let group = base_rekey_group(&prior_root, &community, Epoch(1));
792 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &new_root).unwrap();
793 let commit = epoch_key_commitment(Epoch(0), &prior_root);
794 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &commit, &[blob], 100).unwrap();
795 assert_eq!(chunks.len(), 1);
796
797 let opened = stream::open_wrap(&chunks[0], &group).unwrap();
798 let chunk = parse_rekey_chunk(&opened).unwrap();
799 assert!(matches!(chunk.scope, RekeyScope::Root));
800 let mine = find_my_blob(&chunk.blobs, &chunk.rotator.to_bytes(), &xonly(&recipient), chunk.scope, chunk.new_epoch).unwrap();
802 assert_eq!(open_blob_local(recipient.secret_key(), &chunk.rotator, chunk.scope, chunk.new_epoch, mine).unwrap(), new_root);
803
804 let wrong = base_rekey_group(&[0u8; 32], &community, Epoch(1));
806 assert!(stream::open_wrap(&chunks[0], &wrong).is_err());
807 }
808
809 #[test]
810 fn a_full_send_chunk_stays_under_the_relay_size_limit() {
811 let rotator = keys(1);
814 let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
815 let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT)
816 .map(|i| {
817 let r = keys((i % 200 + 20) as u8);
818 build_blob_local(rotator.secret_key(), &xonly(&rotator), &r.public_key(), RekeyScope::Root, Epoch(1), &[0xCDu8; 32]).unwrap()
819 })
820 .collect();
821 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &blobs, 100).unwrap();
822 assert_eq!(chunks.len(), 1, "a full send chunk is exactly one event");
823 assert!(chunks[0].as_json().len() <= 65_536, "a full chunk must fit a 64KB relay event");
824 }
825
826 #[test]
827 fn oversize_recipient_set_splits_into_chunks() {
828 let rotator = keys(1);
829 let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
830 let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT + 1)
832 .map(|_| RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() })
833 .collect();
834 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(2), Epoch(1), &[0u8; 32], &blobs, 100).unwrap();
835 assert_eq!(chunks.len(), 2);
836 let parsed: Vec<RekeyChunk> = chunks.iter().map(|w| parse_rekey_chunk(&stream::open_wrap(w, &group).unwrap()).unwrap()).collect();
837 assert_eq!(parsed[0].chunk, (1, 2));
838 assert_eq!(parsed[1].chunk, (2, 2));
839 assert_eq!(parsed[0].blobs.len(), MAX_REKEY_BLOBS_PER_EVENT);
840 assert_eq!(parsed[1].blobs.len(), 1);
841 }
842
843 #[test]
844 fn plaintext_sealed_rekey_is_rejected() {
845 let rotator = keys(1);
846 let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
847 let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[], 1, 1, 100).unwrap();
848 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group, &rotator).unwrap();
849 let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
850 let opened = stream::open_wrap(&wrap, &group).unwrap();
851 assert!(parse_rekey_chunk(&opened).is_err(), "the rekey plane must be encrypted-sealed");
852 }
853
854 #[test]
855 fn non_monotonic_epoch_is_refused_at_mint_and_on_parse() {
856 let rotator = keys(1);
857 assert!(matches!(
858 build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(1), &[0u8; 32], &[], 1, 1, 100),
859 Err(RekeyError::NonMonotonicEpoch)
860 ));
861 }
862
863 #[test]
864 fn bad_chunk_indices_are_refused() {
865 let rotator = keys(1);
866 for (i, n) in [(0u32, 1u32), (2, 1), (1, 0)] {
867 assert!(
868 matches!(build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &[], i, n, 100), Err(RekeyError::BadChunkIndex)),
869 "chunk ({i},{n}) must be rejected"
870 );
871 }
872 }
873
874 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 {
877 RekeyChunk {
878 rotator: rotator.public_key(),
879 scope,
880 new_epoch: Epoch(new_epoch),
881 prev_epoch: Epoch(prev_epoch),
882 prev_commit: epoch_key_commitment(Epoch(prev_epoch), prev_key),
883 chunk: (i, n),
884 blobs,
885 }
886 }
887
888 #[test]
889 fn continuity_extends_gaps_and_forks() {
890 let rotator = keys(1);
891 let held = [0x33u8; 32];
892 let good = chunk_at(&rotator, RekeyScope::Root, 3, 2, &held, vec![], 1, 1);
894 assert_eq!(check_continuity(&good, Epoch(2), &held), Continuity::Extends);
895 let ahead = chunk_at(&rotator, RekeyScope::Root, 5, 4, &held, vec![], 1, 1);
897 assert_eq!(check_continuity(&ahead, Epoch(2), &held), Continuity::Gap);
898 let fork = chunk_at(&rotator, RekeyScope::Root, 3, 2, &[0x99u8; 32], vec![], 1, 1);
900 assert_eq!(check_continuity(&fork, Epoch(2), &held), Continuity::Fork);
901 let stale = chunk_at(&rotator, RekeyScope::Root, 2, 1, &held, vec![], 1, 1);
903 assert_eq!(check_continuity(&stale, Epoch(2), &held), Continuity::Fork);
904 }
905
906 #[test]
907 fn a_missing_chunk_is_never_a_removal() {
908 let rotator = keys(1);
911 let me = keys(8);
912 let my_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &me.public_key(), RekeyScope::Root, Epoch(1), &[0xAAu8; 32]).unwrap();
914 let c1 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![RekeyBlob { locator: "bb".repeat(32), wrapped: "x".into() }], 1, 2);
915 let rots = collect_rotations(&[c1.clone()]);
916 assert_eq!(rots.len(), 1);
917 assert!(!rots[0].is_complete(), "one of two chunks held → incomplete");
918 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), None, "incomplete → keep recovering, never conclude removal");
919
920 let c2 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![my_blob.clone()], 2, 2);
922 let rots = collect_rotations(&[c1, c2]);
923 assert!(rots[0].is_complete());
924 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(false));
925 let mine = find_my_blob(&rots[0].blobs, &rots[0].rotator.to_bytes(), &xonly(&me), RekeyScope::Root, Epoch(1)).unwrap();
927 assert_eq!(open_blob_local(me.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), mine).unwrap(), [0xAAu8; 32]);
928 }
929
930 #[test]
931 fn a_complete_rotation_without_my_blob_is_a_removal() {
932 let rotator = keys(1);
933 let me = keys(8);
934 let other = keys(9);
935 let their_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &other.public_key(), RekeyScope::Root, Epoch(1), &[0xBBu8; 32]).unwrap();
937 let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![their_blob], 1, 1);
938 let rots = collect_rotations(&[c]);
939 assert!(rots[0].is_complete());
940 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(true), "complete rotation, no blob for me → removed");
941 }
942
943 #[test]
944 fn collect_rotations_separates_concurrent_rotators_and_scopes() {
945 let rot_a = keys(1);
948 let rot_b = keys(2);
949 let ca = chunk_at(&rot_a, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
950 let cb = chunk_at(&rot_b, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
951 let cc = chunk_at(&rot_a, RekeyScope::Channel(CHAN), 2, 1, &[7u8; 32], vec![], 1, 1);
952 let rots = collect_rotations(&[ca, cb, cc]);
953 assert_eq!(rots.len(), 3, "different rotator or scope ⇒ different rotation");
954 }
955
956 #[test]
957 fn duplicate_chunk_delivery_is_idempotent() {
958 let rotator = keys(1);
959 let blob = RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() };
960 let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![blob.clone()], 1, 1);
961 let rots = collect_rotations(&[c.clone(), c]);
962 assert_eq!(rots.len(), 1);
963 assert_eq!(rots[0].blobs.len(), 1, "re-delivering a chunk must not double its blobs");
964 }
965
966 #[test]
967 fn lowest_key_fork_winner_is_deterministic() {
968 let keys_a = [[0x03u8; 32], [0x01u8; 32], [0x02u8; 32]];
971 assert_eq!(lowest_key_winner(&keys_a), Some(1));
972 let keys_b = [[0x01u8; 32], [0x02u8; 32], [0x03u8; 32]];
973 assert_eq!(lowest_key_winner(&keys_b), Some(0));
974 assert_eq!(lowest_key_winner(&[]), None);
975 }
976}