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, control_signer_group_key, epoch_key_commitment,
48    recipient_locator, GroupKey,
49};
50use super::stream::{self, OpenedStream, SealForm, StreamError};
51
52/// Max recipients (blobs) Vector puts in ONE 3303 event when SENDING. Lower than
53/// the spec's stated 120 because a v2 rekey rides the CORD-01 double-wrap (blob
54/// array → encrypted seal → wrap, two NIP-44 base64 expansions): a 120-blob event
55/// measures ~77 KB, over strfry's 64 KB `maxEventSize`, while 80 blobs measure
56/// ~55 KB (a full one is size-guarded by test). The spec's 120 assumes a lighter
57/// envelope — a CORD-06 erratum (see the divergence ledger). A larger recipient
58/// set splits across chunk events.
59pub const MAX_REKEY_BLOBS_PER_EVENT: usize = 80;
60
61/// Max blobs Vector will ACCEPT in one received 3303 chunk (a DoS bound checked
62/// after decrypt). Kept at the spec's stated 120 — higher than the send cap — so
63/// a chunk minted by another client at the spec limit (and delivered by a relay
64/// with a larger `maxEventSize`) still parses. An array over this is rejected.
65pub const MAX_REKEY_BLOBS_RECEIVED: usize = 120;
66
67const TAG_SCOPE: &str = "scope";
68const TAG_NEW_EPOCH: &str = "newepoch";
69const TAG_PREV_EPOCH: &str = "prevepoch";
70const TAG_PREV_COMMIT: &str = "prevcommit";
71const TAG_CHUNK: &str = "chunk";
72/// Root-scope containment marker (CORD-06 Severing Refounding): an adopter
73/// revokes its own public invite links instead of refreshing them into the new
74/// epoch. Grants no authority of its own — it rides a rotation the adopt gate
75/// has already verified.
76const TAG_SEVER: &str = "sever";
77
78/// What a rekey rotates (CORD-06 §1). The 32-byte scope id is stamped into every
79/// blob's plaintext so a blob can't be spliced onto another coordinate.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum RekeyScope {
82    /// A specific private channel being rekeyed.
83    Channel(ChannelId),
84    /// The community_root (a base rotation / Refounding) — the all-zero sentinel
85    /// (a random channel id never collides with it).
86    Root,
87}
88
89impl RekeyScope {
90    /// The 32-byte scope id: the channel id, or the all-zero root sentinel.
91    pub fn id32(&self) -> [u8; 32] {
92        match self {
93            RekeyScope::Channel(c) => c.0,
94            RekeyScope::Root => [0u8; 32],
95        }
96    }
97
98    fn to_hex(self) -> String {
99        crate::simd::hex::bytes_to_hex_32(&self.id32())
100    }
101
102    fn from_hex(hex: &str) -> Option<RekeyScope> {
103        if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
104            return None;
105        }
106        let bytes = crate::simd::hex::hex_to_bytes_32(hex);
107        Some(if bytes == [0u8; 32] {
108            RekeyScope::Root
109        } else {
110            RekeyScope::Channel(ChannelId(bytes))
111        })
112    }
113}
114
115/// One located, wrapped rekey blob — the unit a 3303 event carries N of.
116///
117/// `locator` is the public [`recipient_locator`] hex (a lookup index — proves
118/// nothing, D1); `wrapped` is the NIP-44 payload string whose plaintext is
119/// `base64(scope_id ‖ epoch_be ‖ new_key)` (D5).
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct RekeyBlob {
122    pub locator: String,
123    pub wrapped: String,
124}
125
126/// Errors from the rekey layer.
127#[derive(Debug)]
128pub enum RekeyError {
129    Stream(StreamError),
130    Crypto(String),
131    /// The wrapped plaintext isn't the expected 72-byte layout.
132    BadBlobLength(usize),
133    /// A base blob's plaintext isn't one of the three fixed widths (CORD-06 §1):
134    /// 72 (legacy pre-split), 104 (member), 136 (staff).
135    BadBaseBlobWidth(usize),
136    /// A 136-byte base blob's `new_control_root` doesn't derive to its
137    /// `new_control_pk` — refused whole rather than adopting a plane split from
138    /// its readers (CORD-06 §1).
139    ControlPairMismatch,
140    /// A Grant's `control_wrap` plaintext isn't the fixed 40 bytes (CORD-04 §3).
141    BadControlWrapLength(usize),
142    /// The blob's bound scope ≠ the coordinate it's being opened under (splice).
143    ScopeSplice,
144    /// The blob's bound epoch ≠ the coordinate it's being opened under (splice).
145    EpochSplice,
146    /// The rumor isn't a kind-3303 rekey.
147    NotARekey(u16),
148    /// A required tag is absent, duplicated, or malformed.
149    BadTag(&'static str),
150    /// `new_epoch <= prev_epoch` — a rotation must advance the chain.
151    NonMonotonicEpoch,
152    /// A chunk index is out of range (`i < 1`, `i > n`, or `n < 1`).
153    BadChunkIndex,
154    /// The blob array exceeds the cap.
155    TooManyBlobs(usize),
156}
157
158impl std::fmt::Display for RekeyError {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self {
161            RekeyError::Stream(e) => write!(f, "stream: {e}"),
162            RekeyError::Crypto(e) => write!(f, "crypto: {e}"),
163            RekeyError::BadBlobLength(n) => write!(f, "rekey blob plaintext is {n} bytes, expected 72"),
164            RekeyError::BadBaseBlobWidth(n) => write!(f, "base rekey blob plaintext is {n} bytes, expected 72, 104 or 136"),
165            RekeyError::ControlPairMismatch => write!(f, "base rekey blob control_root does not derive to its control_pk"),
166            RekeyError::BadControlWrapLength(n) => write!(f, "control_wrap plaintext is {n} bytes, expected 40"),
167            RekeyError::ScopeSplice => write!(f, "rekey blob scope binding mismatch (splice)"),
168            RekeyError::EpochSplice => write!(f, "rekey blob epoch binding mismatch (splice)"),
169            RekeyError::NotARekey(k) => write!(f, "rumor kind {k} is not a rekey"),
170            RekeyError::BadTag(t) => write!(f, "missing/duplicate/malformed rekey tag: {t}"),
171            RekeyError::NonMonotonicEpoch => write!(f, "rekey new_epoch must exceed prev_epoch"),
172            RekeyError::BadChunkIndex => write!(f, "rekey chunk index out of range"),
173            RekeyError::TooManyBlobs(n) => write!(f, "rekey carries {n} blobs, over the cap"),
174        }
175    }
176}
177
178impl std::error::Error for RekeyError {}
179
180impl From<StreamError> for RekeyError {
181    fn from(e: StreamError) -> Self {
182        RekeyError::Stream(e)
183    }
184}
185
186// ── The blob atom ────────────────────────────────────────────────────────────
187
188/// The 72-byte bound plaintext: `scope_id[32] ‖ epoch_be[8] ‖ new_key[32]`.
189/// Fixed-width, so no separators are needed to parse it unambiguously.
190fn bound_plaintext(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> [u8; 72] {
191    let mut pt = [0u8; 72];
192    pt[..32].copy_from_slice(&scope.id32());
193    pt[32..40].copy_from_slice(&epoch.0.to_be_bytes());
194    pt[40..].copy_from_slice(new_key);
195    pt
196}
197
198/// The base64 string a blob's NIP-44 layer actually encrypts (D5). Exposed so
199/// the service-layer bunker path can `signer.nip44_encrypt(recipient, this)`.
200pub fn bound_plaintext_b64(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> String {
201    base64_simd::STANDARD.encode_to_string(bound_plaintext(scope, epoch, new_key))
202}
203
204/// Parse + verify a decrypted bound plaintext (the base64 already stripped),
205/// checking scope+epoch strict-equal the coordinate it was opened under before
206/// yielding `new_key`. Exposed for the bunker open path.
207pub fn parse_bound_plaintext(pt: &[u8], scope: RekeyScope, epoch: Epoch) -> Result<[u8; 32], RekeyError> {
208    if pt.len() != 72 {
209        return Err(RekeyError::BadBlobLength(pt.len()));
210    }
211    if pt[..32] != scope.id32() {
212        return Err(RekeyError::ScopeSplice);
213    }
214    let mut epoch_be = [0u8; 8];
215    epoch_be.copy_from_slice(&pt[32..40]);
216    if u64::from_be_bytes(epoch_be) != epoch.0 {
217        return Err(RekeyError::EpochSplice);
218    }
219    let mut new_key = [0u8; 32];
220    new_key.copy_from_slice(&pt[40..72]);
221    Ok(new_key)
222}
223
224// ── The base-rotation blob forms (CORD-06 §1) ────────────────────────────────
225//
226// A base rotation's blob widens past the 72-byte channel layout to carry the
227// next epoch's Control Plane keys (CORD-02 §2): every member's blob appends
228// `new_control_pk[32]` (104 bytes), a staff recipient's additionally
229// `new_control_root[32]` (136). The width declares the form; a 72-byte BASE
230// blob is the legacy pre-split rotation — honored when reading old epochs,
231// never minted by a compliant Rotator. Any other width is malformed.
232
233/// What a base blob delivered (CORD-06 §1); the width declared the form.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct BaseKeyDelivery {
236    pub new_root: [u8; 32],
237    /// The next epoch's Control Plane address — absent on a legacy 72-byte
238    /// blob, whose acceptor folds that epoch's Control at the legacy
239    /// member-derivable address instead (CORD-06 §3).
240    pub control_pk: Option<[u8; 32]>,
241    /// The staff write secret (136-byte form only), already verified to derive
242    /// to `control_pk`.
243    pub control_root: Option<[u8; 32]>,
244}
245
246fn bound_base_plaintext(epoch: Epoch, new_root: &[u8; 32], control_pk: &[u8; 32], control_root: Option<&[u8; 32]>) -> Vec<u8> {
247    let mut pt = Vec::with_capacity(if control_root.is_some() { 136 } else { 104 });
248    pt.extend_from_slice(&bound_plaintext(RekeyScope::Root, epoch, new_root));
249    pt.extend_from_slice(control_pk);
250    if let Some(cr) = control_root {
251        pt.extend_from_slice(cr);
252    }
253    pt
254}
255
256/// The base64 string a BASE blob's NIP-44 layer encrypts (D5) — the 104-byte
257/// member form, or the 136-byte staff form when `control_root` rides.
258pub fn bound_base_plaintext_b64(epoch: Epoch, new_root: &[u8; 32], control_pk: &[u8; 32], control_root: Option<&[u8; 32]>) -> String {
259    base64_simd::STANDARD.encode_to_string(bound_base_plaintext(epoch, new_root, control_pk, control_root))
260}
261
262/// Parse + verify a decrypted BASE blob plaintext (CORD-06 §1): 72 (legacy
263/// pre-split), 104 (member), 136 (staff). The scope must be the all-zero base
264/// sentinel and the epoch must match the coordinate (unspliceable), and a
265/// 136-byte blob's `new_control_root` must derive to exactly its
266/// `new_control_pk` (CORD-02 §5) — a mismatched pair refuses the WHOLE blob
267/// rather than adopting a plane split from its readers.
268///
269/// A width ABOVE 136 is a future form this build predates. Refusing it would
270/// re-create the pre-split brick (a member forks off at the rotation until
271/// they update), so it degrades per CORD-06 §3's lenient grade instead: the
272/// frozen 72-byte prefix yields the root (membership and every Chat plane
273/// survive), the family's appended offsets yield the control pair when they
274/// still verify (the derive check fails closed on a reordered layout), and
275/// whatever the extra bytes govern freezes — a safe prompt to update, never a
276/// fork. Widths between the defined forms fit no append-only extension and
277/// stay malformed.
278pub fn parse_bound_base_plaintext(pt: &[u8], community_id: &CommunityId, epoch: Epoch) -> Result<BaseKeyDelivery, RekeyError> {
279    if !matches!(pt.len(), 72 | 104 | 136) && pt.len() < 137 {
280        return Err(RekeyError::BadBaseBlobWidth(pt.len()));
281    }
282    let new_root = parse_bound_plaintext(&pt[..72], RekeyScope::Root, epoch)?;
283    if pt.len() == 72 {
284        return Ok(BaseKeyDelivery { new_root, control_pk: None, control_root: None });
285    }
286    let mut control_pk = [0u8; 32];
287    control_pk.copy_from_slice(&pt[72..104]);
288    if pt.len() == 104 {
289        return Ok(BaseKeyDelivery { new_root, control_pk: Some(control_pk), control_root: None });
290    }
291    let mut control_root = [0u8; 32];
292    control_root.copy_from_slice(&pt[104..136]);
293    if control_signer_group_key(&control_root, community_id, epoch).pk().to_bytes() != control_pk {
294        if pt.len() == 136 {
295            return Err(RekeyError::ControlPairMismatch);
296        }
297        // Future form whose 104..136 bytes are no longer the secret: keep the
298        // verified prefix fields, drop the unverifiable ones.
299        return Ok(BaseKeyDelivery { new_root, control_pk: Some(control_pk), control_root: None });
300    }
301    Ok(BaseKeyDelivery { new_root, control_pk: Some(control_pk), control_root: Some(control_root) })
302}
303
304/// Build one BASE blob via a [`VectorSigner`] — the 104-byte member form, or
305/// 136 with `control_root` for a staff recipient (CORD-04 §3). Mirrors
306/// [`build_blob`]; the locator is the same public Root-scope locator.
307pub async fn build_base_blob<S: crate::signer::VectorSigner + ?Sized>(
308    signer: &S,
309    rotator_xonly: &[u8; 32],
310    recipient_pk: &PublicKey,
311    epoch: Epoch,
312    new_root: &[u8; 32],
313    control_pk: &[u8; 32],
314    control_root: Option<&[u8; 32]>,
315) -> Result<RekeyBlob, RekeyError> {
316    let inner_b64 = Zeroizing::new(bound_base_plaintext_b64(epoch, new_root, control_pk, control_root));
317    let wrapped = signer
318        .nip44_encrypt_async(recipient_pk, inner_b64.as_str())
319        .await
320        .map_err(|e| RekeyError::Crypto(e.to_string()))?;
321    Ok(RekeyBlob {
322        locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), RekeyScope::Root, epoch),
323        wrapped,
324    })
325}
326
327/// Open a BASE blob addressed to me via a [`VectorSigner`]. Mirror of
328/// [`open_blob`], but width-tolerant per CORD-06 §1 — the returned delivery
329/// says which form arrived.
330pub async fn open_base_blob<S: crate::signer::VectorSigner + ?Sized>(
331    signer: &S,
332    rotator_pk: &PublicKey,
333    community_id: &CommunityId,
334    epoch: Epoch,
335    blob: &RekeyBlob,
336) -> Result<BaseKeyDelivery, RekeyError> {
337    let inner_b64 = Zeroizing::new(
338        signer
339            .nip44_decrypt_async(rotator_pk, &blob.wrapped)
340            .await
341            .map_err(|e| RekeyError::Crypto(e.to_string()))?,
342    );
343    let pt = Zeroizing::new(
344        base64_simd::STANDARD
345            .decode_to_vec(inner_b64.as_bytes())
346            .map_err(|e| RekeyError::Crypto(e.to_string()))?,
347    );
348    parse_bound_base_plaintext(&pt, community_id, epoch)
349}
350
351// ── The Grant's control_wrap plaintext (CORD-04 §3) ──────────────────────────
352
353/// `epoch_be[8] ‖ control_root[32]` — the staff write key as delivered inside a
354/// staff-making Grant, NIP-44-encrypted under the granter↔member pairwise key
355/// (the rekey-blob discipline: fixed width, the epoch INSIDE the ciphertext).
356pub fn encode_control_wrap(epoch: Epoch, control_root: &[u8; 32]) -> [u8; 40] {
357    let mut pt = [0u8; 40];
358    pt[..8].copy_from_slice(&epoch.0.to_be_bytes());
359    pt[8..].copy_from_slice(control_root);
360    pt
361}
362
363/// The base64 string a control_wrap's NIP-44 layer encrypts (string-typed
364/// signer surfaces, the D5 transport discipline).
365pub fn control_wrap_b64(epoch: Epoch, control_root: &[u8; 32]) -> String {
366    base64_simd::STANDARD.encode_to_string(encode_control_wrap(epoch, control_root))
367}
368
369/// Parse a decrypted 40-byte control_wrap plaintext. The caller verifies the
370/// secret derives to the `control_pk` it holds for the named epoch — any
371/// mismatch is dropped, never adopted (CORD-04 §3).
372pub fn parse_control_wrap(pt: &[u8]) -> Result<(Epoch, [u8; 32]), RekeyError> {
373    if pt.len() != 40 {
374        return Err(RekeyError::BadControlWrapLength(pt.len()));
375    }
376    let mut epoch_be = [0u8; 8];
377    epoch_be.copy_from_slice(&pt[..8]);
378    let mut control_root = [0u8; 32];
379    control_root.copy_from_slice(&pt[8..]);
380    Ok((Epoch(u64::from_be_bytes(epoch_be)), control_root))
381}
382
383/// The public per-recipient locator (D1). Both parties compute it from public
384/// keys alone; it addresses the blob and nothing more.
385pub fn blob_locator(rotator_xonly: &[u8; 32], recipient_xonly: &[u8; 32], scope: RekeyScope, epoch: Epoch) -> String {
386    crate::simd::hex::bytes_to_hex_32(&recipient_locator(rotator_xonly, recipient_xonly, &scope.id32(), epoch))
387}
388
389/// Build one blob with LOCAL keys (the bunker path drives the same wire via the
390/// `_b64` helpers + a NIP-46 `nip44_encrypt`). The wrap is the pairwise
391/// conversation key `ConversationKey::derive(rotator_sk, recipient_pk)`, so only
392/// the recipient's identity key opens it.
393pub fn build_blob_local(
394    rotator_sk: &SecretKey,
395    rotator_xonly: &[u8; 32],
396    recipient_pk: &PublicKey,
397    scope: RekeyScope,
398    epoch: Epoch,
399    new_key: &[u8; 32],
400) -> Result<RekeyBlob, RekeyError> {
401    let inner_b64 = Zeroizing::new(bound_plaintext_b64(scope, epoch, new_key));
402    let ck = ConversationKey::derive(rotator_sk, recipient_pk).map_err(|e| RekeyError::Crypto(e.to_string()))?;
403    let payload = crate::community::cipher::encrypt_with_random_nonce(&ck, inner_b64.as_bytes()).map_err(|e| RekeyError::Crypto(e.to_string()))?;
404    Ok(RekeyBlob {
405        locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), scope, epoch),
406        wrapped: base64_simd::STANDARD.encode_to_string(&payload),
407    })
408}
409
410/// Open a blob addressed to me with LOCAL keys. Per D1 this does NOT check the
411/// locator: the decrypt (only my identity key opens a blob wrapped to me by the
412/// rotator) plus the bound scope/epoch ARE the authenticity boundary. A blob
413/// relocated to a foreign locator still won't decrypt for a non-recipient, and
414/// a spliced one fails the bound check.
415pub fn open_blob_local(
416    my_sk: &SecretKey,
417    rotator_pk: &PublicKey,
418    scope: RekeyScope,
419    epoch: Epoch,
420    blob: &RekeyBlob,
421) -> Result<[u8; 32], RekeyError> {
422    let ck = ConversationKey::derive(my_sk, rotator_pk).map_err(|e| RekeyError::Crypto(e.to_string()))?;
423    let payload = base64_simd::STANDARD
424        .decode_to_vec(blob.wrapped.as_bytes())
425        .map_err(|e| RekeyError::Crypto(e.to_string()))?;
426    let inner_b64 = Zeroizing::new(decrypt_to_bytes(&ck, &payload).map_err(|e| RekeyError::Crypto(e.to_string()))?);
427    let pt = Zeroizing::new(
428        base64_simd::STANDARD
429            .decode_to_vec(inner_b64.as_slice())
430            .map_err(|e| RekeyError::Crypto(e.to_string()))?,
431    );
432    parse_bound_plaintext(&pt, scope, epoch)
433}
434
435/// Build one blob via a [`VectorSigner`] (the bunker / NIP-55 path). Wire-identical
436/// to [`build_blob_local`]: `signer.nip44_encrypt(recipient, bound_plaintext_b64)`
437/// whose conversation key is ECDH(signer_identity, recipient) — the same key the
438/// local path derives from the raw rotator secret. The `wrapped` field is the
439/// standard NIP-44 payload string (D5), so both paths emit identical wire.
440pub async fn build_blob<S: crate::signer::VectorSigner + ?Sized>(
441    signer: &S,
442    rotator_xonly: &[u8; 32],
443    recipient_pk: &PublicKey,
444    scope: RekeyScope,
445    epoch: Epoch,
446    new_key: &[u8; 32],
447) -> Result<RekeyBlob, RekeyError> {
448    let inner_b64 = Zeroizing::new(bound_plaintext_b64(scope, epoch, new_key));
449    let wrapped = signer
450        .nip44_encrypt_async(recipient_pk, inner_b64.as_str())
451        .await
452        .map_err(|e| RekeyError::Crypto(e.to_string()))?;
453    Ok(RekeyBlob {
454        locator: blob_locator(rotator_xonly, &recipient_pk.to_bytes(), scope, epoch),
455        wrapped,
456    })
457}
458
459/// Open a blob addressed to me via a [`VectorSigner`]. Mirror of [`open_blob_local`]:
460/// `signer.nip44_decrypt(rotator, blob.wrapped)` yields the base64 bound plaintext,
461/// then the scope/epoch bound check gates it. Per D1 the locator is NOT gated.
462pub async fn open_blob<S: crate::signer::VectorSigner + ?Sized>(
463    signer: &S,
464    rotator_pk: &PublicKey,
465    scope: RekeyScope,
466    epoch: Epoch,
467    blob: &RekeyBlob,
468) -> Result<[u8; 32], RekeyError> {
469    let inner_b64 = Zeroizing::new(
470        signer
471            .nip44_decrypt_async(rotator_pk, &blob.wrapped)
472            .await
473            .map_err(|e| RekeyError::Crypto(e.to_string()))?,
474    );
475    let pt = Zeroizing::new(
476        base64_simd::STANDARD
477            .decode_to_vec(inner_b64.as_bytes())
478            .map_err(|e| RekeyError::Crypto(e.to_string()))?,
479    );
480    parse_bound_plaintext(&pt, scope, epoch)
481}
482
483/// Find my blob in a chunk's array by my public locator (the lookup step, D1).
484/// `None` means this chunk doesn't carry my key — never a removal on its own
485/// (only "removed" once ALL chunks are held and none has it).
486pub fn find_my_blob<'a>(
487    blobs: &'a [RekeyBlob],
488    rotator_xonly: &[u8; 32],
489    my_xonly: &[u8; 32],
490    scope: RekeyScope,
491    epoch: Epoch,
492) -> Option<&'a RekeyBlob> {
493    find_my_blobs(blobs, rotator_xonly, my_xonly, scope, epoch).next()
494}
495
496/// EVERY blob at my locator, in order. A locator is a public lookup index that
497/// proves nothing, so anyone can publish a blob at mine; with the union in
498/// [`collect_rotations`] more than one can survive to the open. Taking only the
499/// first would let a junk blob shadow the real key and park an adopter that in
500/// fact holds it — so the caller tries each and adopts on the first that opens.
501pub fn find_my_blobs<'a>(
502    blobs: &'a [RekeyBlob],
503    rotator_xonly: &[u8; 32],
504    my_xonly: &[u8; 32],
505    scope: RekeyScope,
506    epoch: Epoch,
507) -> impl Iterator<Item = &'a RekeyBlob> {
508    let want = blob_locator(rotator_xonly, my_xonly, scope, epoch);
509    blobs.iter().filter(move |b| b.locator == want)
510}
511
512// ── The 3303 event (a v2 stream event) ───────────────────────────────────────
513
514/// A parsed, seal-verified 3303 chunk. The `rotator` is the seal's real signer
515/// (the ECDH counterparty AND the authority actor the caller gates on).
516#[derive(Debug, Clone)]
517pub struct RekeyChunk {
518    pub rotator: PublicKey,
519    pub scope: RekeyScope,
520    pub new_epoch: Epoch,
521    pub prev_epoch: Epoch,
522    pub prev_commit: [u8; 32],
523    /// This chunk's `(i, n)` — 1-based, `i <= n`.
524    pub chunk: (u32, u32),
525    pub blobs: Vec<RekeyBlob>,
526    /// The rotator's `vac` (CORD-06 §Authority: "a rotation cites the Grant it
527    /// acts under like any authority action"). `None` when the owner rotates.
528    pub citation: Option<crate::community::edition::AuthorityCitation>,
529    /// Severing containment marker ([`TAG_SEVER`], root scope only).
530    pub severed: bool,
531}
532
533/// The key that groups chunks of ONE rotation: `(rotator, scope_id, new_epoch,
534/// prev_commit)`. Two rotators racing the same epoch, or one rotator over two
535/// channels, never alias.
536pub type RotationKey = ([u8; 32], [u8; 32], u64, [u8; 32]);
537
538impl RekeyChunk {
539    /// This chunk's [`RotationKey`].
540    pub fn correlation(&self) -> RotationKey {
541        (self.rotator.to_bytes(), self.scope.id32(), self.new_epoch.0, self.prev_commit)
542    }
543}
544
545/// Build the unsigned 3303 rumor (rotator is the pubkey; the seal will carry the
546/// signature). Enforces the monotonic-epoch and chunk-range invariants at mint.
547#[allow(clippy::too_many_arguments)]
548pub fn build_rekey_rumor(
549    rotator: PublicKey,
550    scope: RekeyScope,
551    new_epoch: Epoch,
552    prev_epoch: Epoch,
553    prev_commit: &[u8; 32],
554    blobs: &[RekeyBlob],
555    chunk_i: u32,
556    chunk_n: u32,
557    at_secs: u64,
558    citation: Option<&crate::community::edition::AuthorityCitation>,
559    severed: bool,
560) -> Result<UnsignedEvent, RekeyError> {
561    if new_epoch.0 <= prev_epoch.0 {
562        return Err(RekeyError::NonMonotonicEpoch);
563    }
564    if chunk_n < 1 || chunk_i < 1 || chunk_i > chunk_n {
565        return Err(RekeyError::BadChunkIndex);
566    }
567    if blobs.len() > MAX_REKEY_BLOBS_PER_EVENT {
568        return Err(RekeyError::TooManyBlobs(blobs.len()));
569    }
570    let content = serde_json::to_string(blobs).map_err(|e| RekeyError::Crypto(e.to_string()))?;
571    let mut tags = vec![
572        Tag::custom(TAG_SCOPE, [scope.to_hex()]),
573        Tag::custom(TAG_NEW_EPOCH, [new_epoch.0.to_string()]),
574        Tag::custom(TAG_PREV_EPOCH, [prev_epoch.0.to_string()]),
575        Tag::custom(TAG_PREV_COMMIT, [crate::simd::hex::bytes_to_hex_32(prev_commit)]),
576        Tag::custom(TAG_CHUNK, [chunk_i.to_string(), chunk_n.to_string()]),
577    ];
578    // CORD-06 §Authority: "a rotation cites the Grant it acts under like any
579    // authority action (CORD-04's `vac`), so a just-demoted admin's rotation is
580    // never honored by a lagging client." The owner cites nothing.
581    if let Some(c) = citation {
582        tags.push(c.to_tag());
583    }
584    if severed {
585        tags.push(Tag::custom(TAG_SEVER, ["1"]));
586    }
587    // Rekeys fold by their tags, not time; still stamp created_at for the wire.
588    Ok(stream::build_rumor_secs(super::kind::REKEY, rotator, &content, tags, at_secs))
589}
590
591/// The rekey group key for a CHANNEL rekey addressed under `addressing_root`.
592/// The caller chooses the root: a STANDALONE channel rekey rides the CURRENT
593/// root (`MANAGE_CHANNELS`); a channel rekey forced by a removal rides the PRIOR
594/// root alongside the base rekey (D2 — inherits the removal's `BAN` authority,
595/// and the prior-root address is exactly what distinguishes the two classes on
596/// the wire so a base-fork loser can still open it).
597pub fn channel_rekey_group(addressing_root: &[u8; 32], channel_id: &ChannelId, new_epoch: Epoch) -> GroupKey {
598    channel_rekey_group_key(addressing_root, channel_id, new_epoch)
599}
600
601/// The rekey group key for a BASE rotation — always under the PRIOR root (the
602/// one handle every retained member still holds through the rotation).
603pub fn base_rekey_group(prior_root: &[u8; 32], community_id: &CommunityId, new_epoch: Epoch) -> GroupKey {
604    base_rekey_group_key(prior_root, community_id, new_epoch)
605}
606
607/// Seal + wrap a 3303 rumor into its stream event at the rekey address. The seal
608/// is ENCRYPTED (20013 — the rekey plane MUST NOT be plaintext-sealed) and
609/// signed by the rotator; the wrap by the rekey group key.
610pub fn seal_rekey_chunk(
611    rumor: &UnsignedEvent,
612    rekey_group: &GroupKey,
613    rotator_keys: &Keys,
614    wrap_at: Timestamp,
615) -> Result<(Event, Keys), RekeyError> {
616    let seal = stream::build_seal(rumor, SealForm::Encrypted, rekey_group, rotator_keys)?;
617    Ok(stream::wrap_seal(&seal, rekey_group, stream::KIND_WRAP, wrap_at)?)
618}
619
620/// Split a full recipient blob set into 3303 chunk events (≤120 blobs each),
621/// all sharing the rotation's `(scope, new_epoch, prev_commit)` so a receiver
622/// correlates them. Local-keys convenience.
623#[allow(clippy::too_many_arguments)]
624pub fn build_rekey_chunks_local(
625    rotator_keys: &Keys,
626    rekey_group: &GroupKey,
627    scope: RekeyScope,
628    new_epoch: Epoch,
629    prev_epoch: Epoch,
630    prev_commit: &[u8; 32],
631    blobs: &[RekeyBlob],
632    at_secs: u64,
633    citation: Option<&crate::community::edition::AuthorityCitation>,
634    severed: bool,
635) -> Result<Vec<Event>, RekeyError> {
636    let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
637        vec![&[]]
638    } else {
639        blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
640    };
641    let n = groups.len() as u32;
642    let mut out = Vec::with_capacity(groups.len());
643    for (idx, group_blobs) in groups.iter().enumerate() {
644        let rumor = build_rekey_rumor(
645            rotator_keys.public_key(),
646            scope,
647            new_epoch,
648            prev_epoch,
649            prev_commit,
650            group_blobs,
651            idx as u32 + 1,
652            n,
653            at_secs,
654            citation,
655            severed,
656        )?;
657        let (wrap, _) = seal_rekey_chunk(&rumor, rekey_group, rotator_keys, Timestamp::from_secs(at_secs))?;
658        out.push(wrap);
659    }
660    Ok(out)
661}
662
663/// Signer-driven twin of [`build_rekey_chunks_local`] for bunker / NIP-55 accounts:
664/// each chunk's encrypted seal signs through a [`VectorSigner`]. `rotator_pk` must
665/// equal `my_public_key()`. Wire-identical to the local path.
666#[allow(clippy::too_many_arguments)]
667pub async fn build_rekey_chunks<S: crate::signer::VectorSigner + ?Sized>(
668    signer: &S,
669    rotator_pk: PublicKey,
670    rekey_group: &GroupKey,
671    scope: RekeyScope,
672    new_epoch: Epoch,
673    prev_epoch: Epoch,
674    prev_commit: &[u8; 32],
675    blobs: &[RekeyBlob],
676    at_secs: u64,
677    citation: Option<&crate::community::edition::AuthorityCitation>,
678    severed: bool,
679) -> Result<Vec<Event>, RekeyError> {
680    let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
681        vec![&[]]
682    } else {
683        blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
684    };
685    let n = groups.len() as u32;
686    let mut out = Vec::with_capacity(groups.len());
687    for (idx, group_blobs) in groups.iter().enumerate() {
688        let rumor = build_rekey_rumor(rotator_pk, scope, new_epoch, prev_epoch, prev_commit, group_blobs, idx as u32 + 1, n, at_secs, citation, severed)?;
689        let (wrap, _) = stream::seal_and_wrap_signed(signer, rotator_pk, &rumor, SealForm::Encrypted, rekey_group, stream::KIND_WRAP, Timestamp::from_secs(at_secs), &[]).await?;
690        out.push(wrap);
691    }
692    Ok(out)
693}
694
695/// Extend an already-published rotation's chunk set with additional blobs — the
696/// catch-up path for members a rotation forgot.
697///
698/// Indices start at `start_after + 1` and the declared total covers them, so no
699/// reader ever sees two chunks claiming the same slot. That constraint is load-
700/// bearing on DEPLOYED clients: [`collect_rotations`] keeps the first chunk seen
701/// per index, so a colliding slot silently drops the loser's blobs — and for an
702/// original recipient that reads as their own removal. Disjoint indices merge as
703/// a pure union under every fetch order, and [`Rotation::is_complete`] only
704/// requires `1..=declared_first_seen`, which extras never break.
705#[allow(clippy::too_many_arguments)]
706pub async fn build_rekey_chunk_extension<S: crate::signer::VectorSigner + ?Sized>(
707    signer: &S,
708    rotator_pk: PublicKey,
709    rekey_group: &GroupKey,
710    scope: RekeyScope,
711    new_epoch: Epoch,
712    prev_epoch: Epoch,
713    prev_commit: &[u8; 32],
714    blobs: &[RekeyBlob],
715    at_secs: u64,
716    citation: Option<&crate::community::edition::AuthorityCitation>,
717    severed: bool,
718    start_after: u32,
719) -> Result<Vec<Event>, RekeyError> {
720    let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
721        vec![&[]]
722    } else {
723        blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
724    };
725    let n = start_after + groups.len() as u32;
726    let mut out = Vec::with_capacity(groups.len());
727    for (idx, group_blobs) in groups.iter().enumerate() {
728        let i = start_after + idx as u32 + 1;
729        let rumor = build_rekey_rumor(rotator_pk, scope, new_epoch, prev_epoch, prev_commit, group_blobs, i, n, at_secs, citation, severed)?;
730        let (wrap, _) = stream::seal_and_wrap_signed(signer, rotator_pk, &rumor, SealForm::Encrypted, rekey_group, stream::KIND_WRAP, Timestamp::from_secs(at_secs), &[]).await?;
731        out.push(wrap);
732    }
733    Ok(out)
734}
735
736/// Parse a 3303 chunk from a seal-verified stream open. Rejects a non-3303
737/// rumor, a plaintext seal (the rekey plane is encrypted-only), malformed or
738/// duplicate machinery tags, a bad chunk range, and an over-cap blob array.
739pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError> {
740    if opened.seal_form != SealForm::Encrypted {
741        // A plaintext-sealed rekey would be a liftable public artifact — reject.
742        return Err(RekeyError::Stream(StreamError::BadSealKind(stream::KIND_SEAL_PLAINTEXT)));
743    }
744    let rumor = &opened.rumor;
745    if rumor.kind.as_u16() != super::kind::REKEY {
746        return Err(RekeyError::NotARekey(rumor.kind.as_u16()));
747    }
748    let scope = RekeyScope::from_hex(&unique_tag(rumor, TAG_SCOPE)?.ok_or(RekeyError::BadTag(TAG_SCOPE))?)
749        .ok_or(RekeyError::BadTag(TAG_SCOPE))?;
750    let new_epoch = Epoch(parse_u64(rumor, TAG_NEW_EPOCH)?);
751    let prev_epoch = Epoch(parse_u64(rumor, TAG_PREV_EPOCH)?);
752    if new_epoch.0 <= prev_epoch.0 {
753        return Err(RekeyError::NonMonotonicEpoch);
754    }
755    let prev_hex = unique_tag(rumor, TAG_PREV_COMMIT)?.ok_or(RekeyError::BadTag(TAG_PREV_COMMIT))?;
756    if prev_hex.len() != 64 || !prev_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
757        return Err(RekeyError::BadTag(TAG_PREV_COMMIT));
758    }
759    let prev_commit = crate::simd::hex::hex_to_bytes_32(&prev_hex);
760
761    let (chunk_i, chunk_n) = parse_chunk(rumor)?;
762
763    let blobs: Vec<RekeyBlob> = serde_json::from_str(&rumor.content).map_err(|_| RekeyError::BadTag("blobs"))?;
764    if blobs.len() > MAX_REKEY_BLOBS_RECEIVED {
765        return Err(RekeyError::TooManyBlobs(blobs.len()));
766    }
767
768    // Spec-shaped: present means exactly "1" (CORD-01 §5 strictness), absent means false.
769    let severed = match unique_tag(rumor, TAG_SEVER)?.as_deref() {
770        None => false,
771        Some("1") => true,
772        Some(_) => return Err(RekeyError::BadTag(TAG_SEVER)),
773    };
774
775    Ok(RekeyChunk {
776        rotator: opened.author,
777        scope,
778        new_epoch,
779        prev_epoch,
780        prev_commit,
781        chunk: (chunk_i, chunk_n),
782        blobs,
783        citation: crate::community::edition::AuthorityCitation::from_tags(&rumor.tags),
784        severed,
785    })
786}
787
788// ── Continuity + removal + fork resolution (CORD-06 §2/§3) ───────────────────
789
790/// The verdict of the prevcommit continuity check (CORD-06 §2).
791#[derive(Debug, Clone, Copy, PartialEq, Eq)]
792pub enum Continuity {
793    /// The commitment matches the key I hold at `prev_epoch` — this rotation
794    /// extends my chain; adopt it.
795    Extends,
796    /// `prev_epoch` is higher than the key I hold — I missed a rotation; fetch
797    /// the gap first, don't adopt yet.
798    Gap,
799    /// The commitment doesn't match at the same epoch — a fork or garbage.
800    Fork,
801}
802
803/// Check a rotation's `prev_commit` against the `(epoch, key)` I currently hold
804/// for its scope. A match proves the rotation extends the very key I hold; a
805/// higher `prev_epoch` means I'm behind; anything else is a fork/garbage.
806pub fn check_continuity(chunk: &RekeyChunk, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
807    if chunk.prev_epoch.0 == held_epoch.0 {
808        if epoch_key_commitment(held_epoch, held_key) == chunk.prev_commit {
809            Continuity::Extends
810        } else {
811            Continuity::Fork
812        }
813    } else if chunk.prev_epoch.0 > held_epoch.0 {
814        Continuity::Gap
815    } else {
816        // prev_epoch < held: this rotation is older than where I am — a stale
817        // fork; a settled epoch only ever heals DOWN to a sibling, never back.
818        Continuity::Fork
819    }
820}
821
822/// A collected rotation: all chunks sharing one correlation key, and whether the
823/// set is complete (all `n` chunks present).
824#[derive(Debug, Clone)]
825pub struct Rotation {
826    pub rotator: PublicKey,
827    pub scope: RekeyScope,
828    pub new_epoch: Epoch,
829    pub prev_epoch: Epoch,
830    pub prev_commit: [u8; 32],
831    /// The union of every chunk's blobs.
832    pub blobs: Vec<RekeyBlob>,
833    /// Total chunk count `n` declared by the chunks.
834    pub declared_chunks: u32,
835    /// Distinct chunk indices actually held.
836    pub held_chunks: std::collections::BTreeSet<u32>,
837    /// The rotator's `vac`, taken from the first chunk seen (every chunk of one
838    /// rotation carries the same citation — they share a signer and an action).
839    pub citation: Option<crate::community::edition::AuthorityCitation>,
840    /// OR across chunks: any severed chunk marks the whole rotation severed, so
841    /// an extension (rescue) can never launder the flag away.
842    pub severed: bool,
843}
844
845impl Rotation {
846    /// True once every declared chunk index `1..=n` is held — the precondition
847    /// for concluding removal (a missing chunk is "keep recovering", never a
848    /// removal).
849    pub fn is_complete(&self) -> bool {
850        self.declared_chunks >= 1 && (1..=self.declared_chunks).all(|i| self.held_chunks.contains(&i))
851    }
852
853    /// This rotation's continuity against the `(epoch, key)` I hold for its scope
854    /// — the [`check_continuity`] verdict at the aggregated-rotation level (same
855    /// prevcommit test), so a follower can gate adoption without a raw chunk.
856    pub fn continuity(&self, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
857        if self.prev_epoch.0 == held_epoch.0 {
858            if epoch_key_commitment(held_epoch, held_key) == self.prev_commit {
859                Continuity::Extends
860            } else {
861                Continuity::Fork
862            }
863        } else if self.prev_epoch.0 > held_epoch.0 {
864            Continuity::Gap
865        } else {
866            Continuity::Fork
867        }
868    }
869}
870
871/// Group a batch of parsed chunks into rotations by correlation key. Chunks whose
872/// `n` disagrees with their siblings, or that repeat an index, are the caller's
873/// concern to police; here the first-seen `n` per correlation wins and duplicate
874/// indices are ignored (idempotent re-delivery).
875pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec<Rotation> {
876    use std::collections::BTreeMap;
877    let mut by_key: BTreeMap<RotationKey, Rotation> = BTreeMap::new();
878    for c in chunks {
879        let entry = by_key.entry(c.correlation()).or_insert_with(|| Rotation {
880            rotator: c.rotator,
881            scope: c.scope,
882            new_epoch: c.new_epoch,
883            prev_epoch: c.prev_epoch,
884            prev_commit: c.prev_commit,
885            blobs: Vec::new(),
886            declared_chunks: c.chunk.1,
887            held_chunks: std::collections::BTreeSet::new(),
888            citation: c.citation.clone(),
889            severed: c.severed,
890        });
891        entry.severed |= c.severed;
892        // UNION the blobs, even when the index is already held. Treating a second
893        // chunk at a known index as pure idempotent re-delivery is safe only while
894        // re-delivery is byte-identical; when two chunks genuinely differ (a
895        // catch-up extension that re-claimed a slot), dropping the loser silently
896        // deletes its recipients from the union — and a recipient with no blob reads
897        // as REMOVED, which tears their community down. Union can only ever ADD
898        // blobs, so it can turn a false removal into an adoption but never the
899        // reverse. Exact duplicates are dropped so ordinary re-delivery stays free.
900        entry.held_chunks.insert(c.chunk.0);
901        for b in &c.blobs {
902            if !entry.blobs.iter().any(|held| held.locator == b.locator && held.wrapped == b.wrapped) {
903                entry.blobs.push(b.clone());
904            }
905        }
906    }
907    by_key.into_values().collect()
908}
909
910/// Have I been removed by this rotation? Only answerable on a COMPLETE rotation
911/// (all `n` chunks held): I'm removed iff none of the union's blobs carries my
912/// locator. On an incomplete rotation the answer is `None` — keep recovering.
913pub fn am_i_removed(rotation: &Rotation, my_xonly: &[u8; 32]) -> Option<bool> {
914    if !rotation.is_complete() {
915        return None;
916    }
917    let mine = find_my_blob(&rotation.blobs, &rotation.rotator.to_bytes(), my_xonly, rotation.scope, rotation.new_epoch);
918    Some(mine.is_none())
919}
920
921/// Deterministic same-epoch fork winner (CORD-06 §3): among candidate rotations
922/// at one continuity point, the one whose decrypted `new_key` is lexicographically
923/// lowest wins. Every retained member decrypts its own blob from each fork and
924/// computes the identical winner. Returns the index into `candidates` of the
925/// winner, or `None` if the caller decrypted no candidate.
926pub fn lowest_key_winner(candidate_keys: &[[u8; 32]]) -> Option<usize> {
927    candidate_keys
928        .iter()
929        .enumerate()
930        .min_by(|(_, a), (_, b)| a.cmp(b))
931        .map(|(i, _)| i)
932}
933
934// ── helpers ──────────────────────────────────────────────────────────────────
935
936fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, RekeyError> {
937    let mut found: Option<String> = None;
938    for t in rumor.tags.iter() {
939        let s = t.as_slice();
940        if s.len() >= 2 && s[0] == name {
941            if found.is_some() {
942                return Err(RekeyError::BadTag(name));
943            }
944            found = Some(s[1].clone());
945        }
946    }
947    Ok(found)
948}
949
950fn parse_u64(rumor: &UnsignedEvent, name: &'static str) -> Result<u64, RekeyError> {
951    let raw = unique_tag(rumor, name)?.ok_or(RekeyError::BadTag(name))?;
952    // Spec-shaped decimal, not a bare `parse` — that takes "+2" and "02" as
953    // epochs a stricter peer refuses (CORD-01 §5).
954    if !crate::community::edition::is_tag_decimal(&raw) {
955        return Err(RekeyError::BadTag(name));
956    }
957    raw.parse::<u64>().map_err(|_| RekeyError::BadTag(name))
958}
959
960fn parse_chunk(rumor: &UnsignedEvent) -> Result<(u32, u32), RekeyError> {
961    let mut found: Option<(u32, u32)> = None;
962    for t in rumor.tags.iter() {
963        let s = t.as_slice();
964        if s.len() >= 3 && s[0] == TAG_CHUNK {
965            if found.is_some() {
966                return Err(RekeyError::BadTag(TAG_CHUNK));
967            }
968            if !crate::community::edition::is_tag_decimal(&s[1]) || !crate::community::edition::is_tag_decimal(&s[2]) {
969                return Err(RekeyError::BadTag(TAG_CHUNK));
970            }
971            let i: u32 = s[1].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
972            let n: u32 = s[2].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
973            found = Some((i, n));
974        }
975    }
976    let (i, n) = found.ok_or(RekeyError::BadTag(TAG_CHUNK))?;
977    if n < 1 || i < 1 || i > n {
978        return Err(RekeyError::BadChunkIndex);
979    }
980    Ok((i, n))
981}
982
983#[cfg(test)]
984mod tests {
985
986    /// Wrap raw `Keys` as the polymorphic signer. `VectorSigner` pins its error to
987    /// `SignerError`, which bare `Keys` doesn't satisfy, so tests go through the
988    /// same enum the app uses.
989    fn as_signer(k: &Keys) -> crate::signer::ActiveSigner {
990        crate::signer::ActiveSigner::Keys(k.clone())
991    }
992    use super::*;
993
994    fn keys(byte: u8) -> Keys {
995        Keys::new(SecretKey::from_slice(&[byte; 32]).unwrap())
996    }
997
998    fn xonly(k: &Keys) -> [u8; 32] {
999        k.public_key().to_bytes()
1000    }
1001
1002    const CHAN: ChannelId = ChannelId([0x42u8; 32]);
1003
1004    // ── blob atom ────────────────────────────────────────────────────────────
1005
1006    #[test]
1007    fn bound_plaintext_layout_is_frozen() {
1008        let pt = bound_plaintext(RekeyScope::Root, Epoch(1), &[0xABu8; 32]);
1009        let expected = format!("{}{}{}", "00".repeat(32), "0000000000000001", "ab".repeat(32));
1010        assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
1011        let pt2 = bound_plaintext(RekeyScope::Channel(ChannelId([0x11u8; 32])), Epoch(0x0102), &[0xCDu8; 32]);
1012        let expected2 = format!("{}{}{}", "11".repeat(32), "0000000000000102", "cd".repeat(32));
1013        assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt2), expected2);
1014    }
1015
1016    #[test]
1017    fn blob_round_trips_both_scopes() {
1018        let rotator = keys(7);
1019        let recipient = keys(8);
1020        for (scope, epoch, key) in [
1021            (RekeyScope::Root, Epoch(1), [0xABu8; 32]),
1022            (RekeyScope::Channel(CHAN), Epoch(5), [0xCDu8; 32]),
1023        ] {
1024            let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
1025            let got = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob).unwrap();
1026            assert_eq!(got, key, "the recipient recovers the fresh key");
1027        }
1028    }
1029
1030    #[tokio::test]
1031    async fn signer_blob_is_wire_compatible_with_local_both_directions() {
1032        // A remote signer (here a plain Keys, which impls VectorSigner) must build
1033        // AND open blobs interchangeably with the raw-key path — the CORD-06 D5
1034        // "identical wire" guarantee the bunker/NIP-55 integration rests on.
1035        let rotator = keys(7);
1036        let recipient = keys(8);
1037        let scope = RekeyScope::Root;
1038        let epoch = Epoch(3);
1039        let key = [0x5Au8; 32];
1040
1041        // local build -> signer open
1042        let blob_l = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
1043        let got_s = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_l).await.unwrap();
1044        assert_eq!(got_s, key, "signer opens a local-built blob");
1045
1046        // signer build -> local open (+ identical public locator)
1047        let blob_s = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).await.unwrap();
1048        assert_eq!(blob_s.locator, blob_l.locator, "same public locator regardless of build path");
1049        let got_l = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob_s).unwrap();
1050        assert_eq!(got_l, key, "local opens a signer-built blob");
1051
1052        // signer build -> signer open
1053        let got_ss = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_s).await.unwrap();
1054        assert_eq!(got_ss, key, "signer round-trips its own blob");
1055    }
1056
1057    #[tokio::test]
1058    async fn signer_blob_bound_check_still_gates_scope_epoch_splice() {
1059        let rotator = keys(7);
1060        let recipient = keys(8);
1061        let blob = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[9u8; 32]).await.unwrap();
1062        assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob).await.is_err(), "epoch splice rejected");
1063        assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob).await.is_err(), "scope splice rejected");
1064    }
1065
1066    #[test]
1067    fn locator_is_public_and_computable_from_pubkeys_alone() {
1068        // D1: the locator derives from PUBLIC inputs, so the rotator computing a
1069        // recipient's slot and the recipient computing their own must agree — no
1070        // secret needed either side (bunker parity).
1071        let rotator = keys(7);
1072        let recipient = keys(8);
1073        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(2), &[1u8; 32]).unwrap();
1074        let recomputed = blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(2));
1075        assert_eq!(blob.locator, recomputed, "both sides compute the same public locator");
1076    }
1077
1078    #[test]
1079    fn a_non_recipient_cannot_open_even_holding_the_public_locator() {
1080        // The D1 security relocation: the locator authenticates NOTHING (anyone
1081        // holding both npubs computes it), yet an outsider still can't open —
1082        // the pairwise decrypt is the gate, not the locator.
1083        let rotator = keys(7);
1084        let recipient = keys(8);
1085        let outsider = keys(9);
1086        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[2u8; 32]).unwrap();
1087        // The outsider can trivially recompute the (public) locator...
1088        assert_eq!(blob.locator, blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)));
1089        // ...but pairing (outsider_sk, rotator_pk) yields a different conversation
1090        // key, so the decrypt fails.
1091        assert!(open_blob_local(outsider.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).is_err());
1092    }
1093
1094    #[test]
1095    fn a_relocated_blob_still_opens_by_decrypt_not_locator() {
1096        // Because open ignores the locator (D1), corrupting the locator does NOT
1097        // break a legitimate recipient's open — the decrypt + bound check govern.
1098        // (Contrast v1, where a locator mismatch was a hard reject.) The FIND
1099        // step uses the locator; OPEN does not.
1100        let rotator = keys(7);
1101        let recipient = keys(8);
1102        let mut blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[3u8; 32]).unwrap();
1103        blob.locator = "ff".repeat(32);
1104        // Handed the blob directly (locator bypassed), the recipient still opens it.
1105        assert_eq!(
1106            open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).unwrap(),
1107            [3u8; 32]
1108        );
1109        // But find_my_blob won't LOCATE it under the corrupted locator.
1110        assert!(find_my_blob(std::slice::from_ref(&blob), &xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)).is_none());
1111    }
1112
1113    #[test]
1114    fn scope_and_epoch_splices_are_rejected_on_open() {
1115        let rotator = keys(7);
1116        let recipient = keys(8);
1117        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[4u8; 32]).unwrap();
1118        // Opened under a different scope → bound scope mismatch.
1119        assert!(matches!(
1120            open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob),
1121            Err(RekeyError::ScopeSplice)
1122        ));
1123        // Opened under a different epoch → bound epoch mismatch.
1124        assert!(matches!(
1125            open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob),
1126            Err(RekeyError::EpochSplice)
1127        ));
1128    }
1129
1130    #[test]
1131    fn wrapped_carries_base64_of_the_72_bytes_for_bunker_parity() {
1132        // D5: the NIP-44 layer's plaintext is base64(72 bytes) — a UTF-8 string a
1133        // NIP-46 signer can nip44_encrypt/decrypt. Prove the decrypted inner is
1134        // exactly that base64 string, matching the `_b64` helper the bunker uses.
1135        let rotator = keys(7);
1136        let recipient = keys(8);
1137        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[5u8; 32]).unwrap();
1138        let ck = ConversationKey::derive(recipient.secret_key(), &rotator.public_key()).unwrap();
1139        let payload = base64_simd::STANDARD.decode_to_vec(blob.wrapped.as_bytes()).unwrap();
1140        let inner = decrypt_to_bytes(&ck, &payload).unwrap();
1141        assert_eq!(String::from_utf8(inner).unwrap(), bound_plaintext_b64(RekeyScope::Root, Epoch(1), &[5u8; 32]));
1142    }
1143
1144    #[test]
1145    fn distinct_recipients_and_scopes_get_distinct_locators() {
1146        let rotator = keys(7);
1147        let r1 = keys(8);
1148        let r2 = keys(9);
1149        assert_ne!(
1150            blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
1151            blob_locator(&xonly(&rotator), &xonly(&r2), RekeyScope::Root, Epoch(1))
1152        );
1153        // Same pair, a base blob and a channel blob at one epoch don't collide.
1154        assert_ne!(
1155            blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
1156            blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Channel(CHAN), Epoch(1))
1157        );
1158    }
1159
1160    // ── 3303 event ─────────────────────────────────────────────────────────
1161
1162    fn root() -> [u8; 32] {
1163        [0x55u8; 32]
1164    }
1165
1166    #[test]
1167    fn channel_rekey_round_trips_through_the_stream() {
1168        let rotator = keys(1);
1169        let recipient = keys(8);
1170        let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
1171        let key = [0xABu8; 32];
1172        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &key).unwrap();
1173        let commit = epoch_key_commitment(Epoch(0), &[0xEEu8; 32]);
1174        let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &commit, &[blob.clone()], 1, 1, 100, None, false).unwrap();
1175        let (wrap, _) = seal_rekey_chunk(&rumor, &group, &rotator, Timestamp::from_secs(100)).unwrap();
1176
1177        // The wrap is signed by the group key, not the rotator (no identity on the wire).
1178        assert_ne!(wrap.pubkey, rotator.public_key());
1179        assert_eq!(wrap.pubkey, group.pk());
1180
1181        let opened = stream::open_wrap(&wrap, &group).unwrap();
1182        let chunk = parse_rekey_chunk(&opened).unwrap();
1183        assert_eq!(chunk.rotator, rotator.public_key(), "rotator recovered from the seal");
1184        assert!(matches!(chunk.scope, RekeyScope::Channel(c) if c.0 == CHAN.0));
1185        assert_eq!(chunk.new_epoch, Epoch(1));
1186        assert_eq!(chunk.prev_epoch, Epoch(0));
1187        assert_eq!(chunk.prev_commit, commit);
1188        assert_eq!(chunk.chunk, (1, 1));
1189        assert_eq!(chunk.blobs, vec![blob]);
1190    }
1191
1192    #[test]
1193    fn base_rekey_addresses_under_the_prior_root() {
1194        // A base rotation rides the PRIOR root: a member holding the prior root
1195        // derives the same group key and opens it; a non-holder can't.
1196        let rotator = keys(1);
1197        let recipient = keys(8);
1198        let prior_root = [0x66u8; 32];
1199        let community = CommunityId([0x77u8; 32]);
1200        let new_root = [0x99u8; 32];
1201        let group = base_rekey_group(&prior_root, &community, Epoch(1));
1202        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &new_root).unwrap();
1203        let commit = epoch_key_commitment(Epoch(0), &prior_root);
1204        let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &commit, &[blob], 100, None, false).unwrap();
1205        assert_eq!(chunks.len(), 1);
1206
1207        let opened = stream::open_wrap(&chunks[0], &group).unwrap();
1208        let chunk = parse_rekey_chunk(&opened).unwrap();
1209        assert!(matches!(chunk.scope, RekeyScope::Root));
1210        // The recipient recovers the NEW root from their blob.
1211        let mine = find_my_blob(&chunk.blobs, &chunk.rotator.to_bytes(), &xonly(&recipient), chunk.scope, chunk.new_epoch).unwrap();
1212        assert_eq!(open_blob_local(recipient.secret_key(), &chunk.rotator, chunk.scope, chunk.new_epoch, mine).unwrap(), new_root);
1213
1214        // A non-holder of the prior root can't even open the wrap.
1215        let wrong = base_rekey_group(&[0u8; 32], &community, Epoch(1));
1216        assert!(stream::open_wrap(&chunks[0], &wrong).is_err());
1217    }
1218
1219    #[test]
1220    fn a_full_send_chunk_stays_under_the_relay_size_limit() {
1221        // The send cap exists so one chunk fits a 64KB strfry event. A full chunk
1222        // must serialize under it or relays reject the event.
1223        let rotator = keys(1);
1224        let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
1225        let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT)
1226            .map(|i| {
1227                let r = keys((i % 200 + 20) as u8);
1228                build_blob_local(rotator.secret_key(), &xonly(&rotator), &r.public_key(), RekeyScope::Root, Epoch(1), &[0xCDu8; 32]).unwrap()
1229            })
1230            .collect();
1231        let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &blobs, 100, None, false).unwrap();
1232        assert_eq!(chunks.len(), 1, "a full send chunk is exactly one event");
1233        assert!(chunks[0].as_json().len() <= 65_536, "a full chunk must fit a 64KB relay event");
1234    }
1235
1236    #[test]
1237    fn oversize_recipient_set_splits_into_chunks() {
1238        let rotator = keys(1);
1239        let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
1240        // One over the send cap → 2 chunks, labeled (1,2) and (2,2).
1241        let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT + 1)
1242            .map(|_| RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() })
1243            .collect();
1244        let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(2), Epoch(1), &[0u8; 32], &blobs, 100, None, false).unwrap();
1245        assert_eq!(chunks.len(), 2);
1246        let parsed: Vec<RekeyChunk> = chunks.iter().map(|w| parse_rekey_chunk(&stream::open_wrap(w, &group).unwrap()).unwrap()).collect();
1247        assert_eq!(parsed[0].chunk, (1, 2));
1248        assert_eq!(parsed[1].chunk, (2, 2));
1249        assert_eq!(parsed[0].blobs.len(), MAX_REKEY_BLOBS_PER_EVENT);
1250        assert_eq!(parsed[1].blobs.len(), 1);
1251    }
1252
1253    #[test]
1254    fn plaintext_sealed_rekey_is_rejected() {
1255        let rotator = keys(1);
1256        let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
1257        let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[], 1, 1, 100, None, false).unwrap();
1258        let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group, &rotator).unwrap();
1259        let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
1260        let opened = stream::open_wrap(&wrap, &group).unwrap();
1261        assert!(parse_rekey_chunk(&opened).is_err(), "the rekey plane must be encrypted-sealed");
1262    }
1263
1264    #[test]
1265    fn non_monotonic_epoch_is_refused_at_mint_and_on_parse() {
1266        let rotator = keys(1);
1267        assert!(matches!(
1268            build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(1), &[0u8; 32], &[], 1, 1, 100, None, false),
1269            Err(RekeyError::NonMonotonicEpoch)
1270        ));
1271    }
1272
1273    #[test]
1274    fn bad_chunk_indices_are_refused() {
1275        let rotator = keys(1);
1276        for (i, n) in [(0u32, 1u32), (2, 1), (1, 0)] {
1277            assert!(
1278                matches!(build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &[], i, n, 100, None, false), Err(RekeyError::BadChunkIndex)),
1279                "chunk ({i},{n}) must be rejected"
1280            );
1281        }
1282    }
1283
1284    // ── continuity + removal + fork ──────────────────────────────────────────
1285
1286    fn chunk_at(rotator: &Keys, scope: RekeyScope, new_epoch: u64, prev_epoch: u64, prev_key: &[u8; 32], blobs: Vec<RekeyBlob>, i: u32, n: u32) -> RekeyChunk {
1287        RekeyChunk {
1288            rotator: rotator.public_key(),
1289            scope,
1290            new_epoch: Epoch(new_epoch),
1291            prev_epoch: Epoch(prev_epoch),
1292            prev_commit: epoch_key_commitment(Epoch(prev_epoch), prev_key),
1293            chunk: (i, n),
1294            blobs,
1295            citation: None,
1296            severed: false,
1297        }
1298    }
1299
1300    #[test]
1301    fn a_colliding_chunk_index_must_not_swallow_a_recipients_blob() {
1302        // Chunks are correlated by index, and a second chunk claiming an index
1303        // already held is treated as idempotent re-delivery — its blobs dropped.
1304        // That is safe only while re-delivery is byte-identical. The catch-up path
1305        // that extends a rotation computes its starting index from a fetch that can
1306        // come back blind, and then re-claims index 1; whichever copy a client sees
1307        // first wins, so the original recipients in the loser vanish from the union.
1308        // A recipient whose blob vanishes reads as REMOVED — and removal deletes the
1309        // community locally. Losing a blob to a bookkeeping collision must never be
1310        // able to masquerade as a removal verdict.
1311        let rotator = keys(1);
1312        let prev = [0x55u8; 32];
1313        let mine = RekeyBlob { locator: "aa".repeat(32), wrapped: "mine".into() };
1314        let other = RekeyBlob { locator: "bb".repeat(32), wrapped: "other".into() };
1315
1316        // Two chunks both claiming index 1: the original (carrying my blob) and a
1317        // re-claim (carrying someone else's).
1318        let original = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![mine.clone()], 1, 1);
1319        let reclaim = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![other.clone()], 1, 1);
1320
1321        for (order, chunks) in [
1322            ("original first", vec![original.clone(), reclaim.clone()]),
1323            ("reclaim first", vec![reclaim.clone(), original.clone()]),
1324        ] {
1325            let rotations = collect_rotations(&chunks);
1326            assert_eq!(rotations.len(), 1, "{order}: same correlation key");
1327            let has_mine = rotations[0].blobs.iter().any(|b| b.locator == mine.locator);
1328            assert!(
1329                has_mine,
1330                "{order}: my blob was dropped by an index collision — that reads as a removal \
1331                 and removal deletes the community",
1332            );
1333        }
1334    }
1335
1336    #[test]
1337    fn a_severed_rotation_cannot_be_laundered_by_an_unmarked_chunk() {
1338        // The containment marker rides EVERY chunk of a rotation, but a rotation's
1339        // chunk set can be extended later (the catch-up path for members a rotation
1340        // forgot). An extension built without the marker would, under first-seen
1341        // semantics, present a severed epoch as an ordinary one — and an adopter
1342        // reading it that way refreshes the very invite links the containment meant
1343        // to bury, re-opening the door the raid came through. The fold is therefore
1344        // an OR across chunks, in either arrival order.
1345        let rotator = keys(1);
1346        let prev = [0x44u8; 32];
1347        let mut marked = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![], 1, 2);
1348        marked.severed = true;
1349        let unmarked = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![], 2, 2);
1350
1351        for (order, chunks) in [
1352            ("marked first", vec![marked.clone(), unmarked.clone()]),
1353            ("unmarked first", vec![unmarked.clone(), marked.clone()]),
1354        ] {
1355            let rotations = collect_rotations(&chunks);
1356            assert_eq!(rotations.len(), 1, "{order}: one correlation key, one rotation");
1357            assert!(
1358                rotations[0].severed,
1359                "{order}: an unmarked sibling must never launder the containment away",
1360            );
1361            assert!(rotations[0].is_complete(), "{order}: both chunks held");
1362        }
1363    }
1364
1365    #[test]
1366    fn continuity_extends_gaps_and_forks() {
1367        let rotator = keys(1);
1368        let held = [0x33u8; 32];
1369        // Extends: prev_epoch == held epoch AND commitment matches the held key.
1370        let good = chunk_at(&rotator, RekeyScope::Root, 3, 2, &held, vec![], 1, 1);
1371        assert_eq!(check_continuity(&good, Epoch(2), &held), Continuity::Extends);
1372        // Gap: the rotation is FROM a higher epoch than I hold — I missed one.
1373        let ahead = chunk_at(&rotator, RekeyScope::Root, 5, 4, &held, vec![], 1, 1);
1374        assert_eq!(check_continuity(&ahead, Epoch(2), &held), Continuity::Gap);
1375        // Fork: same epoch but the commitment names a different prior key.
1376        let fork = chunk_at(&rotator, RekeyScope::Root, 3, 2, &[0x99u8; 32], vec![], 1, 1);
1377        assert_eq!(check_continuity(&fork, Epoch(2), &held), Continuity::Fork);
1378        // Fork: a rotation older than where I am (stale).
1379        let stale = chunk_at(&rotator, RekeyScope::Root, 2, 1, &held, vec![], 1, 1);
1380        assert_eq!(check_continuity(&stale, Epoch(2), &held), Continuity::Fork);
1381    }
1382
1383    #[test]
1384    fn a_missing_chunk_is_never_a_removal() {
1385        // The core no-false-removal guarantee: until ALL n chunks are held, "am I
1386        // removed" is unanswerable, even if the chunks I DO hold lack my blob.
1387        let rotator = keys(1);
1388        let me = keys(8);
1389        // Two-chunk rotation; my blob is in chunk 2, which I haven't received.
1390        let my_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &me.public_key(), RekeyScope::Root, Epoch(1), &[0xAAu8; 32]).unwrap();
1391        let c1 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![RekeyBlob { locator: "bb".repeat(32), wrapped: "x".into() }], 1, 2);
1392        let rots = collect_rotations(&[c1.clone()]);
1393        assert_eq!(rots.len(), 1);
1394        assert!(!rots[0].is_complete(), "one of two chunks held → incomplete");
1395        assert_eq!(am_i_removed(&rots[0], &xonly(&me)), None, "incomplete → keep recovering, never conclude removal");
1396
1397        // Now chunk 2 (with my blob) arrives → complete, and I am NOT removed.
1398        let c2 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![my_blob.clone()], 2, 2);
1399        let rots = collect_rotations(&[c1, c2]);
1400        assert!(rots[0].is_complete());
1401        assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(false));
1402        // And my key is recoverable from the union.
1403        let mine = find_my_blob(&rots[0].blobs, &rots[0].rotator.to_bytes(), &xonly(&me), RekeyScope::Root, Epoch(1)).unwrap();
1404        assert_eq!(open_blob_local(me.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), mine).unwrap(), [0xAAu8; 32]);
1405    }
1406
1407    #[test]
1408    fn a_complete_rotation_without_my_blob_is_a_removal() {
1409        let rotator = keys(1);
1410        let me = keys(8);
1411        let other = keys(9);
1412        // A complete 1-chunk rotation carrying only someone else's blob.
1413        let their_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &other.public_key(), RekeyScope::Root, Epoch(1), &[0xBBu8; 32]).unwrap();
1414        let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![their_blob], 1, 1);
1415        let rots = collect_rotations(&[c]);
1416        assert!(rots[0].is_complete());
1417        assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(true), "complete rotation, no blob for me → removed");
1418    }
1419
1420    #[test]
1421    fn collect_rotations_separates_concurrent_rotators_and_scopes() {
1422        // Two rotators racing the same epoch, plus a channel rekey at the same
1423        // numbers — three distinct rotations, never merged.
1424        let rot_a = keys(1);
1425        let rot_b = keys(2);
1426        let ca = chunk_at(&rot_a, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1427        let cb = chunk_at(&rot_b, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1428        let cc = chunk_at(&rot_a, RekeyScope::Channel(CHAN), 2, 1, &[7u8; 32], vec![], 1, 1);
1429        let rots = collect_rotations(&[ca, cb, cc]);
1430        assert_eq!(rots.len(), 3, "different rotator or scope ⇒ different rotation");
1431    }
1432
1433    #[test]
1434    fn duplicate_chunk_delivery_is_idempotent() {
1435        let rotator = keys(1);
1436        let blob = RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() };
1437        let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![blob.clone()], 1, 1);
1438        let rots = collect_rotations(&[c.clone(), c]);
1439        assert_eq!(rots.len(), 1);
1440        assert_eq!(rots[0].blobs.len(), 1, "re-delivering a chunk must not double its blobs");
1441    }
1442
1443    // ── base blob forms + control_wrap (CORD-06 §1, CORD-04 §3) ──────────────
1444
1445    #[test]
1446    fn base_blob_forms_are_width_declared() {
1447        let community = CommunityId([0x77u8; 32]);
1448        let new_root = [0xABu8; 32];
1449        let control_root = [0x5Cu8; 32];
1450        let epoch = Epoch(3);
1451        let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1452
1453        // 104-byte member form: root + pk, no secret.
1454        let member = bound_base_plaintext(epoch, &new_root, &control_pk, None);
1455        assert_eq!(member.len(), 104);
1456        let d = parse_bound_base_plaintext(&member, &community, epoch).unwrap();
1457        assert_eq!(d.new_root, new_root);
1458        assert_eq!(d.control_pk, Some(control_pk));
1459        assert_eq!(d.control_root, None);
1460
1461        // 136-byte staff form carries the secret, verified against its own pk.
1462        let staff = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&control_root));
1463        assert_eq!(staff.len(), 136);
1464        let d = parse_bound_base_plaintext(&staff, &community, epoch).unwrap();
1465        assert_eq!(d.control_root, Some(control_root));
1466        assert_eq!(d.control_pk, Some(control_pk));
1467
1468        // Legacy 72-byte base form yields the root alone.
1469        let legacy = bound_plaintext(RekeyScope::Root, epoch, &new_root);
1470        let d = parse_bound_base_plaintext(&legacy, &community, epoch).unwrap();
1471        assert_eq!(d.new_root, new_root);
1472        assert_eq!(d.control_pk, None);
1473        assert_eq!(d.control_root, None);
1474    }
1475
1476    #[test]
1477    fn base_blob_mismatched_control_pair_is_refused_whole() {
1478        let community = CommunityId([0x77u8; 32]);
1479        let control_pk = control_signer_group_key(&[0x5Cu8; 32], &community, Epoch(3)).pk().to_bytes();
1480        // A secret that does NOT derive to the carried pk — the whole blob is
1481        // dropped, the new_root included (never a partial adoption).
1482        let bad = bound_base_plaintext(Epoch(3), &[0xABu8; 32], &control_pk, Some(&[0x11u8; 32]));
1483        assert!(matches!(
1484            parse_bound_base_plaintext(&bad, &community, Epoch(3)),
1485            Err(RekeyError::ControlPairMismatch)
1486        ));
1487    }
1488
1489    #[test]
1490    fn base_blob_rejects_any_other_width_and_splices() {
1491        let community = CommunityId([0x77u8; 32]);
1492        // Below and between the defined forms: no append-only extension fits.
1493        for n in [0usize, 71, 73, 100, 105, 135] {
1494            assert!(matches!(
1495                parse_bound_base_plaintext(&vec![0u8; n], &community, Epoch(1)),
1496                Err(RekeyError::BadBaseBlobWidth(m)) if m == n
1497            ));
1498        }
1499        // The scope and epoch bind INSIDE the ciphertext (unspliceable).
1500        let control_root = [0x5Cu8; 32];
1501        let pk = control_signer_group_key(&control_root, &community, Epoch(3)).pk().to_bytes();
1502        let pt = bound_base_plaintext(Epoch(3), &[0xABu8; 32], &pk, None);
1503        assert!(matches!(parse_bound_base_plaintext(&pt, &community, Epoch(4)), Err(RekeyError::EpochSplice)));
1504        let mut channel_scoped = pt.clone();
1505        channel_scoped[..32].copy_from_slice(&[0x42u8; 32]);
1506        assert!(matches!(parse_bound_base_plaintext(&channel_scoped, &community, Epoch(3)), Err(RekeyError::ScopeSplice)));
1507    }
1508
1509    #[tokio::test]
1510    async fn base_blob_round_trips_member_and_staff_forms() {
1511        let rotator = keys(7);
1512        let member = keys(8);
1513        let staffer = keys(9);
1514        let community = CommunityId([0x77u8; 32]);
1515        let epoch = Epoch(2);
1516        let new_root = [0xEEu8; 32];
1517        let control_root = [0xDDu8; 32];
1518        let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1519
1520        let mb = build_base_blob(&as_signer(&rotator), &xonly(&rotator), &member.public_key(), epoch, &new_root, &control_pk, None).await.unwrap();
1521        let d = open_base_blob(&as_signer(&member), &rotator.public_key(), &community, epoch, &mb).await.unwrap();
1522        assert_eq!(d.new_root, new_root);
1523        assert_eq!(d.control_pk, Some(control_pk));
1524        assert_eq!(d.control_root, None, "a member blob never carries the secret");
1525
1526        let sb = build_base_blob(&as_signer(&rotator), &xonly(&rotator), &staffer.public_key(), epoch, &new_root, &control_pk, Some(&control_root)).await.unwrap();
1527        let d = open_base_blob(&as_signer(&staffer), &rotator.public_key(), &community, epoch, &sb).await.unwrap();
1528        assert_eq!(d.control_root, Some(control_root));
1529
1530        // The locator is the same public Root-scope slot the legacy form used.
1531        assert_eq!(mb.locator, blob_locator(&xonly(&rotator), &xonly(&member), RekeyScope::Root, epoch));
1532        // And a legacy 72-byte blob still opens through the base opener.
1533        let legacy = build_blob(&as_signer(&rotator), &xonly(&rotator), &member.public_key(), RekeyScope::Root, epoch, &new_root).await.unwrap();
1534        let d = open_base_blob(&as_signer(&member), &rotator.public_key(), &community, epoch, &legacy).await.unwrap();
1535        assert_eq!(d.new_root, new_root);
1536        assert_eq!(d.control_pk, None);
1537    }
1538
1539    #[test]
1540    fn a_future_wider_base_blob_degrades_instead_of_forking() {
1541        // The pre-split lesson (CORD-06 §3): a width this build predates must
1542        // never park the member at the old epoch — extract the frozen prefix
1543        // and whatever appended fields still verify.
1544        let community = CommunityId([0x77u8; 32]);
1545        let new_root = [0xABu8; 32];
1546        let control_root = [0x5Cu8; 32];
1547        let epoch = Epoch(3);
1548        let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1549
1550        // A hypothetical 168-byte form that appended a field after the secret.
1551        let mut future = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&control_root));
1552        future.extend_from_slice(&[0x99u8; 32]);
1553        let d = parse_bound_base_plaintext(&future, &community, epoch).unwrap();
1554        assert_eq!(d.new_root, new_root);
1555        assert_eq!(d.control_pk, Some(control_pk));
1556        assert_eq!(d.control_root, Some(control_root), "held offsets still verify → full function");
1557
1558        // One that REPLACED the secret's bytes: the derive check fails closed
1559        // to the verified prefix, never a refusal (membership must survive).
1560        let mut reordered = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&[0x44u8; 32]));
1561        reordered.extend_from_slice(&[0x99u8; 32]);
1562        let d = parse_bound_base_plaintext(&reordered, &community, epoch).unwrap();
1563        assert_eq!(d.new_root, new_root);
1564        assert_eq!(d.control_pk, Some(control_pk));
1565        assert_eq!(d.control_root, None, "an unverifiable secret is dropped, not adopted");
1566
1567        // The prefix's splice bindings still gate a future form.
1568        assert!(parse_bound_base_plaintext(&future, &community, Epoch(4)).is_err());
1569    }
1570
1571    #[test]
1572    fn control_wrap_layout_is_frozen_and_round_trips() {
1573        let pt = encode_control_wrap(Epoch(7), &[0xCDu8; 32]);
1574        assert_eq!(pt.len(), 40);
1575        let expected = format!("{}{}", "0000000000000007", "cd".repeat(32));
1576        assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
1577        let (epoch, root) = parse_control_wrap(&pt).unwrap();
1578        assert_eq!(epoch, Epoch(7));
1579        assert_eq!(root, [0xCDu8; 32]);
1580        for n in [0usize, 39, 41, 72] {
1581            assert!(matches!(parse_control_wrap(&vec![0u8; n]), Err(RekeyError::BadControlWrapLength(m)) if m == n));
1582        }
1583    }
1584
1585    #[test]
1586    fn lowest_key_fork_winner_is_deterministic() {
1587        // CORD-06 §3: among candidates, the lexicographically lowest new key wins,
1588        // so every retained member converges. Input order must not change it.
1589        let keys_a = [[0x03u8; 32], [0x01u8; 32], [0x02u8; 32]];
1590        assert_eq!(lowest_key_winner(&keys_a), Some(1));
1591        let keys_b = [[0x01u8; 32], [0x02u8; 32], [0x03u8; 32]];
1592        assert_eq!(lowest_key_winner(&keys_b), Some(0));
1593        assert_eq!(lowest_key_winner(&[]), None);
1594    }
1595}