Skip to main content

wm_core/
kdf.rs

1//! HKDF-SHA256 derivation for domain-separated subkeys (S9 x Q39).
2//!
3//! One root (the node key material) fans out into purpose-scoped subkeys;
4//! subkeys never cross-derive. The info strings are versioned and ruled in
5//! `docs/Q39_CRYPTO_ERASURE_DESIGN.md` §2 and
6//! `docs/S9_SECURITY_BRIEF_2026-09-10.md` §2.1 — do not invent new strings
7//! without updating both.
8
9#![forbid(unsafe_code)]
10
11/// Mesh node identity subkey (`wm/mesh-identity/v1`).
12pub const MESH_IDENTITY_INFO: &str = "wm/mesh-identity/v1";
13
14/// Record-attestation signer subkey (`wm/record-attestation/v1`).
15pub const RECORD_ATTESTATION_INFO: &str = "wm/record-attestation/v1";
16
17/// Galaxy DEK-wrap KEK info-string prefix (`wm/galaxy-dek/v1`, Q39 §2/§3).
18///
19/// One KEK per galaxy derives from the store root key as
20/// `hkdf32(rk, galaxy_dek_info(<galaxy-db-name>))`; the full info string is
21/// also the wrapping AEAD's AAD, so a wrapped DEK cannot be transplanted
22/// between galaxies. Declared in `docs/Q39_CRYPTO_ERASURE_DESIGN.md`.
23pub const GALAXY_DEK_INFO_PREFIX: &str = "wm/galaxy-dek/v1";
24
25/// Info string (and AEAD AAD) binding a galaxy DEK wrap to its galaxy:
26/// `wm/galaxy-dek/v1/<galaxy-db-name>`.
27#[must_use]
28pub fn galaxy_dek_info(galaxy_db_name: &str) -> String {
29    format!("{GALAXY_DEK_INFO_PREFIX}/{galaxy_db_name}")
30}
31
32/// Release-artifact signing subkey (`wm/release-signing/v1`) — reserved, not yet wired.
33///
34/// The CI release manifest still signs with the hex-decoded lineage key and
35/// records that in `key_lineage`; do not switch it on without the
36/// release-side migration.
37pub const RELEASE_SIGNING_INFO: &str = "wm/release-signing/v1";
38
39/// HKDF-SHA256: derive 32 bytes from root material with a versioned info string.
40///
41/// `salt = None` (RFC 5869 permits an all-zero salt; the root material is
42/// high-entropy and per-node).
43#[must_use]
44pub fn hkdf32(root: &[u8], info: &str) -> [u8; 32] {
45    let mut out = [0u8; 32];
46    expand(root, info, &mut out);
47    out
48}
49
50/// HKDF-SHA256 expand into a caller-provided buffer (for non-32-byte outputs).
51///
52/// # Panics
53///
54/// Panics when `okm` exceeds the HKDF-SHA256 maximum (`255 * 32` bytes).
55pub fn expand(root: &[u8], info: &str, okm: &mut [u8]) {
56    let hk = hkdf::Hkdf::<sha2::Sha256>::new(None, root);
57    hk.expand(info.as_bytes(), okm)
58        .expect("HKDF-SHA256 output length must be <= 255*32 bytes");
59}
60
61/// Canonical root material from an environment/config value (S9 §2.1).
62///
63/// A 64-hex-char key is decoded to its 32 canonical bytes; anything else is
64/// used raw so legacy/test material keeps a stable identity. The mesh
65/// identity and the record-attestation subkey derive from the same canonical
66/// root; purpose APIs may additionally require canonical material (record
67/// attestations derive only from a 64-hex root — an honest negative, never an
68/// off-contract root).
69#[must_use]
70pub fn root_bytes(material: &str) -> Vec<u8> {
71    let trimmed = material.trim();
72    if trimmed.len() == 64 {
73        if let Some(decoded) = decode_hex32(trimmed) {
74            return decoded.to_vec();
75        }
76    }
77    material.as_bytes().to_vec()
78}
79
80fn decode_hex32(hex: &str) -> Option<[u8; 32]> {
81    if hex.len() != 64 {
82        return None;
83    }
84    let mut out = [0u8; 32];
85    for (i, chunk) in hex.as_bytes().chunks_exact(2).enumerate() {
86        let hi = hex_val(chunk[0])?;
87        let lo = hex_val(chunk[1])?;
88        out[i] = (hi << 4) | lo;
89    }
90    Some(out)
91}
92
93const fn hex_val(b: u8) -> Option<u8> {
94    match b {
95        b'0'..=b'9' => Some(b - b'0'),
96        b'a'..=b'f' => Some(b - b'a' + 10),
97        b'A'..=b'F' => Some(b - b'A' + 10),
98        _ => None,
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    fn hex(bytes: &[u8]) -> String {
107        const HEX: &[u8; 16] = b"0123456789abcdef";
108        let mut out = String::with_capacity(bytes.len() * 2);
109        for b in bytes {
110            out.push(HEX[(b >> 4) as usize] as char);
111            out.push(HEX[(b & 0x0f) as usize] as char);
112        }
113        out
114    }
115
116    #[test]
117    fn rfc5869_test_case_1_is_reproduced() {
118        // RFC 5869 A.1 — pins the underlying HKDF-SHA256 implementation.
119        let ikm = [0x0b; 22];
120        let salt: [u8; 13] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
121        let info: [u8; 10] = [0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9];
122        let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&salt), &ikm);
123        let mut okm = [0u8; 42];
124        hk.expand(&info, &mut okm).unwrap();
125        assert_eq!(
126            hex(&okm),
127            "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf\
128             34007208d5b887185865"
129        );
130    }
131
132    #[test]
133    fn derivation_is_deterministic_and_domain_separated() {
134        let root = b"node-root-material-0123456789";
135        let mesh_a = hkdf32(root, MESH_IDENTITY_INFO);
136        let mesh_b = hkdf32(root, MESH_IDENTITY_INFO);
137        assert_eq!(
138            mesh_a, mesh_b,
139            "same root + info must derive the same subkey"
140        );
141
142        let attestation = hkdf32(root, RECORD_ATTESTATION_INFO);
143        assert_ne!(
144            mesh_a, attestation,
145            "mesh identity and attestation subkeys must never collide"
146        );
147        assert_ne!(mesh_a, hkdf32(root, "wm/beacon/v1"));
148        assert_ne!(mesh_a, hkdf32(b"another-root", MESH_IDENTITY_INFO));
149    }
150
151    #[test]
152    fn galaxy_dek_info_is_versioned_and_per_galaxy() {
153        assert_eq!(galaxy_dek_info("codex"), "wm/galaxy-dek/v1/codex");
154        assert_eq!(
155            galaxy_dek_info("codex"),
156            format!("{GALAXY_DEK_INFO_PREFIX}/codex")
157        );
158        assert_ne!(galaxy_dek_info("codex"), galaxy_dek_info("sessions"));
159        assert_ne!(
160            galaxy_dek_info("codex"),
161            RECORD_ATTESTATION_INFO,
162            "DEK wrapping must never share purpose space with signers"
163        );
164    }
165
166    #[test]
167    fn expand_writes_only_the_requested_length() {
168        let root = b"short-root";
169        let mut out = [0xAAu8; 64];
170        expand(root, MESH_IDENTITY_INFO, &mut out[..16]);
171        assert_eq!(
172            &out[16..],
173            [0xAA; 48],
174            "bytes past the output length untouched"
175        );
176    }
177
178    #[test]
179    fn root_material_is_canonical_hex_decode_with_raw_fallback() {
180        let hex_key = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
181        let decoded = root_bytes(hex_key);
182        assert_eq!(decoded.len(), 32);
183        assert_eq!(decoded[0], 0x00);
184        assert_eq!(decoded[3], 0x33);
185        assert_eq!(
186            hkdf32(&decoded, MESH_IDENTITY_INFO),
187            hkdf32(&root_bytes(hex_key), MESH_IDENTITY_INFO),
188            "same env material must always derive the same subkey"
189        );
190        assert_ne!(
191            decoded.as_slice(),
192            hex_key.as_bytes(),
193            "64-hex is decoded, not used as ASCII"
194        );
195
196        assert_eq!(root_bytes("legacy-or-test-key"), b"legacy-or-test-key");
197        let invalid_64 = "z".repeat(64);
198        assert_eq!(
199            root_bytes(&invalid_64),
200            invalid_64.as_bytes(),
201            "64 chars that are not hex fall back to raw bytes"
202        );
203    }
204}