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::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
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 = 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
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/// Build one blob via a [`VectorSigner`] (the bunker / NIP-55 path). Wire-identical
259/// to [`build_blob_local`]: `signer.nip44_encrypt(recipient, bound_plaintext_b64)`
260/// whose conversation key is ECDH(signer_identity, recipient) — the same key the
261/// local path derives from the raw rotator secret. The `wrapped` field is the
262/// standard NIP-44 payload string (D5), so both paths emit identical wire.
263pub 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
282/// Open a blob addressed to me via a [`VectorSigner`]. Mirror of [`open_blob_local`]:
283/// `signer.nip44_decrypt(rotator, blob.wrapped)` yields the base64 bound plaintext,
284/// then the scope/epoch bound check gates it. Per D1 the locator is NOT gated.
285pub 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
306/// Find my blob in a chunk's array by my public locator (the lookup step, D1).
307/// `None` means this chunk doesn't carry my key — never a removal on its own
308/// (only "removed" once ALL chunks are held and none has it).
309pub 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// ── The 3303 event (a v2 stream event) ───────────────────────────────────────
321
322/// A parsed, seal-verified 3303 chunk. The `rotator` is the seal's real signer
323/// (the ECDH counterparty AND the authority actor the caller gates on).
324#[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    /// This chunk's `(i, n)` — 1-based, `i <= n`.
332    pub chunk: (u32, u32),
333    pub blobs: Vec<RekeyBlob>,
334    /// The rotator's `vac` (CORD-06 §Authority: "a rotation cites the Grant it
335    /// acts under like any authority action"). `None` when the owner rotates.
336    pub citation: Option<crate::community::edition::AuthorityCitation>,
337}
338
339/// The key that groups chunks of ONE rotation: `(rotator, scope_id, new_epoch,
340/// prev_commit)`. Two rotators racing the same epoch, or one rotator over two
341/// channels, never alias.
342pub type RotationKey = ([u8; 32], [u8; 32], u64, [u8; 32]);
343
344impl RekeyChunk {
345    /// This chunk's [`RotationKey`].
346    pub fn correlation(&self) -> RotationKey {
347        (self.rotator.to_bytes(), self.scope.id32(), self.new_epoch.0, self.prev_commit)
348    }
349}
350
351/// Build the unsigned 3303 rumor (rotator is the pubkey; the seal will carry the
352/// signature). Enforces the monotonic-epoch and chunk-range invariants at mint.
353#[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    // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
384    // authority action (CORD-04's `vac`), so a just-demoted admin's rotation is
385    // never honored by a lagging client." The owner cites nothing.
386    if let Some(c) = citation {
387        tags.push(c.to_tag());
388    }
389    // Rekeys fold by their tags, not time; still stamp created_at for the wire.
390    Ok(stream::build_rumor_secs(super::kind::REKEY, rotator, &content, tags, at_secs))
391}
392
393/// The rekey group key for a CHANNEL rekey addressed under `addressing_root`.
394/// The caller chooses the root: a STANDALONE channel rekey rides the CURRENT
395/// root (`MANAGE_CHANNELS`); a channel rekey forced by a removal rides the PRIOR
396/// root alongside the base rekey (D2 — inherits the removal's `BAN` authority,
397/// and the prior-root address is exactly what distinguishes the two classes on
398/// the wire so a base-fork loser can still open it).
399pub 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
403/// The rekey group key for a BASE rotation — always under the PRIOR root (the
404/// one handle every retained member still holds through the rotation).
405pub 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
409/// Seal + wrap a 3303 rumor into its stream event at the rekey address. The seal
410/// is ENCRYPTED (20013 — the rekey plane MUST NOT be plaintext-sealed) and
411/// signed by the rotator; the wrap by the rekey group key.
412pub 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/// Split a full recipient blob set into 3303 chunk events (≤120 blobs each),
423/// all sharing the rotation's `(scope, new_epoch, prev_commit)` so a receiver
424/// correlates them. Local-keys convenience.
425#[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/// Signer-driven twin of [`build_rekey_chunks_local`] for bunker / NIP-55 accounts:
464/// each chunk's encrypted seal signs through a [`VectorSigner`]. `rotator_pk` must
465/// equal `my_public_key()`. Wire-identical to the local path.
466#[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
494/// Parse a 3303 chunk from a seal-verified stream open. Rejects a non-3303
495/// rumor, a plaintext seal (the rekey plane is encrypted-only), malformed or
496/// duplicate machinery tags, a bad chunk range, and an over-cap blob array.
497pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError> {
498    if opened.seal_form != SealForm::Encrypted {
499        // A plaintext-sealed rekey would be a liftable public artifact — reject.
500        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// ── Continuity + removal + fork resolution (CORD-06 §2/§3) ───────────────────
539
540/// The verdict of the prevcommit continuity check (CORD-06 §2).
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum Continuity {
543    /// The commitment matches the key I hold at `prev_epoch` — this rotation
544    /// extends my chain; adopt it.
545    Extends,
546    /// `prev_epoch` is higher than the key I hold — I missed a rotation; fetch
547    /// the gap first, don't adopt yet.
548    Gap,
549    /// The commitment doesn't match at the same epoch — a fork or garbage.
550    Fork,
551}
552
553/// Check a rotation's `prev_commit` against the `(epoch, key)` I currently hold
554/// for its scope. A match proves the rotation extends the very key I hold; a
555/// higher `prev_epoch` means I'm behind; anything else is a fork/garbage.
556pub 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        // prev_epoch < held: this rotation is older than where I am — a stale
567        // fork; a settled epoch only ever heals DOWN to a sibling, never back.
568        Continuity::Fork
569    }
570}
571
572/// A collected rotation: all chunks sharing one correlation key, and whether the
573/// set is complete (all `n` chunks present).
574#[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    /// The union of every chunk's blobs.
582    pub blobs: Vec<RekeyBlob>,
583    /// Total chunk count `n` declared by the chunks.
584    pub declared_chunks: u32,
585    /// Distinct chunk indices actually held.
586    pub held_chunks: std::collections::BTreeSet<u32>,
587    /// The rotator's `vac`, taken from the first chunk seen (every chunk of one
588    /// rotation carries the same citation — they share a signer and an action).
589    pub citation: Option<crate::community::edition::AuthorityCitation>,
590}
591
592impl Rotation {
593    /// True once every declared chunk index `1..=n` is held — the precondition
594    /// for concluding removal (a missing chunk is "keep recovering", never a
595    /// removal).
596    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    /// This rotation's continuity against the `(epoch, key)` I hold for its scope
601    /// — the [`check_continuity`] verdict at the aggregated-rotation level (same
602    /// prevcommit test), so a follower can gate adoption without a raw chunk.
603    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
618/// Group a batch of parsed chunks into rotations by correlation key. Chunks whose
619/// `n` disagrees with their siblings, or that repeat an index, are the caller's
620/// concern to police; here the first-seen `n` per correlation wins and duplicate
621/// indices are ignored (idempotent re-delivery).
622pub 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
644/// Have I been removed by this rotation? Only answerable on a COMPLETE rotation
645/// (all `n` chunks held): I'm removed iff none of the union's blobs carries my
646/// locator. On an incomplete rotation the answer is `None` — keep recovering.
647pub 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
655/// Deterministic same-epoch fork winner (CORD-06 §3): among candidate rotations
656/// at one continuity point, the one whose decrypted `new_key` is lexicographically
657/// lowest wins. Every retained member decrypts its own blob from each fork and
658/// computes the identical winner. Returns the index into `candidates` of the
659/// winner, or `None` if the caller decrypted no candidate.
660pub 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
668// ── helpers ──────────────────────────────────────────────────────────────────
669
670fn 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    // Spec-shaped decimal, not a bare `parse` — that takes "+2" and "02" as
687    // epochs a stricter peer refuses (CORD-01 §5).
688    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    /// Wrap raw `Keys` as the polymorphic signer. `VectorSigner` pins its error to
721    /// `SignerError`, which bare `Keys` doesn't satisfy, so tests go through the
722    /// same enum the app uses.
723    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    // ── blob atom ────────────────────────────────────────────────────────────
739
740    #[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        // A remote signer (here a plain Keys, which impls VectorSigner) must build
767        // AND open blobs interchangeably with the raw-key path — the CORD-06 D5
768        // "identical wire" guarantee the bunker/NIP-55 integration rests on.
769        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        // local build -> signer open
776        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        // signer build -> local open (+ identical public locator)
781        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        // signer build -> signer open
787        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        // D1: the locator derives from PUBLIC inputs, so the rotator computing a
803        // recipient's slot and the recipient computing their own must agree — no
804        // secret needed either side (bunker parity).
805        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        // The D1 security relocation: the locator authenticates NOTHING (anyone
815        // holding both npubs computes it), yet an outsider still can't open —
816        // the pairwise decrypt is the gate, not the locator.
817        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        // The outsider can trivially recompute the (public) locator...
822        assert_eq!(blob.locator, blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)));
823        // ...but pairing (outsider_sk, rotator_pk) yields a different conversation
824        // key, so the decrypt fails.
825        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        // Because open ignores the locator (D1), corrupting the locator does NOT
831        // break a legitimate recipient's open — the decrypt + bound check govern.
832        // (Contrast v1, where a locator mismatch was a hard reject.) The FIND
833        // step uses the locator; OPEN does not.
834        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        // Handed the blob directly (locator bypassed), the recipient still opens it.
839        assert_eq!(
840            open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).unwrap(),
841            [3u8; 32]
842        );
843        // But find_my_blob won't LOCATE it under the corrupted locator.
844        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        // Opened under a different scope → bound scope mismatch.
853        assert!(matches!(
854            open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob),
855            Err(RekeyError::ScopeSplice)
856        ));
857        // Opened under a different epoch → bound epoch mismatch.
858        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        // D5: the NIP-44 layer's plaintext is base64(72 bytes) — a UTF-8 string a
867        // NIP-46 signer can nip44_encrypt/decrypt. Prove the decrypted inner is
868        // exactly that base64 string, matching the `_b64` helper the bunker uses.
869        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        // Same pair, a base blob and a channel blob at one epoch don't collide.
888        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    // ── 3303 event ─────────────────────────────────────────────────────────
895
896    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        // The wrap is signed by the group key, not the rotator (no identity on the wire).
912        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        // A base rotation rides the PRIOR root: a member holding the prior root
929        // derives the same group key and opens it; a non-holder can't.
930        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        // The recipient recovers the NEW root from their blob.
945        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        // A non-holder of the prior root can't even open the wrap.
949        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        // The send cap exists so one chunk fits a 64KB strfry event. A full chunk
956        // must serialize under it or relays reject the event.
957        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        // One over the send cap → 2 chunks, labeled (1,2) and (2,2).
975        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    // ── continuity + removal + fork ──────────────────────────────────────────
1019
1020    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        // Extends: prev_epoch == held epoch AND commitment matches the held key.
1038        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        // Gap: the rotation is FROM a higher epoch than I hold — I missed one.
1041        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        // Fork: same epoch but the commitment names a different prior key.
1044        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        // Fork: a rotation older than where I am (stale).
1047        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        // The core no-false-removal guarantee: until ALL n chunks are held, "am I
1054        // removed" is unanswerable, even if the chunks I DO hold lack my blob.
1055        let rotator = keys(1);
1056        let me = keys(8);
1057        // Two-chunk rotation; my blob is in chunk 2, which I haven't received.
1058        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        // Now chunk 2 (with my blob) arrives → complete, and I am NOT removed.
1066        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        // And my key is recoverable from the union.
1071        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        // A complete 1-chunk rotation carrying only someone else's blob.
1081        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        // Two rotators racing the same epoch, plus a channel rekey at the same
1091        // numbers — three distinct rotations, never merged.
1092        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        // CORD-06 §3: among candidates, the lexicographically lowest new key wins,
1114        // so every retained member converges. Input order must not change it.
1115        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}