Skip to main content

vector_core/community/v2/
derive.rs

1//! Concord v2 key derivations — CORD-02 Appendix A. **FROZEN.**
2//!
3//! Everything v2 addresses on the wire derives from a Community secret through
4//! one of the shapes below; changing any labeled byte re-addresses every prior
5//! event ("a breaking change re-labels and becomes a different universe").
6//! The layout is locked by the golden vectors in the test module — minted by an
7//! independent implementation — treat those as the spec.
8//!
9//! Construction (A.1): `HKDF-SHA256(ikm=secret, salt=∅, info, L=32)` where
10//! `info = utf8(label) || 0x00 || id[32] || epoch_be[8]?`
11//!   - `id` is ALWAYS present: 32 raw bytes, all-zeroes where a label has no
12//!     meaningful id.
13//!   - the epoch (u64 big-endian) is the ONLY omittable field: labels marked
14//!     no-epoch omit the 8 bytes entirely.
15//!   - the `scalar_normalize` retry counter (A.3) appends AFTER whatever fields
16//!     are present, starting at byte value 0. (v1's equivalent starts its retry
17//!     byte at 1 — the two conventions differ only in a ~2⁻¹²⁸ branch, but v2
18//!     follows the spec exactly.)
19//!
20//! These are DISTINCT from v1's `vector-community/v1/*` labels — the two
21//! protocols are different address universes by construction. The one label the
22//! specs share is the edition hash (`vector-community/v1/edition`,
23//! `community::version::EDITION_LABEL`), which upstream froze verbatim.
24
25use hkdf::Hkdf;
26use nostr_sdk::prelude::nip44::v2::ConversationKey;
27use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
28use sha2::{Digest, Sha256};
29
30use super::super::{ChannelId, CommunityId, Epoch};
31
32/// A.6 purpose labels. Part of the wire format — append, never edit or reuse.
33const LABEL_CHANNEL: &str = "concord/channel";
34const LABEL_CONTROL: &str = "concord/control";
35const LABEL_CONTROL_SIGNER: &str = "concord/control-signer";
36const LABEL_REKEY_PSEUDONYM: &str = "concord/rekey-pseudonym";
37const LABEL_BASE_REKEY_PSEUDONYM: &str = "concord/base-rekey-pseudonym";
38const LABEL_RECIPIENT_PSEUDONYM: &str = "concord/recipient-pseudonym";
39const LABEL_GUESTBOOK: &str = "concord/guestbook";
40const LABEL_VOICE_SIGNER: &str = "concord/voice-signer";
41const LABEL_VOICE_MEDIA: &str = "concord/voice-media";
42const LABEL_VOICE_SENDER: &str = "concord/voice-sender";
43const LABEL_DISSOLVED: &str = "concord/dissolved";
44const LABEL_GRANT: &str = "concord/grant";
45const LABEL_BANLIST: &str = "concord/banlist";
46const LABEL_PINS: &str = "concord/pins";
47const LABEL_INVITE_LINKS: &str = "concord/invite-links";
48const LABEL_INVITE_KEY: &str = "concord/invite-key";
49/// A.4 community_id commitment prefix — plain SHA-256, NOT the hkdf shape.
50const LABEL_COMMUNITY: &str = "concord/community";
51/// A.5 epoch-key commitment prefix — plain SHA-256.
52const LABEL_EPOCH_COMMITMENT: &str = "concord/epoch-key-commitment";
53
54const ZERO32: [u8; 32] = [0u8; 32];
55
56/// The size of a public-invite unlock token (CORD-05 §2) — 16 bytes in v2
57/// (v1 tokens were 32).
58pub const TOKEN_LEN: usize = 16;
59
60/// Build the frozen A.1 `info` byte string. `epoch` is `None` for the no-epoch
61/// labels (grant/banlist/invite-links/invite-key/dissolved/voice-sender).
62fn build_info(label: &str, id32: &[u8; 32], epoch: Option<u64>) -> Vec<u8> {
63    let mut info = Vec::with_capacity(label.len() + 1 + 32 + 8);
64    info.extend_from_slice(label.as_bytes());
65    info.push(0x00);
66    info.extend_from_slice(id32);
67    if let Some(e) = epoch {
68        info.extend_from_slice(&e.to_be_bytes());
69    }
70    info
71}
72
73/// HKDF-SHA256 to 32 bytes with a zero-length salt (RFC 5869: identical PRK to
74/// a 32-zero-byte salt under HMAC-SHA256). `ikm` length varies by caller: 32
75/// for keys/ids, 64 for the recipient-locator pair, 16 for an invite token.
76fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32] {
77    let hk = Hkdf::<Sha256>::new(None, ikm);
78    let mut okm = [0u8; 32];
79    hk.expand(info, &mut okm)
80        .expect("HKDF expand of 32 bytes is infallible");
81    okm
82}
83
84/// A.3 `scalar_normalize`: reduce an hkdf seed to a valid secp256k1 secret key.
85/// First attempt carries NO counter byte; on rejection append one incrementing
86/// counter byte to the info and retry, the counter starting at 0. The reject
87/// branch is ~2⁻¹²⁸ rare; the counter keeps it deterministic cross-impl.
88fn hkdf_to_secret_key(ikm: &[u8], base_info: &[u8]) -> SecretKey {
89    if let Ok(sk) = SecretKey::from_slice(&hkdf32(ikm, base_info)) {
90        return sk;
91    }
92    for counter in 0u8..=255 {
93        let mut info = base_info.to_vec();
94        info.push(counter);
95        if let Ok(sk) = SecretKey::from_slice(&hkdf32(ikm, &info)) {
96            return sk;
97        }
98    }
99    unreachable!("secp256k1 scalar rejection 257 times running is impossible")
100}
101
102/// A.2 `group_key` — a plane's stream keypair. The x-only pubkey is the on-wire
103/// Stream address (the `authors` filter), the secret key signs the plane's
104/// wraps, and the NIP-44 self-ECDH conversation key encrypts them. Only a
105/// holder of the deriving secret can produce any of the three, so only members
106/// can even *identify* a plane's traffic.
107#[derive(Clone)]
108pub struct GroupKey {
109    keys: Keys,
110    conv_key: ConversationKey,
111}
112
113impl GroupKey {
114    /// Assemble a SPLIT write group (CORD-01 Write-Restricted Streams): the
115    /// control_root-derived signer keypair paired with the community_root-derived
116    /// read conv_key. Only the Control Plane composes keys this way — every
117    /// other plane's signer and conv_key come from one derivation.
118    pub(crate) fn from_parts(keys: Keys, conv_key: ConversationKey) -> Self {
119        GroupKey { keys, conv_key }
120    }
121
122    fn derive(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> Self {
123        // Memoized: the HKDF is cheap, but Keys::new + the self-ECDH are two
124        // EC point-multiplies, and the dispatch trial re-paid them for every
125        // held community on every arriving wrap — a full core on a live
126        // evening. Pure function of its inputs, so the memo is exact; keyed
127        // by a digest so no input secret sits in the map, session-scoped so
128        // an account's derived keys die with its session.
129        let memo_key: [u8; 32] = {
130            let mut h = Sha256::new();
131            h.update(label.as_bytes());
132            h.update([0u8]);
133            h.update(secret);
134            h.update(id32);
135            h.update(epoch.unwrap_or(u64::MAX).to_be_bytes());
136            h.update([epoch.is_some() as u8]);
137            h.finalize().into()
138        };
139        struct GroupKeyMemo;
140        type MemoMap = std::sync::Mutex<std::collections::HashMap<[u8; 32], GroupKey>>;
141        let memo: std::sync::Arc<MemoMap> =
142            crate::db::current_session().scoped::<GroupKeyMemo, MemoMap>();
143        if let Some(hit) = memo.lock().unwrap_or_else(|e| e.into_inner()).get(&memo_key) {
144            return hit.clone();
145        }
146        let info = build_info(label, id32, epoch);
147        let sk = hkdf_to_secret_key(secret, &info);
148        let keys = Keys::new(sk);
149        let conv_key = ConversationKey::derive(keys.secret_key(), &keys.public_key())
150            .expect("self-ECDH of a valid keypair cannot fail");
151        let out = GroupKey { keys, conv_key };
152        let mut map = memo.lock().unwrap_or_else(|e| e.into_inner());
153        // Bound: communities x channels x a few epochs is tiny; a runaway
154        // (mass rekey probing) resets rather than grows.
155        if map.len() >= 1024 {
156            map.clear();
157        }
158        map.insert(memo_key, out.clone());
159        out
160    }
161
162    /// The Stream address (x-only pubkey) — what `authors` filters match.
163    pub fn pk(&self) -> PublicKey {
164        self.keys.public_key()
165    }
166
167    /// The Stream address as lowercase hex.
168    pub fn pk_hex(&self) -> String {
169        self.keys.public_key().to_hex()
170    }
171
172    /// The keypair that signs this plane's wraps.
173    pub fn keys(&self) -> &Keys {
174        &self.keys
175    }
176
177    /// The NIP-44 conversation key (self-ECDH) that encrypts this plane's wraps.
178    pub fn conv_key(&self) -> &ConversationKey {
179        &self.conv_key
180    }
181}
182
183impl std::fmt::Debug for GroupKey {
184    // No key material in logs — address only.
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.debug_struct("GroupKey").field("pk", &self.pk_hex()).finish()
187    }
188}
189
190// ── Plane keys (CORD-02 §5, CORD-03 §1, CORD-06 §2) ─────────────────────────
191
192/// A Channel's Chat Plane group key. `secret` is the `community_root` for a
193/// Public Channel (at the root epoch) or the Channel's independent key for a
194/// Private one (at its own channel epoch) — CORD-03 §1. The channel id in the
195/// derivation gives every Channel a distinct address regardless of which secret
196/// feeds it.
197pub fn channel_group_key(secret: &[u8; 32], channel_id: &ChannelId, epoch: Epoch) -> GroupKey {
198    GroupKey::derive(LABEL_CHANNEL, secret, &channel_id.0, Some(epoch.0))
199}
200
201/// The Control Plane's community_root-keyed group key (CORD-02 §5).
202///
203/// Post-split this is the plane's READ key: its conv_key encrypts the wraps for
204/// every member. On a LEGACY (pre-split) epoch the same derivation was the whole
205/// plane — its pk the address and wrap signer too — retained for reading such
206/// epochs; the two schemes never collide (different labels, different addresses).
207pub fn control_group_key(community_root: &[u8; 32], community_id: &CommunityId, epoch: Epoch) -> GroupKey {
208    GroupKey::derive(LABEL_CONTROL, community_root, &community_id.0, Some(epoch.0))
209}
210
211/// The Control Plane's control_root-keyed SIGNER keypair (CORD-02 §2/§5): its pk
212/// is the plane's address, its staff-only sk signs the wraps. Every member holds
213/// the derived `control_pk` (delivered, never derived — only the owner and staff
214/// hold the `control_root` input); wrap content encrypts under
215/// [`control_group_key`]'s conv_key, not this one's.
216pub fn control_signer_group_key(control_root: &[u8; 32], community_id: &CommunityId, epoch: Epoch) -> GroupKey {
217    GroupKey::derive(LABEL_CONTROL_SIGNER, control_root, &community_id.0, Some(epoch.0))
218}
219
220/// The Guestbook Plane's group key (community_root-keyed, community-id-bound).
221pub fn guestbook_group_key(community_root: &[u8; 32], community_id: &CommunityId, epoch: Epoch) -> GroupKey {
222    GroupKey::derive(LABEL_GUESTBOOK, community_root, &community_id.0, Some(epoch.0))
223}
224
225/// A private Channel's rekey address for `new_epoch`, keyed by the
226/// community_root the receiver already holds (CORD-06 §2) — root-keyed, not
227/// channel-keyed, so any member recovers any epoch's rekey directly (no
228/// ratchet; epochs stay independently recoverable).
229pub fn channel_rekey_group_key(root: &[u8; 32], channel_id: &ChannelId, new_epoch: Epoch) -> GroupKey {
230    GroupKey::derive(LABEL_REKEY_PSEUDONYM, root, &channel_id.0, Some(new_epoch.0))
231}
232
233/// The base-rotation rekey address for `new_epoch`, keyed by the PRIOR
234/// community_root — the base has no stable key above it, so the prior root is
235/// the one handle every retained member holds through the rotation (CORD-06 §2/§3).
236pub fn base_rekey_group_key(prior_root: &[u8; 32], community_id: &CommunityId, new_epoch: Epoch) -> GroupKey {
237    GroupKey::derive(LABEL_BASE_REKEY_PSEUDONYM, prior_root, &community_id.0, Some(new_epoch.0))
238}
239
240/// The dissolution tombstone's group key — derived from the community_id ALONE
241/// (no key, no epoch), so every member past or present resolves the same
242/// address and a Refounding can never strand the grave (CORD-02 §9).
243pub fn dissolved_group_key(community_id: &CommunityId) -> GroupKey {
244    GroupKey::derive(LABEL_DISSOLVED, &community_id.0, &ZERO32, None)
245}
246
247// ── Voice sub-keys (CORD-07 §1/§3 — Vector defers voice; derivations frozen
248//    now so the registry can't drift) ─────────────────────────────────────────
249
250/// A voice Channel's SFU room keypair: `pk` IS the room name, `sk` signs token
251/// grants. Same (secret, epoch) pair that addresses the Channel's Chat Plane,
252/// so the room rolls exactly when the Channel's key does.
253pub fn voice_group_key(secret: &[u8; 32], channel_id: &ChannelId, epoch: Epoch) -> GroupKey {
254    GroupKey::derive(LABEL_VOICE_SIGNER, secret, &channel_id.0, Some(epoch.0))
255}
256
257/// A voice Channel's raw 32-byte media-encryption root — never feeds a cipher
258/// directly, every publisher's per-sender frame key derives from it.
259pub fn voice_media_key(secret: &[u8; 32], channel_id: &ChannelId, epoch: Epoch) -> [u8; 32] {
260    hkdf32(secret, &build_info(LABEL_VOICE_MEDIA, &channel_id.0, Some(epoch.0)))
261}
262
263/// A publisher's per-sender frame key material:
264/// `hkdf(voice_media_key, "concord/voice-sender", sha256(utf8(identity)))` —
265/// epoch omitted, the media key already carries it. Distinct per-sender keys
266/// partition the AEAD nonce domains.
267pub fn voice_sender_key(media_key: &[u8; 32], identity: &str) -> [u8; 32] {
268    let id: [u8; 32] = Sha256::digest(identity.as_bytes()).into();
269    hkdf32(media_key, &build_info(LABEL_VOICE_SENDER, &id, None))
270}
271
272// ── Keyless coordinates (32-byte edition locators; community-id-bound so they
273//    survive every Refounding — CORD-04 §1) ──────────────────────────────────
274
275/// A member's Grant entity coordinate (the edition `eid`).
276pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] {
277    hkdf32(&community_id.0, &build_info(LABEL_GRANT, member_xonly, None))
278}
279
280/// The community-wide Banlist coordinate.
281pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] {
282    hkdf32(&community_id.0, &build_info(LABEL_BANLIST, &ZERO32, None))
283}
284
285/// A Channel's Pin List coordinate (CORD-04 §7).
286pub fn pins_locator(community_id: &CommunityId, channel_id: &ChannelId) -> [u8; 32] {
287    hkdf32(&community_id.0, &build_info(LABEL_PINS, &channel_id.0, None))
288}
289
290/// A creator's invite-link Registry coordinate (CORD-05 §5) — bound to the
291/// creator so each creator owns exactly their own list.
292pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] {
293    hkdf32(&community_id.0, &build_info(LABEL_INVITE_LINKS, creator_xonly, None))
294}
295
296/// A rekey blob's per-recipient locator (CORD-06 §2):
297/// `hkdf(rotator_xonly || recipient_xonly, "concord/recipient-pseudonym", scope_id, new_epoch)`.
298///
299/// Derived from PUBLIC inputs on purpose (full NIP-46 bunker parity, no raw-key
300/// access) — which means a locator match proves NOTHING. It is a lookup index
301/// only; authenticity rests on the rotator's seal + authority check and the
302/// blob's bound plaintext (D1 security relocation — never port v1's
303/// locator-match-⇒-authentic assumption).
304pub fn recipient_locator(
305    rotator_xonly: &[u8; 32],
306    recipient_xonly: &[u8; 32],
307    scope_id: &[u8; 32],
308    new_epoch: Epoch,
309) -> [u8; 32] {
310    let mut ikm = [0u8; 64];
311    ikm[..32].copy_from_slice(rotator_xonly);
312    ikm[32..].copy_from_slice(recipient_xonly);
313    hkdf32(&ikm, &build_info(LABEL_RECIPIENT_PSEUDONYM, scope_id, Some(new_epoch.0)))
314}
315
316/// The public-invite bundle decrypt key, derived from the link's 16-byte
317/// unlock token alone (CORD-05 §2).
318pub fn invite_bundle_key(token: &[u8; TOKEN_LEN]) -> [u8; 32] {
319    hkdf32(token, &build_info(LABEL_INVITE_KEY, &ZERO32, None))
320}
321
322// ── A.4: the self-certifying community_id ────────────────────────────────────
323
324/// `community_id = sha256("concord/community" || owner_xonly || owner_salt)` —
325/// a plain SHA-256 commitment, NOT the hkdf shape. Ownership is a property of
326/// the id itself: forging a different owner onto an existing id is a
327/// second-preimage on SHA-256. (This is the root fix for the v1 forgeable
328/// owner-attestation anchor.)
329pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId {
330    let mut h = Sha256::new();
331    h.update(LABEL_COMMUNITY.as_bytes());
332    h.update(owner_xonly);
333    h.update(owner_salt);
334    CommunityId(h.finalize().into())
335}
336
337/// Verify a claimed `(owner, salt)` pair reproduces `community_id`. Every
338/// bundle, pointer, and rehydrate path MUST pass this before trusting a claimed
339/// owner.
340pub fn verify_community_id(community_id: &CommunityId, owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> bool {
341    community_id_of(owner_xonly, owner_salt) == *community_id
342}
343
344// ── A.5: the epoch-key commitment ────────────────────────────────────────────
345
346/// `sha256("concord/epoch-key-commitment" || prev_epoch_be[8] || prev_key[32])`
347/// — the `prevcommit` continuity check on every rekey (CORD-06 §2). A
348/// convergence mechanism, never a secrecy one.
349pub fn epoch_key_commitment(prev_epoch: Epoch, prev_key: &[u8; 32]) -> [u8; 32] {
350    let mut h = Sha256::new();
351    h.update(LABEL_EPOCH_COMMITMENT.as_bytes());
352    h.update(prev_epoch.0.to_be_bytes());
353    h.update(prev_key);
354    h.finalize().into()
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    // Fixed test inputs. The golden hex below was produced by an INDEPENDENT
362    // implementation (Python: hmac+hashlib RFC 5869 HKDF, and pure-integer
363    // secp256k1 point math for the x-only pubkeys), so a match proves the
364    // construction — including the secp keypair step — is correct
365    // cross-implementation, not merely self-consistent. If any of these
366    // assertions ever change, the wire format changed — that must be a
367    // conscious, versioned decision.
368    fn secret() -> [u8; 32] {
369        // 0x00,0x01,..,0x1f
370        let mut k = [0u8; 32];
371        for (i, b) in k.iter_mut().enumerate() {
372            *b = i as u8;
373        }
374        k
375    }
376
377    fn id32() -> [u8; 32] {
378        // 0xff,0xfe,..,0xe0
379        let mut id = [0u8; 32];
380        for (i, b) in id.iter_mut().enumerate() {
381            *b = (255 - i) as u8;
382        }
383        id
384    }
385
386    fn alt() -> [u8; 32] {
387        [0x11u8; 32]
388    }
389
390    fn cid() -> CommunityId {
391        CommunityId(id32())
392    }
393
394    fn chan() -> ChannelId {
395        ChannelId(id32())
396    }
397
398    /// A multibyte epoch whose big-endian bytes are order-revealing.
399    const EPOCH_MULTI: u64 = 0x0102030405060708;
400
401    const GOLDEN_CHANNEL_E0_SEED: &str = "1a99a5958bf9fcc5336e6e19db42aabf36ffbfa12f38a1d5fbde2ae383ed751b";
402    const GOLDEN_CHANNEL_E0_PK: &str = "7a5c5dff759a63f1fc2779864487432bae3d1ea72c4ffabd39f4c1fdaf62097a";
403    const GOLDEN_CHANNEL_EMULTI_PK: &str = "f20c7d192cc87615d7341e86f38f85303f4708b40232d4fea521ab8217767391";
404    const GOLDEN_CONTROL_E0_PK: &str = "c43df20bf4d6eeaea5149619662ffe9b211f31e11bb4a59f56b6e906f702d46f";
405    const GOLDEN_CONTROL_SIGNER_E0_SEED: &str = "c4a3e8354d95137132087356412b67b53e025d127d45de45cff9ecf45b0c24f6";
406    const GOLDEN_CONTROL_SIGNER_E0_PK: &str = "718aef388257f3fd9f1bfae5cf2cbd0594a2ffc31adb5c1fe22c502c046acaee";
407    const GOLDEN_CONTROL_SIGNER_EMULTI_PK: &str = "e27235cc13be2f9ad65648e01ff2b63402846469c8638b5386c625688194ec7d";
408    const GOLDEN_GUESTBOOK_E0_PK: &str = "ad09de582026fa7a052db18bb5827fa24c15e929d59aadcc91efb8508f5368ad";
409    const GOLDEN_CHANNEL_REKEY_E1_PK: &str = "7c55cdb957e9db2b4800d687b2a07d3f7066b1a35824a1e86ba871f55e87e8b5";
410    const GOLDEN_BASE_REKEY_E1_PK: &str = "fb2fa44fba66ba15595f784255a1cb569531db8784432ac0e4fe838498dd9dea";
411    const GOLDEN_DISSOLVED_PK: &str = "4d3d55d88fdf9d9c2089651e5cbb0dfa93b6b9b10cdcb2319b0dce1a1398096a";
412    const GOLDEN_GRANT_LOCATOR: &str = "fd2f88cc7f1eb8d7d862c91dc22afe700c358d1845158b3f353b769ce4898e35";
413    const GOLDEN_BANLIST_LOCATOR: &str = "88089214afae6d3c412fd817ada44d6df4d485a53565646471e74476397693c9";
414    const GOLDEN_INVITE_LINKS_LOCATOR: &str = "f4ae29994165767bac23e8dce630f81b926d2c8aa150e5cbf0bdf75865e8379a";
415    const GOLDEN_RECIPIENT_LOCATOR: &str = "342deb400e191f0f52c81f27600934552550beb85aa9bf169f02d0e7f826cf74";
416    const GOLDEN_INVITE_KEY: &str = "94bf8b0d89e579ddaeccf8d9db3f5de5c86a1259c597f2560ff0120173bc5e1f";
417    const GOLDEN_VOICE_MEDIA_E0: &str = "8ab5b935c5e17f156563860ae6263f3700bfd836c326f8b3d7082be2fbaef6a0";
418    const GOLDEN_VOICE_SIGNER_E0_PK: &str = "7591f1306c265ee1dce6a07b72c76fadb3af13bf9ccce0d284cb3af6134211a1";
419    const GOLDEN_VOICE_SENDER: &str = "9ce1c11a39ce16a84b72c2697724a39e5c41ec07f4d703dcb01241271837599e";
420    const GOLDEN_COMMUNITY_ID: &str = "2b790bd59df98bdc52092b74ebd6933a89ef8eaeecc9030861cbdeae7c814c46";
421    const GOLDEN_EPOCH_COMMITMENT: &str = "3e6d6a3c9973c16d1ca7c5602d36979927c55c21a7e2c840f883af3f047e80a4";
422
423    fn hex(bytes: &[u8]) -> String {
424        crate::simd::hex::bytes_to_hex_32(bytes.try_into().expect("32 bytes"))
425    }
426
427    #[test]
428    fn channel_group_key_golden_vector() {
429        let gk = channel_group_key(&secret(), &chan(), Epoch(0));
430        // The hkdf seed is a valid scalar (overwhelming case), so sk == seed —
431        // pinning both proves hkdf AND the secp keypair step.
432        assert_eq!(hex(gk.keys().secret_key().as_secret_bytes()), GOLDEN_CHANNEL_E0_SEED);
433        assert_eq!(gk.pk_hex(), GOLDEN_CHANNEL_E0_PK);
434    }
435
436    #[test]
437    fn channel_group_key_golden_multibyte_epoch_is_big_endian() {
438        let gk = channel_group_key(&secret(), &chan(), Epoch(EPOCH_MULTI));
439        assert_eq!(gk.pk_hex(), GOLDEN_CHANNEL_EMULTI_PK);
440    }
441
442    #[test]
443    fn control_group_key_golden_vector() {
444        assert_eq!(control_group_key(&secret(), &cid(), Epoch(0)).pk_hex(), GOLDEN_CONTROL_E0_PK);
445    }
446
447    #[test]
448    fn control_signer_group_key_golden_vectors() {
449        // Same fixed inputs as the control read key, different label — the split's
450        // signer address must land elsewhere (CORD-02 §5: the two never collide).
451        let gk = control_signer_group_key(&secret(), &cid(), Epoch(0));
452        assert_eq!(hex(gk.keys().secret_key().as_secret_bytes()), GOLDEN_CONTROL_SIGNER_E0_SEED);
453        assert_eq!(gk.pk_hex(), GOLDEN_CONTROL_SIGNER_E0_PK);
454        assert_ne!(gk.pk_hex(), GOLDEN_CONTROL_E0_PK);
455        assert_eq!(
456            control_signer_group_key(&secret(), &cid(), Epoch(EPOCH_MULTI)).pk_hex(),
457            GOLDEN_CONTROL_SIGNER_EMULTI_PK
458        );
459    }
460
461    #[test]
462    fn guestbook_group_key_golden_vector() {
463        assert_eq!(guestbook_group_key(&secret(), &cid(), Epoch(0)).pk_hex(), GOLDEN_GUESTBOOK_E0_PK);
464    }
465
466    #[test]
467    fn rekey_group_keys_golden_vectors() {
468        assert_eq!(
469            channel_rekey_group_key(&secret(), &chan(), Epoch(1)).pk_hex(),
470            GOLDEN_CHANNEL_REKEY_E1_PK
471        );
472        assert_eq!(
473            base_rekey_group_key(&secret(), &cid(), Epoch(1)).pk_hex(),
474            GOLDEN_BASE_REKEY_E1_PK
475        );
476    }
477
478    #[test]
479    fn dissolved_group_key_golden_and_is_epoch_free() {
480        assert_eq!(dissolved_group_key(&cid()).pk_hex(), GOLDEN_DISSOLVED_PK);
481        // Epoch omission is real omission, not epoch=0: a manual derivation WITH
482        // an epoch field of 0 must land elsewhere.
483        let with_epoch = GroupKey::derive(LABEL_DISSOLVED, &cid().0, &ZERO32, Some(0));
484        assert_ne!(with_epoch.pk_hex(), GOLDEN_DISSOLVED_PK);
485    }
486
487    #[test]
488    fn locator_golden_vectors() {
489        assert_eq!(hex(&grant_locator(&cid(), &alt())), GOLDEN_GRANT_LOCATOR);
490        assert_eq!(hex(&banlist_locator(&cid())), GOLDEN_BANLIST_LOCATOR);
491        assert_eq!(hex(&invite_links_locator(&cid(), &alt())), GOLDEN_INVITE_LINKS_LOCATOR);
492        assert_eq!(
493            hex(&recipient_locator(&secret(), &alt(), &id32(), Epoch(3))),
494            GOLDEN_RECIPIENT_LOCATOR
495        );
496        assert_eq!(hex(&invite_bundle_key(&[0x07u8; TOKEN_LEN])), GOLDEN_INVITE_KEY);
497    }
498
499    #[test]
500    fn voice_golden_vectors() {
501        let media = voice_media_key(&secret(), &chan(), Epoch(0));
502        assert_eq!(hex(&media), GOLDEN_VOICE_MEDIA_E0);
503        assert_eq!(voice_group_key(&secret(), &chan(), Epoch(0)).pk_hex(), GOLDEN_VOICE_SIGNER_E0_PK);
504        assert_eq!(
505            hex(&voice_sender_key(&media, "00112233445566778899aabbccddeeff")),
506            GOLDEN_VOICE_SENDER
507        );
508    }
509
510    #[test]
511    fn community_id_golden_and_verifies() {
512        let id = community_id_of(&secret(), &alt());
513        assert_eq!(hex(&id.0), GOLDEN_COMMUNITY_ID);
514        assert!(verify_community_id(&id, &secret(), &alt()));
515        // Wrong owner or wrong salt must fail the commitment.
516        assert!(!verify_community_id(&id, &alt(), &alt()));
517        assert!(!verify_community_id(&id, &secret(), &id32()));
518    }
519
520    #[test]
521    fn epoch_key_commitment_golden_and_binds_both_inputs() {
522        assert_eq!(hex(&epoch_key_commitment(Epoch(2), &secret())), GOLDEN_EPOCH_COMMITMENT);
523        assert_ne!(hex(&epoch_key_commitment(Epoch(3), &secret())), GOLDEN_EPOCH_COMMITMENT);
524        assert_ne!(hex(&epoch_key_commitment(Epoch(2), &alt())), GOLDEN_EPOCH_COMMITMENT);
525    }
526
527    #[test]
528    fn labels_domain_separate_every_plane() {
529        // One (secret, id, epoch) triple across every keyed label — all
530        // addresses must be pairwise distinct.
531        let pks = [
532            channel_group_key(&secret(), &chan(), Epoch(0)).pk_hex(),
533            control_group_key(&secret(), &cid(), Epoch(0)).pk_hex(),
534            control_signer_group_key(&secret(), &cid(), Epoch(0)).pk_hex(),
535            guestbook_group_key(&secret(), &cid(), Epoch(0)).pk_hex(),
536            channel_rekey_group_key(&secret(), &chan(), Epoch(0)).pk_hex(),
537            base_rekey_group_key(&secret(), &cid(), Epoch(0)).pk_hex(),
538            voice_group_key(&secret(), &chan(), Epoch(0)).pk_hex(),
539        ];
540        let unique: std::collections::HashSet<_> = pks.iter().collect();
541        assert_eq!(unique.len(), pks.len(), "two labels collided on one address");
542    }
543
544    #[test]
545    fn epoch_rotates_every_keyed_address() {
546        assert_ne!(
547            channel_group_key(&secret(), &chan(), Epoch(0)).pk_hex(),
548            channel_group_key(&secret(), &chan(), Epoch(1)).pk_hex()
549        );
550        assert_ne!(
551            control_group_key(&secret(), &cid(), Epoch(0)).pk_hex(),
552            control_group_key(&secret(), &cid(), Epoch(1)).pk_hex()
553        );
554        assert_ne!(
555            guestbook_group_key(&secret(), &cid(), Epoch(0)).pk_hex(),
556            guestbook_group_key(&secret(), &cid(), Epoch(1)).pk_hex()
557        );
558    }
559
560    #[test]
561    fn recipient_locator_binds_direction_scope_and_epoch() {
562        let base = recipient_locator(&secret(), &alt(), &id32(), Epoch(1));
563        // Rotator↔recipient direction matters (concatenation order).
564        assert_ne!(recipient_locator(&alt(), &secret(), &id32(), Epoch(1)), base);
565        assert_ne!(recipient_locator(&secret(), &alt(), &id32(), Epoch(2)), base);
566        assert_ne!(recipient_locator(&secret(), &alt(), &ZERO32, Epoch(1)), base);
567    }
568
569    #[test]
570    fn conv_key_is_deterministic_self_ecdh() {
571        let a = channel_group_key(&secret(), &chan(), Epoch(0));
572        let b = channel_group_key(&secret(), &chan(), Epoch(0));
573        assert_eq!(a.conv_key().as_bytes(), b.conv_key().as_bytes());
574    }
575}