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