nx_hkdf.nx source
↩ module page · 240 lines · 9710 B
1// hkdf.nx -- HKDF-SHA-256 (RFC 5869).
2//
3// HMAC-based Extract-and-Expand Key Derivation Function. Central
4// to TLS 1.3 (RFC 8446 §7.1) key schedule:
5//
6// Early Secret = HKDF-Extract(0, PSK)
7// Handshake Secret = HKDF-Extract(Derive-Secret(Early, ""), ECDHE)
8// Master Secret = HKDF-Extract(Derive-Secret(Handshake, ""), 0)
9// <traffic secrets> = Derive-Secret(<stage>, "<label>", transcript)
10//
11// Two-stage design:
12// Extract(salt, IKM) -> PRK
13// Treats IKM as an entropy source; produces a uniformly-random
14// pseudorandom key. Implemented as HMAC-SHA-256(salt, IKM).
15// Expand(PRK, info, L) -> OKM
16// Deterministically derives L bytes of keying material from PRK
17// and a context string `info`. Iterates HMAC over a counter.
18//
19// Properties (RFC 5869 §3.3):
20// - Deterministic: same (salt, IKM, info, L) -> same OKM.
21// - Context-binding: different `info` -> independent OKMs.
22// - Output limited to 255 * HashLen = 8160 bytes for SHA-256.
23//
24// Invariants:
25// HK1 PRK is always exactly HashLen (32 bytes for SHA-256).
26// HK2 Expand iterates exactly ceil(L / HashLen) times; each
27// iteration feeds the previous output back in, creating a
28// chain that prevents block-substitution attacks.
29// HK3 Counter bytes are 1-indexed; wraps at 255 per RFC 5869
30// §2.3. We cap L at 8160 and refuse larger requests.
31// HK4 No branches on salt/IKM/info VALUES; branches only on
32// LENGTHS.
33//
34// license_tier: INDEPENDENT_REDERIVE
35// genealogy_id: international-research-sources/ietf/rfc_5869
36//
37// nx_safety_envelope: (schema: nishi-library/seeds/safety-critical-standards.toml)
38// intended_use: "HKDF-SHA-256 key derivation -- TLS 1.3
39// record-protection schedule (RFC 8446 §7.1) +
40// Noise protocol KDF + any KDF needing
41// Extract-then-Expand pattern"
42// sil_target: SIL3 (key derivation; output reused
43// across security boundaries)
44// asil_target: QM
45// dal_target: DAL B
46// iec_62304_class: B
47// evidence: [no_floating_point, sealed_enum_complete,
48// bit_equal_reproducible,
49// RFC_5869_test_vectors_A1_A2_A3_VERIFIED,
50// inherits_nx_hmac_evidence_chain,
51// constant_time_by_construction]
52// hazard_register: [bug-tape-info-string-concat-injection,
53// bug-tape-okm-length-exceeds-255-blocks,
54// bug-tape-salt-not-domain-separated]
55// residual_risk: "info context string MUST be domain-
56// separated by caller (RFC 5869 §3.2);
57// substrate cannot enforce uniqueness across
58// protocol versions. okm_len capped at
59// 255*HashLen by RFC 5869 §2.3."
60// verdict: NOT_YET_EVALUATED
61
62import "nx_syscalls.nx"
63import "nx_hmac.nx"
64import "nx_hmac_sha384.nx"
65
66const HKDF_HASH: i64 = 32 // SHA-256 output
67const HKDF_MAX_L: i64 = 8160 // 255 * 32
68const HKDF_HASH384: i64 = 48 // SHA-384 output
69const HKDF_MAX_L384: i64 = 12240 // 255 * 48
70
71// HKDF-Extract. Writes 32-byte PRK to `prk`. `salt` may be NULL
72// (caller passes zero pointer + salt_len=0) in which case RFC 5869
73// mandates a zero-filled HashLen salt.
74func hkdf_extract(salt: *u8, salt_len: i64,
75 ikm: *u8, ikm_len: i64,
76 prk: *u8) -> i64 {
77 if salt_len == 0 {
78 let zero_salt: *u8 = sys_mmap(HKDF_HASH)
79 var i: i64 = 0
80 while i < HKDF_HASH { zero_salt[i] = 0; i = i + 1 }
81 hmac_sha256(zero_salt, HKDF_HASH, ikm, ikm_len, prk)
82 } else {
83 hmac_sha256(salt, salt_len, ikm, ikm_len, prk)
84 }
85 return 0
86}
87
88// HKDF-Expand. Writes `L` bytes of keying material to `out`.
89// Returns 0 on success, -1 if L exceeds the HKDF ceiling.
90func hkdf_expand(prk: *u8,
91 info: *u8, info_len: i64,
92 l: i64, out: *u8) -> i64 {
93 if l > HKDF_MAX_L { return -1 }
94 if l < 0 { return -1 }
95
96 // n = ceil(L / HashLen); each iteration appends one block.
97 // T(0) = empty; T(i) = HMAC(PRK, T(i-1) || info || i_byte).
98 let t_prev: *u8 = sys_mmap(HKDF_HASH)
99 let t_curr: *u8 = sys_mmap(HKDF_HASH)
100 let buf_cap: i64 = HKDF_HASH + info_len + 1
101 let buf: *u8 = sys_mmap(buf_cap)
102
103 var prev_len: i64 = 0
104 var produced: i64 = 0
105 var counter: i64 = 1
106 while produced < l {
107 // Build input: T(i-1) || info || counter
108 var bi: i64 = 0
109 var k: i64 = 0
110 while k < prev_len { buf[bi + k] = t_prev[k]; k = k + 1 }
111 bi = bi + prev_len
112 k = 0
113 while k < info_len { buf[bi + k] = info[k]; k = k + 1 }
114 bi = bi + info_len
115 buf[bi] = counter & 0xFF
116 bi = bi + 1
117
118 hmac_sha256(prk, HKDF_HASH, buf, bi, t_curr)
119
120 // Copy up to HashLen bytes of T(i) into output.
121 let remain: i64 = l - produced
122 var take: i64 = HKDF_HASH
123 if remain < HKDF_HASH { take = remain }
124 k = 0
125 while k < take { out[produced + k] = t_curr[k]; k = k + 1 }
126 produced = produced + take
127
128 // T(i) becomes T(i-1) for next round.
129 k = 0
130 while k < HKDF_HASH { t_prev[k] = t_curr[k]; k = k + 1 }
131 prev_len = HKDF_HASH
132 counter = counter + 1
133 }
134 return 0
135}
136
137// ===== HASH-PARAMETERIZED HKDF (2026-08-03) ==========================
138// WHY: TLS 1.3 cipher suite 0x1302 (AES-256-GCM-SHA384) uses SHA-384 for
139// the ENTIRE key schedule, not just the AEAD. Our client offered 0x1302
140// and then could not derive its keys -- servers that prefer it (VaM hub
141// among them) were unreachable, and the failure surfaced as a bare
142// handshake verdict=5. The primitives all existed: hmac_sha384 (this
143// module's new import), sha384_digest, nx_aes256_gcm_seal/open. The gap
144// was WIRING, not capability -- the banked adoption-gap class again.
145//
146// ADDITIVE BY CONSTRUCTION (Rule 19): the original hkdf_extract/expand
147// are UNTOUCHED above and remain the SHA-256 path every existing caller
148// already links; these _h twins take hash_len and dispatch. A caller that
149// passes 32 gets byte-identical output to the original -- asserted as a
150// regression tooth, not assumed.
151//
152// hash_len is REFUSED unless 32 or 48: a silent fallback to SHA-256 on an
153// unknown length would derive the WRONG keys and present as a decrypt
154// failure three layers away. Fail closed, at the definition site.
155
156// HKDF-Extract, hash-parameterized. PRK is exactly hash_len bytes.
157// Extract IS HMAC(salt, IKM) by definition (RFC 5869 s2.2), so this
158// stays a pure dispatch -- there is no second construction to get wrong.
159func hkdf_extract_h(salt: *u8, salt_len: i64,
160 ikm: *u8, ikm_len: i64,
161 hash_len: i64, prk: *u8) -> i64 {
162 if hash_len != HKDF_HASH { if hash_len != HKDF_HASH384 { return 0 - 2 } }
163 if salt_len == 0 {
164 let zero_salt: *u8 = sys_mmap(hash_len)
165 var i: i64 = 0
166 while i < hash_len { zero_salt[i] = 0; i = i + 1 }
167 if hash_len == HKDF_HASH384 {
168 hmac_sha384(zero_salt, hash_len, ikm, ikm_len, prk)
169 } else {
170 hmac_sha256(zero_salt, hash_len, ikm, ikm_len, prk)
171 }
172 return 0
173 }
174 if hash_len == HKDF_HASH384 {
175 hmac_sha384(salt, salt_len, ikm, ikm_len, prk)
176 } else {
177 hmac_sha256(salt, salt_len, ikm, ikm_len, prk)
178 }
179 return 0
180}
181
182// HKDF-Expand, hash-parameterized. The RFC 5869 s2.3 chain is identical
183// for either hash; only the block size and the 255-block ceiling move.
184func hkdf_expand_h(prk: *u8, hash_len: i64,
185 info: *u8, info_len: i64,
186 l: i64, out: *u8) -> i64 {
187 if hash_len != HKDF_HASH { if hash_len != HKDF_HASH384 { return 0 - 2 } }
188 var maxl: i64 = HKDF_MAX_L
189 if hash_len == HKDF_HASH384 { maxl = HKDF_MAX_L384 }
190 if l > maxl { return 0 - 1 }
191 if l < 0 { return 0 - 1 }
192
193 let t_prev: *u8 = sys_mmap(hash_len)
194 let t_curr: *u8 = sys_mmap(hash_len)
195 let buf_cap: i64 = hash_len + info_len + 1
196 let buf: *u8 = sys_mmap(buf_cap)
197
198 var prev_len: i64 = 0
199 var produced: i64 = 0
200 var counter: i64 = 1
201 while produced < l {
202 var bi: i64 = 0
203 var k: i64 = 0
204 while k < prev_len { buf[bi + k] = t_prev[k]; k = k + 1 }
205 bi = bi + prev_len
206 k = 0
207 while k < info_len { buf[bi + k] = info[k]; k = k + 1 }
208 bi = bi + info_len
209 buf[bi] = counter & 0xFF
210 bi = bi + 1
211
212 if hash_len == HKDF_HASH384 {
213 hmac_sha384(prk, hash_len, buf, bi, t_curr)
214 } else {
215 hmac_sha256(prk, hash_len, buf, bi, t_curr)
216 }
217
218 let remain: i64 = l - produced
219 var take: i64 = hash_len
220 if remain < hash_len { take = remain }
221 k = 0
222 while k < take { out[produced + k] = t_curr[k]; k = k + 1 }
223 produced = produced + take
224
225 k = 0
226 while k < hash_len { t_prev[k] = t_curr[k]; k = k + 1 }
227 prev_len = hash_len
228 counter = counter + 1
229 }
230 return 0
231}
232
233// Real KAT execution lives in runtime/nx_hkdf_test.nx (RFC 5869
234// §A.1-A.3, three worked SHA-256 examples). Removed stub main so
235// importing this module from a test or TLS-KDF file doesn't
236// double-define main.
237// SHA-384 coverage: runtime/_hdl_build/nx_hkdf384_gate.nx -- RFC 5869 A.1
238// through the new path (published vector), plus DEFINITIONAL identities
239// recomputed in the gate. No HKDF-SHA-384 test vector is published in RFC
240// 5869, and inventing one would be fabrication wearing a citation.