Skip to main content

styrene_identity/
derive.rs

1//! HKDF key derivation hierarchy — derives protocol-specific keys from root secret.
2//!
3//! Uses a two-tier structure:
4//! - **Flat purposes** (fixed info strings) for protocol keys
5//! - **Two-level HKDF** for parameterized families (SSH user keys, agent keys, etc.)
6//!
7//! ```text
8//! root_secret (32 bytes)
9//!   HKDF-Extract(salt="styrene-identity-v1", IKM=root_secret) = PRK
10//!
11//!     ── THE IDENTITY ──
12//!     → Expand(PRK, "styrene-signing-v1")             → Ed25519 seed (THE identity key)
13//!
14//!     ── ENCRYPTION ──
15//!     → Expand(PRK, "styrene-rns-encryption-v1")      → RNS X25519
16//!     → Expand(PRK, "styrene-age-v1")                 → age X25519
17//!     → Expand(PRK, "styrene-wireguard-v1")           → WireGuard Curve25519
18//!
19//!     ── DEVICE ──
20//!     → Expand(PRK, "styrene-ssh-host-v1")            → SSH host Ed25519
21//!
22//!     ── OVERLAY TRANSPORTS ──
23//!     → Expand(PRK, "styrene-yggdrasil-v1")           → Yggdrasil Ed25519
24//!     → Expand(PRK, "styrene-i2p-signing-v1")         → I2P destination Ed25519
25//!     → Expand(PRK, "styrene-i2p-encryption-v1")      → I2P destination X25519
26//!     → Expand(PRK, "styrene-tor-v1")                 → Tor onion v3 Ed25519
27//!
28//!     ── PARAMETERIZED FAMILIES ──
29//!     → Expand(PRK, "styrene-ssh-user-master-v1")     → SSH user master
30//!         → Expand(master_PRK, label)                 → per-host SSH key
31//!     → Expand(PRK, "styrene-agent-master-v1")        → agent master
32//!         → Expand(master_PRK, agent_name)            → per-agent signing key
33//!     → Expand(PRK, "styrene-i2p-service-master-v1")  → I2P service master
34//!         → Expand(master_PRK, service_name)          → per-service destination keys
35//!     → Expand(PRK, "styrene-onion-master-v1")        → Tor service master
36//!         → Expand(master_PRK, service_name)          → per-service onion keys
37//! ```
38
39use hkdf::Hkdf;
40use sha2::Sha256;
41use zeroize::Zeroize;
42
43/// Error from parameterized key derivation (agent keys, SSH user keys, etc.).
44#[derive(Debug, thiserror::Error)]
45pub enum DeriveError {
46    /// The name/label parameter was empty.
47    #[error("key derivation label must not be empty")]
48    EmptyLabel,
49}
50
51/// Validate an agent name or SSH user key label before derivation.
52///
53/// Returns `Ok(())` if the label is valid, `Err(DeriveError)` with a
54/// descriptive error otherwise. Use this at config-load time to catch
55/// invalid labels before they reach `derive_agent_key()` or
56/// `derive_ssh_user_key()`.
57pub fn validate_label(label: &str) -> Result<(), DeriveError> {
58    if label.is_empty() {
59        return Err(DeriveError::EmptyLabel);
60    }
61    Ok(())
62}
63
64/// Fixed domain-separation salt for HKDF-Extract.
65const HKDF_SALT: &[u8] = b"styrene-identity-v1";
66/// Level-2 salt for the agent key derivation tree.
67const HKDF_SALT_AGENT: &[u8] = b"styrene-identity-agent-v1";
68/// Level-2 salt for the SSH user key derivation tree.
69const HKDF_SALT_SSH_USER: &[u8] = b"styrene-identity-ssh-user-v1";
70/// Level-2 salt for the I2P per-service derivation tree.
71const HKDF_SALT_I2P_SERVICE: &[u8] = b"styrene-identity-i2p-service-v1";
72/// Level-2 salt for the Tor per-service derivation tree.
73const HKDF_SALT_ONION_SERVICE: &[u8] = b"styrene-identity-onion-service-v1";
74
75/// Key derivation purpose — maps to HKDF info strings for flat (non-parameterized) keys.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77pub enum KeyPurpose {
78    // ── The Identity ──
79
80    /// THE identity signing key (Ed25519).
81    /// Used for: mesh signing, git commits, personal attribution.
82    /// Identity hash = SHA-256(pubkey) truncated to 16 bytes.
83    /// This IS you.
84    Signing,
85
86    // ── Encryption (different curves) ──
87
88    /// RNS X25519 encryption key.
89    RnsEncryption,
90    /// age X25519 encryption key.
91    Age,
92    /// WireGuard Curve25519 key.
93    WireGuard,
94
95    // ── Device ──
96
97    /// SSH host Ed25519 key (identifies the machine, not the person).
98    SshHost,
99
100    // ── Overlay transports ──
101
102    /// Yggdrasil Ed25519 key (IPv6 overlay network identity).
103    Yggdrasil,
104    /// I2P destination Ed25519 signing key.
105    I2pSigning,
106    /// I2P destination X25519 encryption key.
107    I2pEncryption,
108    /// Tor onion v3 service Ed25519 key.
109    Tor,
110
111    // ── Legacy aliases ──
112    // These derive the SAME bytes as their canonical counterparts.
113    // Kept for backwards compatibility with existing code that
114    // references the old purpose names.
115
116    /// Legacy alias for `Signing`. Derives identical bytes.
117    #[deprecated(note = "use KeyPurpose::Signing — RnsSigning and GitSigning are now unified")]
118    RnsSigning,
119    /// Legacy alias for `Signing`. Derives identical bytes.
120    #[deprecated(note = "use KeyPurpose::Signing — RnsSigning and GitSigning are now unified")]
121    GitSigning,
122}
123
124impl KeyPurpose {
125    /// HKDF info string for this purpose.
126    pub fn info(&self) -> &'static [u8] {
127        match self {
128            // THE identity key — uses the RnsSigning info string for backwards
129            // compatibility. Existing vaults produce the same derived bytes.
130            Self::Signing => b"styrene-rns-signing-v1",
131
132            Self::RnsEncryption => b"styrene-rns-encryption-v1",
133            Self::Age => b"styrene-age-v1",
134            Self::WireGuard => b"styrene-wireguard-v1",
135            Self::SshHost => b"styrene-ssh-host-v1",
136            Self::Yggdrasil => b"styrene-yggdrasil-v1",
137            Self::I2pSigning => b"styrene-i2p-signing-v1",
138            Self::I2pEncryption => b"styrene-i2p-encryption-v1",
139            Self::Tor => b"styrene-tor-v1",
140
141            // Legacy aliases — derive the same bytes as Signing
142            #[allow(deprecated)]
143            Self::RnsSigning => b"styrene-rns-signing-v1",
144            #[allow(deprecated)]
145            Self::GitSigning => b"styrene-rns-signing-v1",
146        }
147    }
148
149    /// All current (non-deprecated) purposes.
150    pub fn all() -> &'static [KeyPurpose] {
151        &[
152            Self::Signing,
153            Self::RnsEncryption,
154            Self::Age,
155            Self::WireGuard,
156            Self::SshHost,
157            Self::Yggdrasil,
158            Self::I2pSigning,
159            Self::I2pEncryption,
160            Self::Tor,
161        ]
162    }
163}
164
165/// Cached HKDF pseudo-random key with zeroize-on-drop.
166///
167/// Runs HKDF-Extract once at construction with the fixed domain-separation
168/// salt, stores the 32-byte PRK, and reconstructs the HKDF expander on
169/// each derive call. The PRK is root-equivalent key material and is
170/// zeroized when the `KeyDeriver` is dropped.
171pub struct KeyDeriver {
172    /// The pseudo-random key extracted from the root secret.
173    /// Zeroized on drop — this is root-equivalent material.
174    prk: [u8; 32],
175}
176
177impl Drop for KeyDeriver {
178    fn drop(&mut self) {
179        self.prk.zeroize();
180    }
181}
182
183impl KeyDeriver {
184    /// Create from a root secret. Runs HKDF-Extract once and stores the PRK.
185    pub fn new(root_secret: &[u8; 32]) -> Self {
186        let (prk_hmac, _) = Hkdf::<Sha256>::extract(Some(HKDF_SALT), root_secret);
187        let mut prk_bytes = [0u8; 32];
188        prk_bytes.copy_from_slice(prk_hmac.as_slice());
189        Self { prk: prk_bytes }
190    }
191
192    /// Reconstruct the HKDF expander from stored PRK bytes.
193    fn expander(&self) -> Hkdf<Sha256> {
194        Hkdf::<Sha256>::from_prk(&self.prk).expect("32-byte PRK is always valid for HKDF-SHA256")
195    }
196
197    /// Derive a 32-byte key for a flat (non-parameterized) purpose.
198    pub fn derive(&self, purpose: KeyPurpose) -> [u8; 32] {
199        let mut okm = [0u8; 32];
200        self.expander()
201            .expand(purpose.info(), &mut okm)
202            .expect("HKDF-SHA256 expand to 32 bytes should never fail");
203        okm
204    }
205
206    /// Derive all flat-purpose keys.
207    pub fn derive_all(&self) -> DerivedKeys {
208        DerivedKeys {
209            signing: self.derive(KeyPurpose::Signing),
210            rns_encryption: self.derive(KeyPurpose::RnsEncryption),
211            age: self.derive(KeyPurpose::Age),
212            wireguard: self.derive(KeyPurpose::WireGuard),
213            ssh_host: self.derive(KeyPurpose::SshHost),
214            yggdrasil: self.derive(KeyPurpose::Yggdrasil),
215            i2p_signing: self.derive(KeyPurpose::I2pSigning),
216            i2p_encryption: self.derive(KeyPurpose::I2pEncryption),
217            tor: self.derive(KeyPurpose::Tor),
218        }
219    }
220
221    // ── Convenience methods ──
222
223    /// Derive THE identity Ed25519 seed (32 bytes).
224    /// Used for mesh signing, git commit signing, personal attribution.
225    pub fn signing_seed(&self) -> [u8; 32] {
226        self.derive(KeyPurpose::Signing)
227    }
228
229    /// Derive SSH host Ed25519 seed (32 bytes).
230    pub fn ssh_host_seed(&self) -> [u8; 32] {
231        self.derive(KeyPurpose::SshHost)
232    }
233
234    /// Derive age X25519 private key (32 bytes).
235    pub fn age_secret(&self) -> [u8; 32] {
236        self.derive(KeyPurpose::Age)
237    }
238
239    /// Derive git commit signing Ed25519 seed (32 bytes).
240    /// This is now the same key as `signing_seed()` — the unified identity key.
241    pub fn git_signing_seed(&self) -> [u8; 32] {
242        self.derive(KeyPurpose::Signing)
243    }
244
245    /// Derive I2P destination signing key (Ed25519 seed, 32 bytes).
246    pub fn i2p_signing_seed(&self) -> [u8; 32] {
247        self.derive(KeyPurpose::I2pSigning)
248    }
249
250    /// Derive I2P destination encryption key (X25519, 32 bytes).
251    pub fn i2p_encryption_secret(&self) -> [u8; 32] {
252        self.derive(KeyPurpose::I2pEncryption)
253    }
254
255    /// Derive Tor onion v3 service key (Ed25519 seed, 32 bytes).
256    pub fn tor_seed(&self) -> [u8; 32] {
257        self.derive(KeyPurpose::Tor)
258    }
259
260    // ── Parameterized families (two-level HKDF) ──
261
262    /// Derive a per-agent Ed25519 signing seed via two-level HKDF.
263    pub fn derive_agent_key(&self, agent_name: &str) -> Result<[u8; 32], DeriveError> {
264        self.derive_parameterized(b"styrene-agent-master-v1", HKDF_SALT_AGENT, agent_name)
265    }
266
267    /// Derive a per-label SSH user Ed25519 seed via two-level HKDF.
268    pub fn derive_ssh_user_key(&self, label: &str) -> Result<[u8; 32], DeriveError> {
269        self.derive_parameterized(b"styrene-ssh-user-master-v1", HKDF_SALT_SSH_USER, label)
270    }
271
272    /// Derive a per-service I2P destination key pair via two-level HKDF.
273    /// Returns (signing_seed, encryption_secret) — both 32 bytes.
274    pub fn derive_i2p_service(&self, service_name: &str) -> Result<([u8; 32], [u8; 32]), DeriveError> {
275        if service_name.is_empty() {
276            return Err(DeriveError::EmptyLabel);
277        }
278
279        // Signing key
280        let signing = self.derive_parameterized(
281            b"styrene-i2p-service-master-v1",
282            HKDF_SALT_I2P_SERVICE,
283            &format!("{service_name}/signing"),
284        )?;
285
286        // Encryption key (same master, different label suffix)
287        let encryption = self.derive_parameterized(
288            b"styrene-i2p-service-master-v1",
289            HKDF_SALT_I2P_SERVICE,
290            &format!("{service_name}/encryption"),
291        )?;
292
293        Ok((signing, encryption))
294    }
295
296    /// Derive a per-service Tor onion v3 key via two-level HKDF.
297    pub fn derive_onion_service(&self, service_name: &str) -> Result<[u8; 32], DeriveError> {
298        self.derive_parameterized(b"styrene-onion-master-v1", HKDF_SALT_ONION_SERVICE, service_name)
299    }
300
301    /// Generic two-level HKDF derivation for parameterized families.
302    fn derive_parameterized(
303        &self,
304        master_info: &[u8],
305        level2_salt: &[u8],
306        label: &str,
307    ) -> Result<[u8; 32], DeriveError> {
308        if label.is_empty() {
309            return Err(DeriveError::EmptyLabel);
310        }
311
312        let mut master = [0u8; 32];
313        self.expander()
314            .expand(master_info, &mut master)
315            .expect("HKDF expand should not fail");
316
317        let hk2 = Hkdf::<Sha256>::new(Some(level2_salt), &master);
318        master.zeroize();
319
320        let mut okm = [0u8; 32];
321        hk2.expand(label.as_bytes(), &mut okm)
322            .expect("HKDF expand should not fail");
323        Ok(okm)
324    }
325}
326
327/// Derive a 32-byte key for a specific purpose from the root secret.
328///
329/// Convenience wrapper around [`KeyDeriver`]. For multiple derivations from
330/// the same root, prefer constructing a `KeyDeriver` to avoid redundant
331/// HKDF-Extract calls.
332pub fn derive_key(root_secret: &[u8; 32], purpose: KeyPurpose) -> [u8; 32] {
333    KeyDeriver::new(root_secret).derive(purpose)
334}
335
336/// All flat-purpose derived keys from a root secret.
337///
338/// Debug output is redacted — key material is never printed.
339/// Parameterized keys (SSH user, agent, I2P service, onion service) are
340/// derived separately via their respective methods.
341#[derive(Zeroize)]
342#[zeroize(drop)]
343pub struct DerivedKeys {
344    /// THE identity Ed25519 signing key seed (mesh + git + attribution).
345    pub signing: [u8; 32],
346    /// RNS X25519 encryption key (32 bytes).
347    pub rns_encryption: [u8; 32],
348    /// age X25519 private key (32 bytes).
349    pub age: [u8; 32],
350    /// WireGuard Curve25519 private key (32 bytes).
351    pub wireguard: [u8; 32],
352    /// SSH host Ed25519 seed (32 bytes).
353    pub ssh_host: [u8; 32],
354    /// Yggdrasil Ed25519 key (32 bytes).
355    pub yggdrasil: [u8; 32],
356    /// I2P destination Ed25519 signing key seed (32 bytes).
357    pub i2p_signing: [u8; 32],
358    /// I2P destination X25519 encryption key (32 bytes).
359    pub i2p_encryption: [u8; 32],
360    /// Tor onion v3 Ed25519 key seed (32 bytes).
361    pub tor: [u8; 32],
362}
363
364impl std::fmt::Debug for DerivedKeys {
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366        f.write_str("DerivedKeys([REDACTED])")
367    }
368}
369
370/// Derive all core protocol keys from a root secret.
371///
372/// Convenience wrapper around [`KeyDeriver::derive_all`].
373pub fn derive_keys(root_secret: &[u8; 32]) -> DerivedKeys {
374    KeyDeriver::new(root_secret).derive_all()
375}
376
377#[cfg(test)]
378#[allow(deprecated)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn derive_key_deterministic() {
384        let root = [42u8; 32];
385        let k1 = derive_key(&root, KeyPurpose::RnsEncryption);
386        let k2 = derive_key(&root, KeyPurpose::RnsEncryption);
387        assert_eq!(k1, k2);
388    }
389
390    #[test]
391    fn different_purposes_produce_different_keys() {
392        let root = [42u8; 32];
393        let keys: Vec<[u8; 32]> = KeyPurpose::all().iter().map(|p| derive_key(&root, *p)).collect();
394
395        for i in 0..keys.len() {
396            for j in (i + 1)..keys.len() {
397                assert_ne!(keys[i], keys[j], "collision between purposes {i} and {j}");
398            }
399        }
400    }
401
402    #[test]
403    fn different_roots_produce_different_keys() {
404        let k1 = derive_key(&[1u8; 32], KeyPurpose::RnsEncryption);
405        let k2 = derive_key(&[2u8; 32], KeyPurpose::RnsEncryption);
406        assert_ne!(k1, k2);
407    }
408
409    #[test]
410    fn derive_keys_produces_all() {
411        let root = [99u8; 32];
412        let keys = derive_keys(&root);
413        assert_ne!(keys.signing, [0u8; 32]);
414        assert_ne!(keys.rns_encryption, [0u8; 32]);
415        assert_ne!(keys.yggdrasil, [0u8; 32]);
416        assert_ne!(keys.wireguard, [0u8; 32]);
417        assert_ne!(keys.ssh_host, [0u8; 32]);
418        assert_ne!(keys.age, [0u8; 32]);
419        assert_ne!(keys.i2p_signing, [0u8; 32]);
420        assert_ne!(keys.i2p_encryption, [0u8; 32]);
421        assert_ne!(keys.tor, [0u8; 32]);
422        assert_ne!(keys.signing, keys.rns_encryption);
423    }
424
425    #[test]
426    fn all_purposes_covered() {
427        assert_eq!(KeyPurpose::all().len(), 9);
428    }
429
430    #[test]
431    fn key_deriver_matches_free_function() {
432        let root = [42u8; 32];
433        let deriver = KeyDeriver::new(&root);
434        for purpose in KeyPurpose::all() {
435            assert_eq!(deriver.derive(*purpose), derive_key(&root, *purpose));
436        }
437    }
438
439    #[test]
440    fn key_deriver_derive_all_matches_individual() {
441        let root = [77u8; 32];
442        let deriver = KeyDeriver::new(&root);
443        let all = deriver.derive_all();
444        assert_eq!(all.signing, deriver.derive(KeyPurpose::Signing));
445        assert_eq!(all.rns_encryption, deriver.derive(KeyPurpose::RnsEncryption));
446        assert_eq!(all.yggdrasil, deriver.derive(KeyPurpose::Yggdrasil));
447        assert_eq!(all.wireguard, deriver.derive(KeyPurpose::WireGuard));
448        assert_eq!(all.ssh_host, deriver.derive(KeyPurpose::SshHost));
449        assert_eq!(all.age, deriver.derive(KeyPurpose::Age));
450        assert_eq!(all.i2p_signing, deriver.derive(KeyPurpose::I2pSigning));
451        assert_eq!(all.i2p_encryption, deriver.derive(KeyPurpose::I2pEncryption));
452        assert_eq!(all.tor, deriver.derive(KeyPurpose::Tor));
453    }
454
455    #[test]
456    fn ssh_host_and_age_non_zero_and_distinct() {
457        let root = [55u8; 32];
458        let deriver = KeyDeriver::new(&root);
459        let ssh = deriver.ssh_host_seed();
460        let age = deriver.age_secret();
461        assert_ne!(ssh, [0u8; 32]);
462        assert_ne!(age, [0u8; 32]);
463        assert_ne!(ssh, age);
464    }
465
466    // --- Unified signing key tests ---
467
468    #[test]
469    fn signing_equals_legacy_rns_signing() {
470        let d = KeyDeriver::new(&[42u8; 32]);
471        assert_eq!(
472            d.derive(KeyPurpose::Signing),
473            d.derive(KeyPurpose::RnsSigning),
474            "Signing must produce same bytes as legacy RnsSigning"
475        );
476    }
477
478    #[test]
479    fn signing_equals_legacy_git_signing() {
480        let d = KeyDeriver::new(&[42u8; 32]);
481        // GitSigning now maps to the same info string as Signing/RnsSigning
482        assert_eq!(
483            d.derive(KeyPurpose::Signing),
484            d.derive(KeyPurpose::GitSigning),
485            "Signing must produce same bytes as legacy GitSigning (unified)"
486        );
487    }
488
489    #[test]
490    fn git_signing_seed_equals_signing_seed() {
491        let d = KeyDeriver::new(&[42u8; 32]);
492        assert_eq!(d.git_signing_seed(), d.signing_seed());
493    }
494
495    // --- SSH user key (two-level HKDF) tests ---
496
497    #[test]
498    fn ssh_user_key_deterministic() {
499        let d = KeyDeriver::new(&[42u8; 32]);
500        let k1 = d.derive_ssh_user_key("github").unwrap();
501        let k2 = d.derive_ssh_user_key("github").unwrap();
502        assert_eq!(k1, k2);
503    }
504
505    #[test]
506    fn ssh_user_key_different_labels() {
507        let d = KeyDeriver::new(&[42u8; 32]);
508        let github = d.derive_ssh_user_key("github").unwrap();
509        let work = d.derive_ssh_user_key("work").unwrap();
510        assert_ne!(github, work);
511    }
512
513    #[test]
514    fn ssh_user_key_no_collision_with_flat_purposes() {
515        let d = KeyDeriver::new(&[42u8; 32]);
516        let ssh_user = d.derive_ssh_user_key("github").unwrap();
517
518        for purpose in KeyPurpose::all() {
519            let flat = d.derive(*purpose);
520            assert_ne!(ssh_user, flat, "SSH user key collides with {:?}", purpose);
521        }
522    }
523
524    #[test]
525    fn ssh_user_key_different_roots() {
526        let k1 = KeyDeriver::new(&[1u8; 32]).derive_ssh_user_key("github").unwrap();
527        let k2 = KeyDeriver::new(&[2u8; 32]).derive_ssh_user_key("github").unwrap();
528        assert_ne!(k1, k2);
529    }
530
531    #[test]
532    fn ssh_user_key_empty_label_rejected() {
533        let d = KeyDeriver::new(&[42u8; 32]);
534        assert!(d.derive_ssh_user_key("").is_err());
535    }
536
537    // --- Agent key tests ---
538
539    #[test]
540    fn agent_key_deterministic() {
541        let d = KeyDeriver::new(&[42u8; 32]);
542        let k1 = d.derive_agent_key("omegon-primary").unwrap();
543        let k2 = d.derive_agent_key("omegon-primary").unwrap();
544        assert_eq!(k1, k2);
545    }
546
547    #[test]
548    fn agent_key_different_names() {
549        let d = KeyDeriver::new(&[42u8; 32]);
550        let primary = d.derive_agent_key("omegon-primary").unwrap();
551        let cleave = d.derive_agent_key("omegon-cleave-0").unwrap();
552        assert_ne!(primary, cleave);
553    }
554
555    #[test]
556    fn agent_key_no_collision_with_flat_or_ssh() {
557        let d = KeyDeriver::new(&[42u8; 32]);
558        let agent = d.derive_agent_key("omegon-primary").unwrap();
559
560        for purpose in KeyPurpose::all() {
561            assert_ne!(agent, d.derive(*purpose), "agent key collides with {:?}", purpose);
562        }
563        assert_ne!(agent, d.derive_ssh_user_key("github").unwrap());
564    }
565
566    #[test]
567    fn agent_key_differs_from_ssh_user_same_label() {
568        let d = KeyDeriver::new(&[42u8; 32]);
569        let ssh = d.derive_ssh_user_key("github").unwrap();
570        let agent = d.derive_agent_key("github").unwrap();
571        assert_ne!(ssh, agent);
572    }
573
574    #[test]
575    fn agent_key_empty_name_rejected() {
576        let d = KeyDeriver::new(&[42u8; 32]);
577        assert!(d.derive_agent_key("").is_err());
578    }
579
580    // --- I2P service key tests ---
581
582    #[test]
583    fn i2p_service_deterministic() {
584        let d = KeyDeriver::new(&[42u8; 32]);
585        let (s1, e1) = d.derive_i2p_service("forge").unwrap();
586        let (s2, e2) = d.derive_i2p_service("forge").unwrap();
587        assert_eq!(s1, s2);
588        assert_eq!(e1, e2);
589    }
590
591    #[test]
592    fn i2p_service_signing_differs_from_encryption() {
593        let d = KeyDeriver::new(&[42u8; 32]);
594        let (signing, encryption) = d.derive_i2p_service("forge").unwrap();
595        assert_ne!(signing, encryption);
596    }
597
598    #[test]
599    fn i2p_service_different_names() {
600        let d = KeyDeriver::new(&[42u8; 32]);
601        let (s1, _) = d.derive_i2p_service("forge").unwrap();
602        let (s2, _) = d.derive_i2p_service("wiki").unwrap();
603        assert_ne!(s1, s2);
604    }
605
606    #[test]
607    fn i2p_service_no_collision_with_flat_i2p() {
608        let d = KeyDeriver::new(&[42u8; 32]);
609        let (per_service, _) = d.derive_i2p_service("forge").unwrap();
610        let flat = d.derive(KeyPurpose::I2pSigning);
611        assert_ne!(per_service, flat, "per-service I2P key should differ from flat I2P key");
612    }
613
614    #[test]
615    fn i2p_service_empty_name_rejected() {
616        let d = KeyDeriver::new(&[42u8; 32]);
617        assert!(d.derive_i2p_service("").is_err());
618    }
619
620    // --- Tor onion service key tests ---
621
622    #[test]
623    fn onion_service_deterministic() {
624        let d = KeyDeriver::new(&[42u8; 32]);
625        let k1 = d.derive_onion_service("forge").unwrap();
626        let k2 = d.derive_onion_service("forge").unwrap();
627        assert_eq!(k1, k2);
628    }
629
630    #[test]
631    fn onion_service_different_names() {
632        let d = KeyDeriver::new(&[42u8; 32]);
633        let k1 = d.derive_onion_service("forge").unwrap();
634        let k2 = d.derive_onion_service("wiki").unwrap();
635        assert_ne!(k1, k2);
636    }
637
638    #[test]
639    fn onion_service_no_collision_with_flat_tor() {
640        let d = KeyDeriver::new(&[42u8; 32]);
641        let per_service = d.derive_onion_service("forge").unwrap();
642        let flat = d.derive(KeyPurpose::Tor);
643        assert_ne!(per_service, flat);
644    }
645
646    // --- Pinned test vectors (backwards compatibility) ---
647
648    #[test]
649    fn test_vector_flat_purposes() {
650        let d = KeyDeriver::new(&[0x42u8; 32]);
651
652        // RnsEncryption vector unchanged
653        assert_eq!(
654            hex::encode(d.derive(KeyPurpose::RnsEncryption)),
655            "aefdbd63fb6746c2edb73bba3bcb34f61909077f65fe033c9372b55f6ace0c0c"
656        );
657
658        // Signing uses the RnsSigning info string — must match the original RnsSigning vector
659        let signing_hex = hex::encode(d.derive(KeyPurpose::Signing));
660        let legacy_rns_hex = hex::encode(d.derive(KeyPurpose::RnsSigning));
661        assert_eq!(signing_hex, legacy_rns_hex);
662    }
663
664    #[test]
665    fn test_vector_git_signing_is_now_signing() {
666        let d = KeyDeriver::new(&[0x42u8; 32]);
667        // GitSigning NOW produces the same as Signing (was different before unification)
668        // Old GitSigning vector: 6eb3d3ef12a2447f... — this is NO LONGER produced.
669        // New GitSigning = Signing = RnsSigning.
670        assert_eq!(
671            hex::encode(d.derive(KeyPurpose::Signing)),
672            hex::encode(d.derive(KeyPurpose::GitSigning)),
673        );
674    }
675
676    #[test]
677    fn test_vector_ssh_user_key() {
678        let d = KeyDeriver::new(&[0x42u8; 32]);
679        assert_eq!(
680            hex::encode(d.derive_ssh_user_key("github").unwrap()),
681            "3c261af80e084a637fd20e0f7274a4106702894f0d23c47e855f6c9adce20d75"
682        );
683    }
684
685    #[test]
686    fn test_vector_agent_key() {
687        let d = KeyDeriver::new(&[0x42u8; 32]);
688        assert_eq!(
689            hex::encode(d.derive_agent_key("omegon-primary").unwrap()),
690            "4dd66edcda091a5e3d15aa3fb8ec32d81e212d94760b61915b1d6f204b0672e2"
691        );
692    }
693
694    #[test]
695    fn salt_provides_domain_separation() {
696        let root = [42u8; 32];
697        let salted = Hkdf::<Sha256>::new(Some(HKDF_SALT), &root);
698        let unsalted = Hkdf::<Sha256>::new(None, &root);
699
700        let mut s_out = [0u8; 32];
701        let mut u_out = [0u8; 32];
702        let info = KeyPurpose::RnsEncryption.info();
703        salted.expand(info, &mut s_out).expect("expand");
704        unsalted.expand(info, &mut u_out).expect("expand");
705
706        assert_ne!(s_out, u_out, "salt must change derived output");
707    }
708
709    // --- Overlay transport key isolation ---
710
711    #[test]
712    fn overlay_keys_all_distinct() {
713        let d = KeyDeriver::new(&[42u8; 32]);
714        let signing = d.signing_seed();
715        let yggdrasil = d.derive(KeyPurpose::Yggdrasil);
716        let i2p_sig = d.i2p_signing_seed();
717        let i2p_enc = d.i2p_encryption_secret();
718        let tor = d.tor_seed();
719
720        let keys = [signing, yggdrasil, i2p_sig, i2p_enc, tor];
721        for i in 0..keys.len() {
722            for j in (i + 1)..keys.len() {
723                assert_ne!(keys[i], keys[j], "overlay keys {i} and {j} must differ");
724            }
725        }
726    }
727}