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