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, 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 = crate::community::cipher::encrypt_with_random_nonce(&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 async fn build_blob<S: crate::signer::VectorSigner + ?Sized>(
264 signer: &S,
265 rotator_xonly: &[u8; 32],
266 recipient_pk: &PublicKey,
267 scope: RekeyScope,
268 epoch: Epoch,
269 new_key: &[u8; 32],
270) -> Result<RekeyBlob, RekeyError> {
271 let inner_b64 = Zeroizing::new(bound_plaintext_b64(scope, epoch, new_key));
272 let wrapped = signer
273 .nip44_encrypt_async(recipient_pk, inner_b64.as_str())
274 .await
275 .map_err(|e| RekeyError::Crypto(e.to_string()))?;
276 Ok(RekeyBlob {
277 locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), scope, epoch),
278 wrapped,
279 })
280}
281
282pub async fn open_blob<S: crate::signer::VectorSigner + ?Sized>(
286 signer: &S,
287 rotator_pk: &PublicKey,
288 scope: RekeyScope,
289 epoch: Epoch,
290 blob: &RekeyBlob,
291) -> Result<[u8; 32], RekeyError> {
292 let inner_b64 = Zeroizing::new(
293 signer
294 .nip44_decrypt_async(rotator_pk, &blob.wrapped)
295 .await
296 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
297 );
298 let pt = Zeroizing::new(
299 base64_simd::STANDARD
300 .decode_to_vec(inner_b64.as_bytes())
301 .map_err(|e| RekeyError::Crypto(e.to_string()))?,
302 );
303 parse_bound_plaintext(&pt, scope, epoch)
304}
305
306pub fn find_my_blob<'a>(
310 blobs: &'a [RekeyBlob],
311 rotator_xonly: &[u8; 32],
312 my_xonly: &[u8; 32],
313 scope: RekeyScope,
314 epoch: Epoch,
315) -> Option<&'a RekeyBlob> {
316 let want = blob_locator(rotator_xonly, my_xonly, scope, epoch);
317 blobs.iter().find(|b| b.locator == want)
318}
319
320#[derive(Debug, Clone)]
325pub struct RekeyChunk {
326 pub rotator: PublicKey,
327 pub scope: RekeyScope,
328 pub new_epoch: Epoch,
329 pub prev_epoch: Epoch,
330 pub prev_commit: [u8; 32],
331 pub chunk: (u32, u32),
333 pub blobs: Vec<RekeyBlob>,
334 pub citation: Option<crate::community::edition::AuthorityCitation>,
337}
338
339pub type RotationKey = ([u8; 32], [u8; 32], u64, [u8; 32]);
343
344impl RekeyChunk {
345 pub fn correlation(&self) -> RotationKey {
347 (self.rotator.to_bytes(), self.scope.id32(), self.new_epoch.0, self.prev_commit)
348 }
349}
350
351#[allow(clippy::too_many_arguments)]
354pub fn build_rekey_rumor(
355 rotator: PublicKey,
356 scope: RekeyScope,
357 new_epoch: Epoch,
358 prev_epoch: Epoch,
359 prev_commit: &[u8; 32],
360 blobs: &[RekeyBlob],
361 chunk_i: u32,
362 chunk_n: u32,
363 at_secs: u64,
364 citation: Option<&crate::community::edition::AuthorityCitation>,
365) -> Result<UnsignedEvent, RekeyError> {
366 if new_epoch.0 <= prev_epoch.0 {
367 return Err(RekeyError::NonMonotonicEpoch);
368 }
369 if chunk_n < 1 || chunk_i < 1 || chunk_i > chunk_n {
370 return Err(RekeyError::BadChunkIndex);
371 }
372 if blobs.len() > MAX_REKEY_BLOBS_PER_EVENT {
373 return Err(RekeyError::TooManyBlobs(blobs.len()));
374 }
375 let content = serde_json::to_string(blobs).map_err(|e| RekeyError::Crypto(e.to_string()))?;
376 let mut tags = vec![
377 Tag::custom(TAG_SCOPE, [scope.to_hex()]),
378 Tag::custom(TAG_NEW_EPOCH, [new_epoch.0.to_string()]),
379 Tag::custom(TAG_PREV_EPOCH, [prev_epoch.0.to_string()]),
380 Tag::custom(TAG_PREV_COMMIT, [crate::simd::hex::bytes_to_hex_32(prev_commit)]),
381 Tag::custom(TAG_CHUNK, [chunk_i.to_string(), chunk_n.to_string()]),
382 ];
383 if let Some(c) = citation {
387 tags.push(c.to_tag());
388 }
389 Ok(stream::build_rumor_secs(super::kind::REKEY, rotator, &content, tags, at_secs))
391}
392
393pub fn channel_rekey_group(addressing_root: &[u8; 32], channel_id: &ChannelId, new_epoch: Epoch) -> GroupKey {
400 channel_rekey_group_key(addressing_root, channel_id, new_epoch)
401}
402
403pub fn base_rekey_group(prior_root: &[u8; 32], community_id: &CommunityId, new_epoch: Epoch) -> GroupKey {
406 base_rekey_group_key(prior_root, community_id, new_epoch)
407}
408
409pub fn seal_rekey_chunk(
413 rumor: &UnsignedEvent,
414 rekey_group: &GroupKey,
415 rotator_keys: &Keys,
416 wrap_at: Timestamp,
417) -> Result<(Event, Keys), RekeyError> {
418 let seal = stream::build_seal(rumor, SealForm::Encrypted, rekey_group, rotator_keys)?;
419 Ok(stream::wrap_seal(&seal, rekey_group, stream::KIND_WRAP, wrap_at)?)
420}
421
422#[allow(clippy::too_many_arguments)]
426pub fn build_rekey_chunks_local(
427 rotator_keys: &Keys,
428 rekey_group: &GroupKey,
429 scope: RekeyScope,
430 new_epoch: Epoch,
431 prev_epoch: Epoch,
432 prev_commit: &[u8; 32],
433 blobs: &[RekeyBlob],
434 at_secs: u64,
435 citation: Option<&crate::community::edition::AuthorityCitation>,
436) -> Result<Vec<Event>, RekeyError> {
437 let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
438 vec![&[]]
439 } else {
440 blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
441 };
442 let n = groups.len() as u32;
443 let mut out = Vec::with_capacity(groups.len());
444 for (idx, group_blobs) in groups.iter().enumerate() {
445 let rumor = build_rekey_rumor(
446 rotator_keys.public_key(),
447 scope,
448 new_epoch,
449 prev_epoch,
450 prev_commit,
451 group_blobs,
452 idx as u32 + 1,
453 n,
454 at_secs,
455 citation,
456 )?;
457 let (wrap, _) = seal_rekey_chunk(&rumor, rekey_group, rotator_keys, Timestamp::from_secs(at_secs))?;
458 out.push(wrap);
459 }
460 Ok(out)
461}
462
463#[allow(clippy::too_many_arguments)]
467pub async fn build_rekey_chunks<S: crate::signer::VectorSigner + ?Sized>(
468 signer: &S,
469 rotator_pk: PublicKey,
470 rekey_group: &GroupKey,
471 scope: RekeyScope,
472 new_epoch: Epoch,
473 prev_epoch: Epoch,
474 prev_commit: &[u8; 32],
475 blobs: &[RekeyBlob],
476 at_secs: u64,
477 citation: Option<&crate::community::edition::AuthorityCitation>,
478) -> Result<Vec<Event>, RekeyError> {
479 let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
480 vec![&[]]
481 } else {
482 blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
483 };
484 let n = groups.len() as u32;
485 let mut out = Vec::with_capacity(groups.len());
486 for (idx, group_blobs) in groups.iter().enumerate() {
487 let rumor = build_rekey_rumor(rotator_pk, scope, new_epoch, prev_epoch, prev_commit, group_blobs, idx as u32 + 1, n, at_secs, citation)?;
488 let (wrap, _) = stream::seal_and_wrap_signed(signer, rotator_pk, &rumor, SealForm::Encrypted, rekey_group, stream::KIND_WRAP, Timestamp::from_secs(at_secs), &[]).await?;
489 out.push(wrap);
490 }
491 Ok(out)
492}
493
494pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError> {
498 if opened.seal_form != SealForm::Encrypted {
499 return Err(RekeyError::Stream(StreamError::BadSealKind(stream::KIND_SEAL_PLAINTEXT)));
501 }
502 let rumor = &opened.rumor;
503 if rumor.kind.as_u16() != super::kind::REKEY {
504 return Err(RekeyError::NotARekey(rumor.kind.as_u16()));
505 }
506 let scope = RekeyScope::from_hex(&unique_tag(rumor, TAG_SCOPE)?.ok_or(RekeyError::BadTag(TAG_SCOPE))?)
507 .ok_or(RekeyError::BadTag(TAG_SCOPE))?;
508 let new_epoch = Epoch(parse_u64(rumor, TAG_NEW_EPOCH)?);
509 let prev_epoch = Epoch(parse_u64(rumor, TAG_PREV_EPOCH)?);
510 if new_epoch.0 <= prev_epoch.0 {
511 return Err(RekeyError::NonMonotonicEpoch);
512 }
513 let prev_hex = unique_tag(rumor, TAG_PREV_COMMIT)?.ok_or(RekeyError::BadTag(TAG_PREV_COMMIT))?;
514 if prev_hex.len() != 64 || !prev_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
515 return Err(RekeyError::BadTag(TAG_PREV_COMMIT));
516 }
517 let prev_commit = crate::simd::hex::hex_to_bytes_32(&prev_hex);
518
519 let (chunk_i, chunk_n) = parse_chunk(rumor)?;
520
521 let blobs: Vec<RekeyBlob> = serde_json::from_str(&rumor.content).map_err(|_| RekeyError::BadTag("blobs"))?;
522 if blobs.len() > MAX_REKEY_BLOBS_RECEIVED {
523 return Err(RekeyError::TooManyBlobs(blobs.len()));
524 }
525
526 Ok(RekeyChunk {
527 rotator: opened.author,
528 scope,
529 new_epoch,
530 prev_epoch,
531 prev_commit,
532 chunk: (chunk_i, chunk_n),
533 blobs,
534 citation: crate::community::edition::AuthorityCitation::from_tags(&rumor.tags),
535 })
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum Continuity {
543 Extends,
546 Gap,
549 Fork,
551}
552
553pub fn check_continuity(chunk: &RekeyChunk, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
557 if chunk.prev_epoch.0 == held_epoch.0 {
558 if epoch_key_commitment(held_epoch, held_key) == chunk.prev_commit {
559 Continuity::Extends
560 } else {
561 Continuity::Fork
562 }
563 } else if chunk.prev_epoch.0 > held_epoch.0 {
564 Continuity::Gap
565 } else {
566 Continuity::Fork
569 }
570}
571
572#[derive(Debug, Clone)]
575pub struct Rotation {
576 pub rotator: PublicKey,
577 pub scope: RekeyScope,
578 pub new_epoch: Epoch,
579 pub prev_epoch: Epoch,
580 pub prev_commit: [u8; 32],
581 pub blobs: Vec<RekeyBlob>,
583 pub declared_chunks: u32,
585 pub held_chunks: std::collections::BTreeSet<u32>,
587 pub citation: Option<crate::community::edition::AuthorityCitation>,
590}
591
592impl Rotation {
593 pub fn is_complete(&self) -> bool {
597 self.declared_chunks >= 1 && (1..=self.declared_chunks).all(|i| self.held_chunks.contains(&i))
598 }
599
600 pub fn continuity(&self, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
604 if self.prev_epoch.0 == held_epoch.0 {
605 if epoch_key_commitment(held_epoch, held_key) == self.prev_commit {
606 Continuity::Extends
607 } else {
608 Continuity::Fork
609 }
610 } else if self.prev_epoch.0 > held_epoch.0 {
611 Continuity::Gap
612 } else {
613 Continuity::Fork
614 }
615 }
616}
617
618pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec<Rotation> {
623 use std::collections::BTreeMap;
624 let mut by_key: BTreeMap<RotationKey, Rotation> = BTreeMap::new();
625 for c in chunks {
626 let entry = by_key.entry(c.correlation()).or_insert_with(|| Rotation {
627 rotator: c.rotator,
628 scope: c.scope,
629 new_epoch: c.new_epoch,
630 prev_epoch: c.prev_epoch,
631 prev_commit: c.prev_commit,
632 blobs: Vec::new(),
633 declared_chunks: c.chunk.1,
634 held_chunks: std::collections::BTreeSet::new(),
635 citation: c.citation.clone(),
636 });
637 if entry.held_chunks.insert(c.chunk.0) {
638 entry.blobs.extend(c.blobs.iter().cloned());
639 }
640 }
641 by_key.into_values().collect()
642}
643
644pub fn am_i_removed(rotation: &Rotation, my_xonly: &[u8; 32]) -> Option<bool> {
648 if !rotation.is_complete() {
649 return None;
650 }
651 let mine = find_my_blob(&rotation.blobs, &rotation.rotator.to_bytes(), my_xonly, rotation.scope, rotation.new_epoch);
652 Some(mine.is_none())
653}
654
655pub fn lowest_key_winner(candidate_keys: &[[u8; 32]]) -> Option<usize> {
661 candidate_keys
662 .iter()
663 .enumerate()
664 .min_by(|(_, a), (_, b)| a.cmp(b))
665 .map(|(i, _)| i)
666}
667
668fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, RekeyError> {
671 let mut found: Option<String> = None;
672 for t in rumor.tags.iter() {
673 let s = t.as_slice();
674 if s.len() >= 2 && s[0] == name {
675 if found.is_some() {
676 return Err(RekeyError::BadTag(name));
677 }
678 found = Some(s[1].clone());
679 }
680 }
681 Ok(found)
682}
683
684fn parse_u64(rumor: &UnsignedEvent, name: &'static str) -> Result<u64, RekeyError> {
685 let raw = unique_tag(rumor, name)?.ok_or(RekeyError::BadTag(name))?;
686 if !crate::community::edition::is_tag_decimal(&raw) {
689 return Err(RekeyError::BadTag(name));
690 }
691 raw.parse::<u64>().map_err(|_| RekeyError::BadTag(name))
692}
693
694fn parse_chunk(rumor: &UnsignedEvent) -> Result<(u32, u32), RekeyError> {
695 let mut found: Option<(u32, u32)> = None;
696 for t in rumor.tags.iter() {
697 let s = t.as_slice();
698 if s.len() >= 3 && s[0] == TAG_CHUNK {
699 if found.is_some() {
700 return Err(RekeyError::BadTag(TAG_CHUNK));
701 }
702 if !crate::community::edition::is_tag_decimal(&s[1]) || !crate::community::edition::is_tag_decimal(&s[2]) {
703 return Err(RekeyError::BadTag(TAG_CHUNK));
704 }
705 let i: u32 = s[1].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
706 let n: u32 = s[2].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
707 found = Some((i, n));
708 }
709 }
710 let (i, n) = found.ok_or(RekeyError::BadTag(TAG_CHUNK))?;
711 if n < 1 || i < 1 || i > n {
712 return Err(RekeyError::BadChunkIndex);
713 }
714 Ok((i, n))
715}
716
717#[cfg(test)]
718mod tests {
719
720 fn as_signer(k: &Keys) -> crate::signer::ActiveSigner {
724 crate::signer::ActiveSigner::Keys(k.clone())
725 }
726 use super::*;
727
728 fn keys(byte: u8) -> Keys {
729 Keys::new(SecretKey::from_slice(&[byte; 32]).unwrap())
730 }
731
732 fn xonly(k: &Keys) -> [u8; 32] {
733 k.public_key().to_bytes()
734 }
735
736 const CHAN: ChannelId = ChannelId([0x42u8; 32]);
737
738 #[test]
741 fn bound_plaintext_layout_is_frozen() {
742 let pt = bound_plaintext(RekeyScope::Root, Epoch(1), &[0xABu8; 32]);
743 let expected = format!("{}{}{}", "00".repeat(32), "0000000000000001", "ab".repeat(32));
744 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
745 let pt2 = bound_plaintext(RekeyScope::Channel(ChannelId([0x11u8; 32])), Epoch(0x0102), &[0xCDu8; 32]);
746 let expected2 = format!("{}{}{}", "11".repeat(32), "0000000000000102", "cd".repeat(32));
747 assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt2), expected2);
748 }
749
750 #[test]
751 fn blob_round_trips_both_scopes() {
752 let rotator = keys(7);
753 let recipient = keys(8);
754 for (scope, epoch, key) in [
755 (RekeyScope::Root, Epoch(1), [0xABu8; 32]),
756 (RekeyScope::Channel(CHAN), Epoch(5), [0xCDu8; 32]),
757 ] {
758 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
759 let got = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob).unwrap();
760 assert_eq!(got, key, "the recipient recovers the fresh key");
761 }
762 }
763
764 #[tokio::test]
765 async fn signer_blob_is_wire_compatible_with_local_both_directions() {
766 let rotator = keys(7);
770 let recipient = keys(8);
771 let scope = RekeyScope::Root;
772 let epoch = Epoch(3);
773 let key = [0x5Au8; 32];
774
775 let blob_l = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
777 let got_s = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_l).await.unwrap();
778 assert_eq!(got_s, key, "signer opens a local-built blob");
779
780 let blob_s = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).await.unwrap();
782 assert_eq!(blob_s.locator, blob_l.locator, "same public locator regardless of build path");
783 let got_l = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob_s).unwrap();
784 assert_eq!(got_l, key, "local opens a signer-built blob");
785
786 let got_ss = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_s).await.unwrap();
788 assert_eq!(got_ss, key, "signer round-trips its own blob");
789 }
790
791 #[tokio::test]
792 async fn signer_blob_bound_check_still_gates_scope_epoch_splice() {
793 let rotator = keys(7);
794 let recipient = keys(8);
795 let blob = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[9u8; 32]).await.unwrap();
796 assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob).await.is_err(), "epoch splice rejected");
797 assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob).await.is_err(), "scope splice rejected");
798 }
799
800 #[test]
801 fn locator_is_public_and_computable_from_pubkeys_alone() {
802 let rotator = keys(7);
806 let recipient = keys(8);
807 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(2), &[1u8; 32]).unwrap();
808 let recomputed = blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(2));
809 assert_eq!(blob.locator, recomputed, "both sides compute the same public locator");
810 }
811
812 #[test]
813 fn a_non_recipient_cannot_open_even_holding_the_public_locator() {
814 let rotator = keys(7);
818 let recipient = keys(8);
819 let outsider = keys(9);
820 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[2u8; 32]).unwrap();
821 assert_eq!(blob.locator, blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)));
823 assert!(open_blob_local(outsider.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).is_err());
826 }
827
828 #[test]
829 fn a_relocated_blob_still_opens_by_decrypt_not_locator() {
830 let rotator = keys(7);
835 let recipient = keys(8);
836 let mut blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[3u8; 32]).unwrap();
837 blob.locator = "ff".repeat(32);
838 assert_eq!(
840 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).unwrap(),
841 [3u8; 32]
842 );
843 assert!(find_my_blob(std::slice::from_ref(&blob), &xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)).is_none());
845 }
846
847 #[test]
848 fn scope_and_epoch_splices_are_rejected_on_open() {
849 let rotator = keys(7);
850 let recipient = keys(8);
851 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[4u8; 32]).unwrap();
852 assert!(matches!(
854 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob),
855 Err(RekeyError::ScopeSplice)
856 ));
857 assert!(matches!(
859 open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob),
860 Err(RekeyError::EpochSplice)
861 ));
862 }
863
864 #[test]
865 fn wrapped_carries_base64_of_the_72_bytes_for_bunker_parity() {
866 let rotator = keys(7);
870 let recipient = keys(8);
871 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[5u8; 32]).unwrap();
872 let ck = ConversationKey::derive(recipient.secret_key(), &rotator.public_key()).unwrap();
873 let payload = base64_simd::STANDARD.decode_to_vec(blob.wrapped.as_bytes()).unwrap();
874 let inner = decrypt_to_bytes(&ck, &payload).unwrap();
875 assert_eq!(String::from_utf8(inner).unwrap(), bound_plaintext_b64(RekeyScope::Root, Epoch(1), &[5u8; 32]));
876 }
877
878 #[test]
879 fn distinct_recipients_and_scopes_get_distinct_locators() {
880 let rotator = keys(7);
881 let r1 = keys(8);
882 let r2 = keys(9);
883 assert_ne!(
884 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
885 blob_locator(&xonly(&rotator), &xonly(&r2), RekeyScope::Root, Epoch(1))
886 );
887 assert_ne!(
889 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
890 blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Channel(CHAN), Epoch(1))
891 );
892 }
893
894 fn root() -> [u8; 32] {
897 [0x55u8; 32]
898 }
899
900 #[test]
901 fn channel_rekey_round_trips_through_the_stream() {
902 let rotator = keys(1);
903 let recipient = keys(8);
904 let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
905 let key = [0xABu8; 32];
906 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &key).unwrap();
907 let commit = epoch_key_commitment(Epoch(0), &[0xEEu8; 32]);
908 let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &commit, &[blob.clone()], 1, 1, 100, None).unwrap();
909 let (wrap, _) = seal_rekey_chunk(&rumor, &group, &rotator, Timestamp::from_secs(100)).unwrap();
910
911 assert_ne!(wrap.pubkey, rotator.public_key());
913 assert_eq!(wrap.pubkey, group.pk());
914
915 let opened = stream::open_wrap(&wrap, &group).unwrap();
916 let chunk = parse_rekey_chunk(&opened).unwrap();
917 assert_eq!(chunk.rotator, rotator.public_key(), "rotator recovered from the seal");
918 assert!(matches!(chunk.scope, RekeyScope::Channel(c) if c.0 == CHAN.0));
919 assert_eq!(chunk.new_epoch, Epoch(1));
920 assert_eq!(chunk.prev_epoch, Epoch(0));
921 assert_eq!(chunk.prev_commit, commit);
922 assert_eq!(chunk.chunk, (1, 1));
923 assert_eq!(chunk.blobs, vec![blob]);
924 }
925
926 #[test]
927 fn base_rekey_addresses_under_the_prior_root() {
928 let rotator = keys(1);
931 let recipient = keys(8);
932 let prior_root = [0x66u8; 32];
933 let community = CommunityId([0x77u8; 32]);
934 let new_root = [0x99u8; 32];
935 let group = base_rekey_group(&prior_root, &community, Epoch(1));
936 let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &new_root).unwrap();
937 let commit = epoch_key_commitment(Epoch(0), &prior_root);
938 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &commit, &[blob], 100, None).unwrap();
939 assert_eq!(chunks.len(), 1);
940
941 let opened = stream::open_wrap(&chunks[0], &group).unwrap();
942 let chunk = parse_rekey_chunk(&opened).unwrap();
943 assert!(matches!(chunk.scope, RekeyScope::Root));
944 let mine = find_my_blob(&chunk.blobs, &chunk.rotator.to_bytes(), &xonly(&recipient), chunk.scope, chunk.new_epoch).unwrap();
946 assert_eq!(open_blob_local(recipient.secret_key(), &chunk.rotator, chunk.scope, chunk.new_epoch, mine).unwrap(), new_root);
947
948 let wrong = base_rekey_group(&[0u8; 32], &community, Epoch(1));
950 assert!(stream::open_wrap(&chunks[0], &wrong).is_err());
951 }
952
953 #[test]
954 fn a_full_send_chunk_stays_under_the_relay_size_limit() {
955 let rotator = keys(1);
958 let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
959 let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT)
960 .map(|i| {
961 let r = keys((i % 200 + 20) as u8);
962 build_blob_local(rotator.secret_key(), &xonly(&rotator), &r.public_key(), RekeyScope::Root, Epoch(1), &[0xCDu8; 32]).unwrap()
963 })
964 .collect();
965 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &blobs, 100, None).unwrap();
966 assert_eq!(chunks.len(), 1, "a full send chunk is exactly one event");
967 assert!(chunks[0].as_json().len() <= 65_536, "a full chunk must fit a 64KB relay event");
968 }
969
970 #[test]
971 fn oversize_recipient_set_splits_into_chunks() {
972 let rotator = keys(1);
973 let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
974 let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT + 1)
976 .map(|_| RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() })
977 .collect();
978 let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(2), Epoch(1), &[0u8; 32], &blobs, 100, None).unwrap();
979 assert_eq!(chunks.len(), 2);
980 let parsed: Vec<RekeyChunk> = chunks.iter().map(|w| parse_rekey_chunk(&stream::open_wrap(w, &group).unwrap()).unwrap()).collect();
981 assert_eq!(parsed[0].chunk, (1, 2));
982 assert_eq!(parsed[1].chunk, (2, 2));
983 assert_eq!(parsed[0].blobs.len(), MAX_REKEY_BLOBS_PER_EVENT);
984 assert_eq!(parsed[1].blobs.len(), 1);
985 }
986
987 #[test]
988 fn plaintext_sealed_rekey_is_rejected() {
989 let rotator = keys(1);
990 let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
991 let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[], 1, 1, 100, None).unwrap();
992 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group, &rotator).unwrap();
993 let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
994 let opened = stream::open_wrap(&wrap, &group).unwrap();
995 assert!(parse_rekey_chunk(&opened).is_err(), "the rekey plane must be encrypted-sealed");
996 }
997
998 #[test]
999 fn non_monotonic_epoch_is_refused_at_mint_and_on_parse() {
1000 let rotator = keys(1);
1001 assert!(matches!(
1002 build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(1), &[0u8; 32], &[], 1, 1, 100, None),
1003 Err(RekeyError::NonMonotonicEpoch)
1004 ));
1005 }
1006
1007 #[test]
1008 fn bad_chunk_indices_are_refused() {
1009 let rotator = keys(1);
1010 for (i, n) in [(0u32, 1u32), (2, 1), (1, 0)] {
1011 assert!(
1012 matches!(build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &[], i, n, 100, None), Err(RekeyError::BadChunkIndex)),
1013 "chunk ({i},{n}) must be rejected"
1014 );
1015 }
1016 }
1017
1018 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 {
1021 RekeyChunk {
1022 rotator: rotator.public_key(),
1023 scope,
1024 new_epoch: Epoch(new_epoch),
1025 prev_epoch: Epoch(prev_epoch),
1026 prev_commit: epoch_key_commitment(Epoch(prev_epoch), prev_key),
1027 chunk: (i, n),
1028 blobs,
1029 citation: None,
1030 }
1031 }
1032
1033 #[test]
1034 fn continuity_extends_gaps_and_forks() {
1035 let rotator = keys(1);
1036 let held = [0x33u8; 32];
1037 let good = chunk_at(&rotator, RekeyScope::Root, 3, 2, &held, vec![], 1, 1);
1039 assert_eq!(check_continuity(&good, Epoch(2), &held), Continuity::Extends);
1040 let ahead = chunk_at(&rotator, RekeyScope::Root, 5, 4, &held, vec![], 1, 1);
1042 assert_eq!(check_continuity(&ahead, Epoch(2), &held), Continuity::Gap);
1043 let fork = chunk_at(&rotator, RekeyScope::Root, 3, 2, &[0x99u8; 32], vec![], 1, 1);
1045 assert_eq!(check_continuity(&fork, Epoch(2), &held), Continuity::Fork);
1046 let stale = chunk_at(&rotator, RekeyScope::Root, 2, 1, &held, vec![], 1, 1);
1048 assert_eq!(check_continuity(&stale, Epoch(2), &held), Continuity::Fork);
1049 }
1050
1051 #[test]
1052 fn a_missing_chunk_is_never_a_removal() {
1053 let rotator = keys(1);
1056 let me = keys(8);
1057 let my_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &me.public_key(), RekeyScope::Root, Epoch(1), &[0xAAu8; 32]).unwrap();
1059 let c1 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![RekeyBlob { locator: "bb".repeat(32), wrapped: "x".into() }], 1, 2);
1060 let rots = collect_rotations(&[c1.clone()]);
1061 assert_eq!(rots.len(), 1);
1062 assert!(!rots[0].is_complete(), "one of two chunks held → incomplete");
1063 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), None, "incomplete → keep recovering, never conclude removal");
1064
1065 let c2 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![my_blob.clone()], 2, 2);
1067 let rots = collect_rotations(&[c1, c2]);
1068 assert!(rots[0].is_complete());
1069 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(false));
1070 let mine = find_my_blob(&rots[0].blobs, &rots[0].rotator.to_bytes(), &xonly(&me), RekeyScope::Root, Epoch(1)).unwrap();
1072 assert_eq!(open_blob_local(me.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), mine).unwrap(), [0xAAu8; 32]);
1073 }
1074
1075 #[test]
1076 fn a_complete_rotation_without_my_blob_is_a_removal() {
1077 let rotator = keys(1);
1078 let me = keys(8);
1079 let other = keys(9);
1080 let their_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &other.public_key(), RekeyScope::Root, Epoch(1), &[0xBBu8; 32]).unwrap();
1082 let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![their_blob], 1, 1);
1083 let rots = collect_rotations(&[c]);
1084 assert!(rots[0].is_complete());
1085 assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(true), "complete rotation, no blob for me → removed");
1086 }
1087
1088 #[test]
1089 fn collect_rotations_separates_concurrent_rotators_and_scopes() {
1090 let rot_a = keys(1);
1093 let rot_b = keys(2);
1094 let ca = chunk_at(&rot_a, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1095 let cb = chunk_at(&rot_b, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1096 let cc = chunk_at(&rot_a, RekeyScope::Channel(CHAN), 2, 1, &[7u8; 32], vec![], 1, 1);
1097 let rots = collect_rotations(&[ca, cb, cc]);
1098 assert_eq!(rots.len(), 3, "different rotator or scope ⇒ different rotation");
1099 }
1100
1101 #[test]
1102 fn duplicate_chunk_delivery_is_idempotent() {
1103 let rotator = keys(1);
1104 let blob = RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() };
1105 let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![blob.clone()], 1, 1);
1106 let rots = collect_rotations(&[c.clone(), c]);
1107 assert_eq!(rots.len(), 1);
1108 assert_eq!(rots[0].blobs.len(), 1, "re-delivering a chunk must not double its blobs");
1109 }
1110
1111 #[test]
1112 fn lowest_key_fork_winner_is_deterministic() {
1113 let keys_a = [[0x03u8; 32], [0x01u8; 32], [0x02u8; 32]];
1116 assert_eq!(lowest_key_winner(&keys_a), Some(1));
1117 let keys_b = [[0x01u8; 32], [0x02u8; 32], [0x03u8; 32]];
1118 assert_eq!(lowest_key_winner(&keys_b), Some(0));
1119 assert_eq!(lowest_key_winner(&[]), None);
1120 }
1121}