Skip to main content

void_crypto/
kdf.rs

1//! HKDF-SHA256 key derivation with purpose separation.
2//!
3//! All keys are derived from a single root key using HKDF-SHA256 with
4//! purpose-specific info strings. This ensures cryptographic separation
5//! between different key uses (commits, metadata, content, index, etc.).
6
7use hkdf::Hkdf;
8use rand::RngCore;
9use sha2::Sha256;
10use zeroize::{Zeroize, ZeroizeOnDrop};
11
12use crate::{CryptoError, CryptoResult};
13
14const SALT: &[u8] = b"void-v1";
15
16// ============================================================================
17// Macro for secret key newtypes (zeroed on drop, no serialization)
18// ============================================================================
19
20/// Defines a secret key newtype with zeroize-on-drop and redacted debug.
21///
22/// Generated types:
23/// - Implement `Zeroize + ZeroizeOnDrop` for memory safety
24/// - Have redacted `Debug` output to prevent accidental logging
25/// - Do NOT implement `Copy`, `Serialize`, `Display`, or hex encoding
26/// - Only expose `from_bytes()`, `as_bytes()`, and `Clone`
27macro_rules! define_secret_key_newtype {
28    ($(#[$meta:meta])* $name:ident, $size:literal) => {
29        $(#[$meta])*
30        #[derive(Clone, Zeroize, ZeroizeOnDrop)]
31        pub struct $name([u8; $size]);
32
33        impl $name {
34            /// Create from raw bytes.
35            pub fn from_bytes(bytes: [u8; $size]) -> Self {
36                Self(bytes)
37            }
38
39            /// Access the underlying bytes.
40            pub fn as_bytes(&self) -> &[u8; $size] {
41                &self.0
42            }
43        }
44
45        impl std::fmt::Debug for $name {
46            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47                write!(f, "{}([REDACTED])", stringify!($name))
48            }
49        }
50    };
51}
52
53// ============================================================================
54// ShareKey — derived key from share-based unseal (zeroed on drop)
55// ============================================================================
56
57define_secret_key_newtype!(
58    /// A derived key from share-based unseal.
59    ///
60    /// In share-based unseal the derived key serves directly as the content
61    /// key — there is no commit envelope involved. This type ensures share
62    /// keys enter the crypto system through explicit construction, not via
63    /// raw `[u8; 32]` injection.
64    ShareKey, 32
65);
66
67// ============================================================================
68// Identity secret keys (zeroed on drop)
69// ============================================================================
70
71define_secret_key_newtype!(
72    /// Ed25519 secret key for signing (32 bytes). Zeroed on drop.
73    SigningSecretKey, 32
74);
75
76define_secret_key_newtype!(
77    /// X25519 secret key for ECIES decryption (32 bytes). Zeroed on drop.
78    RecipientSecretKey, 32
79);
80
81define_secret_key_newtype!(
82    /// BIP-39 seed bytes (64 bytes). Zeroed on drop.
83    IdentitySeed, 64
84);
85
86define_secret_key_newtype!(
87    /// Secp256k1 secret key for Nostr transport (32 bytes). Zeroed on drop.
88    NostrSecretKey, 32
89);
90
91// ============================================================================
92// SecretKey - A 32-byte key that is zeroed on drop
93// ============================================================================
94
95/// A 32-byte derived key that is zeroed on drop.
96///
97/// # Security
98///
99/// This type holds derived key material (never the root key itself).
100/// It is handed out by `KeyVault` for purpose-specific operations
101/// (index encryption, stash encryption, etc.).
102#[derive(Clone, Zeroize, ZeroizeOnDrop)]
103pub struct SecretKey([u8; 32]);
104
105impl SecretKey {
106    /// Create a new SecretKey from raw bytes.
107    pub fn new(bytes: [u8; 32]) -> Self {
108        Self(bytes)
109    }
110
111    /// Get the underlying bytes as a reference.
112    ///
113    /// # Security
114    ///
115    /// This is intentionally available for callers that need to pass
116    /// the key to low-level AEAD operations. The bytes should never
117    /// be copied into long-lived storage.
118    pub fn as_bytes(&self) -> &[u8; 32] {
119        &self.0
120    }
121
122    /// Generate a new random SecretKey.
123    pub fn generate() -> Self {
124        let mut bytes = [0u8; 32];
125        rand::thread_rng().fill_bytes(&mut bytes);
126        Self(bytes)
127    }
128}
129
130impl AsRef<[u8]> for SecretKey {
131    fn as_ref(&self) -> &[u8] {
132        &self.0
133    }
134}
135
136// ============================================================================
137// ContentKey - Typed wrapper for per-commit derived content keys
138// ============================================================================
139
140/// A 32-byte content key derived from a commit's envelope nonce.
141///
142/// # Security
143///
144/// This newtype prevents accidentally passing a root key where a content key
145/// is expected (or vice versa). Construction is restricted to `pub(crate)` so
146/// only `void-crypto` can mint content keys.
147#[derive(Clone, Copy, PartialEq, Eq, Zeroize, serde::Serialize, serde::Deserialize)]
148pub struct ContentKey([u8; 32]);
149
150impl ContentKey {
151    /// Create a new `ContentKey` from raw bytes.
152    ///
153    /// Restricted to `pub(crate)` so that only void-crypto can mint content keys.
154    pub(crate) fn new(bytes: [u8; 32]) -> Self {
155        Self(bytes)
156    }
157
158    /// Create a `ContentKey` from raw bytes (crate-internal only).
159    pub(crate) fn from_raw(bytes: [u8; 32]) -> Self {
160        Self(bytes)
161    }
162
163    /// Parse a content key from a hex string.
164    ///
165    /// Used for scoped read-only access via `--content-key` CLI flag.
166    /// The content key grants access to a single commit's data without
167    /// requiring the root encryption key.
168    pub fn from_hex(hex: &str) -> CryptoResult<Self> {
169        let bytes = hex::decode(hex.trim())
170            .map_err(|e| CryptoError::InvalidKey(format!("invalid content key hex: {e}")))?;
171        let arr: [u8; 32] = bytes
172            .try_into()
173            .map_err(|_| CryptoError::InvalidKey("content key must be 32 bytes".into()))?;
174        Ok(Self(arr))
175    }
176
177    /// Access the underlying bytes for low-level crypto calls.
178    ///
179    /// # Security
180    ///
181    /// Needed for passing to AEAD encrypt/decrypt. The bytes are a per-commit
182    /// derived key, not the root key.
183    pub fn as_bytes(&self) -> &[u8; 32] {
184        &self.0
185    }
186}
187
188impl std::fmt::Debug for ContentKey {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        write!(f, "ContentKey({}…)", hex::encode(&self.0[..4]))
191    }
192}
193
194// ============================================================================
195// Nonce<N> - Typed wrapper for cryptographic nonces (const-generic sized)
196// ============================================================================
197
198/// A fixed-size cryptographic nonce.
199///
200/// # Security
201///
202/// Using a typed wrapper prevents accidentally passing truncated hashes,
203/// key fragments, or other byte arrays where a nonce is expected. The const
204/// generic parameter enforces size at compile time — `Nonce<16>` and
205/// `Nonce<12>` are distinct types that cannot be mixed.
206#[derive(Clone, Copy, PartialEq, Eq)]
207pub struct Nonce<const N: usize>([u8; N]);
208
209impl<const N: usize> Nonce<N> {
210    /// Size of the nonce in bytes.
211    pub const SIZE: usize = N;
212
213    /// Generate a cryptographically secure random nonce.
214    pub fn generate() -> Self {
215        let mut bytes = [0u8; N];
216        rand::thread_rng().fill_bytes(&mut bytes);
217        Self(bytes)
218    }
219
220    /// Parse a nonce from a byte slice.
221    ///
222    /// Returns `None` if the slice length does not match `N`.
223    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
224        let arr: [u8; N] = bytes.try_into().ok()?;
225        Some(Self(arr))
226    }
227
228    /// Access the underlying bytes.
229    pub fn as_bytes(&self) -> &[u8; N] {
230        &self.0
231    }
232}
233
234impl<const N: usize> AsRef<[u8]> for Nonce<N> {
235    fn as_ref(&self) -> &[u8] {
236        &self.0
237    }
238}
239
240impl<const N: usize> std::fmt::Debug for Nonce<N> {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        let preview_len = N.min(4);
243        write!(f, "Nonce<{}>({}…)", N, hex::encode(&self.0[..preview_len]))
244    }
245}
246
247impl<const N: usize> std::fmt::Display for Nonce<N> {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        write!(f, "{}", hex::encode(&self.0))
250    }
251}
252
253/// 16-byte nonce for VD01 envelope key derivation.
254///
255/// Embedded in bytes 4..20 of the envelope header. Used to derive
256/// a per-commit content key via `HKDF(root_key, "commit:<hex(nonce)>")`.
257pub type KeyNonce = Nonce<16>;
258
259/// 12-byte nonce for AES-256-GCM authenticated encryption.
260pub type AeadNonce = Nonce<12>;
261
262// ============================================================================
263// RepoSecret - A 32-byte random secret for shard path hashing
264// ============================================================================
265
266/// A 32-byte random secret used for shard path hashing (NOT an encryption key).
267///
268/// Distinct from `SecretKey` (derived encryption key) and `RepoKey` (root
269/// encryption key). Used as HMAC-PRF input: `hash(repo_secret || path) →
270/// shard assignment`. Each repository generates its own `RepoSecret` at
271/// init time; it is stored inside `MetadataBundle`.
272#[derive(Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
273pub struct RepoSecret([u8; 32]);
274
275impl RepoSecret {
276    /// Create a new `RepoSecret` from raw bytes.
277    pub fn new(bytes: [u8; 32]) -> Self {
278        Self(bytes)
279    }
280
281    /// Access the underlying bytes.
282    pub fn as_bytes(&self) -> &[u8; 32] {
283        &self.0
284    }
285
286    /// Generate a new random `RepoSecret`.
287    pub fn generate() -> Self {
288        let mut bytes = [0u8; 32];
289        rand::thread_rng().fill_bytes(&mut bytes);
290        Self(bytes)
291    }
292}
293
294impl std::fmt::Debug for RepoSecret {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        write!(f, "RepoSecret([REDACTED])")
297    }
298}
299
300// ============================================================================
301// KeyPurpose - Key derivation purposes
302// ============================================================================
303
304/// Key purposes for derivation.
305///
306/// Each purpose produces a cryptographically independent key from the same
307/// root key via HKDF with a unique info string.
308#[derive(Clone, Debug, PartialEq, Eq)]
309pub enum KeyPurpose {
310    /// Key for encrypting commits.
311    Commits,
312    /// Key for encrypting metadata bundles.
313    Metadata,
314    /// Key for encrypting content (file data).
315    Content,
316    /// Key for encrypting the index.
317    Index,
318    /// Key for encrypting stash entries.
319    Stash,
320    /// Key for encrypting staged blobs.
321    Staged,
322    /// Scoped read key for limited access (scope is a path or branch pattern).
323    ScopedRead(String),
324}
325
326impl KeyPurpose {
327    /// Returns the info bytes used in HKDF for this purpose.
328    fn info(&self) -> Vec<u8> {
329        match self {
330            Self::Commits => b"void-v1-commits".to_vec(),
331            Self::Metadata => b"void-v1-metadata".to_vec(),
332            Self::Content => b"void-v1-content".to_vec(),
333            Self::Index => b"void-v1-index".to_vec(),
334            Self::Stash => b"void-v1-stash".to_vec(),
335            Self::Staged => b"void-v1-staged".to_vec(),
336            Self::ScopedRead(scope) => format!("void-v1-read:{}", scope).into_bytes(),
337        }
338    }
339}
340
341// ============================================================================
342// KeyRing - Holds all derived keys, zeroed on drop
343// ============================================================================
344
345/// Key ring holding all derived keys - zeroed on drop.
346///
347/// # Security
348///
349/// All fields hold purpose-derived keys, never the root key.
350/// The root key is held exclusively by `KeyVault`.
351#[derive(Zeroize, ZeroizeOnDrop)]
352pub struct KeyRing {
353    /// Key for encrypting commits.
354    pub(crate) commits: SecretKey,
355    /// Key for encrypting metadata bundles.
356    pub(crate) metadata: SecretKey,
357    /// Key for encrypting content (file data).
358    pub(crate) content: SecretKey,
359    /// Key for encrypting the index.
360    pub(crate) index: SecretKey,
361    /// Key for encrypting stash entries.
362    pub(crate) stash: SecretKey,
363    /// Key for encrypting staged blobs.
364    pub(crate) staged: SecretKey,
365}
366
367impl KeyRing {
368    /// Create a KeyRing from a root key using V2 (separated keys).
369    pub fn from_root(root_key: &[u8; 32]) -> CryptoResult<Self> {
370        Ok(Self {
371            commits: SecretKey::new(derive_key_for_purpose(root_key, KeyPurpose::Commits)?),
372            metadata: SecretKey::new(derive_key_for_purpose(root_key, KeyPurpose::Metadata)?),
373            content: SecretKey::new(derive_key_for_purpose(root_key, KeyPurpose::Content)?),
374            index: SecretKey::new(derive_key_for_purpose(root_key, KeyPurpose::Index)?),
375            stash: SecretKey::new(derive_key_for_purpose(root_key, KeyPurpose::Stash)?),
376            staged: SecretKey::new(derive_key_for_purpose(root_key, KeyPurpose::Staged)?),
377        })
378    }
379}
380
381// ============================================================================
382// Key derivation functions
383// ============================================================================
384
385/// Derive a purpose-specific key from the root key.
386pub fn derive_key_for_purpose(root_key: &[u8; 32], purpose: KeyPurpose) -> CryptoResult<[u8; 32]> {
387    let hk = Hkdf::<Sha256>::new(Some(SALT), root_key);
388    let mut output = [0u8; 32];
389    hk.expand(&purpose.info(), &mut output)
390        .map_err(|e| CryptoError::InvalidKey(e.to_string()))?;
391    Ok(output)
392}
393
394/// Derive a scoped read key from a root key.
395///
396/// The scope is a path or branch pattern that limits access, e.g.:
397/// - `"refs/heads/main"` - access to main branch only
398/// - `"path:src/"` - access to files under src/
399pub fn derive_scoped_key(root_key: &[u8; 32], scope: &str) -> CryptoResult<[u8; 32]> {
400    derive_key_for_purpose(root_key, KeyPurpose::ScopedRead(scope.to_string()))
401}
402
403/// Generates a cryptographically secure random 32-byte key.
404pub fn generate_key() -> [u8; 32] {
405    let mut key = [0u8; 32];
406    rand::thread_rng().fill_bytes(&mut key);
407    key
408}
409
410/// Derives a 32-byte key from the root key using HKDF-SHA256.
411///
412/// # Arguments
413/// * `root_key` - The root key material
414/// * `info` - Context string (e.g., "encryption", "signing")
415pub fn derive_key(root_key: &[u8; 32], info: &str) -> CryptoResult<[u8; 32]> {
416    let hk = Hkdf::<Sha256>::new(Some(SALT), root_key);
417
418    let mut output = [0u8; 32];
419    hk.expand(info.as_bytes(), &mut output)
420        .map_err(|e| CryptoError::InvalidKey(e.to_string()))?;
421
422    Ok(output)
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn derive_key_deterministic() {
431        let root = [0x42u8; 32];
432        let key1 = derive_key(&root, "encryption").unwrap();
433        let key2 = derive_key(&root, "encryption").unwrap();
434        assert_eq!(key1, key2);
435    }
436
437    #[test]
438    fn derive_key_different_info() {
439        let root = [0x42u8; 32];
440        let key1 = derive_key(&root, "encryption").unwrap();
441        let key2 = derive_key(&root, "signing").unwrap();
442        assert_ne!(key1, key2);
443    }
444
445    #[test]
446    fn derive_key_different_root() {
447        let root1 = [0x42u8; 32];
448        let root2 = [0x43u8; 32];
449        let key1 = derive_key(&root1, "encryption").unwrap();
450        let key2 = derive_key(&root2, "encryption").unwrap();
451        assert_ne!(key1, key2);
452    }
453
454    #[test]
455    fn generate_key_unique() {
456        let key1 = generate_key();
457        let key2 = generate_key();
458        assert_ne!(key1, key2);
459    }
460
461    #[test]
462    fn key_separation_produces_different_keys() {
463        let root = generate_key();
464        let ring = KeyRing::from_root(&root).unwrap();
465
466        assert_ne!(ring.commits.as_bytes(), ring.metadata.as_bytes());
467        assert_ne!(ring.metadata.as_bytes(), ring.content.as_bytes());
468        assert_ne!(ring.content.as_bytes(), ring.index.as_bytes());
469        assert_ne!(ring.index.as_bytes(), ring.stash.as_bytes());
470        assert_ne!(ring.stash.as_bytes(), ring.staged.as_bytes());
471    }
472
473    #[test]
474    fn stash_and_staged_keys_are_distinct() {
475        let root = generate_key();
476        let ring = KeyRing::from_root(&root).unwrap();
477
478        assert_ne!(ring.stash.as_bytes(), ring.index.as_bytes());
479        assert_ne!(ring.staged.as_bytes(), ring.index.as_bytes());
480        assert_ne!(ring.stash.as_bytes(), ring.staged.as_bytes());
481    }
482
483    #[test]
484    fn derive_key_for_purpose_deterministic() {
485        let root = [0x42u8; 32];
486        let key1 = derive_key_for_purpose(&root, KeyPurpose::Commits).unwrap();
487        let key2 = derive_key_for_purpose(&root, KeyPurpose::Commits).unwrap();
488        assert_eq!(key1, key2);
489    }
490
491    #[test]
492    fn derive_key_for_purpose_different_purposes() {
493        let root = [0x42u8; 32];
494
495        let commits = derive_key_for_purpose(&root, KeyPurpose::Commits).unwrap();
496        let metadata = derive_key_for_purpose(&root, KeyPurpose::Metadata).unwrap();
497        let content = derive_key_for_purpose(&root, KeyPurpose::Content).unwrap();
498        let index = derive_key_for_purpose(&root, KeyPurpose::Index).unwrap();
499        let stash = derive_key_for_purpose(&root, KeyPurpose::Stash).unwrap();
500        let staged = derive_key_for_purpose(&root, KeyPurpose::Staged).unwrap();
501
502        let keys = [commits, metadata, content, index, stash, staged];
503        for i in 0..keys.len() {
504            for j in (i + 1)..keys.len() {
505                assert_ne!(keys[i], keys[j], "Keys at {} and {} should differ", i, j);
506            }
507        }
508    }
509
510    #[test]
511    fn secret_key_generate_unique() {
512        let key1 = SecretKey::generate();
513        let key2 = SecretKey::generate();
514        assert_ne!(key1.as_bytes(), key2.as_bytes());
515    }
516
517    #[test]
518    fn secret_key_as_ref() {
519        let bytes = [0x42u8; 32];
520        let key = SecretKey::new(bytes);
521        let slice: &[u8] = key.as_ref();
522        assert_eq!(slice, &bytes);
523    }
524}