Skip to main content

vector_core/community/
rekey.rs

1//! Rekey blob primitive (GROUP_PROTOCOL.md).
2//!
3//! A rotation (a Private removal, a re-founding, or a scheduled rekey) mints a fresh-random key for
4//! the next epoch and delivers it to every member who STAYS, one **per-recipient blob** each.
5//! This module is that atom — the located, wrapped single blob — built and opened in isolation. The
6//! 3303 event that carries a *set* of these blobs, and the apply/recipient-set logic, sit on top
7//! (later sub-pieces).
8//!
9//! Each blob is:
10//! - **located** by `recipient_pseudonym(pairwise_secret, scope, epoch)`: an opaque tag only the
11//!   sender↔recipient pair can compute, so a recipient jumps straight to their own blob (no
12//! trial-decryption) and a removed member can't even find a slot for a pair they're not in; and
13//! - **wrapped** under the same pairwise secret via NIP-44 v2 (`cipher`), so only that recipient
14//!   decrypts it.
15//!
16//! The `pairwise_secret` is the NIP-44 v2 ConversationKey between the two identities — the ECDH-derived
17//! secret names. It is **symmetric**: the sender derives it from `(their sk, recipient pk)`, the
18//! recipient recomputes the identical secret from `(their sk, sender pk)`. (How the recipient learns
19//! the sender's pubkey is the 3303-envelope layer's job, not this atom's — `open_rekey_blob` takes it
20//! as a parameter.)
21//!
22//! The wrapped plaintext **binds `(scope, epoch)`** so a blob can't be spliced into a different
23//! coordinate: even though only the authorized rotator can mint blobs, the binding makes a
24//! cross-scope/epoch reuse fail closed on open, the same discipline as the message envelope.
25
26use nostr_sdk::nips::nip44::v2::ConversationKey;
27use nostr_sdk::prelude::*;
28use serde::{Deserialize, Serialize};
29use sha2::{Digest, Sha256};
30use zeroize::Zeroizing;
31
32use super::cipher;
33use super::derive::{base_rekey_pseudonym, recipient_pseudonym, rekey_pseudonym, RekeyScope};
34use super::{ChannelId, CommunityId, Epoch, Pseudonym, ServerRootKey};
35use crate::stored_event::event_kind;
36
37/// One located, wrapped rekey blob — the unit a 3303 Rekey event carries N of. `locator` is the
38/// recipient-pseudonym hex (where the recipient finds it); `wrapped` is the base64 NIP-44 ciphertext
39/// of `scope_id ‖ epoch ‖ new_key`.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct RekeyBlob {
42    pub locator: String,
43    pub wrapped: String,
44}
45
46/// The pairwise sender↔recipient secret: the NIP-44 v2 ConversationKey, HKDF-extracted from
47/// the ECDH shared point. Symmetric — `pairwise_secret(a_sk, b_pk) == pairwise_secret(b_sk, a_pk)` —
48/// so the recipient recomputes exactly what the sender used, for BOTH the locator and the wrap key.
49pub fn rekey_pairwise_secret(sk: &SecretKey, pk: &PublicKey) -> Result<[u8; 32], String> {
50    let ck = ConversationKey::derive(sk, pk).map_err(|e| format!("pairwise ECDH: {e}"))?;
51    let bytes = ck.as_bytes();
52    bytes
53        .try_into()
54        .map_err(|_| format!("conversation key is {} bytes, expected 32", bytes.len()))
55}
56
57/// The 72-byte bound plaintext a blob wraps: `scope_id[32] ‖ epoch_be[8] ‖ new_key[32]`. Fixed-width
58/// fields, so no separators/length prefixes are needed to be unambiguous.
59fn bound_plaintext(scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32]) -> Vec<u8> {
60    let mut pt = Vec::with_capacity(72);
61    pt.extend_from_slice(&scope.id32());
62    pt.extend_from_slice(&epoch.0.to_be_bytes());
63    pt.extend_from_slice(new_key);
64    pt
65}
66
67/// Build one rekey blob: the fresh `new_key` for `(scope, epoch)`, located + wrapped to `recipient_pk`.
68/// `sender_sk` is the rotator's identity secret (the ECDH half the recipient pairs against).
69pub fn build_rekey_blob(
70    sender_sk: &SecretKey,
71    recipient_pk: &PublicKey,
72    scope: RekeyScope,
73    epoch: Epoch,
74    new_key: &[u8; 32],
75) -> Result<RekeyBlob, String> {
76    // Zeroized intermediates: both the pairwise secret and the plaintext (which holds the fresh epoch
77    // key in bytes 40..72) are wiped on drop, so this atom leaks no key material into freed memory.
78    let secret = Zeroizing::new(rekey_pairwise_secret(sender_sk, recipient_pk)?);
79    let locator = recipient_pseudonym(&secret, scope, epoch).to_hex();
80    let pt = Zeroizing::new(bound_plaintext(scope, epoch, new_key));
81    let wrapped = cipher::seal(&secret, &pt).map_err(|e| format!("wrap rekey blob: {e}"))?;
82    Ok(RekeyBlob { locator, wrapped })
83}
84
85/// Open a blob addressed to me: recompute the pairwise secret from `(my_sk, sender_pk)`, confirm the
86/// blob's `locator` is the one THIS pair+scope+epoch produces (rejects a blob handed to us under the
87/// wrong coordinate), decrypt, and verify the wrapped plaintext binds the SAME `(scope, epoch)` before
88/// returning the new key. Any mismatch (wrong sender, wrong coordinate, tamper, splice) is `Err`.
89pub fn open_rekey_blob(
90    my_sk: &SecretKey,
91    sender_pk: &PublicKey,
92    scope: RekeyScope,
93    epoch: Epoch,
94    blob: &RekeyBlob,
95) -> Result<[u8; 32], String> {
96    let secret = Zeroizing::new(rekey_pairwise_secret(my_sk, sender_pk)?);
97    // The locator is unforgeable without the pairwise secret, so a matching one proves the blob was
98    // minted for this exact (pair, scope, epoch) — reject anything else rather than trust placement.
99    let expected = recipient_pseudonym(&secret, scope, epoch).to_hex();
100    if blob.locator != expected {
101        return Err("rekey blob locator does not match this recipient/scope/epoch".to_string());
102    }
103    let pt = Zeroizing::new(cipher::open(&secret, &blob.wrapped).map_err(|e| format!("open rekey blob: {e}"))?);
104    if pt.len() != 72 {
105        return Err(format!("rekey blob plaintext is {} bytes, expected 72", pt.len()));
106    }
107    // Strict-equality binding (discipline): the wrapped scope+epoch must equal what we opened
108    // under, so a blob can't be lifted from one coordinate into another.
109    if pt[..32] != scope.id32() {
110        return Err("rekey blob scope binding mismatch (splice)".to_string());
111    }
112    let mut epoch_be = [0u8; 8];
113    epoch_be.copy_from_slice(&pt[32..40]);
114    if u64::from_be_bytes(epoch_be) != epoch.0 {
115        return Err("rekey blob epoch binding mismatch (splice)".to_string());
116    }
117    let mut new_key = [0u8; 32];
118    new_key.copy_from_slice(&pt[40..72]);
119    Ok(new_key)
120}
121
122// --- The 3303 Rekey event (carries N blobs) -----------------------------------------------------
123
124/// Outer protocol-version tag (same discipline as the message envelope) — checked before decrypt.
125const PROTOCOL_VERSION: &str = "1";
126const TAG_VERSION: &str = "v";
127const TAG_SCOPE: &str = "scope";
128const TAG_NEW_EPOCH: &str = "newepoch";
129const TAG_PREV_EPOCH: &str = "prevepoch";
130const TAG_PREV_COMMIT: &str = "prevcommit";
131
132/// strfry's default `maxEventSize` (the NIP-11 `max_event_size` the common relay enforces) — the basis
133/// for [`MAX_REKEY_BLOBS`], asserted by the size-guard test. Test-only (the cap encodes the conclusion).
134#[cfg(test)]
135const STRFRY_MAX_EVENT_SIZE: usize = 65536;
136
137/// Max recipients (blobs) in ONE Rekey event. MEASURED against [`STRFRY_MAX_EVENT_SIZE`]: 126 blobs
138/// serialize to 55,131 bytes (the NIP-44 padding bucket), 127 jumps to 66,055 (over 64KB). So 126 is the
139/// hard ceiling; we cap at **120** — slightly under, in the same 55KB bucket (~10KB margin under 64KB)
140/// plus count-headroom so a future blob-format tweak can't silently tip a full event over the limit
141/// (the `max_rekey_blobs_event_stays_under_relay_size_limit` test guards this). A rotation whose recipient
142/// set exceeds this must SPLIT across events (— deferred); the send side fails closed at this cap, and
143/// the receiver rejects a larger array (checked AFTER `cipher::open`, so a hostile array is also
144/// relay-size-bounded). `pub(crate)` so the send side fails closed BEFORE publishing an unacceptable event.
145pub(crate) const MAX_REKEY_BLOBS: usize = 120;
146
147/// A commitment to the prior epoch's key (fork detection): a Rekey references it so two managers
148/// who both rotate epoch N→N+1 produce a *detectable* fork (resolved by authority-first→time→id),
149/// and so a recipient who holds the prior key can confirm the rotator did too (a legitimacy check).
150/// Domain-separated SHA-256 over `prev_epoch_be ‖ prev_key`; the epoch binds the commitment to the
151/// specific link in the chain.
152pub fn epoch_key_commitment(prev_epoch: Epoch, prev_key: &[u8; 32]) -> [u8; 32] {
153    let mut h = Sha256::new();
154    h.update(b"vector-community/v1/epoch-key-commitment");
155    h.update(prev_epoch.0.to_be_bytes());
156    h.update(prev_key);
157    h.finalize().into()
158}
159
160/// A parsed, inner-signature-verified Rekey — what `open_rekey_event` yields. Authority (does the
161/// rotator's roster rank permit this rotation?) and blob-opening are SEPARATE later steps: the
162/// `rotator` pubkey here is what the recipient pairs against in [`open_rekey_blob`], and what the
163/// roster check is run against.
164#[derive(Debug, Clone)]
165pub struct ParsedRekey {
166    /// The verified rotator (the inner event's real author) — the ECDH identity + the authority actor.
167    pub rotator: PublicKey,
168    /// The scope this rekey rotates (a channel, or the server root).
169    pub scope: RekeyScope,
170    /// The epoch this rekey introduces.
171    pub new_epoch: Epoch,
172    /// The epoch being rotated FROM (the chain link this extends).
173    pub prev_epoch: Epoch,
174    /// Commitment to the prior epoch's key (fork detection); verified against the held prior key
175    /// by the apply path, not here.
176    pub prev_key_commitment: [u8; 32],
177    /// The per-recipient blobs; the recipient finds theirs by locator and opens it (`open_rekey_blob`).
178    pub blobs: Vec<RekeyBlob>,
179}
180
181/// Encode a [`RekeyScope`] for the signed `scope` tag: the 32-byte scope id as hex (channel id, or the
182/// all-zero server-root sentinel). A channel id is random-32, so all-zero unambiguously means server
183/// root (same non-collision argument as [`crate::community::SERVER_ROOT_SCOPE_HEX`]).
184fn scope_to_hex(scope: RekeyScope) -> String {
185    crate::simd::hex::bytes_to_hex_32(&scope.id32())
186}
187
188fn scope_from_hex(hex: &str) -> Option<RekeyScope> {
189    if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
190        return None;
191    }
192    let bytes = crate::simd::hex::hex_to_bytes_32(hex);
193    Some(if bytes == [0u8; 32] {
194        RekeyScope::ServerRoot
195    } else {
196        RekeyScope::Channel(ChannelId(bytes))
197    })
198}
199
200/// Build the rotator-signed INNER rekey event (shared by channel + server-root rekeys): kind 3303,
201/// real-npub sig, carrying the scope / epochs / prior-key commitment / blobs. Enforces the 
202/// monotonic-epoch invariant at mint (fail closed — a non-advancing epoch would otherwise surface only
203/// downstream as a spurious fork). The caller seals it under the appropriate envelope key + address.
204fn build_rekey_inner(
205    rotator: &Keys,
206    scope: RekeyScope,
207    new_epoch: Epoch,
208    prev_epoch: Epoch,
209    prev_key_commitment: &[u8; 32],
210    blobs: &[RekeyBlob],
211) -> Result<Event, String> {
212    if new_epoch.0 <= prev_epoch.0 {
213        return Err(format!("rekey new_epoch {} must exceed prev_epoch {}", new_epoch.0, prev_epoch.0));
214    }
215    let blobs_json = serde_json::to_string(blobs).map_err(|e| format!("serialize blobs: {e}"))?;
216    EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), blobs_json)
217        .tags([
218            Tag::custom(TagKind::Custom(TAG_SCOPE.into()), [scope_to_hex(scope)]),
219            Tag::custom(TagKind::Custom(TAG_NEW_EPOCH.into()), [new_epoch.0.to_string()]),
220            Tag::custom(TagKind::Custom(TAG_PREV_EPOCH.into()), [prev_epoch.0.to_string()]),
221            Tag::custom(TagKind::Custom(TAG_PREV_COMMIT.into()), [crate::simd::hex::bytes_to_hex_32(prev_key_commitment)]),
222        ])
223        .sign_with_keys(rotator)
224        .map_err(|e| format!("sign rekey inner: {e}"))
225}
226
227/// Seal a signed inner rekey into the ephemeral-signed outer: encrypt under `envelope_key`, address by
228/// `address` (the `z` pseudonym a member fetches it by). `open_rekey_event(outer, envelope_key)` is the
229/// inverse for both rekey kinds.
230fn seal_rekey_outer(ephemeral: &Keys, inner: &Event, envelope_key: &[u8; 32], address: &Pseudonym) -> Result<Event, String> {
231    let content = cipher::seal(envelope_key, inner.as_json().as_bytes()).map_err(|e| format!("seal rekey: {e}"))?;
232    EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), content)
233        .tags([
234            Tag::custom(TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), [address.to_hex()]),
235            Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
236        ])
237        .sign_with_keys(ephemeral)
238        .map_err(|e| format!("sign rekey outer: {e}"))
239}
240
241/// Build a signed 3303 channel-Rekey distributing `blobs` introducing `new_epoch` for `channel_id`.
242///
243/// Per the rekey is **enveloped under the (stable) server-root key** — NOT the prior channel key
244/// — and addressed by the **server-root-derived** `rekey_pseudonym(server_root, channel_id, new_epoch)`.
245/// Because both the envelope key and the address come from the server root (which every member always
246/// holds, unchanged by a channel rotation), a member recovers ANY epoch's key independently — derive
247/// that epoch's rekey address, fetch it — with no dependency on the prior epoch's channel key. No
248/// ratchet: epochs are recoverable by choice (latest only, or all in parallel). The new channel key
249/// lives ONLY in the per-recipient ECDH `blobs`, never under the server-root envelope, so a member
250/// removed in this rotation reads the header but recovers no new key.
251#[allow(clippy::too_many_arguments)]
252pub fn build_channel_rekey_event(
253    ephemeral: &Keys,
254    rotator: &Keys,
255    server_root: &[u8; 32],
256    channel_id: &ChannelId,
257    new_epoch: Epoch,
258    prev_epoch: Epoch,
259    prev_key_commitment: &[u8; 32],
260    blobs: &[RekeyBlob],
261) -> Result<Event, String> {
262    let inner = build_rekey_inner(rotator, RekeyScope::Channel(*channel_id), new_epoch, prev_epoch, prev_key_commitment, blobs)?;
263    // Envelope under the SERVER ROOT (stable across channel rotations), addressed by the server-root-
264    // derived per-epoch pseudonym — so any member finds + decrypts it without the channel's prior key.
265    let address = rekey_pseudonym(&ServerRootKey(*server_root), channel_id, new_epoch);
266    seal_rekey_outer(ephemeral, &inner, server_root, &address)
267}
268
269/// Build a 3303 SERVER-ROOT rekey (a base rotation): the new server root reaches recipients ONLY
270/// via per-recipient ECDH blobs (`RekeyScope::ServerRoot`), while the event itself is enveloped under
271/// the **PRIOR** root and addressed by `base_rekey_pseudonym(prior_root, community_id, new_epoch)`.
272///
273/// The base layer has no stable key above it (unlike a channel, which rides the server root), so the
274/// prior root is the best handle every current member holds: a returning member derives the address
275/// from the root they have, finds the event, learns the ROTATOR from its inner sig, and recovers the
276/// new root from their blob — a short forward-walk (base rotations are rare). The prior root only
277/// *addresses + hides* the event; the new root is NOT under it (it lives in the ECDH blobs), so a
278/// member removed in this rotation can find the event but recovers no new root. Re-anchoring the
279/// control plane under the new epoch is a SEPARATE step the rotation orchestration performs.
280#[allow(clippy::too_many_arguments)]
281pub fn build_server_root_rekey_event(
282    ephemeral: &Keys,
283    rotator: &Keys,
284    prior_root: &[u8; 32],
285    community_id: &CommunityId,
286    new_epoch: Epoch,
287    prev_epoch: Epoch,
288    prev_key_commitment: &[u8; 32],
289    blobs: &[RekeyBlob],
290) -> Result<Event, String> {
291    let inner = build_rekey_inner(rotator, RekeyScope::ServerRoot, new_epoch, prev_epoch, prev_key_commitment, blobs)?;
292    let address = base_rekey_pseudonym(&ServerRootKey(*prior_root), community_id, new_epoch);
293    seal_rekey_outer(ephemeral, &inner, prior_root, &address)
294}
295
296/// Open + verify a 3303 Rekey outer with the **server-root key** (which every member always holds):
297/// version-check, decrypt, parse the inner, verify the rotator's inner signature, and read the rekey
298/// fields. Does NOT check authority (the rotator's roster rank) or open any blob; the caller does
299/// both, pairing against the returned `rotator`. A wrong key (or non-member) fails the MAC → `Err`.
300pub fn open_rekey_event(outer: &Event, server_root: &[u8; 32]) -> Result<ParsedRekey, String> {
301    if outer.kind.as_u16() != event_kind::COMMUNITY_REKEY {
302        return Err("not a rekey outer (kind != 3303)".to_string());
303    }
304    match find_unique_tag(outer, TAG_VERSION)?.as_deref() {
305        Some(PROTOCOL_VERSION) => {}
306        other => return Err(format!("unsupported rekey version: {other:?}")),
307    }
308    let plaintext = cipher::open(server_root, &outer.content).map_err(|e| format!("open rekey: {e}"))?;
309    let json = String::from_utf8(plaintext).map_err(|e| format!("rekey inner utf8: {e}"))?;
310    let inner = Event::from_json(&json).map_err(|e| format!("rekey inner parse: {e}"))?;
311
312    // Inner authorship signature — proves the rotator authored this (and yields their pubkey).
313    inner.verify().map_err(|_| "rekey inner signature invalid".to_string())?;
314    if inner.kind.as_u16() != event_kind::COMMUNITY_REKEY {
315        return Err("rekey inner is not kind 3303".to_string());
316    }
317
318    let scope = find_unique_tag(&inner, TAG_SCOPE)?
319        .and_then(|h| scope_from_hex(&h))
320        .ok_or("rekey missing/invalid scope")?;
321    let new_epoch = Epoch(parse_u64_tag(&inner, TAG_NEW_EPOCH)?);
322    let prev_epoch = Epoch(parse_u64_tag(&inner, TAG_PREV_EPOCH)?);
323    let prev_hex = find_unique_tag(&inner, TAG_PREV_COMMIT)?.ok_or("rekey missing prev-commit")?;
324    if prev_hex.len() != 64 || !prev_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
325        return Err("rekey prev-commit is not 32-byte hex".to_string());
326    }
327    let prev_key_commitment = crate::simd::hex::hex_to_bytes_32(&prev_hex);
328
329    let blobs: Vec<RekeyBlob> =
330        serde_json::from_str(&inner.content).map_err(|e| format!("parse rekey blobs: {e}"))?;
331    if blobs.len() > MAX_REKEY_BLOBS {
332        return Err(format!("rekey carries {} blobs, over the cap", blobs.len()));
333    }
334
335    Ok(ParsedRekey {
336        rotator: inner.pubkey,
337        scope,
338        new_epoch,
339        prev_epoch,
340        prev_key_commitment,
341        blobs,
342    })
343}
344
345/// Value of a tag required to appear at most once (a duplicate makes the signed inner ambiguous —
346/// reject rather than trust first-match, the envelope discipline).
347fn find_unique_tag(event: &Event, name: &str) -> Result<Option<String>, String> {
348    let mut found = None;
349    for t in event.tags.iter() {
350        let s = t.as_slice();
351        if s.len() >= 2 && s[0] == name {
352            if found.is_some() {
353                return Err(format!("duplicate rekey tag: {name}"));
354            }
355            found = Some(s[1].clone());
356        }
357    }
358    Ok(found)
359}
360
361fn parse_u64_tag(event: &Event, name: &str) -> Result<u64, String> {
362    find_unique_tag(event, name)?
363        .ok_or_else(|| format!("rekey missing tag: {name}"))?
364        .parse::<u64>()
365        .map_err(|_| format!("rekey tag {name} is not a u64"))
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    /// FROZEN size guard: a full `MAX_REKEY_BLOBS`-recipient rekey event must serialize under strfry's
373    /// default `maxEventSize` (65536). Measured boundary: 126 blobs = 55,131 bytes (fits), 127 = 66,055
374    /// (over). The cap (120) sits in the 55KB bucket; if a blob-format change pushes a full event over the
375    /// limit this test goes red BEFORE shipping an event relays would reject. Both rekey scopes use the
376    /// same wire builder (`build_rekey_inner`/`seal_rekey_outer`), so the server-root case bounds both.
377    #[test]
378    fn max_rekey_blobs_event_stays_under_relay_size_limit() {
379        use nostr_sdk::JsonUtil;
380        let rotator = Keys::generate();
381        let blobs: Vec<RekeyBlob> = (0..MAX_REKEY_BLOBS)
382            .map(|_| {
383                let r = Keys::generate();
384                build_rekey_blob(rotator.secret_key(), &r.public_key(), RekeyScope::ServerRoot, Epoch(1), &[0xCDu8; 32]).unwrap()
385            })
386            .collect();
387        let outer = build_server_root_rekey_event(
388            &Keys::generate(), &rotator, &[0x07u8; 32], &CommunityId([0x09u8; 32]), Epoch(1), Epoch(0), &[0u8; 32], &blobs,
389        )
390        .unwrap();
391        let size = outer.as_json().len();
392        assert!(
393            size <= STRFRY_MAX_EVENT_SIZE,
394            "a full {MAX_REKEY_BLOBS}-blob rekey event is {size} bytes; must stay <= {STRFRY_MAX_EVENT_SIZE} (strfry maxEventSize)"
395        );
396    }
397
398    fn sk(byte: u8) -> SecretKey {
399        SecretKey::from_slice(&[byte; 32]).unwrap()
400    }
401
402    #[test]
403    fn bound_plaintext_layout_is_frozen() {
404        // The 72-byte wrapped layout is wire format (frozen-layout discipline): scope_id[32] ‖
405        // epoch_be[8] ‖ new_key[32]. Pin it byte-exact so a field-order/width change can't slip in.
406        let pt = bound_plaintext(RekeyScope::ServerRoot, Epoch(1), &[0xABu8; 32]);
407        let expected = format!("{}{}{}", "00".repeat(32), "0000000000000001", "ab".repeat(32));
408        assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt), expected);
409        // A channel scope puts the channel id in the first 32 bytes; a multi-byte epoch is big-endian.
410        let pt2 = bound_plaintext(RekeyScope::Channel(ChannelId([0x11u8; 32])), Epoch(0x0102), &[0xCDu8; 32]);
411        let expected2 = format!("{}{}{}", "11".repeat(32), "0000000000000102", "cd".repeat(32));
412        assert_eq!(crate::simd::hex::bytes_to_hex_string(&pt2), expected2);
413    }
414
415    #[test]
416    fn pairwise_secret_regression_pin() {
417        // Regression pin for the pairwise-secret derivation = NIP-44 v2 ConversationKey (HKDF-extract
418        // of the ECDH shared point) between sk=[1;32] and pk(of sk=[2;32]). Pins the ECDH→IKM step the
419        // recipient-pseudonym golden vector (which starts from a literal secret) does not cover, so a
420        // change to which secret feeds the locator can't slip through silently.
421        let a = Keys::new(sk(1));
422        let b = Keys::new(sk(2));
423        let secret = rekey_pairwise_secret(a.secret_key(), &b.public_key()).unwrap();
424        assert_eq!(crate::simd::hex::bytes_to_hex_string(&secret), GOLDEN_PAIRWISE_SECRET);
425    }
426
427    // Captured from the NIP-44 v2 ConversationKey::derive(sk=[1;32], pk=secp(sk=[2;32])). Pins the
428    // exact pairwise secret that locates + wraps a rekey blob.
429    const GOLDEN_PAIRWISE_SECRET: &str =
430        "59c6d24d9c3a7bf8ca4cec54031a3e2ecfaa553452a2b2fa3147e31ee55f33d5";
431
432    #[test]
433    fn pairwise_secret_is_symmetric_and_deterministic() {
434        let a = Keys::new(sk(1));
435        let b = Keys::new(sk(2));
436        let from_a = rekey_pairwise_secret(a.secret_key(), &b.public_key()).unwrap();
437        let from_b = rekey_pairwise_secret(b.secret_key(), &a.public_key()).unwrap();
438        assert_eq!(from_a, from_b, "ECDH is symmetric: both sides derive the same secret");
439        // Deterministic across calls.
440        assert_eq!(from_a, rekey_pairwise_secret(a.secret_key(), &b.public_key()).unwrap());
441        // A third party derives something different.
442        let c = Keys::new(sk(3));
443        assert_ne!(from_a, rekey_pairwise_secret(a.secret_key(), &c.public_key()).unwrap());
444    }
445
446    #[test]
447    fn blob_round_trips_server_root_scope() {
448        let sender = Keys::new(sk(7));
449        let recipient = Keys::new(sk(8));
450        let new_key = [0xABu8; 32];
451        let blob = build_rekey_blob(
452            sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &new_key,
453        )
454        .unwrap();
455        let got = open_rekey_blob(
456            recipient.secret_key(), &sender.public_key(), RekeyScope::ServerRoot, Epoch(1), &blob,
457        )
458        .unwrap();
459        assert_eq!(got, new_key, "the recipient recovers the fresh key");
460    }
461
462    #[test]
463    fn blob_round_trips_channel_scope() {
464        let sender = Keys::new(sk(7));
465        let recipient = Keys::new(sk(8));
466        let chan = RekeyScope::Channel(ChannelId([0x42u8; 32]));
467        let new_key = [0xCDu8; 32];
468        let blob = build_rekey_blob(sender.secret_key(), &recipient.public_key(), chan, Epoch(5), &new_key).unwrap();
469        let got = open_rekey_blob(recipient.secret_key(), &sender.public_key(), chan, Epoch(5), &blob).unwrap();
470        assert_eq!(got, new_key);
471    }
472
473    #[test]
474    fn locator_is_the_recipient_pseudonym() {
475        // The blob's locator must equal recipient_pseudonym(pairwise_secret, scope, epoch) — that is how
476        // the recipient finds it (compute their own, look it up) with no trial-decryption.
477        let sender = Keys::new(sk(7));
478        let recipient = Keys::new(sk(8));
479        let secret = rekey_pairwise_secret(sender.secret_key(), &recipient.public_key()).unwrap();
480        let blob = build_rekey_blob(
481            sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(2), &[1u8; 32],
482        )
483        .unwrap();
484        assert_eq!(blob.locator, recipient_pseudonym(&secret, RekeyScope::ServerRoot, Epoch(2)).to_hex());
485    }
486
487    #[test]
488    fn wrong_sender_cannot_open() {
489        // A removed member who guesses the slot but pairs against the wrong sender derives a different
490        // secret → wrong locator (rejected before even decrypting).
491        let sender = Keys::new(sk(7));
492        let recipient = Keys::new(sk(8));
493        let impostor = Keys::new(sk(9));
494        let blob = build_rekey_blob(
495            sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[2u8; 32],
496        )
497        .unwrap();
498        let err = open_rekey_blob(
499            recipient.secret_key(), &impostor.public_key(), RekeyScope::ServerRoot, Epoch(1), &blob,
500        );
501        assert!(err.is_err(), "pairing against the wrong sender must fail");
502    }
503
504    #[test]
505    fn non_recipient_cannot_open() {
506        // A different recipient (not the one the blob was wrapped to) derives a different pairwise
507        // secret with the sender, so the locator won't match.
508        let sender = Keys::new(sk(7));
509        let recipient = Keys::new(sk(8));
510        let other = Keys::new(sk(10));
511        let blob = build_rekey_blob(
512            sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[3u8; 32],
513        )
514        .unwrap();
515        assert!(open_rekey_blob(
516            other.secret_key(), &sender.public_key(), RekeyScope::ServerRoot, Epoch(1), &blob,
517        )
518        .is_err());
519    }
520
521    #[test]
522    fn scope_splice_is_rejected() {
523        // A blob minted for the server root, presented at a channel coordinate: the locator differs, so
524        // it's rejected. (And even past the locator, the plaintext scope binding would fire.)
525        let sender = Keys::new(sk(7));
526        let recipient = Keys::new(sk(8));
527        let blob = build_rekey_blob(
528            sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[4u8; 32],
529        )
530        .unwrap();
531        let chan = RekeyScope::Channel(ChannelId([0x42u8; 32]));
532        assert!(open_rekey_blob(recipient.secret_key(), &sender.public_key(), chan, Epoch(1), &blob).is_err());
533    }
534
535    #[test]
536    fn epoch_splice_is_rejected() {
537        let sender = Keys::new(sk(7));
538        let recipient = Keys::new(sk(8));
539        let blob = build_rekey_blob(
540            sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[5u8; 32],
541        )
542        .unwrap();
543        assert!(open_rekey_blob(
544            recipient.secret_key(), &sender.public_key(), RekeyScope::ServerRoot, Epoch(2), &blob,
545        )
546        .is_err());
547    }
548
549    #[test]
550    fn relocated_blob_is_rejected() {
551        // An attacker who moves a valid blob to a DIFFERENT recipient's locator string can't make it
552        // open there: open recomputes the expected locator and refuses a mismatch.
553        let sender = Keys::new(sk(7));
554        let recipient = Keys::new(sk(8));
555        let mut blob = build_rekey_blob(
556            sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[6u8; 32],
557        )
558        .unwrap();
559        blob.locator = "ff".repeat(32);
560        assert!(open_rekey_blob(
561            recipient.secret_key(), &sender.public_key(), RekeyScope::ServerRoot, Epoch(1), &blob,
562        )
563        .is_err());
564    }
565
566    #[test]
567    fn tampered_ciphertext_is_rejected() {
568        let sender = Keys::new(sk(7));
569        let recipient = Keys::new(sk(8));
570        let mut blob = build_rekey_blob(
571            sender.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[7u8; 32],
572        )
573        .unwrap();
574        // Flip a byte mid-ciphertext → NIP-44 MAC fails.
575        let mut bytes = base64_simd::STANDARD.decode_to_vec(blob.wrapped.as_bytes()).unwrap();
576        let mid = bytes.len() / 2;
577        bytes[mid] ^= 0xff;
578        blob.wrapped = base64_simd::STANDARD.encode_to_string(&bytes);
579        assert!(open_rekey_blob(
580            recipient.secret_key(), &sender.public_key(), RekeyScope::ServerRoot, Epoch(1), &blob,
581        )
582        .is_err());
583    }
584
585    // --- 3303 Rekey event tests ---
586
587    // A fixed server-root key for the event tests — the rekey envelope key (NOT a channel key).
588    const SR: [u8; 32] = [0x55u8; 32];
589    const CHAN: [u8; 32] = [0x42u8; 32];
590
591    #[test]
592    fn epoch_key_commitment_golden_and_binds_epoch() {
593        // Pin the fork-detection commitment: domain ‖ prev_epoch_be ‖ prev_key.
594        let c = epoch_key_commitment(Epoch(1), &[0x33u8; 32]);
595        assert_eq!(crate::simd::hex::bytes_to_hex_32(&c), GOLDEN_EPOCH_COMMITMENT);
596        // The epoch binds: same key, different epoch → different commitment.
597        assert_ne!(c, epoch_key_commitment(Epoch(2), &[0x33u8; 32]));
598        // The key binds: same epoch, different key → different commitment.
599        assert_ne!(c, epoch_key_commitment(Epoch(1), &[0x34u8; 32]));
600    }
601
602    #[test]
603    fn channel_rekey_round_trips() {
604        let rotator = Keys::new(sk(1));
605        let scope = RekeyScope::Channel(ChannelId(CHAN));
606        let commit = epoch_key_commitment(Epoch(0), &[0xEEu8; 32]);
607        let blob = build_rekey_blob(rotator.secret_key(), &Keys::new(sk(8)).public_key(), scope, Epoch(1), &[0xABu8; 32]).unwrap();
608
609        let outer = build_channel_rekey_event(
610            &Keys::generate(), &rotator, &SR, &ChannelId(CHAN), Epoch(1), Epoch(0), &commit, &[blob.clone()],
611        )
612        .unwrap();
613        // The outer must NOT be signed by the rotator (no identity on the wire).
614        assert_ne!(outer.pubkey, rotator.public_key());
615
616        // Opened with the SERVER ROOT (not a channel key) — every member always holds it.
617        let parsed = open_rekey_event(&outer, &SR).unwrap();
618        assert_eq!(parsed.rotator, rotator.public_key(), "rotator recovered from the inner sig");
619        assert!(matches!(parsed.scope, RekeyScope::Channel(c) if c.0 == CHAN));
620        assert_eq!(parsed.new_epoch, Epoch(1));
621        assert_eq!(parsed.prev_epoch, Epoch(0));
622        assert_eq!(parsed.prev_key_commitment, commit);
623        assert_eq!(parsed.blobs, vec![blob]);
624    }
625
626    #[test]
627    fn epochs_are_independently_recoverable_with_only_the_server_root() {
628        // THE no-ratchet property: a member holding ONLY the server root (no channel key at ANY epoch)
629        // recovers the LATEST epoch's key directly, skipping every intermediate rekey. This is what
630        // makes catch-up a parallel choose-what-you-want fetch, not a forward chain walk.
631        let rotator = Keys::new(sk(1));
632        let recipient = Keys::new(sk(8));
633        let scope = RekeyScope::Channel(ChannelId(CHAN));
634
635        // Rotations introduced epochs 1..=5. The member was away for all of them and holds no channel key.
636        let mut events = Vec::new();
637        let mut keys = std::collections::HashMap::new();
638        for e in 1..=5u64 {
639            let key = [e as u8; 32];
640            keys.insert(e, key);
641            let blob = build_rekey_blob(rotator.secret_key(), &recipient.public_key(), scope, Epoch(e), &key).unwrap();
642            events.push(build_channel_rekey_event(
643                &Keys::generate(), &rotator, &SR, &ChannelId(CHAN), Epoch(e), Epoch(e - 1),
644                &epoch_key_commitment(Epoch(e - 1), &[0u8; 32]), &[blob],
645            ).unwrap());
646        }
647
648        // Jump straight to epoch 5: address it from the server root alone, open it, recover key_5 —
649        // NEVER touching epochs 1..4 or any prior channel key.
650        let want = rekey_pseudonym(&ServerRootKey(SR), &ChannelId(CHAN), Epoch(5)).to_hex();
651        let latest = events.iter().find(|ev| ev.tags.iter().any(|t| {
652            let s = t.as_slice();
653            s.len() >= 2 && s[0] == "z" && s[1] == want
654        })).expect("epoch-5 rekey is addressable from the server root");
655        let parsed = open_rekey_event(latest, &SR).unwrap();
656        let secret = rekey_pairwise_secret(recipient.secret_key(), &parsed.rotator).unwrap();
657        let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
658        let mine = parsed.blobs.iter().find(|b| b.locator == loc).unwrap();
659        let got = open_rekey_blob(recipient.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
660        assert_eq!(got, keys[&5], "recovered the latest key with only the server root, skipping all earlier epochs");
661    }
662
663    #[test]
664    fn end_to_end_recipient_opens_their_new_key() {
665        // The full #3a+#3b path: a rotator builds a Rekey carrying a blob for a recipient; the recipient
666        // opens the event (learns the rotator pubkey from the inner sig), then opens THEIR blob.
667        let rotator = Keys::new(sk(1));
668        let recipient = Keys::new(sk(8));
669        let scope = RekeyScope::Channel(ChannelId(CHAN));
670        let new_key = [0xCDu8; 32];
671        let blob = build_rekey_blob(rotator.secret_key(), &recipient.public_key(), scope, Epoch(1), &new_key).unwrap();
672        let outer = build_channel_rekey_event(
673            &Keys::generate(), &rotator, &SR, &ChannelId(CHAN), Epoch(1), Epoch(0),
674            &epoch_key_commitment(Epoch(0), &[0u8; 32]), &[blob],
675        )
676        .unwrap();
677
678        let parsed = open_rekey_event(&outer, &SR).unwrap();
679        // The recipient finds their blob by computing their own locator, then opens it.
680        let secret = rekey_pairwise_secret(recipient.secret_key(), &parsed.rotator).unwrap();
681        let my_locator = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
682        let mine = parsed.blobs.iter().find(|b| b.locator == my_locator).expect("my blob is present");
683        let got = open_rekey_blob(recipient.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
684        assert_eq!(got, new_key, "recipient recovers the fresh epoch key end to end");
685    }
686
687    #[test]
688    fn server_root_rekey_round_trips_under_the_prior_root() {
689        // A base rotation: enveloped under the PRIOR root, addressed by base_rekey_pseudonym(prior_root,
690        // community, new_epoch), ServerRoot-scope blobs. A recipient opens with the PRIOR root (the one
691        // they hold), learns the rotator, recovers the NEW root from their blob.
692        let rotator = Keys::new(sk(1));
693        let recipient = Keys::new(sk(8));
694        let prior_root = [0x66u8; 32];
695        let community_id = CommunityId([0x77u8; 32]);
696        let new_root = [0x99u8; 32];
697        let blob = build_rekey_blob(rotator.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &new_root).unwrap();
698        let commit = epoch_key_commitment(Epoch(0), &prior_root);
699        let outer = build_server_root_rekey_event(
700            &Keys::generate(), &rotator, &prior_root, &community_id, Epoch(1), Epoch(0), &commit, &[blob],
701        )
702        .unwrap();
703        assert_ne!(outer.pubkey, rotator.public_key(), "outer is ephemeral, not the rotator");
704
705        // Addressed by the PRIOR-root-derived base pseudonym (so a member finds it with the root they hold).
706        let expected = base_rekey_pseudonym(&ServerRootKey(prior_root), &community_id, Epoch(1)).to_hex();
707        let z = outer.tags.iter().find_map(|t| {
708            let s = t.as_slice();
709            (s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
710        });
711        assert_eq!(z.as_deref(), Some(expected.as_str()));
712
713        // Opened under the PRIOR root; the recipient recovers the NEW root from their ServerRoot blob.
714        let parsed = open_rekey_event(&outer, &prior_root).unwrap();
715        assert!(matches!(parsed.scope, RekeyScope::ServerRoot));
716        assert_eq!(parsed.rotator, rotator.public_key());
717        let secret = rekey_pairwise_secret(recipient.secret_key(), &parsed.rotator).unwrap();
718        let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
719        let mine = parsed.blobs.iter().find(|b| b.locator == loc).unwrap();
720        let got = open_rekey_blob(recipient.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
721        assert_eq!(got, new_root, "recipient recovers the new server root");
722    }
723
724    #[test]
725    fn base_and_channel_blobs_to_same_recipient_same_epoch_do_not_collide() {
726        // A base rotation and a channel rekey at the same epoch to the same member land their
727        // per-recipient blobs at DIFFERENT locators (the recipient_pseudonym scope disambiguates), so
728        // neither overwrites the other inside the recipient's view.
729        let sender = Keys::new(sk(1));
730        let recipient = Keys::new(sk(8));
731        let chan = RekeyScope::Channel(ChannelId([0x42u8; 32]));
732        let base = RekeyScope::ServerRoot;
733        let cb = build_rekey_blob(sender.secret_key(), &recipient.public_key(), chan, Epoch(1), &[0xAAu8; 32]).unwrap();
734        let bb = build_rekey_blob(sender.secret_key(), &recipient.public_key(), base, Epoch(1), &[0xBBu8; 32]).unwrap();
735        assert_ne!(cb.locator, bb.locator, "channel-scope and base-scope blobs must not collide");
736    }
737
738    #[test]
739    fn server_root_rekey_not_openable_without_the_prior_root() {
740        let rotator = Keys::new(sk(1));
741        let recipient = Keys::new(sk(8));
742        let prior_root = [0x66u8; 32];
743        let community_id = CommunityId([0x77u8; 32]);
744        let blob = build_rekey_blob(rotator.secret_key(), &recipient.public_key(), RekeyScope::ServerRoot, Epoch(1), &[0x99u8; 32]).unwrap();
745        let outer = build_server_root_rekey_event(
746            &Keys::generate(), &rotator, &prior_root, &community_id, Epoch(1), Epoch(0),
747            &epoch_key_commitment(Epoch(0), &prior_root), &[blob],
748        )
749        .unwrap();
750        // A member who doesn't hold the prior root (e.g. a never-member, or removed before this epoch)
751        // can't even read the envelope.
752        assert!(open_rekey_event(&outer, &[0x00u8; 32]).is_err());
753    }
754
755    #[test]
756    fn outer_address_is_server_root_derived_not_channel_key() {
757        // The crux of the no-ratchet fix: the `z` address is rekey_pseudonym(server_root, channel, NEW
758        // epoch) — derivable by any member from the server root alone, independent of any channel key.
759        let rotator = Keys::new(sk(1));
760        let outer = build_channel_rekey_event(
761            &Keys::generate(), &rotator, &SR, &ChannelId(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[],
762        )
763        .unwrap();
764        let expected = rekey_pseudonym(&ServerRootKey(SR), &ChannelId(CHAN), Epoch(1)).to_hex();
765        let z = outer.tags.iter().find_map(|t| {
766            let s = t.as_slice();
767            (s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
768        });
769        assert_eq!(z.as_deref(), Some(expected.as_str()));
770    }
771
772    #[test]
773    fn wrong_server_root_cannot_open() {
774        let rotator = Keys::new(sk(1));
775        let outer = build_channel_rekey_event(
776            &Keys::generate(), &rotator, &SR, &ChannelId(CHAN), Epoch(1), Epoch(0), &[0u8; 32], &[],
777        )
778        .unwrap();
779        assert!(open_rekey_event(&outer, &[0x00u8; 32]).is_err(), "a non-member (wrong server root) can't read it");
780    }
781
782    #[test]
783    fn forged_inner_signature_is_rejected() {
784        // Tamper the inner after signing, re-seal, re-wrap → inner sig must fail on open.
785        let rotator = Keys::new(sk(1));
786        let old_key = SR;
787        let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), "[]")
788            .tags([
789                Tag::custom(TagKind::Custom(TAG_SCOPE.into()), [scope_to_hex(RekeyScope::ServerRoot)]),
790                Tag::custom(TagKind::Custom(TAG_NEW_EPOCH.into()), ["1".to_string()]),
791                Tag::custom(TagKind::Custom(TAG_PREV_EPOCH.into()), ["0".to_string()]),
792                Tag::custom(TagKind::Custom(TAG_PREV_COMMIT.into()), ["00".repeat(32)]),
793            ])
794            .sign_with_keys(&rotator)
795            .unwrap();
796        let mut v: serde_json::Value = serde_json::from_str(&inner.as_json()).unwrap();
797        v["content"] = serde_json::Value::String("[{\"locator\":\"x\",\"wrapped\":\"y\"}]".into());
798        let tampered = serde_json::to_string(&v).unwrap();
799        let content = cipher::seal(&old_key, tampered.as_bytes()).unwrap();
800        let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), content)
801            .tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()])])
802            .sign_with_keys(&Keys::generate())
803            .unwrap();
804        assert!(open_rekey_event(&outer, &old_key).is_err());
805    }
806
807    #[test]
808    fn wrong_outer_kind_and_bad_version_rejected() {
809        let old_key = [0x55u8; 32];
810        // Wrong outer kind.
811        let not_rekey = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x")
812            .sign_with_keys(&Keys::generate())
813            .unwrap();
814        assert!(open_rekey_event(&not_rekey, &old_key).is_err());
815        // Right kind, missing/garbage version (checked before decrypt).
816        let bad_ver = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), "x")
817            .tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), ["999".to_string()])])
818            .sign_with_keys(&Keys::generate())
819            .unwrap();
820        assert!(open_rekey_event(&bad_ver, &old_key).is_err());
821    }
822
823    #[test]
824    fn version_is_checked_before_decrypt() {
825        // Prove ordering: a bogus version tag AND a key we can't decrypt under must fail on VERSION,
826        // never reaching cipher::open. (`wrong_outer_kind_and_bad_version_rejected` can't prove this —
827        // its content would fail decrypt anyway, so a regression moving the check after decrypt would
828        // still error there. Here the error must specifically be the version rejection.)
829        let real_key = [0x55u8; 32];
830        let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), "[]")
831            .sign_with_keys(&Keys::generate())
832            .unwrap();
833        let content = cipher::seal(&real_key, inner.as_json().as_bytes()).unwrap();
834        let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REKEY), content)
835            .tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), ["999".to_string()])])
836            .sign_with_keys(&Keys::generate())
837            .unwrap();
838        // Open with a DIFFERENT key: if the version check were after decrypt, this would surface a
839        // decrypt error instead of the version error.
840        let err = open_rekey_event(&outer, &[0x00u8; 32]).unwrap_err();
841        assert!(err.contains("version"), "must reject on version before decrypt, got: {err}");
842    }
843
844    const GOLDEN_EPOCH_COMMITMENT: &str =
845        "5e706f60f1c6f39208071e914d3284dab5f93a1c8d178260e7daf5d23e26a81f";
846
847    #[test]
848    fn distinct_recipients_get_distinct_locators() {
849        // Two recipients of the same rotation land at different slots (no collision, O(1) lookup each).
850        let sender = Keys::new(sk(7));
851        let r1 = Keys::new(sk(8));
852        let r2 = Keys::new(sk(9));
853        let b1 = build_rekey_blob(sender.secret_key(), &r1.public_key(), RekeyScope::ServerRoot, Epoch(1), &[1u8; 32]).unwrap();
854        let b2 = build_rekey_blob(sender.secret_key(), &r2.public_key(), RekeyScope::ServerRoot, Epoch(1), &[1u8; 32]).unwrap();
855        assert_ne!(b1.locator, b2.locator);
856    }
857}