1use hkdf::Hkdf;
40use sha2::Sha256;
41use zeroize::Zeroize;
42
43#[derive(Debug, thiserror::Error)]
45pub enum DeriveError {
46 #[error("key derivation label must not be empty")]
48 EmptyLabel,
49}
50
51pub fn validate_label(label: &str) -> Result<(), DeriveError> {
58 if label.is_empty() {
59 return Err(DeriveError::EmptyLabel);
60 }
61 Ok(())
62}
63
64const HKDF_SALT: &[u8] = b"styrene-identity-v1";
66const HKDF_SALT_AGENT: &[u8] = b"styrene-identity-agent-v1";
68const HKDF_SALT_SSH_USER: &[u8] = b"styrene-identity-ssh-user-v1";
70const HKDF_SALT_I2P_SERVICE: &[u8] = b"styrene-identity-i2p-service-v1";
72const HKDF_SALT_ONION_SERVICE: &[u8] = b"styrene-identity-onion-service-v1";
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77pub enum KeyPurpose {
78 Signing,
85
86 RnsEncryption,
90 Age,
92 WireGuard,
94
95 SshHost,
99
100 Yggdrasil,
104 I2pSigning,
106 I2pEncryption,
108 Tor,
110
111 #[deprecated(note = "use KeyPurpose::Signing — RnsSigning and GitSigning are now unified")]
118 RnsSigning,
119 #[deprecated(note = "use KeyPurpose::Signing — RnsSigning and GitSigning are now unified")]
121 GitSigning,
122}
123
124impl KeyPurpose {
125 pub fn info(&self) -> &'static [u8] {
127 match self {
128 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 #[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 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
165pub struct KeyDeriver {
172 prk: [u8; 32],
175}
176
177impl Drop for KeyDeriver {
178 fn drop(&mut self) {
179 self.prk.zeroize();
180 }
181}
182
183impl KeyDeriver {
184 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 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 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 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 pub fn signing_seed(&self) -> [u8; 32] {
226 self.derive(KeyPurpose::Signing)
227 }
228
229 pub fn ssh_host_seed(&self) -> [u8; 32] {
231 self.derive(KeyPurpose::SshHost)
232 }
233
234 pub fn age_secret(&self) -> [u8; 32] {
236 self.derive(KeyPurpose::Age)
237 }
238
239 pub fn git_signing_seed(&self) -> [u8; 32] {
242 self.derive(KeyPurpose::Signing)
243 }
244
245 pub fn i2p_signing_seed(&self) -> [u8; 32] {
247 self.derive(KeyPurpose::I2pSigning)
248 }
249
250 pub fn i2p_encryption_secret(&self) -> [u8; 32] {
252 self.derive(KeyPurpose::I2pEncryption)
253 }
254
255 pub fn tor_seed(&self) -> [u8; 32] {
257 self.derive(KeyPurpose::Tor)
258 }
259
260 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 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 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 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 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 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 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
327pub fn derive_key(root_secret: &[u8; 32], purpose: KeyPurpose) -> [u8; 32] {
333 KeyDeriver::new(root_secret).derive(purpose)
334}
335
336#[derive(Zeroize)]
342#[zeroize(drop)]
343pub struct DerivedKeys {
344 pub signing: [u8; 32],
346 pub rns_encryption: [u8; 32],
348 pub age: [u8; 32],
350 pub wireguard: [u8; 32],
352 pub ssh_host: [u8; 32],
354 pub yggdrasil: [u8; 32],
356 pub i2p_signing: [u8; 32],
358 pub i2p_encryption: [u8; 32],
360 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
370pub 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 #[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 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 #[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 #[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 #[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 #[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 #[test]
649 fn test_vector_flat_purposes() {
650 let d = KeyDeriver::new(&[0x42u8; 32]);
651
652 assert_eq!(
654 hex::encode(d.derive(KeyPurpose::RnsEncryption)),
655 "aefdbd63fb6746c2edb73bba3bcb34f61909077f65fe033c9372b55f6ace0c0c"
656 );
657
658 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 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 #[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}