Skip to main content

void_crypto/
seed.rs

1//! BIP-39 mnemonic and deterministic key derivation for void identities.
2//!
3//! This module provides:
4//! - 24-word BIP-39 mnemonic generation
5//! - Mnemonic → seed conversion
6//! - HKDF-SHA256 derivation of signing and recipient keys from a seed
7//!
8//! The derivation uses domain-separated HKDF so that the same seed produces
9//! distinct, deterministic Ed25519 signing and X25519 recipient keys.
10
11use hkdf::Hkdf;
12use sha2::Sha256;
13
14use crate::kdf::{IdentitySeed, NostrSecretKey, RecipientSecretKey, SigningSecretKey};
15
16/// Salt for identity key derivation — distinct from the repo key salt in `kdf`.
17const IDENTITY_SALT: &[u8] = b"void-identity-v1";
18
19/// HKDF info for Ed25519 signing key derivation.
20const SIGNING_INFO: &[u8] = b"void-identity-signing";
21
22/// HKDF info for X25519 recipient key derivation.
23const RECIPIENT_INFO: &[u8] = b"void-identity-recipient";
24
25/// HKDF info for secp256k1 Nostr key derivation.
26const NOSTR_INFO: &[u8] = b"void-identity-nostr";
27
28/// Salt for per-repo owner key derivation — distinct from identity keys.
29const REPO_OWNER_SALT: &[u8] = b"void-repo-owner-v1";
30
31/// Errors during seed/mnemonic operations.
32#[derive(Debug, thiserror::Error)]
33pub enum SeedError {
34    #[error("invalid mnemonic phrase: {0}")]
35    InvalidMnemonic(String),
36
37    #[error("key derivation failed")]
38    DerivationFailed,
39}
40
41/// Generate a new 24-word BIP-39 mnemonic.
42///
43/// Uses 256 bits of entropy for maximum security, producing a 24-word phrase.
44pub fn generate_mnemonic() -> String {
45    bip39::Mnemonic::generate(24)
46        .expect("24 is a valid word count")
47        .to_string()
48}
49
50/// Convert a BIP-39 mnemonic phrase to a 64-byte seed.
51///
52/// The mnemonic is validated for correct word count and checksum.
53/// Uses an empty passphrase (standard BIP-39 behavior).
54pub fn mnemonic_to_seed(mnemonic: &str) -> Result<IdentitySeed, SeedError> {
55    let parsed = bip39::Mnemonic::parse(mnemonic)
56        .map_err(|e| SeedError::InvalidMnemonic(e.to_string()))?;
57
58    let seed_bytes: [u8; 64] = parsed.to_seed("");
59    Ok(IdentitySeed::from_bytes(seed_bytes))
60}
61
62/// Derive an Ed25519 signing secret key from an identity seed.
63///
64/// Uses HKDF-SHA256 with domain separation to produce a deterministic
65/// 32-byte key from the 64-byte seed.
66pub fn derive_signing_key(seed: &IdentitySeed) -> Result<SigningSecretKey, SeedError> {
67    let hk = Hkdf::<Sha256>::new(Some(IDENTITY_SALT), seed.as_bytes());
68    let mut output = [0u8; 32];
69    hk.expand(SIGNING_INFO, &mut output)
70        .map_err(|_| SeedError::DerivationFailed)?;
71    Ok(SigningSecretKey::from_bytes(output))
72}
73
74/// Derive an X25519 recipient secret key from an identity seed.
75///
76/// Uses HKDF-SHA256 with domain separation to produce a deterministic
77/// 32-byte key from the 64-byte seed. The info string differs from
78/// `derive_signing_key` to ensure distinct keys.
79pub fn derive_recipient_key(seed: &IdentitySeed) -> Result<RecipientSecretKey, SeedError> {
80    let hk = Hkdf::<Sha256>::new(Some(IDENTITY_SALT), seed.as_bytes());
81    let mut output = [0u8; 32];
82    hk.expand(RECIPIENT_INFO, &mut output)
83        .map_err(|_| SeedError::DerivationFailed)?;
84    Ok(RecipientSecretKey::from_bytes(output))
85}
86
87/// Derive a secp256k1 Nostr secret key from an identity seed.
88///
89/// Uses HKDF-SHA256 with domain separation to produce a deterministic
90/// 32-byte key from the 64-byte seed. The info string differs from
91/// signing and recipient keys to ensure distinct keys.
92pub fn derive_nostr_key(seed: &IdentitySeed) -> Result<NostrSecretKey, SeedError> {
93    let hk = Hkdf::<Sha256>::new(Some(IDENTITY_SALT), seed.as_bytes());
94    let mut output = [0u8; 32];
95    hk.expand(NOSTR_INFO, &mut output)
96        .map_err(|_| SeedError::DerivationFailed)?;
97    Ok(NostrSecretKey::from_bytes(output))
98}
99
100/// Derive a per-repo Ed25519 owner signing key from an identity seed and repo ID.
101///
102/// Uses HKDF-SHA256 with a repo-owner-specific salt and the repo_id as info,
103/// producing a deterministic key unique to each (seed, repo) pair. This key is
104/// used for governance operations (policy updates, contributor management),
105/// while the identity key continues to sign commits (authorship).
106///
107/// Because derivation is deterministic from the seed, the repo owner key is
108/// automatically recoverable from the user's mnemonic phrase.
109pub fn derive_repo_owner_key(seed: &IdentitySeed, repo_id: &str) -> Result<SigningSecretKey, SeedError> {
110    let hk = Hkdf::<Sha256>::new(Some(REPO_OWNER_SALT), seed.as_bytes());
111    let mut output = [0u8; 32];
112    hk.expand(repo_id.as_bytes(), &mut output)
113        .map_err(|_| SeedError::DerivationFailed)?;
114    Ok(SigningSecretKey::from_bytes(output))
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn generate_mnemonic_is_24_words() {
123        let mnemonic = generate_mnemonic();
124        let words: Vec<&str> = mnemonic.split_whitespace().collect();
125        assert_eq!(words.len(), 24);
126    }
127
128    #[test]
129    fn mnemonic_to_seed_deterministic() {
130        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
131        let seed1 = mnemonic_to_seed(mnemonic).unwrap();
132        let seed2 = mnemonic_to_seed(mnemonic).unwrap();
133        assert_eq!(seed1.as_bytes(), seed2.as_bytes());
134    }
135
136    #[test]
137    fn derive_signing_key_deterministic() {
138        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
139        let seed = mnemonic_to_seed(mnemonic).unwrap();
140        let key1 = derive_signing_key(&seed).unwrap();
141        let key2 = derive_signing_key(&seed).unwrap();
142        assert_eq!(key1.as_bytes(), key2.as_bytes());
143    }
144
145    #[test]
146    fn derive_recipient_key_deterministic() {
147        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
148        let seed = mnemonic_to_seed(mnemonic).unwrap();
149        let key1 = derive_recipient_key(&seed).unwrap();
150        let key2 = derive_recipient_key(&seed).unwrap();
151        assert_eq!(key1.as_bytes(), key2.as_bytes());
152    }
153
154    #[test]
155    fn signing_and_recipient_keys_differ() {
156        let mnemonic = generate_mnemonic();
157        let seed = mnemonic_to_seed(&mnemonic).unwrap();
158        let signing = derive_signing_key(&seed).unwrap();
159        let recipient = derive_recipient_key(&seed).unwrap();
160        assert_ne!(signing.as_bytes(), recipient.as_bytes());
161    }
162
163    #[test]
164    fn invalid_mnemonic_rejected() {
165        assert!(mnemonic_to_seed("not a valid mnemonic").is_err());
166        assert!(mnemonic_to_seed("").is_err());
167        assert!(mnemonic_to_seed("abandon").is_err());
168    }
169
170    #[test]
171    fn derive_repo_owner_key_deterministic() {
172        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
173        let seed = mnemonic_to_seed(mnemonic).unwrap();
174        let key1 = derive_repo_owner_key(&seed, "repo-123").unwrap();
175        let key2 = derive_repo_owner_key(&seed, "repo-123").unwrap();
176        assert_eq!(key1.as_bytes(), key2.as_bytes());
177    }
178
179    #[test]
180    fn derive_repo_owner_key_different_repos() {
181        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
182        let seed = mnemonic_to_seed(mnemonic).unwrap();
183        let key_a = derive_repo_owner_key(&seed, "repo-a").unwrap();
184        let key_b = derive_repo_owner_key(&seed, "repo-b").unwrap();
185        assert_ne!(key_a.as_bytes(), key_b.as_bytes());
186    }
187
188    #[test]
189    fn repo_owner_key_differs_from_identity_keys() {
190        let mnemonic = generate_mnemonic();
191        let seed = mnemonic_to_seed(&mnemonic).unwrap();
192        let signing = derive_signing_key(&seed).unwrap();
193        let recipient = derive_recipient_key(&seed).unwrap();
194        let repo_owner = derive_repo_owner_key(&seed, "test-repo").unwrap();
195        assert_ne!(repo_owner.as_bytes(), signing.as_bytes());
196        assert_ne!(repo_owner.as_bytes(), recipient.as_bytes());
197    }
198
199    #[test]
200    fn derive_nostr_key_deterministic() {
201        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
202        let seed = mnemonic_to_seed(mnemonic).unwrap();
203        let key1 = derive_nostr_key(&seed).unwrap();
204        let key2 = derive_nostr_key(&seed).unwrap();
205        assert_eq!(key1.as_bytes(), key2.as_bytes());
206    }
207
208    #[test]
209    fn nostr_key_differs_from_other_keys() {
210        let mnemonic = generate_mnemonic();
211        let seed = mnemonic_to_seed(&mnemonic).unwrap();
212        let signing = derive_signing_key(&seed).unwrap();
213        let recipient = derive_recipient_key(&seed).unwrap();
214        let nostr = derive_nostr_key(&seed).unwrap();
215        assert_ne!(nostr.as_bytes(), signing.as_bytes());
216        assert_ne!(nostr.as_bytes(), recipient.as_bytes());
217    }
218
219    #[test]
220    fn different_mnemonics_different_keys() {
221        let mnemonic1 = generate_mnemonic();
222        let mnemonic2 = generate_mnemonic();
223
224        let seed1 = mnemonic_to_seed(&mnemonic1).unwrap();
225        let seed2 = mnemonic_to_seed(&mnemonic2).unwrap();
226
227        let signing1 = derive_signing_key(&seed1).unwrap();
228        let signing2 = derive_signing_key(&seed2).unwrap();
229
230        assert_ne!(signing1.as_bytes(), signing2.as_bytes());
231    }
232}