Skip to main content

vector_core/community/v2/
rekey.rs

1//! Concord v2 rekeys (CORD-06) — the per-recipient key-delivery atom + the 3303
2//! event that carries a rotation.
3//!
4//! A rotation mints a fresh-random key for the next epoch and delivers it only
5//! to the members who STAY, one **per-recipient blob** each. Two structural
6//! shifts from v1, both security-load-bearing:
7//!
8//! **D1 — the locator is PUBLIC and authenticates nothing.** v1 located a blob
9//! by `HKDF(pairwise_ECDH_secret, …)` — a value only the sender↔recipient pair
10//! could compute, so in v1 a matching locator *proved* the blob was minted for
11//! that pair. v2 locates by `HKDF(rotator_xonly || recipient_xonly, …)` — public
12//! inputs (full NIP-46 bunker parity: a bunker computes it without a raw key).
13//! A public locator proves NOTHING; it is a lookup index only. Authenticity now
14//! rests entirely on (a) the crate's **rotator seal + roster authority** —
15//! verified by the caller before any blob is trusted — and (b) the blob's
16//! **bound plaintext** (scope+epoch checked after decrypt). So [`open_blob`]
17//! does NOT gate on the locator (that was v1's `open_rekey_blob` assumption —
18//! do not port it): the decrypt itself (only the addressed recipient's key
19//! opens a blob wrapped to them) plus the bound-plaintext check are the gate.
20//!
21//! **D5 — the blob wrap carries base64.** NIP-44/NIP-46 encrypt surfaces are
22//! string-typed, and the 72 raw bytes aren't valid UTF-8, so the wrapped
23//! plaintext is `base64(scope_id ‖ epoch_be ‖ new_key)` — a string a bunker can
24//! `nip44_encrypt`/`nip44_decrypt` to the recipient's identity key with no raw
25//! secret. The `wrapped` field is then the standard NIP-44 payload string, so a
26//! local-keys wrap and a bunker wrap produce identical wire output.
27//!
28//! The 3303 event itself is a v2 stream event (kind-1059 wrap, ENCRYPTED seal
29//! signed by the rotator's real identity) at the rekey address — reusing
30//! [`super::stream`]. Its seal is what tells the recipient WHO rotated, which is
31//! both the ECDH counterparty and the authority actor.
32//!
33//! What lives here: the blob atom, the 3303 build/parse, chunk-set assembly, and
34//! the continuity/fork comparators — all PURE. The stateful orchestration
35//! (recipient-set computation, the base+channels lockstep read-cut, DB epoch
36//! archival, and the D2 BAN-vs-MANAGE_CHANNELS authority gate, which is an
37//! apply-path concern keyed on prior-vs-current-root addressing) sits in the
38//! service layer.
39
40use 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
51/// Max recipients (blobs) Vector puts in ONE 3303 event when SENDING. Lower than
52/// the spec's stated 120 because a v2 rekey rides the CORD-01 double-wrap (blob
53/// array → encrypted seal → wrap, two NIP-44 base64 expansions): a 120-blob event
54/// measures ~77 KB, over strfry's 64 KB `maxEventSize`, while 80 blobs measure
55/// ~55 KB (a full one is size-guarded by test). The spec's 120 assumes a lighter
56/// envelope — a CORD-06 erratum (see the divergence ledger). A larger recipient
57/// set splits across chunk events.
58pub const MAX_REKEY_BLOBS_PER_EVENT: usize = 80;
59
60/// Max blobs Vector will ACCEPT in one received 3303 chunk (a DoS bound checked
61/// after decrypt). Kept at the spec's stated 120 — higher than the send cap — so
62/// a chunk minted by another client at the spec limit (and delivered by a relay
63/// with a larger `maxEventSize`) still parses. An array over this is rejected.
64pub 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/// What a rekey rotates (CORD-06 §1). The 32-byte scope id is stamped into every
73/// blob's plaintext so a blob can't be spliced onto another coordinate.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum RekeyScope {
76    /// A specific private channel being rekeyed.
77    Channel(ChannelId),
78    /// The community_root (a base rotation / Refounding) — the all-zero sentinel
79    /// (a random channel id never collides with it).
80    Root,
81}
82
83impl RekeyScope {
84    /// The 32-byte scope id: the channel id, or the all-zero root sentinel.
85    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/// One located, wrapped rekey blob — the unit a 3303 event carries N of.
110///
111/// `locator` is the public [`recipient_locator`] hex (a lookup index — proves
112/// nothing, D1); `wrapped` is the NIP-44 payload string whose plaintext is
113/// `base64(scope_id ‖ epoch_be ‖ new_key)` (D5).
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct RekeyBlob {
116    pub locator: String,
117    pub wrapped: String,
118}
119
120/// Errors from the rekey layer.
121#[derive(Debug)]
122pub enum RekeyError {
123    Stream(StreamError),
124    Crypto(String),
125    /// The wrapped plaintext isn't the expected 72-byte layout.
126    BadBlobLength(usize),
127    /// The blob's bound scope ≠ the coordinate it's being opened under (splice).
128    ScopeSplice,
129    /// The blob's bound epoch ≠ the coordinate it's being opened under (splice).
130    EpochSplice,
131    /// The rumor isn't a kind-3303 rekey.
132    NotARekey(u16),
133    /// A required tag is absent, duplicated, or malformed.
134    BadTag(&'static str),
135    /// `new_epoch <= prev_epoch` — a rotation must advance the chain.
136    NonMonotonicEpoch,
137    /// A chunk index is out of range (`i < 1`, `i > n`, or `n < 1`).
138    BadChunkIndex,
139    /// The blob array exceeds the cap.
140    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
168// ── The blob atom ────────────────────────────────────────────────────────────
169
170/// The 72-byte bound plaintext: `scope_id[32] ‖ epoch_be[8] ‖ new_key[32]`.
171/// Fixed-width, so no separators are needed to parse it unambiguously.
172fn 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
180/// The base64 string a blob's NIP-44 layer actually encrypts (D5). Exposed so
181/// the service-layer bunker path can `signer.nip44_encrypt(recipient, this)`.
182pub 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
186/// Parse + verify a decrypted bound plaintext (the base64 already stripped),
187/// checking scope+epoch strict-equal the coordinate it was opened under before
188/// yielding `new_key`. Exposed for the bunker open path.
189pub 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
206/// The public per-recipient locator (D1). Both parties compute it from public
207/// keys alone; it addresses the blob and nothing more.
208pub 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
212/// Build one blob with LOCAL keys (the bunker path drives the same wire via the
213/// `_b64` helpers + a NIP-46 `nip44_encrypt`). The wrap is the pairwise
214/// conversation key `ConversationKey::derive(rotator_sk, recipient_pk)`, so only
215/// the recipient's identity key opens it.
216pub 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
233/// Open a blob addressed to me with LOCAL keys. Per D1 this does NOT check the
234/// locator: the decrypt (only my identity key opens a blob wrapped to me by the
235/// rotator) plus the bound scope/epoch ARE the authenticity boundary. A blob
236/// relocated to a foreign locator still won't decrypt for a non-recipient, and
237/// a spliced one fails the bound check.
238pub 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
258/// Find my blob in a chunk's array by my public locator (the lookup step, D1).
259/// `None` means this chunk doesn't carry my key — never a removal on its own
260/// (only "removed" once ALL chunks are held and none has it).
261pub 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// ── The 3303 event (a v2 stream event) ───────────────────────────────────────
273
274/// A parsed, seal-verified 3303 chunk. The `rotator` is the seal's real signer
275/// (the ECDH counterparty AND the authority actor the caller gates on).
276#[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    /// This chunk's `(i, n)` — 1-based, `i <= n`.
284    pub chunk: (u32, u32),
285    pub blobs: Vec<RekeyBlob>,
286}
287
288/// The key that groups chunks of ONE rotation: `(rotator, scope_id, new_epoch,
289/// prev_commit)`. Two rotators racing the same epoch, or one rotator over two
290/// channels, never alias.
291pub type RotationKey = ([u8; 32], [u8; 32], u64, [u8; 32]);
292
293impl RekeyChunk {
294    /// This chunk's [`RotationKey`].
295    pub fn correlation(&self) -> RotationKey {
296        (self.rotator.to_bytes(), self.scope.id32(), self.new_epoch.0, self.prev_commit)
297    }
298}
299
300/// Build the unsigned 3303 rumor (rotator is the pubkey; the seal will carry the
301/// signature). Enforces the monotonic-epoch and chunk-range invariants at mint.
302#[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    // Rekeys fold by their tags, not time; still stamp created_at for the wire.
332    Ok(stream::build_rumor_secs(super::kind::REKEY, rotator, &content, tags, at_secs))
333}
334
335/// The rekey group key for a CHANNEL rekey addressed under `addressing_root`.
336/// The caller chooses the root: a STANDALONE channel rekey rides the CURRENT
337/// root (`MANAGE_CHANNELS`); a channel rekey forced by a removal rides the PRIOR
338/// root alongside the base rekey (D2 — inherits the removal's `BAN` authority,
339/// and the prior-root address is exactly what distinguishes the two classes on
340/// the wire so a base-fork loser can still open it).
341pub 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
345/// The rekey group key for a BASE rotation — always under the PRIOR root (the
346/// one handle every retained member still holds through the rotation).
347pub 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
351/// Seal + wrap a 3303 rumor into its stream event at the rekey address. The seal
352/// is ENCRYPTED (20013 — the rekey plane MUST NOT be plaintext-sealed) and
353/// signed by the rotator; the wrap by the rekey group key.
354pub 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/// Split a full recipient blob set into 3303 chunk events (≤120 blobs each),
365/// all sharing the rotation's `(scope, new_epoch, prev_commit)` so a receiver
366/// correlates them. Local-keys convenience.
367#[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
403/// Parse a 3303 chunk from a seal-verified stream open. Rejects a non-3303
404/// rumor, a plaintext seal (the rekey plane is encrypted-only), malformed or
405/// duplicate machinery tags, a bad chunk range, and an over-cap blob array.
406pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError> {
407    if opened.seal_form != SealForm::Encrypted {
408        // A plaintext-sealed rekey would be a liftable public artifact — reject.
409        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// ── Continuity + removal + fork resolution (CORD-06 §2/§3) ───────────────────
447
448/// The verdict of the prevcommit continuity check (CORD-06 §2).
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum Continuity {
451    /// The commitment matches the key I hold at `prev_epoch` — this rotation
452    /// extends my chain; adopt it.
453    Extends,
454    /// `prev_epoch` is higher than the key I hold — I missed a rotation; fetch
455    /// the gap first, don't adopt yet.
456    Gap,
457    /// The commitment doesn't match at the same epoch — a fork or garbage.
458    Fork,
459}
460
461/// Check a rotation's `prev_commit` against the `(epoch, key)` I currently hold
462/// for its scope. A match proves the rotation extends the very key I hold; a
463/// higher `prev_epoch` means I'm behind; anything else is a fork/garbage.
464pub 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        // prev_epoch < held: this rotation is older than where I am — a stale
475        // fork; a settled epoch only ever heals DOWN to a sibling, never back.
476        Continuity::Fork
477    }
478}
479
480/// A collected rotation: all chunks sharing one correlation key, and whether the
481/// set is complete (all `n` chunks present).
482#[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    /// The union of every chunk's blobs.
490    pub blobs: Vec<RekeyBlob>,
491    /// Total chunk count `n` declared by the chunks.
492    pub declared_chunks: u32,
493    /// Distinct chunk indices actually held.
494    pub held_chunks: std::collections::BTreeSet<u32>,
495}
496
497impl Rotation {
498    /// True once every declared chunk index `1..=n` is held — the precondition
499    /// for concluding removal (a missing chunk is "keep recovering", never a
500    /// removal).
501    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    /// This rotation's continuity against the `(epoch, key)` I hold for its scope
506    /// — the [`check_continuity`] verdict at the aggregated-rotation level (same
507    /// prevcommit test), so a follower can gate adoption without a raw chunk.
508    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
523/// Group a batch of parsed chunks into rotations by correlation key. Chunks whose
524/// `n` disagrees with their siblings, or that repeat an index, are the caller's
525/// concern to police; here the first-seen `n` per correlation wins and duplicate
526/// indices are ignored (idempotent re-delivery).
527pub 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
548/// Have I been removed by this rotation? Only answerable on a COMPLETE rotation
549/// (all `n` chunks held): I'm removed iff none of the union's blobs carries my
550/// locator. On an incomplete rotation the answer is `None` — keep recovering.
551pub 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
559/// Deterministic same-epoch fork winner (CORD-06 §3): among candidate rotations
560/// at one continuity point, the one whose decrypted `new_key` is lexicographically
561/// lowest wins. Every retained member decrypts its own blob from each fork and
562/// computes the identical winner. Returns the index into `candidates` of the
563/// winner, or `None` if the caller decrypted no candidate.
564pub 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
572// ── helpers ──────────────────────────────────────────────────────────────────
573
574fn 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    // ── blob atom ────────────────────────────────────────────────────────────
631
632    #[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        // D1: the locator derives from PUBLIC inputs, so the rotator computing a
659        // recipient's slot and the recipient computing their own must agree — no
660        // secret needed either side (bunker parity).
661        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        // The D1 security relocation: the locator authenticates NOTHING (anyone
671        // holding both npubs computes it), yet an outsider still can't open —
672        // the pairwise decrypt is the gate, not the locator.
673        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        // The outsider can trivially recompute the (public) locator...
678        assert_eq!(blob.locator, blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)));
679        // ...but pairing (outsider_sk, rotator_pk) yields a different conversation
680        // key, so the decrypt fails.
681        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        // Because open ignores the locator (D1), corrupting the locator does NOT
687        // break a legitimate recipient's open — the decrypt + bound check govern.
688        // (Contrast v1, where a locator mismatch was a hard reject.) The FIND
689        // step uses the locator; OPEN does not.
690        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        // Handed the blob directly (locator bypassed), the recipient still opens it.
695        assert_eq!(
696            open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).unwrap(),
697            [3u8; 32]
698        );
699        // But find_my_blob won't LOCATE it under the corrupted locator.
700        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        // Opened under a different scope → bound scope mismatch.
709        assert!(matches!(
710            open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob),
711            Err(RekeyError::ScopeSplice)
712        ));
713        // Opened under a different epoch → bound epoch mismatch.
714        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        // D5: the NIP-44 layer's plaintext is base64(72 bytes) — a UTF-8 string a
723        // NIP-46 signer can nip44_encrypt/decrypt. Prove the decrypted inner is
724        // exactly that base64 string, matching the `_b64` helper the bunker uses.
725        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        // Same pair, a base blob and a channel blob at one epoch don't collide.
744        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    // ── 3303 event ─────────────────────────────────────────────────────────
751
752    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        // The wrap is signed by the group key, not the rotator (no identity on the wire).
768        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        // A base rotation rides the PRIOR root: a member holding the prior root
785        // derives the same group key and opens it; a non-holder can't.
786        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        // The recipient recovers the NEW root from their blob.
801        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        // A non-holder of the prior root can't even open the wrap.
805        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        // The send cap exists so one chunk fits a 64KB strfry event. A full chunk
812        // must serialize under it or relays reject the event.
813        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        // One over the send cap → 2 chunks, labeled (1,2) and (2,2).
831        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    // ── continuity + removal + fork ──────────────────────────────────────────
875
876    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        // Extends: prev_epoch == held epoch AND commitment matches the held key.
893        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        // Gap: the rotation is FROM a higher epoch than I hold — I missed one.
896        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        // Fork: same epoch but the commitment names a different prior key.
899        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        // Fork: a rotation older than where I am (stale).
902        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        // The core no-false-removal guarantee: until ALL n chunks are held, "am I
909        // removed" is unanswerable, even if the chunks I DO hold lack my blob.
910        let rotator = keys(1);
911        let me = keys(8);
912        // Two-chunk rotation; my blob is in chunk 2, which I haven't received.
913        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        // Now chunk 2 (with my blob) arrives → complete, and I am NOT removed.
921        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        // And my key is recoverable from the union.
926        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        // A complete 1-chunk rotation carrying only someone else's blob.
936        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        // Two rotators racing the same epoch, plus a channel rekey at the same
946        // numbers — three distinct rotations, never merged.
947        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        // CORD-06 §3: among candidates, the lexicographically lowest new key wins,
969        // so every retained member converges. Input order must not change it.
970        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}