Skip to content

hmac_drbg: own the DRBG key; HKDF/Finished no longer leak K - #279

Merged
JC-000 merged 3 commits into
masterfrom
fix/drbg-own-key
Oct 6, 2026
Merged

JC-000 merged 3 commits into
masterfrom
fix/drbg-own-key

Conversation

@JC-000

@JC-000 JC-000 commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Defect

hmac_key is the key input of hmac_sha256. HKDF and the Finished MAC write it, and it was also the HMAC_DRBG's K. Every handshake therefore left K holding a key-schedule value the peer knows; after tls_derive_traffic_keys that is the server application traffic secret.

A second 'G' in the same boot then drew client_random, which is sent in clear, and the ECDHE private key from that K:

K' = HMAC(Kx, R || 00), V' = HMAC(K', R), privkey = HMAC(K', V')

So anyone who knew the previous session's key schedule could compute the next session's ECDHE key and decrypt that session passively. This includes a MITM whose handshake the client rejected at the certificate, because the handshake keys are derived before verification. A retry after a rejected MITM is exactly that second 'G'.

Fix

  • 1c51d39: K is now drbg_k, 32 B of BSS in src/crypto/hmac_drbg.s, and it is not exported. Any other module that names it fails the link. drbg_hmac (11 B) loads it into hmac_key before each DRBG HMAC.
  • f0c878f (defence in depth): tls_connect calls drbg_reseed, an SP 800-90A update with 32 fresh SID/CIA bytes on the running state, before its draws. That entropy is unmeasured; the separation in 1c51d39 is the fix.
  • f49d8ab: tests only (model the reseed, pin drbg_hmac) and an honest reseed comment. Every PRG is byte-identical to f0c878f.

Evidence

Tests:

  • tools/test_drbg_isolation.py (VICE): key-schedule HMACs leave K and V untouched; a host model of the DRBG reproduces tls_connect's draws; the attacker's prediction does not give the ECDHE key.
  • tools/test_drbg_state_owner.py (pytest, no build): only hmac_drbg.s names drbg_k or stores V.
Build drbg_isolation drbg_state_owner
master 249f65d (ip65, PRG 5f261d3c…) 2/8: both prediction cases FAIL, the predicted privkey equals the actual one 3 of 5 FAIL
this branch (ip65, PRG a361dbb6…) 8/8 5/5

Mutants, each red:

  • re-alias drbg_k to hmac_key
  • generate keyed via hmac_sha256
  • reseed call deleted
  • reseed with no entropy
  • reseed with a constant seed
  • reversed K copy

Hardware: U64E, fw 3.15 3a1ff9ff, FPGA 126, core 1.50, 48 MHz, uci-onchip build.

  • rig_https_banner against en.wikipedia.org/wiki/Commodore_64 (PRG 0dbfd115…): PASS, 751,196 B equals Content-Length.
  • Two 'G' in one boot with the same PRG: en.wikipedia.org/robots.txt returned HTTP 200, then lwn.net returned HTTP 200. Both closed cleanly.

Size

LOADER is down 3 B and NET_CODE down 24 B on every profile. drbg_k takes 32 B of the ip65 COLD_SHADOW alignment hole. uci-m3 is unchanged (b124ea9f…).

PRG sha256 at f49d8ab:

  • ip65 a361dbb6
  • ip65-onchip 275bd5cc
  • uci 214ce9c5
  • uci-onchip 2e24da0f
  • uci-comb 30f34930

Note: f0c878f's message says "+15 B in CRYPTO_AUX_CODE2". That is wrong; f49d8ab corrects it: drbg_hmac +11 B, the reseed split +13 B, NET_CODE -24 B overall.

🤖 Generated with Claude Code

JC-000 and others added 3 commits October 4, 2026 21:26
hmac_key is the key input of hmac_sha256. HKDF (extract and expand) and
the Finished MAC write it, and it was also the HMAC_DRBG's K. Every
handshake therefore left K holding a key-schedule value the peer knows
(after tls_derive_traffic_keys: the server application traffic secret),
and the next tls_connect in the same boot drew client_random (sent in
clear) and then the ECDHE private key from that K:

    K' = HMAC(Kx, R || 00), V' = HMAC(K', R), privkey = HMAC(K', V')

so whoever knew the previous session's key schedule could compute the
next session's ECDHE key and decrypt it passively. That includes a MITM
whose handshake the client rejected at the certificate: the handshake
keys are derived before verification.

K is now drbg_k, 32 B of BSS defined in src/crypto/hmac_drbg.s and not
exported, so no other module can name it without failing the link.
drbg_hmac (11 B, falls into hmac_sha256) loads it into hmac_key before
each of the DRBG's five HMACs; the three K stores go to drbg_k.

Tests:
  tools/test_drbg_isolation.py (VICE, run_all_tests "drbg_isolation"):
    every key-schedule HMAC user leaves K and V byte-identical; a host
    HMAC_DRBG from the image's own K,V reproduces tls_connect's two draws
    (model check); the attacker's prediction from the hmac_key residue
    plus the wire client_random does not give the ECDHE key, after a full
    key schedule and after handshake keys only.
  tools/test_drbg_state_owner.py (pytest, no build): drbg_k defined only
    in hmac_drbg.s and not exported; no DRBG routine calls hmac_sha256 or
    stores hmac_key directly; only hmac_drbg.s stores hmac_val.

Red/green (VICE, -reu -reusize 512):
  master 249f65d, ip65 default    drbg_isolation 2/7: all three isolation
                                  cases and both prediction cases FAIL
                                  (predicted privkey == actual in both
                                  scenarios); only the two model checks
                                  pass. state_owner 2/4.
  master, uci-onchip (45bea88a)   same 5 FAIL (reviewer's PoC also
                                  PREDICTED: True on this image)
  this commit, ip65 default       drbg_isolation 7/7, state_owner 4/4
                                  (PRG e903c753...)
Mutations (each on this commit):
  drbg_k = hmac_key (re-alias)    drbg_isolation 2/7 (the same 5 FAIL),
                                  state_owner FAIL "no longer defines"
  generate's HMAC via hmac_sha256 drbg_isolation 5/7 (both model checks
                                  FAIL), state_owner FAIL "calls
                                  hmac_sha256 directly"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Defence in depth behind the previous commit. The DRBG was seeded once at
boot and then only stepped, so every connection's client_random and ECDHE
key were a deterministic function of the boot seed and of every draw
since. drbg_reseed collects 32 fresh SID osc3 ^ CIA1 timer A bytes and
runs update(seed) on the RUNNING state (SP 800-90A reseed: K and V are
kept, so entropy already in the state is not discarded), then forces a
fresh generate. tls_connect calls it before its two draws.

drbg_init_entropy is split into drbg_collect_seed + instantiate, sharing
the tail with drbg_reseed: +15 B in CRYPTO_AUX_CODE2, +3 B in tls13.s
CODE, no BSS. Cost at run time is four HMACs per connection.

The separation, not this, is the fix: SID/CIA entropy quality is not
measured, and test_drbg_isolation.py's prediction cases deliberately drive
the draws WITHOUT tls_connect, so they keep testing the separation alone.

Test: test_drbg_isolation.py gains "tls_connect reseeds before drawing" --
the real tls_connect (callees after the draws stubbed) must not produce
generate(K, V) of the state it was entered with.
  master 249f65d            FAIL (client_random == generate(K, V))
  this commit               8/8 (default ip65 build, PRG a361dbb6...)
  this commit, reseed call
  deleted from tls13.s      7/8, exactly this case FAIL

Branch head, all profiles (measure_margins --build; uci-m3 links no DRBG
and is byte-identical to master, b124ea9f...):
  ip65 a361dbb6  ip65-onchip 275bd5cc  uci 214ce9c5  uci-onchip 2e24da0f
  uci-comb 30f34930   LOADER -3 B, NET_CODE -24 B on every profile;
  drbg_k takes 32 B of the ip65 COLD_SHADOW alignment hole (103 -> 71).
  Reviewer's PoC on uci-onchip 2e24da0f: PREDICTED: False.

Hardware, U64E fw 3.15 3a1ff9ff / FPGA 126 / core 1.50, 48 MHz, onchip:
  rig_https_local (HTTPS_SNI=www.foo.invalid, PRG 09c31fd1...): PASS
  rig_https_banner (en.wikipedia.org /wiki/Commodore_64, PRG 0dbfd115...):
    PASS, 751,196 B == Content-Length
  two 'G' in one boot, same PRG (scratch driver over the banner rig's
    helpers): en.wikipedia.org /robots.txt HTTP 200, 28,275 B ==
    Content-Length; then lwn.net / HTTP 200, terminal chunk after
    26,119 B. Both closed cleanly to the menu.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Adversarial-review follow-ups on f0c878f. No code bytes change: every
profile's PRG hash is identical to f0c878f (ip65 a361dbb6, ip65-onchip
275bd5cc, uci 214ce9c5, uci-onchip 2e24da0f, uci-comb 30f34930, ip65-wiki
2b458d3b, ip65-onchip-wiki 2800f819, uci-comb-demo-store fcf93260) and
measure_margins --build is byte-identical apart from its temp paths.

test_drbg_isolation.py, "tls_connect reseeds before drawing" (patch from
the review): reads drbg_seed back and requires client_random ==
generate(update(K, V, seed)) -- which is also the first behavioural model
check of update-with-seed -- then re-enters from the same K, V and
requires a different seed and a different client_random. The old check
(client_random != generate(K, V)) passed three mutants:
                       old       now
  noentropy            8/8       7/8  (== generate(update) FAIL, seed FAIL)
  constseed            8/8       7/8  (different-seed FAIL)
  step5raw             8/8       7/8  (== generate(update) FAIL; also
                                       state_owner FAIL)

test_drbg_state_owner.py: drbg_hmac must be exactly the drbg_k ->
hmac_key copy falling into hmac_sha256. Mutant revcopy (copy the other
way) passed the static guard before; now state_owner FAIL
(test_drbg_hmac_is_exactly_the_k_load), and VICE 5/8 (both model checks
and the reseed case FAIL).

hmac_drbg.s: the drbg_reseed comment claimed "32 fresh SID+CIA bytes".
CIA1 timer A decrements at a fixed rate between reads and osc3 moves
slowly, so a sample carries roughly the entropy of its start instant;
the comment now says so and that it is unmeasured.

Not done: zeroing hmac_key/hmac_opad_block after the DRBG's HMACs. It is
hygiene only (the residue is reachable by nothing, and 'Q''s scrub covers
it) and costs more than 6 B, since drbg_hmac falls into hmac_sha256 and a
post-call wipe needs a wrapper.

Correction to f0c878f's message: "+15 B in CRYPTO_AUX_CODE2" is wrong.
Measured: drbg_hmac +11 B (1c51d39), the reseed split +13 B (f0c878f),
NET_CODE -24 B overall, as its own margin line says.

Red/green (VICE, -reu -reusize 512, ip65 default):
  master 249f65d (PRG 5f261d3c)  drbg_isolation 2/8 (only the two model
                                 checks pass), state_owner 3 FAIL of 5
  this commit (PRG a361dbb6)     drbg_isolation 8/8, state_owner 5/5

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant