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.
705pub async fn build_rekey_chunk_extension<S: crate::signer::VectorSigner + ?Sized>(
706    signer: &S,
707    rotator_pk: PublicKey,
708    rekey_group: &GroupKey,
709    scope: RekeyScope,
710    new_epoch: Epoch,
711    prev_epoch: Epoch,
712    prev_commit: &[u8; 32],
713    blobs: &[RekeyBlob],
714    at_secs: u64,
715    citation: Option<&crate::community::edition::AuthorityCitation>,
716    severed: bool,
717    start_after: u32,
718) -> Result<Vec<Event>, RekeyError> {
719    let groups: Vec<&[RekeyBlob]> = if blobs.is_empty() {
720        vec![&[]]
721    } else {
722        blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect()
723    };
724    let n = start_after + groups.len() as u32;
725    let mut out = Vec::with_capacity(groups.len());
726    for (idx, group_blobs) in groups.iter().enumerate() {
727        let i = start_after + idx as u32 + 1;
728        let rumor = build_rekey_rumor(rotator_pk, scope, new_epoch, prev_epoch, prev_commit, group_blobs, i, n, at_secs, citation, severed)?;
729        let (wrap, _) = stream::seal_and_wrap_signed(signer, rotator_pk, &rumor, SealForm::Encrypted, rekey_group, stream::KIND_WRAP, Timestamp::from_secs(at_secs), &[]).await?;
730        out.push(wrap);
731    }
732    Ok(out)
733}
734
735/// Parse a 3303 chunk from a seal-verified stream open. Rejects a non-3303
736/// rumor, a plaintext seal (the rekey plane is encrypted-only), malformed or
737/// duplicate machinery tags, a bad chunk range, and an over-cap blob array.
738pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result<RekeyChunk, RekeyError> {
739    if opened.seal_form != SealForm::Encrypted {
740        // A plaintext-sealed rekey would be a liftable public artifact — reject.
741        return Err(RekeyError::Stream(StreamError::BadSealKind(stream::KIND_SEAL_PLAINTEXT)));
742    }
743    let rumor = &opened.rumor;
744    if rumor.kind.as_u16() != super::kind::REKEY {
745        return Err(RekeyError::NotARekey(rumor.kind.as_u16()));
746    }
747    let scope = RekeyScope::from_hex(&unique_tag(rumor, TAG_SCOPE)?.ok_or(RekeyError::BadTag(TAG_SCOPE))?)
748        .ok_or(RekeyError::BadTag(TAG_SCOPE))?;
749    let new_epoch = Epoch(parse_u64(rumor, TAG_NEW_EPOCH)?);
750    let prev_epoch = Epoch(parse_u64(rumor, TAG_PREV_EPOCH)?);
751    if new_epoch.0 <= prev_epoch.0 {
752        return Err(RekeyError::NonMonotonicEpoch);
753    }
754    let prev_hex = unique_tag(rumor, TAG_PREV_COMMIT)?.ok_or(RekeyError::BadTag(TAG_PREV_COMMIT))?;
755    if prev_hex.len() != 64 || !prev_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
756        return Err(RekeyError::BadTag(TAG_PREV_COMMIT));
757    }
758    let prev_commit = crate::simd::hex::hex_to_bytes_32(&prev_hex);
759
760    let (chunk_i, chunk_n) = parse_chunk(rumor)?;
761
762    let blobs: Vec<RekeyBlob> = serde_json::from_str(&rumor.content).map_err(|_| RekeyError::BadTag("blobs"))?;
763    if blobs.len() > MAX_REKEY_BLOBS_RECEIVED {
764        return Err(RekeyError::TooManyBlobs(blobs.len()));
765    }
766
767    // Spec-shaped: present means exactly "1" (CORD-01 §5 strictness), absent means false.
768    let severed = match unique_tag(rumor, TAG_SEVER)?.as_deref() {
769        None => false,
770        Some("1") => true,
771        Some(_) => return Err(RekeyError::BadTag(TAG_SEVER)),
772    };
773
774    Ok(RekeyChunk {
775        rotator: opened.author,
776        scope,
777        new_epoch,
778        prev_epoch,
779        prev_commit,
780        chunk: (chunk_i, chunk_n),
781        blobs,
782        citation: crate::community::edition::AuthorityCitation::from_tags(&rumor.tags),
783        severed,
784    })
785}
786
787// ── Continuity + removal + fork resolution (CORD-06 §2/§3) ───────────────────
788
789/// The verdict of the prevcommit continuity check (CORD-06 §2).
790#[derive(Debug, Clone, Copy, PartialEq, Eq)]
791pub enum Continuity {
792    /// The commitment matches the key I hold at `prev_epoch` — this rotation
793    /// extends my chain; adopt it.
794    Extends,
795    /// `prev_epoch` is higher than the key I hold — I missed a rotation; fetch
796    /// the gap first, don't adopt yet.
797    Gap,
798    /// The commitment doesn't match at the same epoch — a fork or garbage.
799    Fork,
800}
801
802/// Check a rotation's `prev_commit` against the `(epoch, key)` I currently hold
803/// for its scope. A match proves the rotation extends the very key I hold; a
804/// higher `prev_epoch` means I'm behind; anything else is a fork/garbage.
805pub fn check_continuity(chunk: &RekeyChunk, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
806    if chunk.prev_epoch.0 == held_epoch.0 {
807        if epoch_key_commitment(held_epoch, held_key) == chunk.prev_commit {
808            Continuity::Extends
809        } else {
810            Continuity::Fork
811        }
812    } else if chunk.prev_epoch.0 > held_epoch.0 {
813        Continuity::Gap
814    } else {
815        // prev_epoch < held: this rotation is older than where I am — a stale
816        // fork; a settled epoch only ever heals DOWN to a sibling, never back.
817        Continuity::Fork
818    }
819}
820
821/// A collected rotation: all chunks sharing one correlation key, and whether the
822/// set is complete (all `n` chunks present).
823#[derive(Debug, Clone)]
824pub struct Rotation {
825    pub rotator: PublicKey,
826    pub scope: RekeyScope,
827    pub new_epoch: Epoch,
828    pub prev_epoch: Epoch,
829    pub prev_commit: [u8; 32],
830    /// The union of every chunk's blobs.
831    pub blobs: Vec<RekeyBlob>,
832    /// Total chunk count `n` declared by the chunks.
833    pub declared_chunks: u32,
834    /// Distinct chunk indices actually held.
835    pub held_chunks: std::collections::BTreeSet<u32>,
836    /// The rotator's `vac`, taken from the first chunk seen (every chunk of one
837    /// rotation carries the same citation — they share a signer and an action).
838    pub citation: Option<crate::community::edition::AuthorityCitation>,
839    /// OR across chunks: any severed chunk marks the whole rotation severed, so
840    /// an extension (rescue) can never launder the flag away.
841    pub severed: bool,
842}
843
844impl Rotation {
845    /// True once every declared chunk index `1..=n` is held — the precondition
846    /// for concluding removal (a missing chunk is "keep recovering", never a
847    /// removal).
848    pub fn is_complete(&self) -> bool {
849        self.declared_chunks >= 1 && (1..=self.declared_chunks).all(|i| self.held_chunks.contains(&i))
850    }
851
852    /// This rotation's continuity against the `(epoch, key)` I hold for its scope
853    /// — the [`check_continuity`] verdict at the aggregated-rotation level (same
854    /// prevcommit test), so a follower can gate adoption without a raw chunk.
855    pub fn continuity(&self, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity {
856        if self.prev_epoch.0 == held_epoch.0 {
857            if epoch_key_commitment(held_epoch, held_key) == self.prev_commit {
858                Continuity::Extends
859            } else {
860                Continuity::Fork
861            }
862        } else if self.prev_epoch.0 > held_epoch.0 {
863            Continuity::Gap
864        } else {
865            Continuity::Fork
866        }
867    }
868}
869
870/// Group a batch of parsed chunks into rotations by correlation key. Chunks whose
871/// `n` disagrees with their siblings, or that repeat an index, are the caller's
872/// concern to police; here the first-seen `n` per correlation wins and duplicate
873/// indices are ignored (idempotent re-delivery).
874pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec<Rotation> {
875    use std::collections::BTreeMap;
876    let mut by_key: BTreeMap<RotationKey, Rotation> = BTreeMap::new();
877    for c in chunks {
878        let entry = by_key.entry(c.correlation()).or_insert_with(|| Rotation {
879            rotator: c.rotator,
880            scope: c.scope,
881            new_epoch: c.new_epoch,
882            prev_epoch: c.prev_epoch,
883            prev_commit: c.prev_commit,
884            blobs: Vec::new(),
885            declared_chunks: c.chunk.1,
886            held_chunks: std::collections::BTreeSet::new(),
887            citation: c.citation.clone(),
888            severed: c.severed,
889        });
890        entry.severed |= c.severed;
891        // UNION the blobs, even when the index is already held. Treating a second
892        // chunk at a known index as pure idempotent re-delivery is safe only while
893        // re-delivery is byte-identical; when two chunks genuinely differ (a
894        // catch-up extension that re-claimed a slot), dropping the loser silently
895        // deletes its recipients from the union — and a recipient with no blob reads
896        // as REMOVED, which tears their community down. Union can only ever ADD
897        // blobs, so it can turn a false removal into an adoption but never the
898        // reverse. Exact duplicates are dropped so ordinary re-delivery stays free.
899        entry.held_chunks.insert(c.chunk.0);
900        for b in &c.blobs {
901            if !entry.blobs.iter().any(|held| held.locator == b.locator && held.wrapped == b.wrapped) {
902                entry.blobs.push(b.clone());
903            }
904        }
905    }
906    by_key.into_values().collect()
907}
908
909/// Have I been removed by this rotation? Only answerable on a COMPLETE rotation
910/// (all `n` chunks held): I'm removed iff none of the union's blobs carries my
911/// locator. On an incomplete rotation the answer is `None` — keep recovering.
912pub fn am_i_removed(rotation: &Rotation, my_xonly: &[u8; 32]) -> Option<bool> {
913    if !rotation.is_complete() {
914        return None;
915    }
916    let mine = find_my_blob(&rotation.blobs, &rotation.rotator.to_bytes(), my_xonly, rotation.scope, rotation.new_epoch);
917    Some(mine.is_none())
918}
919
920/// Deterministic same-epoch fork winner (CORD-06 §3): among candidate rotations
921/// at one continuity point, the one whose decrypted `new_key` is lexicographically
922/// lowest wins. Every retained member decrypts its own blob from each fork and
923/// computes the identical winner. Returns the index into `candidates` of the
924/// winner, or `None` if the caller decrypted no candidate.
925pub fn lowest_key_winner(candidate_keys: &[[u8; 32]]) -> Option<usize> {
926    candidate_keys
927        .iter()
928        .enumerate()
929        .min_by(|(_, a), (_, b)| a.cmp(b))
930        .map(|(i, _)| i)
931}
932
933// ── helpers ──────────────────────────────────────────────────────────────────
934
935fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, RekeyError> {
936    let mut found: Option<String> = None;
937    for t in rumor.tags.iter() {
938        let s = t.as_slice();
939        if s.len() >= 2 && s[0] == name {
940            if found.is_some() {
941                return Err(RekeyError::BadTag(name));
942            }
943            found = Some(s[1].clone());
944        }
945    }
946    Ok(found)
947}
948
949fn parse_u64(rumor: &UnsignedEvent, name: &'static str) -> Result<u64, RekeyError> {
950    let raw = unique_tag(rumor, name)?.ok_or(RekeyError::BadTag(name))?;
951    // Spec-shaped decimal, not a bare `parse` — that takes "+2" and "02" as
952    // epochs a stricter peer refuses (CORD-01 §5).
953    if !crate::community::edition::is_tag_decimal(&raw) {
954        return Err(RekeyError::BadTag(name));
955    }
956    raw.parse::<u64>().map_err(|_| RekeyError::BadTag(name))
957}
958
959fn parse_chunk(rumor: &UnsignedEvent) -> Result<(u32, u32), RekeyError> {
960    let mut found: Option<(u32, u32)> = None;
961    for t in rumor.tags.iter() {
962        let s = t.as_slice();
963        if s.len() >= 3 && s[0] == TAG_CHUNK {
964            if found.is_some() {
965                return Err(RekeyError::BadTag(TAG_CHUNK));
966            }
967            if !crate::community::edition::is_tag_decimal(&s[1]) || !crate::community::edition::is_tag_decimal(&s[2]) {
968                return Err(RekeyError::BadTag(TAG_CHUNK));
969            }
970            let i: u32 = s[1].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
971            let n: u32 = s[2].parse().map_err(|_| RekeyError::BadTag(TAG_CHUNK))?;
972            found = Some((i, n));
973        }
974    }
975    let (i, n) = found.ok_or(RekeyError::BadTag(TAG_CHUNK))?;
976    if n < 1 || i < 1 || i > n {
977        return Err(RekeyError::BadChunkIndex);
978    }
979    Ok((i, n))
980}
981
982#[cfg(test)]
983mod tests {
984
985    /// Wrap raw `Keys` as the polymorphic signer. `VectorSigner` pins its error to
986    /// `SignerError`, which bare `Keys` doesn't satisfy, so tests go through the
987    /// same enum the app uses.
988    fn as_signer(k: &Keys) -> crate::signer::ActiveSigner {
989        crate::signer::ActiveSigner::Keys(k.clone())
990    }
991    use super::*;
992
993    fn keys(byte: u8) -> Keys {
994        Keys::new(SecretKey::from_slice(&[byte; 32]).unwrap())
995    }
996
997    fn xonly(k: &Keys) -> [u8; 32] {
998        k.public_key().to_bytes()
999    }
1000
1001    const CHAN: ChannelId = ChannelId([0x42u8; 32]);
1002
1003    // ── blob atom ────────────────────────────────────────────────────────────
1004
1005    #[test]
1006    fn bound_plaintext_layout_is_frozen() {
1007        let pt = bound_plaintext(RekeyScope::Root, Epoch(1), &[0xABu8; 32]);
1008        let expected = format!("{}{}{}", "00".repeat(32), "0000000000000001", "ab".repeat(32));
1009        assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
1010        let pt2 = bound_plaintext(RekeyScope::Channel(ChannelId([0x11u8; 32])), Epoch(0x0102), &[0xCDu8; 32]);
1011        let expected2 = format!("{}{}{}", "11".repeat(32), "0000000000000102", "cd".repeat(32));
1012        assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt2), expected2);
1013    }
1014
1015    #[test]
1016    fn blob_round_trips_both_scopes() {
1017        let rotator = keys(7);
1018        let recipient = keys(8);
1019        for (scope, epoch, key) in [
1020            (RekeyScope::Root, Epoch(1), [0xABu8; 32]),
1021            (RekeyScope::Channel(CHAN), Epoch(5), [0xCDu8; 32]),
1022        ] {
1023            let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
1024            let got = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob).unwrap();
1025            assert_eq!(got, key, "the recipient recovers the fresh key");
1026        }
1027    }
1028
1029    #[tokio::test]
1030    async fn signer_blob_is_wire_compatible_with_local_both_directions() {
1031        // A remote signer (here a plain Keys, which impls VectorSigner) must build
1032        // AND open blobs interchangeably with the raw-key path — the CORD-06 D5
1033        // "identical wire" guarantee the bunker/NIP-55 integration rests on.
1034        let rotator = keys(7);
1035        let recipient = keys(8);
1036        let scope = RekeyScope::Root;
1037        let epoch = Epoch(3);
1038        let key = [0x5Au8; 32];
1039
1040        // local build -> signer open
1041        let blob_l = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).unwrap();
1042        let got_s = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_l).await.unwrap();
1043        assert_eq!(got_s, key, "signer opens a local-built blob");
1044
1045        // signer build -> local open (+ identical public locator)
1046        let blob_s = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), scope, epoch, &key).await.unwrap();
1047        assert_eq!(blob_s.locator, blob_l.locator, "same public locator regardless of build path");
1048        let got_l = open_blob_local(recipient.secret_key(), &rotator.public_key(), scope, epoch, &blob_s).unwrap();
1049        assert_eq!(got_l, key, "local opens a signer-built blob");
1050
1051        // signer build -> signer open
1052        let got_ss = open_blob(&as_signer(&recipient), &rotator.public_key(), scope, epoch, &blob_s).await.unwrap();
1053        assert_eq!(got_ss, key, "signer round-trips its own blob");
1054    }
1055
1056    #[tokio::test]
1057    async fn signer_blob_bound_check_still_gates_scope_epoch_splice() {
1058        let rotator = keys(7);
1059        let recipient = keys(8);
1060        let blob = build_blob(&as_signer(&rotator), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[9u8; 32]).await.unwrap();
1061        assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob).await.is_err(), "epoch splice rejected");
1062        assert!(open_blob(&as_signer(&recipient), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob).await.is_err(), "scope splice rejected");
1063    }
1064
1065    #[test]
1066    fn locator_is_public_and_computable_from_pubkeys_alone() {
1067        // D1: the locator derives from PUBLIC inputs, so the rotator computing a
1068        // recipient's slot and the recipient computing their own must agree — no
1069        // secret needed either side (bunker parity).
1070        let rotator = keys(7);
1071        let recipient = keys(8);
1072        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(2), &[1u8; 32]).unwrap();
1073        let recomputed = blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(2));
1074        assert_eq!(blob.locator, recomputed, "both sides compute the same public locator");
1075    }
1076
1077    #[test]
1078    fn a_non_recipient_cannot_open_even_holding_the_public_locator() {
1079        // The D1 security relocation: the locator authenticates NOTHING (anyone
1080        // holding both npubs computes it), yet an outsider still can't open —
1081        // the pairwise decrypt is the gate, not the locator.
1082        let rotator = keys(7);
1083        let recipient = keys(8);
1084        let outsider = keys(9);
1085        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[2u8; 32]).unwrap();
1086        // The outsider can trivially recompute the (public) locator...
1087        assert_eq!(blob.locator, blob_locator(&xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)));
1088        // ...but pairing (outsider_sk, rotator_pk) yields a different conversation
1089        // key, so the decrypt fails.
1090        assert!(open_blob_local(outsider.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).is_err());
1091    }
1092
1093    #[test]
1094    fn a_relocated_blob_still_opens_by_decrypt_not_locator() {
1095        // Because open ignores the locator (D1), corrupting the locator does NOT
1096        // break a legitimate recipient's open — the decrypt + bound check govern.
1097        // (Contrast v1, where a locator mismatch was a hard reject.) The FIND
1098        // step uses the locator; OPEN does not.
1099        let rotator = keys(7);
1100        let recipient = keys(8);
1101        let mut blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[3u8; 32]).unwrap();
1102        blob.locator = "ff".repeat(32);
1103        // Handed the blob directly (locator bypassed), the recipient still opens it.
1104        assert_eq!(
1105            open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), &blob).unwrap(),
1106            [3u8; 32]
1107        );
1108        // But find_my_blob won't LOCATE it under the corrupted locator.
1109        assert!(find_my_blob(std::slice::from_ref(&blob), &xonly(&rotator), &xonly(&recipient), RekeyScope::Root, Epoch(1)).is_none());
1110    }
1111
1112    #[test]
1113    fn scope_and_epoch_splices_are_rejected_on_open() {
1114        let rotator = keys(7);
1115        let recipient = keys(8);
1116        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[4u8; 32]).unwrap();
1117        // Opened under a different scope → bound scope mismatch.
1118        assert!(matches!(
1119            open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &blob),
1120            Err(RekeyError::ScopeSplice)
1121        ));
1122        // Opened under a different epoch → bound epoch mismatch.
1123        assert!(matches!(
1124            open_blob_local(recipient.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(2), &blob),
1125            Err(RekeyError::EpochSplice)
1126        ));
1127    }
1128
1129    #[test]
1130    fn wrapped_carries_base64_of_the_72_bytes_for_bunker_parity() {
1131        // D5: the NIP-44 layer's plaintext is base64(72 bytes) — a UTF-8 string a
1132        // NIP-46 signer can nip44_encrypt/decrypt. Prove the decrypted inner is
1133        // exactly that base64 string, matching the `_b64` helper the bunker uses.
1134        let rotator = keys(7);
1135        let recipient = keys(8);
1136        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &[5u8; 32]).unwrap();
1137        let ck = ConversationKey::derive(recipient.secret_key(), &rotator.public_key()).unwrap();
1138        let payload = base64_simd::STANDARD.decode_to_vec(blob.wrapped.as_bytes()).unwrap();
1139        let inner = decrypt_to_bytes(&ck, &payload).unwrap();
1140        assert_eq!(String::from_utf8(inner).unwrap(), bound_plaintext_b64(RekeyScope::Root, Epoch(1), &[5u8; 32]));
1141    }
1142
1143    #[test]
1144    fn distinct_recipients_and_scopes_get_distinct_locators() {
1145        let rotator = keys(7);
1146        let r1 = keys(8);
1147        let r2 = keys(9);
1148        assert_ne!(
1149            blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
1150            blob_locator(&xonly(&rotator), &xonly(&r2), RekeyScope::Root, Epoch(1))
1151        );
1152        // Same pair, a base blob and a channel blob at one epoch don't collide.
1153        assert_ne!(
1154            blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Root, Epoch(1)),
1155            blob_locator(&xonly(&rotator), &xonly(&r1), RekeyScope::Channel(CHAN), Epoch(1))
1156        );
1157    }
1158
1159    // ── 3303 event ─────────────────────────────────────────────────────────
1160
1161    fn root() -> [u8; 32] {
1162        [0x55u8; 32]
1163    }
1164
1165    #[test]
1166    fn channel_rekey_round_trips_through_the_stream() {
1167        let rotator = keys(1);
1168        let recipient = keys(8);
1169        let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
1170        let key = [0xABu8; 32];
1171        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Channel(CHAN), Epoch(1), &key).unwrap();
1172        let commit = epoch_key_commitment(Epoch(0), &[0xEEu8; 32]);
1173        let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &commit, &[blob.clone()], 1, 1, 100, None, false).unwrap();
1174        let (wrap, _) = seal_rekey_chunk(&rumor, &group, &rotator, Timestamp::from_secs(100)).unwrap();
1175
1176        // The wrap is signed by the group key, not the rotator (no identity on the wire).
1177        assert_ne!(wrap.pubkey, rotator.public_key());
1178        assert_eq!(wrap.pubkey, group.pk());
1179
1180        let opened = stream::open_wrap(&wrap, &group).unwrap();
1181        let chunk = parse_rekey_chunk(&opened).unwrap();
1182        assert_eq!(chunk.rotator, rotator.public_key(), "rotator recovered from the seal");
1183        assert!(matches!(chunk.scope, RekeyScope::Channel(c) if c.0 == CHAN.0));
1184        assert_eq!(chunk.new_epoch, Epoch(1));
1185        assert_eq!(chunk.prev_epoch, Epoch(0));
1186        assert_eq!(chunk.prev_commit, commit);
1187        assert_eq!(chunk.chunk, (1, 1));
1188        assert_eq!(chunk.blobs, vec![blob]);
1189    }
1190
1191    #[test]
1192    fn base_rekey_addresses_under_the_prior_root() {
1193        // A base rotation rides the PRIOR root: a member holding the prior root
1194        // derives the same group key and opens it; a non-holder can't.
1195        let rotator = keys(1);
1196        let recipient = keys(8);
1197        let prior_root = [0x66u8; 32];
1198        let community = CommunityId([0x77u8; 32]);
1199        let new_root = [0x99u8; 32];
1200        let group = base_rekey_group(&prior_root, &community, Epoch(1));
1201        let blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &recipient.public_key(), RekeyScope::Root, Epoch(1), &new_root).unwrap();
1202        let commit = epoch_key_commitment(Epoch(0), &prior_root);
1203        let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &commit, &[blob], 100, None, false).unwrap();
1204        assert_eq!(chunks.len(), 1);
1205
1206        let opened = stream::open_wrap(&chunks[0], &group).unwrap();
1207        let chunk = parse_rekey_chunk(&opened).unwrap();
1208        assert!(matches!(chunk.scope, RekeyScope::Root));
1209        // The recipient recovers the NEW root from their blob.
1210        let mine = find_my_blob(&chunk.blobs, &chunk.rotator.to_bytes(), &xonly(&recipient), chunk.scope, chunk.new_epoch).unwrap();
1211        assert_eq!(open_blob_local(recipient.secret_key(), &chunk.rotator, chunk.scope, chunk.new_epoch, mine).unwrap(), new_root);
1212
1213        // A non-holder of the prior root can't even open the wrap.
1214        let wrong = base_rekey_group(&[0u8; 32], &community, Epoch(1));
1215        assert!(stream::open_wrap(&chunks[0], &wrong).is_err());
1216    }
1217
1218    #[test]
1219    fn a_full_send_chunk_stays_under_the_relay_size_limit() {
1220        // The send cap exists so one chunk fits a 64KB strfry event. A full chunk
1221        // must serialize under it or relays reject the event.
1222        let rotator = keys(1);
1223        let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
1224        let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT)
1225            .map(|i| {
1226                let r = keys((i % 200 + 20) as u8);
1227                build_blob_local(rotator.secret_key(), &xonly(&rotator), &r.public_key(), RekeyScope::Root, Epoch(1), &[0xCDu8; 32]).unwrap()
1228            })
1229            .collect();
1230        let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &blobs, 100, None, false).unwrap();
1231        assert_eq!(chunks.len(), 1, "a full send chunk is exactly one event");
1232        assert!(chunks[0].as_json().len() <= 65_536, "a full chunk must fit a 64KB relay event");
1233    }
1234
1235    #[test]
1236    fn oversize_recipient_set_splits_into_chunks() {
1237        let rotator = keys(1);
1238        let group = base_rekey_group(&root(), &CommunityId([9u8; 32]), Epoch(1));
1239        // One over the send cap → 2 chunks, labeled (1,2) and (2,2).
1240        let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS_PER_EVENT + 1)
1241            .map(|_| RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() })
1242            .collect();
1243        let chunks = build_rekey_chunks_local(&rotator, &group, RekeyScope::Root, Epoch(2), Epoch(1), &[0u8; 32], &blobs, 100, None, false).unwrap();
1244        assert_eq!(chunks.len(), 2);
1245        let parsed: Vec<RekeyChunk> = chunks.iter().map(|w| parse_rekey_chunk(&stream::open_wrap(w, &group).unwrap()).unwrap()).collect();
1246        assert_eq!(parsed[0].chunk, (1, 2));
1247        assert_eq!(parsed[1].chunk, (2, 2));
1248        assert_eq!(parsed[0].blobs.len(), MAX_REKEY_BLOBS_PER_EVENT);
1249        assert_eq!(parsed[1].blobs.len(), 1);
1250    }
1251
1252    #[test]
1253    fn plaintext_sealed_rekey_is_rejected() {
1254        let rotator = keys(1);
1255        let group = channel_rekey_group(&root(), &CHAN, Epoch(1));
1256        let rumor = build_rekey_rumor(rotator.public_key(), RekeyScope::Channel(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[], 1, 1, 100, None, false).unwrap();
1257        let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group, &rotator).unwrap();
1258        let (wrap, _) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
1259        let opened = stream::open_wrap(&wrap, &group).unwrap();
1260        assert!(parse_rekey_chunk(&opened).is_err(), "the rekey plane must be encrypted-sealed");
1261    }
1262
1263    #[test]
1264    fn non_monotonic_epoch_is_refused_at_mint_and_on_parse() {
1265        let rotator = keys(1);
1266        assert!(matches!(
1267            build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(1), &[0u8; 32], &[], 1, 1, 100, None, false),
1268            Err(RekeyError::NonMonotonicEpoch)
1269        ));
1270    }
1271
1272    #[test]
1273    fn bad_chunk_indices_are_refused() {
1274        let rotator = keys(1);
1275        for (i, n) in [(0u32, 1u32), (2, 1), (1, 0)] {
1276            assert!(
1277                matches!(build_rekey_rumor(rotator.public_key(), RekeyScope::Root, Epoch(1), Epoch(0), &[0u8; 32], &[], i, n, 100, None, false), Err(RekeyError::BadChunkIndex)),
1278                "chunk ({i},{n}) must be rejected"
1279            );
1280        }
1281    }
1282
1283    // ── continuity + removal + fork ──────────────────────────────────────────
1284
1285    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 {
1286        RekeyChunk {
1287            rotator: rotator.public_key(),
1288            scope,
1289            new_epoch: Epoch(new_epoch),
1290            prev_epoch: Epoch(prev_epoch),
1291            prev_commit: epoch_key_commitment(Epoch(prev_epoch), prev_key),
1292            chunk: (i, n),
1293            blobs,
1294            citation: None,
1295            severed: false,
1296        }
1297    }
1298
1299    #[test]
1300    fn a_colliding_chunk_index_must_not_swallow_a_recipients_blob() {
1301        // Chunks are correlated by index, and a second chunk claiming an index
1302        // already held is treated as idempotent re-delivery — its blobs dropped.
1303        // That is safe only while re-delivery is byte-identical. The catch-up path
1304        // that extends a rotation computes its starting index from a fetch that can
1305        // come back blind, and then re-claims index 1; whichever copy a client sees
1306        // first wins, so the original recipients in the loser vanish from the union.
1307        // A recipient whose blob vanishes reads as REMOVED — and removal deletes the
1308        // community locally. Losing a blob to a bookkeeping collision must never be
1309        // able to masquerade as a removal verdict.
1310        let rotator = keys(1);
1311        let prev = [0x55u8; 32];
1312        let mine = RekeyBlob { locator: "aa".repeat(32), wrapped: "mine".into() };
1313        let other = RekeyBlob { locator: "bb".repeat(32), wrapped: "other".into() };
1314
1315        // Two chunks both claiming index 1: the original (carrying my blob) and a
1316        // re-claim (carrying someone else's).
1317        let original = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![mine.clone()], 1, 1);
1318        let reclaim = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![other.clone()], 1, 1);
1319
1320        for (order, chunks) in [
1321            ("original first", vec![original.clone(), reclaim.clone()]),
1322            ("reclaim first", vec![reclaim.clone(), original.clone()]),
1323        ] {
1324            let rotations = collect_rotations(&chunks);
1325            assert_eq!(rotations.len(), 1, "{order}: same correlation key");
1326            let has_mine = rotations[0].blobs.iter().any(|b| b.locator == mine.locator);
1327            assert!(
1328                has_mine,
1329                "{order}: my blob was dropped by an index collision — that reads as a removal \
1330                 and removal deletes the community",
1331            );
1332        }
1333    }
1334
1335    #[test]
1336    fn a_severed_rotation_cannot_be_laundered_by_an_unmarked_chunk() {
1337        // The containment marker rides EVERY chunk of a rotation, but a rotation's
1338        // chunk set can be extended later (the catch-up path for members a rotation
1339        // forgot). An extension built without the marker would, under first-seen
1340        // semantics, present a severed epoch as an ordinary one — and an adopter
1341        // reading it that way refreshes the very invite links the containment meant
1342        // to bury, re-opening the door the raid came through. The fold is therefore
1343        // an OR across chunks, in either arrival order.
1344        let rotator = keys(1);
1345        let prev = [0x44u8; 32];
1346        let mut marked = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![], 1, 2);
1347        marked.severed = true;
1348        let unmarked = chunk_at(&rotator, RekeyScope::Root, 2, 1, &prev, vec![], 2, 2);
1349
1350        for (order, chunks) in [
1351            ("marked first", vec![marked.clone(), unmarked.clone()]),
1352            ("unmarked first", vec![unmarked.clone(), marked.clone()]),
1353        ] {
1354            let rotations = collect_rotations(&chunks);
1355            assert_eq!(rotations.len(), 1, "{order}: one correlation key, one rotation");
1356            assert!(
1357                rotations[0].severed,
1358                "{order}: an unmarked sibling must never launder the containment away",
1359            );
1360            assert!(rotations[0].is_complete(), "{order}: both chunks held");
1361        }
1362    }
1363
1364    #[test]
1365    fn continuity_extends_gaps_and_forks() {
1366        let rotator = keys(1);
1367        let held = [0x33u8; 32];
1368        // Extends: prev_epoch == held epoch AND commitment matches the held key.
1369        let good = chunk_at(&rotator, RekeyScope::Root, 3, 2, &held, vec![], 1, 1);
1370        assert_eq!(check_continuity(&good, Epoch(2), &held), Continuity::Extends);
1371        // Gap: the rotation is FROM a higher epoch than I hold — I missed one.
1372        let ahead = chunk_at(&rotator, RekeyScope::Root, 5, 4, &held, vec![], 1, 1);
1373        assert_eq!(check_continuity(&ahead, Epoch(2), &held), Continuity::Gap);
1374        // Fork: same epoch but the commitment names a different prior key.
1375        let fork = chunk_at(&rotator, RekeyScope::Root, 3, 2, &[0x99u8; 32], vec![], 1, 1);
1376        assert_eq!(check_continuity(&fork, Epoch(2), &held), Continuity::Fork);
1377        // Fork: a rotation older than where I am (stale).
1378        let stale = chunk_at(&rotator, RekeyScope::Root, 2, 1, &held, vec![], 1, 1);
1379        assert_eq!(check_continuity(&stale, Epoch(2), &held), Continuity::Fork);
1380    }
1381
1382    #[test]
1383    fn a_missing_chunk_is_never_a_removal() {
1384        // The core no-false-removal guarantee: until ALL n chunks are held, "am I
1385        // removed" is unanswerable, even if the chunks I DO hold lack my blob.
1386        let rotator = keys(1);
1387        let me = keys(8);
1388        // Two-chunk rotation; my blob is in chunk 2, which I haven't received.
1389        let my_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &me.public_key(), RekeyScope::Root, Epoch(1), &[0xAAu8; 32]).unwrap();
1390        let c1 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![RekeyBlob { locator: "bb".repeat(32), wrapped: "x".into() }], 1, 2);
1391        let rots = collect_rotations(&[c1.clone()]);
1392        assert_eq!(rots.len(), 1);
1393        assert!(!rots[0].is_complete(), "one of two chunks held → incomplete");
1394        assert_eq!(am_i_removed(&rots[0], &xonly(&me)), None, "incomplete → keep recovering, never conclude removal");
1395
1396        // Now chunk 2 (with my blob) arrives → complete, and I am NOT removed.
1397        let c2 = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![my_blob.clone()], 2, 2);
1398        let rots = collect_rotations(&[c1, c2]);
1399        assert!(rots[0].is_complete());
1400        assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(false));
1401        // And my key is recoverable from the union.
1402        let mine = find_my_blob(&rots[0].blobs, &rots[0].rotator.to_bytes(), &xonly(&me), RekeyScope::Root, Epoch(1)).unwrap();
1403        assert_eq!(open_blob_local(me.secret_key(), &rotator.public_key(), RekeyScope::Root, Epoch(1), mine).unwrap(), [0xAAu8; 32]);
1404    }
1405
1406    #[test]
1407    fn a_complete_rotation_without_my_blob_is_a_removal() {
1408        let rotator = keys(1);
1409        let me = keys(8);
1410        let other = keys(9);
1411        // A complete 1-chunk rotation carrying only someone else's blob.
1412        let their_blob = build_blob_local(rotator.secret_key(), &xonly(&rotator), &other.public_key(), RekeyScope::Root, Epoch(1), &[0xBBu8; 32]).unwrap();
1413        let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![their_blob], 1, 1);
1414        let rots = collect_rotations(&[c]);
1415        assert!(rots[0].is_complete());
1416        assert_eq!(am_i_removed(&rots[0], &xonly(&me)), Some(true), "complete rotation, no blob for me → removed");
1417    }
1418
1419    #[test]
1420    fn collect_rotations_separates_concurrent_rotators_and_scopes() {
1421        // Two rotators racing the same epoch, plus a channel rekey at the same
1422        // numbers — three distinct rotations, never merged.
1423        let rot_a = keys(1);
1424        let rot_b = keys(2);
1425        let ca = chunk_at(&rot_a, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1426        let cb = chunk_at(&rot_b, RekeyScope::Root, 2, 1, &[7u8; 32], vec![], 1, 1);
1427        let cc = chunk_at(&rot_a, RekeyScope::Channel(CHAN), 2, 1, &[7u8; 32], vec![], 1, 1);
1428        let rots = collect_rotations(&[ca, cb, cc]);
1429        assert_eq!(rots.len(), 3, "different rotator or scope ⇒ different rotation");
1430    }
1431
1432    #[test]
1433    fn duplicate_chunk_delivery_is_idempotent() {
1434        let rotator = keys(1);
1435        let blob = RekeyBlob { locator: "aa".repeat(32), wrapped: "x".into() };
1436        let c = chunk_at(&rotator, RekeyScope::Root, 1, 0, &[0u8; 32], vec![blob.clone()], 1, 1);
1437        let rots = collect_rotations(&[c.clone(), c]);
1438        assert_eq!(rots.len(), 1);
1439        assert_eq!(rots[0].blobs.len(), 1, "re-delivering a chunk must not double its blobs");
1440    }
1441
1442    // ── base blob forms + control_wrap (CORD-06 §1, CORD-04 §3) ──────────────
1443
1444    #[test]
1445    fn base_blob_forms_are_width_declared() {
1446        let community = CommunityId([0x77u8; 32]);
1447        let new_root = [0xABu8; 32];
1448        let control_root = [0x5Cu8; 32];
1449        let epoch = Epoch(3);
1450        let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1451
1452        // 104-byte member form: root + pk, no secret.
1453        let member = bound_base_plaintext(epoch, &new_root, &control_pk, None);
1454        assert_eq!(member.len(), 104);
1455        let d = parse_bound_base_plaintext(&member, &community, epoch).unwrap();
1456        assert_eq!(d.new_root, new_root);
1457        assert_eq!(d.control_pk, Some(control_pk));
1458        assert_eq!(d.control_root, None);
1459
1460        // 136-byte staff form carries the secret, verified against its own pk.
1461        let staff = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&control_root));
1462        assert_eq!(staff.len(), 136);
1463        let d = parse_bound_base_plaintext(&staff, &community, epoch).unwrap();
1464        assert_eq!(d.control_root, Some(control_root));
1465        assert_eq!(d.control_pk, Some(control_pk));
1466
1467        // Legacy 72-byte base form yields the root alone.
1468        let legacy = bound_plaintext(RekeyScope::Root, epoch, &new_root);
1469        let d = parse_bound_base_plaintext(&legacy, &community, epoch).unwrap();
1470        assert_eq!(d.new_root, new_root);
1471        assert_eq!(d.control_pk, None);
1472        assert_eq!(d.control_root, None);
1473    }
1474
1475    #[test]
1476    fn base_blob_mismatched_control_pair_is_refused_whole() {
1477        let community = CommunityId([0x77u8; 32]);
1478        let control_pk = control_signer_group_key(&[0x5Cu8; 32], &community, Epoch(3)).pk().to_bytes();
1479        // A secret that does NOT derive to the carried pk — the whole blob is
1480        // dropped, the new_root included (never a partial adoption).
1481        let bad = bound_base_plaintext(Epoch(3), &[0xABu8; 32], &control_pk, Some(&[0x11u8; 32]));
1482        assert!(matches!(
1483            parse_bound_base_plaintext(&bad, &community, Epoch(3)),
1484            Err(RekeyError::ControlPairMismatch)
1485        ));
1486    }
1487
1488    #[test]
1489    fn base_blob_rejects_any_other_width_and_splices() {
1490        let community = CommunityId([0x77u8; 32]);
1491        // Below and between the defined forms: no append-only extension fits.
1492        for n in [0usize, 71, 73, 100, 105, 135] {
1493            assert!(matches!(
1494                parse_bound_base_plaintext(&vec![0u8; n], &community, Epoch(1)),
1495                Err(RekeyError::BadBaseBlobWidth(m)) if m == n
1496            ));
1497        }
1498        // The scope and epoch bind INSIDE the ciphertext (unspliceable).
1499        let control_root = [0x5Cu8; 32];
1500        let pk = control_signer_group_key(&control_root, &community, Epoch(3)).pk().to_bytes();
1501        let pt = bound_base_plaintext(Epoch(3), &[0xABu8; 32], &pk, None);
1502        assert!(matches!(parse_bound_base_plaintext(&pt, &community, Epoch(4)), Err(RekeyError::EpochSplice)));
1503        let mut channel_scoped = pt.clone();
1504        channel_scoped[..32].copy_from_slice(&[0x42u8; 32]);
1505        assert!(matches!(parse_bound_base_plaintext(&channel_scoped, &community, Epoch(3)), Err(RekeyError::ScopeSplice)));
1506    }
1507
1508    #[tokio::test]
1509    async fn base_blob_round_trips_member_and_staff_forms() {
1510        let rotator = keys(7);
1511        let member = keys(8);
1512        let staffer = keys(9);
1513        let community = CommunityId([0x77u8; 32]);
1514        let epoch = Epoch(2);
1515        let new_root = [0xEEu8; 32];
1516        let control_root = [0xDDu8; 32];
1517        let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1518
1519        let mb = build_base_blob(&as_signer(&rotator), &xonly(&rotator), &member.public_key(), epoch, &new_root, &control_pk, None).await.unwrap();
1520        let d = open_base_blob(&as_signer(&member), &rotator.public_key(), &community, epoch, &mb).await.unwrap();
1521        assert_eq!(d.new_root, new_root);
1522        assert_eq!(d.control_pk, Some(control_pk));
1523        assert_eq!(d.control_root, None, "a member blob never carries the secret");
1524
1525        let sb = build_base_blob(&as_signer(&rotator), &xonly(&rotator), &staffer.public_key(), epoch, &new_root, &control_pk, Some(&control_root)).await.unwrap();
1526        let d = open_base_blob(&as_signer(&staffer), &rotator.public_key(), &community, epoch, &sb).await.unwrap();
1527        assert_eq!(d.control_root, Some(control_root));
1528
1529        // The locator is the same public Root-scope slot the legacy form used.
1530        assert_eq!(mb.locator, blob_locator(&xonly(&rotator), &xonly(&member), RekeyScope::Root, epoch));
1531        // And a legacy 72-byte blob still opens through the base opener.
1532        let legacy = build_blob(&as_signer(&rotator), &xonly(&rotator), &member.public_key(), RekeyScope::Root, epoch, &new_root).await.unwrap();
1533        let d = open_base_blob(&as_signer(&member), &rotator.public_key(), &community, epoch, &legacy).await.unwrap();
1534        assert_eq!(d.new_root, new_root);
1535        assert_eq!(d.control_pk, None);
1536    }
1537
1538    #[test]
1539    fn a_future_wider_base_blob_degrades_instead_of_forking() {
1540        // The pre-split lesson (CORD-06 §3): a width this build predates must
1541        // never park the member at the old epoch — extract the frozen prefix
1542        // and whatever appended fields still verify.
1543        let community = CommunityId([0x77u8; 32]);
1544        let new_root = [0xABu8; 32];
1545        let control_root = [0x5Cu8; 32];
1546        let epoch = Epoch(3);
1547        let control_pk = control_signer_group_key(&control_root, &community, epoch).pk().to_bytes();
1548
1549        // A hypothetical 168-byte form that appended a field after the secret.
1550        let mut future = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&control_root));
1551        future.extend_from_slice(&[0x99u8; 32]);
1552        let d = parse_bound_base_plaintext(&future, &community, epoch).unwrap();
1553        assert_eq!(d.new_root, new_root);
1554        assert_eq!(d.control_pk, Some(control_pk));
1555        assert_eq!(d.control_root, Some(control_root), "held offsets still verify → full function");
1556
1557        // One that REPLACED the secret's bytes: the derive check fails closed
1558        // to the verified prefix, never a refusal (membership must survive).
1559        let mut reordered = bound_base_plaintext(epoch, &new_root, &control_pk, Some(&[0x44u8; 32]));
1560        reordered.extend_from_slice(&[0x99u8; 32]);
1561        let d = parse_bound_base_plaintext(&reordered, &community, epoch).unwrap();
1562        assert_eq!(d.new_root, new_root);
1563        assert_eq!(d.control_pk, Some(control_pk));
1564        assert_eq!(d.control_root, None, "an unverifiable secret is dropped, not adopted");
1565
1566        // The prefix's splice bindings still gate a future form.
1567        assert!(parse_bound_base_plaintext(&future, &community, Epoch(4)).is_err());
1568    }
1569
1570    #[test]
1571    fn control_wrap_layout_is_frozen_and_round_trips() {
1572        let pt = encode_control_wrap(Epoch(7), &[0xCDu8; 32]);
1573        assert_eq!(pt.len(), 40);
1574        let expected = format!("{}{}", "0000000000000007", "cd".repeat(32));
1575        assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
1576        let (epoch, root) = parse_control_wrap(&pt).unwrap();
1577        assert_eq!(epoch, Epoch(7));
1578        assert_eq!(root, [0xCDu8; 32]);
1579        for n in [0usize, 39, 41, 72] {
1580            assert!(matches!(parse_control_wrap(&vec![0u8; n]), Err(RekeyError::BadControlWrapLength(m)) if m == n));
1581        }
1582    }
1583
1584    #[test]
1585    fn lowest_key_fork_winner_is_deterministic() {
1586        // CORD-06 §3: among candidates, the lexicographically lowest new key wins,
1587        // so every retained member converges. Input order must not change it.
1588        let keys_a = [[0x03u8; 32], [0x01u8; 32], [0x02u8; 32]];
1589        assert_eq!(lowest_key_winner(&keys_a), Some(1));
1590        let keys_b = [[0x01u8; 32], [0x02u8; 32], [0x03u8; 32]];
1591        assert_eq!(lowest_key_winner(&keys_b), Some(0));
1592        assert_eq!(lowest_key_winner(&[]), None);
1593    }
1594}