Skip to main content

vector_core/community/
derive.rs

1//! Key-derivation convention (GROUP_PROTOCOL.md) — FROZEN.
2//!
3//! Every HKDF use in the Community protocol funnels through here. Changing any
4//! byte of the construction shifts every pseudonym and sub-key, orphaning all
5//! prior events — a forced migration to avoid. The layout is
6//! locked by the golden vectors in the test module; treat those as the spec.
7//!
8//! Construction: `HKDF-SHA256(IKM, salt=∅, info, L=32)`, where
9//! `info = utf8(label) || 0x00 || id32 || epoch_be` —
10//!   - `label`    : ASCII purpose string, no terminator
11//!   - `0x00`     : single separator byte
12//!   - `id32`     : raw 32-byte id (channel id, or scope id), never hex
13//!   - `epoch_be` : the epoch as u64 big-endian (8 bytes); omitted where noted
14
15use hkdf::Hkdf;
16use sha2::Sha256;
17
18use super::{ChannelId, ChannelKey, CommunityId, Epoch, Pseudonym, ServerRootKey};
19use nostr_sdk::prelude::SecretKey;
20
21/// Purpose labels. These strings are part of the wire format — append new
22/// ones, never edit or reuse an existing one.
23const LABEL_CHANNEL_PSEUDONYM: &str = "vector-community/v1/channel-pseudonym";
24const LABEL_RECIPIENT_PSEUDONYM: &str = "vector-community/v1/recipient-pseudonym";
25const LABEL_REKEY_PSEUDONYM: &str = "vector-community/v1/rekey-pseudonym";
26const LABEL_BASE_REKEY_PSEUDONYM: &str = "vector-community/v1/base-rekey-pseudonym";
27const LABEL_PUBLIC_INVITE_KEY: &str = "vector-community/v1/public-invite-key";
28const LABEL_PUBLIC_INVITE_LOCATOR: &str = "vector-community/v1/public-invite-locator";
29const LABEL_PUBLIC_INVITE_SIGNER: &str = "vector-community/v1/public-invite-signer";
30const LABEL_BANLIST_LOCATOR: &str = "vector-community/v1/banlist-locator";
31const LABEL_GRANT_LOCATOR: &str = "vector-community/v1/grant-locator";
32const LABEL_INVITE_LINKS_LOCATOR: &str = "vector-community/v1/invite-links-locator";
33const LABEL_DISSOLVED_LOCATOR: &str = "vector-community/v1/dissolved-locator";
34const LABEL_DISSOLVED_PSEUDONYM: &str = "vector-community/v1/dissolved-pseudonym";
35const LABEL_DISSOLVED_ENVELOPE: &str = "vector-community/v1/dissolved-envelope-key";
36
37/// Opaque coordinate for the banlist entity, HKDF-derived from the **community id** — a STABLE
38/// logical id that survives a server-root rotation, so a re-anchored banlist binds the same coordinate
39/// at every epoch (re-anchoring). Member-computable (members hold the community id from their
40/// invite), outsider-opaque (the id is never on the wire — the relay sees only the rotating
41/// `control_pseudonym`), and the content stays server-root-encrypted, so privacy is unchanged.
42pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] {
43    hkdf_sha256_32(&community_id.0, LABEL_BANLIST_LOCATOR.as_bytes())
44}
45
46/// Opaque coordinate for the owner-dissolution tombstone (vsk=10), HKDF-derived from the **community
47/// id** — STABLE across a server-root rotation, exactly like `banlist_locator`. This rotation-stability is
48/// load-bearing for dissolution: a fresh joiner after a re-founding derives only the NEW epoch root, but
49/// can still compute this community-scoped coordinate and discover the tombstone, so a dissolved community
50/// can never look "alive" to anyone who can derive the community id. Member-computable, outsider-opaque.
51pub fn dissolved_locator(community_id: &CommunityId) -> [u8; 32] {
52    hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_LOCATOR.as_bytes())
53}
54
55/// Rotation-stable relay `#z` for the dissolution tombstone — community-id-derived (NOT the per-epoch
56/// `control_pseudonym`), so ANY client that can derive the community id finds the tombstone at the SAME
57/// coordinate regardless of which epoch root it holds. This is what closes the post-rotation
58/// discoverability split: a fresh joiner who only ever derives a later epoch's root still probes this
59/// fixed coordinate and learns the community is dead. Outsider-opaque (community id is never on the wire).
60pub fn dissolved_pseudonym(community_id: &CommunityId) -> String {
61    crate::simd::hex::bytes_to_hex_32(&hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_PSEUDONYM.as_bytes()))
62}
63
64/// Rotation-stable envelope key for the dissolution tombstone — community-id-derived so the tombstone is
65/// openable by any member or joiner at ANY epoch. The control plane is server-root-encrypted (per-epoch),
66/// which a post-rotation joiner can't open for the publish-epoch; this id-derived OUTER envelope carries
67/// no secret, so a community-id key is the right scope — member-computable, outsider-opaque, epoch-free.
68/// NOTE: the v1→v2 migration carrier (§migration) puts the v2 keys in an INNER `m` sealed under the
69/// server root, NOT here — the community id rides in every invite bundle, so this envelope must never
70/// hold key material. The signpost fields it does carry (v2 id/relays/name) grant nothing.
71pub fn dissolved_envelope_key(community_id: &CommunityId) -> [u8; 32] {
72    hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_ENVELOPE.as_bytes())
73}
74
75/// Opaque coordinate for a CREATOR's own invite-links entity (vsk=8) — the per-creator list of
76/// active public-invite-link locators THEY published. Bound to the creator's x-only pubkey exactly like a
77/// per-member grant (`grant_locator`), so a creator can only publish links at their own coordinate, and
78/// members fold every creator's list into the aggregate active-set (`is_public` = aggregate non-empty).
79/// Community-id-derived (stable across rotation, member-computable, outsider-opaque). There is no shared
80/// registry — each creator owns only their own list (per-creator ownership).
81pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] {
82    let info = build_info(LABEL_INVITE_LINKS_LOCATOR, creator_xonly, None);
83    hkdf_sha256_32(&community_id.0, &info)
84}
85
86/// Opaque coordinate for a member's Grant entity (vsk=3), HKDF-derived from the **community id**
87/// bound to the member's x-only pubkey. Community-scoped (not server-root-scoped) so the coordinate is
88/// STABLE across a base rotation — the keystone that lets a re-anchored grant fold under the new root
89/// (re-anchoring): a new joiner holding only the new root still derives the same `entity_id`.
90/// Member-computable, outsider-opaque (community id never on the wire), content still server-root-
91/// encrypted — privacy unchanged. Roles need no locator (their `d`-tag is the role's random id).
92pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] {
93    let info = build_info(LABEL_GRANT_LOCATOR, member_xonly, None);
94    hkdf_sha256_32(&community_id.0, &info)
95}
96
97/// Scope of a per-recipient rekey blob. Disambiguates two blobs a single
98/// sender delivers to the same recipient in one epoch (a server-root rotation and
99/// a channel rekey), which would otherwise collide on the same tag.
100#[derive(Debug, Clone, Copy)]
101pub enum RekeyScope {
102    /// A specific channel being rekeyed.
103    Channel(ChannelId),
104    /// A server-wide root rotation — not channel-scoped, uses the all-zero sentinel.
105    ServerRoot,
106}
107
108impl RekeyScope {
109    /// The 32-byte scope id this rekey binds: the channel id, or the all-zero server-root
110    /// sentinel. Used by `recipient_pseudonym`, the epoch-keys archive scope, and the blob binding.
111    pub fn id32(&self) -> [u8; 32] {
112        match self {
113            RekeyScope::Channel(c) => c.0,
114            RekeyScope::ServerRoot => [0u8; 32],
115        }
116    }
117}
118
119/// Build the frozen `info` byte string. `epoch` is `None` for the no-epoch derivations
120/// (the grant + invite-links locators and the public-invite sub-keys).
121fn build_info(label: &str, id32: &[u8; 32], epoch: Option<Epoch>) -> Vec<u8> {
122    let mut info = Vec::with_capacity(label.len() + 1 + 32 + 8);
123    info.extend_from_slice(label.as_bytes());
124    info.push(0x00);
125    info.extend_from_slice(id32);
126    if let Some(e) = epoch {
127        info.extend_from_slice(&e.0.to_be_bytes());
128    }
129    info
130}
131
132/// HKDF-SHA256 expand to 32 bytes with an empty salt.
133///
134/// RFC 5869 with no salt uses HashLen zero bytes; the `hkdf` crate's `new(None, ..)`
135/// does exactly that, and for HMAC-SHA256 a zero-length salt and a 32-zero-byte salt
136/// produce an identical PRK (both pad to the 64-byte block), so this matches the
137/// spec's "salt=∅". The expand never fails for L=32 (≤ 255·HashLen).
138fn hkdf_sha256_32(ikm: &[u8; 32], info: &[u8]) -> [u8; 32] {
139    let hk = Hkdf::<Sha256>::new(None, ikm);
140    let mut okm = [0u8; 32];
141    hk.expand(info, &mut okm)
142        .expect("HKDF expand of 32 bytes is infallible");
143    okm
144}
145
146/// Channel pseudonym: the value carried in the relay-filterable `z` tag.
147/// Every member derives the same one from the shared channel secret, so it both
148/// addresses and (by rotation) unlinks the channel's traffic.
149pub fn channel_pseudonym(channel_key: &ChannelKey, channel_id: &ChannelId, epoch: Epoch) -> Pseudonym {
150    let info = build_info(LABEL_CHANNEL_PSEUDONYM, &channel_id.0, Some(epoch));
151    Pseudonym(hkdf_sha256_32(channel_key.as_bytes(), &info))
152}
153
154/// The relay-filterable address of a channel REKEY event for `(channel, epoch)`. Derived from
155/// the **server-root key** (NOT the channel key) + the channel id + the epoch the rekey introduces.
156/// Because the IKM is the server root — which every member always holds and which is stable across a
157/// channel rotation — any member can compute this for ANY epoch directly, WITHOUT holding that epoch's
158/// (or the prior epoch's) channel key. That is what makes epochs **independently recoverable**: a
159/// member fetches the rekey for whichever epoch(s) they choose (latest only, or all, in parallel),
160/// rather than chaining forward one key at a time. Distinct from the channel message pseudonym (channel
161/// key IKM) and the control pseudonym (community-id binding) by IKM/id, and domain-separated by label.
162pub fn rekey_pseudonym(server_root: &ServerRootKey, channel_id: &ChannelId, epoch: Epoch) -> Pseudonym {
163    let info = build_info(LABEL_REKEY_PSEUDONYM, &channel_id.0, Some(epoch));
164    Pseudonym(hkdf_sha256_32(server_root.as_bytes(), &info))
165}
166
167/// The relay-filterable address of a SERVER-ROOT (base) rekey for `(community, new_epoch)`.
168/// Keyed by the **PRIOR** server-root key — the base layer has no stable key above it, so the prior
169/// root is the handle every current member holds: a returning member derives this from the root they
170/// currently hold, finds the base rekey, learns the rotator from its inner sig, and recovers the next
171/// root (a short forward-walk; base rotations are rare). Binds the community id + epoch, and is
172/// label-separated from the channel-rekey / channel-message / control pseudonyms.
173pub fn base_rekey_pseudonym(prior_root: &ServerRootKey, community_id: &CommunityId, new_epoch: Epoch) -> Pseudonym {
174    let info = build_info(LABEL_BASE_REKEY_PSEUDONYM, &community_id.0, Some(new_epoch));
175    Pseudonym(hkdf_sha256_32(prior_root.as_bytes(), &info))
176}
177
178/// Per-recipient rekey-blob tag. `IKM` is the pairwise sender↔recipient
179/// ECDH secret (not the channel key), so only that pair can locate the blob and a
180/// removed member cannot derive tags for pairs they are not in.
181pub fn recipient_pseudonym(per_recipient_secret: &[u8; 32], scope: RekeyScope, epoch: Epoch) -> Pseudonym {
182    let info = build_info(LABEL_RECIPIENT_PSEUDONYM, &scope.id32(), Some(epoch));
183    Pseudonym(hkdf_sha256_32(per_recipient_secret, &info))
184}
185
186/// Reduce HKDF output to a valid secp256k1 scalar with reject-and-retry (the reject
187/// branch is ~2^-128 rare but kept deterministic via a counter byte appended to
188/// `info`, so derivation stays reproducible cross-implementation).
189fn hkdf_to_secret_key(ikm: &[u8; 32], base_info: Vec<u8>) -> SecretKey {
190    let mut counter: u8 = 0;
191    loop {
192        let info = if counter == 0 {
193            base_info.clone()
194        } else {
195            let mut extended = base_info.clone();
196            extended.push(counter);
197            extended
198        };
199        let okm = hkdf_sha256_32(ikm, &info);
200        if let Ok(sk) = SecretKey::from_slice(&okm) {
201            return sk;
202        }
203        counter = counter
204            .checked_add(1)
205            .expect("secp256k1 scalar rejection 256 times running is impossible");
206    }
207}
208
209/// Public-invite sub-keys, all derived from the URL fetch-token. The token
210/// is the IKM and there is no channel/epoch context, so the frozen `info` uses the
211/// all-zero id and no epoch — the token alone provides uniqueness. The three labels
212/// domain-separate the decryption key, the relay locator (addressable `d`-tag), and the
213/// bundle's signing key (so the owner can re-post under one coordinate to rotate, and
214/// joiners reject an impostor squatting the locator).
215pub fn public_invite_key(token: &[u8; 32]) -> [u8; 32] {
216    hkdf_sha256_32(token, &build_info(LABEL_PUBLIC_INVITE_KEY, &[0u8; 32], None))
217}
218
219pub fn public_invite_locator(token: &[u8; 32]) -> [u8; 32] {
220    hkdf_sha256_32(token, &build_info(LABEL_PUBLIC_INVITE_LOCATOR, &[0u8; 32], None))
221}
222
223pub fn public_invite_signer(token: &[u8; 32]) -> SecretKey {
224    hkdf_to_secret_key(token, build_info(LABEL_PUBLIC_INVITE_SIGNER, &[0u8; 32], None))
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    // Fixed test inputs. The golden hex below was produced by an INDEPENDENT
232    // HKDF-SHA256 implementation (Python hmac+hashlib, RFC 5869) over these exact
233    // bytes, so a match proves the construction is correct cross-implementation,
234    // not merely self-consistent. If any of these assertions ever change, the wire
235    // format changed — that must be a conscious, versioned decision.
236    fn test_channel_key() -> ChannelKey {
237        // 0x00,0x01,..,0x1f
238        let mut k = [0u8; 32];
239        for (i, b) in k.iter_mut().enumerate() {
240            *b = i as u8;
241        }
242        ChannelKey(k)
243    }
244
245    fn test_channel_id() -> ChannelId {
246        // 0xff,0xfe,..
247        let mut id = [0u8; 32];
248        for (i, b) in id.iter_mut().enumerate() {
249            *b = (255 - i) as u8;
250        }
251        ChannelId(id)
252    }
253
254    #[test]
255    fn channel_pseudonym_is_deterministic() {
256        let key = test_channel_key();
257        let id = test_channel_id();
258        let a = channel_pseudonym(&key, &id, Epoch(0));
259        let b = channel_pseudonym(&key, &id, Epoch(0));
260        assert_eq!(a, b, "same inputs must yield the same pseudonym");
261    }
262
263    #[test]
264    fn channel_pseudonym_golden_epoch0() {
265        let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(0));
266        assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH0);
267    }
268
269    #[test]
270    fn channel_pseudonym_golden_epoch1() {
271        let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(1));
272        assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH1);
273    }
274
275    // Independent (Python hmac+hashlib, RFC 5869) over IKM=0x11*32 (the COMMUNITY id), member=0x22*32,
276    // info = "vector-community/v1/grant-locator" ‖ 0x00 ‖ member.
277    const GOLDEN_GRANT_LOCATOR: &str =
278        "c18d4d5955ecdd258f44240019a493a01fc01d51b5f0b8f7679ae424f8d5bfcc";
279
280    #[test]
281    fn grant_locator_golden() {
282        let loc = grant_locator(&crate::community::CommunityId([0x11u8; 32]), &[0x22u8; 32]);
283        assert_eq!(crate::simd::hex::bytes_to_hex_32(&loc), GOLDEN_GRANT_LOCATOR);
284    }
285
286    #[test]
287    fn invite_links_locator_golden_and_domain_separated() {
288        let cid = crate::community::CommunityId([0x11u8; 32]);
289        let alice = [0x22u8; 32];
290        let bob = [0x33u8; 32];
291        // Frozen output (drift = a silent coordinate change → members lose a creator's links).
292        assert_eq!(
293            crate::simd::hex::bytes_to_hex_32(&invite_links_locator(&cid, &alice)),
294            "cf42937a815ec561da6b4ca5ddd0c361634b0d9744693b744d4f5b34ec209ec2"
295        );
296        // Per-creator: each creator's list lives at a DISTINCT coordinate (no shared registry).
297        assert_ne!(invite_links_locator(&cid, &alice), invite_links_locator(&cid, &bob));
298        // Domain-separated from grant + banlist despite sharing the community-id IKM (distinct label).
299        assert_ne!(invite_links_locator(&cid, &alice), grant_locator(&cid, &alice));
300        assert_ne!(invite_links_locator(&cid, &alice), banlist_locator(&cid));
301        // Community-bound (a different community → a different coordinate).
302        assert_ne!(invite_links_locator(&cid, &alice), invite_links_locator(&crate::community::CommunityId([0x99u8; 32]), &alice));
303    }
304
305    #[test]
306    fn grant_locator_binds_member_and_community() {
307        let cid = crate::community::CommunityId([0x11u8; 32]);
308        // Deterministic for the same inputs.
309        assert_eq!(grant_locator(&cid, &[0x22u8; 32]), grant_locator(&cid, &[0x22u8; 32]));
310        // The member pubkey is bound in: a different member → a different locator.
311        assert_ne!(grant_locator(&cid, &[0x22u8; 32]), grant_locator(&cid, &[0x23u8; 32]));
312        // A different COMMUNITY → a different locator (so coordinates don't collide across communities,
313        // and an outsider without the community id can't compute any).
314        assert_ne!(
315            grant_locator(&cid, &[0x22u8; 32]),
316            grant_locator(&crate::community::CommunityId([0x99u8; 32]), &[0x22u8; 32])
317        );
318    }
319
320    #[test]
321    fn epoch_changes_the_pseudonym() {
322        let key = test_channel_key();
323        let id = test_channel_id();
324        assert_ne!(
325            channel_pseudonym(&key, &id, Epoch(0)),
326            channel_pseudonym(&key, &id, Epoch(1)),
327            "rotating the epoch must rotate the pseudonym (unlinkability)"
328        );
329    }
330
331    #[test]
332    fn different_channel_id_changes_the_pseudonym() {
333        let key = test_channel_key();
334        let other = ChannelId([0x42u8; 32]);
335        assert_ne!(
336            channel_pseudonym(&key, &test_channel_id(), Epoch(0)),
337            channel_pseudonym(&key, &other, Epoch(0)),
338        );
339    }
340
341    #[test]
342    fn different_label_does_not_collide() {
343        // Channel pseudonym and recipient pseudonym share IKM-shape + id + epoch but
344        // differ only by label — domain separation must keep them distinct.
345        let secret = test_channel_key();
346        let id = test_channel_id();
347        let chan = channel_pseudonym(&secret, &id, Epoch(0));
348        let recip = recipient_pseudonym(secret.as_bytes(), RekeyScope::Channel(id), Epoch(0));
349        assert_ne!(chan.0, recip.0, "labels must domain-separate");
350    }
351
352    #[test]
353    fn recipient_pseudonym_golden() {
354        let secret = [7u8; 32];
355        let chan = recipient_pseudonym(&secret, RekeyScope::Channel(test_channel_id()), Epoch(3));
356        let root = recipient_pseudonym(&secret, RekeyScope::ServerRoot, Epoch(3));
357        assert_eq!(chan.to_hex(), GOLDEN_RECIPIENT_CHANNEL_EPOCH3);
358        assert_eq!(root.to_hex(), GOLDEN_RECIPIENT_SERVERROOT_EPOCH3);
359    }
360
361    #[test]
362    fn rekey_pseudonym_is_server_root_derived_and_distinct() {
363        let sr = ServerRootKey([0x07u8; 32]);
364        let chan = test_channel_id();
365        // Deterministic + golden (regression pin for the channel-rekey address derivation).
366        let p = rekey_pseudonym(&sr, &chan, Epoch(1));
367        assert_eq!(p, rekey_pseudonym(&sr, &chan, Epoch(1)));
368        assert_eq!(p.to_hex(), GOLDEN_REKEY_PSEUDONYM);
369        // Server-root-derived: a different root → different address (so a non-member can't compute it,
370        // and crucially a member needs ONLY the server root — not the channel key — to find it).
371        assert_ne!(p, rekey_pseudonym(&ServerRootKey([0x08u8; 32]), &chan, Epoch(1)));
372        // Per-epoch + per-channel binding.
373        assert_ne!(p, rekey_pseudonym(&sr, &chan, Epoch(2)));
374        assert_ne!(p, rekey_pseudonym(&sr, &ChannelId([0x42u8; 32]), Epoch(1)));
375        // Domain-separated from the channel message pseudonym even with the same (id, epoch): the
376        // message pseudonym keys off the CHANNEL key, this off the SERVER ROOT + a different label.
377        let as_chan_key = channel_pseudonym(&ChannelKey(*sr.as_bytes()), &chan, Epoch(1));
378        assert_ne!(p.0, as_chan_key.0, "label must domain-separate rekey-address from channel-message");
379        // The subtle pairing: rekey vs control plane share IKM=server_root AND epoch — separation rests
380        // ENTIRELY on the label (and id namespace). Pin it so a future label edit can't collapse them.
381        let as_control =
382            crate::community::roster::control_pseudonym(&sr, &crate::community::CommunityId(chan.0), Epoch(1));
383        assert_ne!(p.to_hex(), as_control, "label must domain-separate rekey-address from control-plane");
384    }
385
386    #[test]
387    fn base_rekey_pseudonym_is_prior_root_derived_and_distinct() {
388        let root = ServerRootKey([0x07u8; 32]);
389        let community = crate::community::CommunityId([0x09u8; 32]);
390        let p = base_rekey_pseudonym(&root, &community, Epoch(1));
391        assert_eq!(p, base_rekey_pseudonym(&root, &community, Epoch(1)));
392        assert_eq!(p.to_hex(), GOLDEN_BASE_REKEY_PSEUDONYM);
393        // Keyed by the PRIOR root: a different root → different address (so a member needs the root they
394        // hold to find the next base rekey — the forward-walk handle).
395        assert_ne!(p, base_rekey_pseudonym(&ServerRootKey([0x08u8; 32]), &community, Epoch(1)));
396        // Per-epoch + per-community binding.
397        assert_ne!(p, base_rekey_pseudonym(&root, &community, Epoch(2)));
398        assert_ne!(p, base_rekey_pseudonym(&root, &crate::community::CommunityId([0x42u8; 32]), Epoch(1)));
399        // Distinct from the control pseudonym (same IKM=root + community id + epoch) by label.
400        let control = super::super::roster::control_pseudonym(&root, &community, Epoch(1));
401        assert_ne!(p.to_hex(), control, "label must domain-separate base-rekey from control-plane");
402    }
403
404    #[test]
405    fn server_root_scope_sentinel_matches_rekey_scope() {
406        // The epoch-keys archive scopes the base key under `SERVER_ROOT_SCOPE_HEX`; it must equal the
407        // hex of `RekeyScope::ServerRoot`'s all-zero `id32`, so the storage layer and the recipient
408        // pseudonym name the same server-root scope. Pinning this stops the two from drifting apart.
409        assert_eq!(
410            crate::simd::hex::bytes_to_hex_32(&RekeyScope::ServerRoot.id32()),
411            crate::community::SERVER_ROOT_SCOPE_HEX
412        );
413    }
414
415    #[test]
416    fn recipient_scope_disambiguates() {
417        // Same sender, same recipient, same epoch, but a channel rekey vs a
418        // server-root rotation must land on different tags (no blob collision).
419        let secret = [7u8; 32];
420        let chan = recipient_pseudonym(&secret, RekeyScope::Channel(test_channel_id()), Epoch(3));
421        let root = recipient_pseudonym(&secret, RekeyScope::ServerRoot, Epoch(3));
422        assert_ne!(chan.0, root.0);
423    }
424
425    #[test]
426    fn channel_pseudonym_golden_multibyte_epoch_is_big_endian() {
427        // A multi-byte epoch pins big-endian serialization explicitly (epoch 0/1
428        // alone could be satisfied by either order beyond the low byte).
429        let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(0x0102030405060708));
430        assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH_BE);
431    }
432
433    #[test]
434    fn public_invite_subkeys_golden() {
435        // Independent RFC-5869 HKDF over token=[5;32], each label, all-zero id, no epoch.
436        let token = [5u8; 32];
437        assert_eq!(crate::simd::hex::bytes_to_hex_32(&public_invite_key(&token)), GOLDEN_PUBLIC_INVITE_KEY);
438        assert_eq!(crate::simd::hex::bytes_to_hex_32(&public_invite_locator(&token)), GOLDEN_PUBLIC_INVITE_LOCATOR);
439        assert_eq!(public_invite_signer(&token).to_secret_hex(), GOLDEN_PUBLIC_INVITE_SIGNER);
440    }
441
442    #[test]
443    fn public_invite_subkeys_domain_separated_and_token_bound() {
444        let token = [5u8; 32];
445        let other = [6u8; 32];
446        // Three sub-keys from one token must all differ (domain separation).
447        assert_ne!(public_invite_key(&token), public_invite_locator(&token));
448        assert_ne!(
449            public_invite_key(&token).to_vec(),
450            public_invite_signer(&token).as_secret_bytes().to_vec()
451        );
452        // A different token yields different sub-keys (token-bound).
453        assert_ne!(public_invite_key(&token), public_invite_key(&other));
454        assert_ne!(public_invite_locator(&token), public_invite_locator(&other));
455    }
456
457    // --- Golden vectors (independent Python HKDF-SHA256, RFC 5869) ---
458    const GOLDEN_PUBLIC_INVITE_KEY: &str =
459        "7f02a8a832a1744adf286676038446dc94762c2c8332650c9ad62a0c870e0751";
460    const GOLDEN_PUBLIC_INVITE_LOCATOR: &str =
461        "33c098d6e4cddc2b8ee98ab6b5182186794c35f5b71391130a49ae3d88588c2c";
462    const GOLDEN_PUBLIC_INVITE_SIGNER: &str =
463        "9154a3a7e4a03e94eaad2f76efeebd43e25ee9df4fbca12454edcee0ef666e8d";
464
465    // server_root = [7;32], channel id = test_channel_id (0xff,0xfe,..), epoch 1.
466    const GOLDEN_REKEY_PSEUDONYM: &str =
467        "3a848655f79a586510e1113131f078aa1ce0ff8dcb74374507e6af07ff49fd24";
468    // prior_root = [7;32], community id = [9;32], epoch 1.
469    const GOLDEN_BASE_REKEY_PSEUDONYM: &str =
470        "23ced8fd6cad30a21ded43c96bd040311cf20bcfff935453dc0985b41ff660be";
471
472    const GOLDEN_CHANNEL_PSEUDONYM_EPOCH0: &str =
473        "d55b9f5fad668887d41d46b7c08ba63725a39d7c86b602c7c36e2f2e0eff8c40";
474    const GOLDEN_CHANNEL_PSEUDONYM_EPOCH1: &str =
475        "050079d9899c85bebf5c73fd777cdd812132d262e3ceec83c847a056dea41293";
476    // secret = [7;32], epoch 3; channel scope = test_channel_id, root scope = all-zero.
477    const GOLDEN_RECIPIENT_CHANNEL_EPOCH3: &str =
478        "971f69d6a948c79704f8077188cded86bd35c82960e88043ebb2c2c3d60a3b71";
479    const GOLDEN_RECIPIENT_SERVERROOT_EPOCH3: &str =
480        "e50e5d803fd2edc310be8cd7354586d12fcb8e3f30162553be53da1a34a17c46";
481    // channel key [0..31], epoch 0x0102030405060708 (proves u64 big-endian).
482    const GOLDEN_CHANNEL_PSEUDONYM_EPOCH_BE: &str =
483        "cec398094d17688cd127bc609d34fa067331427400b023d0c70ff77fafe17e0b";
484}