hmac_audit - xero/leviathan-crypto GitHub Wiki
HMAC-SHA256 Cryptographic Audit
Audit of the leviathan-crypto WebAssembly HMAC-SHA256 implementation against RFC 2104 and FIPS 198-1, verified against all RFC 4231 test vectors.
Table of Contents
| Meta | Description |
|---|---|
| Conducted: | Week of 2026-03-25 |
| Target: | leviathan-crypto WebAssembly implementation (AssemblyScript) |
| Spec: | RFC 2104 (HMAC, February 1997); FIPS 198-1 (The Keyed-Hash MAC, July 2008) |
| Test vectors: | RFC 4231 |
[!NOTE] The SHA-256 implementation has been audited separately in sha2_audit.md. This audit treats SHA-256 as a verified black box and focuses exclusively on the HMAC construction layered on top.
1. Algorithm Correctness
1.1 Key Processing
RFC 2104 specifies how to process keys of various lengths. Our implementation splits this across two layers:
TypeScript layer (src/ts/sha2/index.ts:169–186, HMAC_SHA256.hash):
let k = key;
if (k.length > 64) {
this.x.sha256Init();
feedHash(this.x, k, this.x.getSha256InputOffset(), 64, this.x.sha256Update);
this.x.sha256Final();
k = mem.slice(this.x.getSha256OutOffset(), this.x.getSha256OutOffset() + 32);
}
mem.set(k, this.x.getSha256InputOffset());
this.x.hmac256Init(k.length);
WASM layer (src/asm/sha2/hmac.ts:63–79, hmac256Init):
for i = 0..keyLen-1: ipad[i] = K[i] ^ 0x36; opad[i] = K[i] ^ 0x5c
for i = keyLen..63: ipad[i] = 0x36; opad[i] = 0x5c
| Key length | RFC 2104 §3 requirement | Implementation |
|---|---|---|
len(K) > B (> 64) |
K' = H(K), then zero-pad to B |
TypeScript pre-hashes to 32 bytes via sha256Init/Update/Final, passes 32-byte result to hmac256Init(32) |
len(K) < B (< 64) |
Zero-pad K to B bytes | hmac256Init loop: keyLen..63 set to 0x00 ^ ipad = 0x36 and 0x00 ^ opad = 0x5c |
len(K) == B (== 64) |
Use as-is | hmac256Init(64). Padding loop range 64..63 is empty, no modification. |
len(K) == L (== 32) |
Zero-pad to B (not hashed, 32 < 64) | TypeScript: 32 > 64 is false, skip hashing. hmac256Init(32) pads bytes 32–63. |
The edge case of len(K) == L (32 bytes, the hash output length) is handled correctly: the key is shorter than B, so it is zero-padded without hashing. This matches RFC 2104 §3 precisely.
[!NOTE] The long-key path (
len(K) > 64) is handled entirely in TypeScript, not in WASM. Thehmac256InitWASM function has akeyLen ≤ 64precondition. TheSHA256_INPUT_OFFSETbuffer is only 64 bytes. The TypeScript wrapper enforces this by pre-hashing long keys before calling the WASM layer. This is documented inhmac.ts:47–49.
1.2 ipad and opad Constants
The ipad and opad values are applied in hmac256Init (hmac.ts:66–74):
| Constant | RFC 2104 §2 | Implementation |
|---|---|---|
| ipad | 0x36 repeated B times |
kb ^ 0x36 for key bytes; 0x36 for pad bytes (hmac.ts:68, 72) |
| opad | 0x5c repeated B times |
kb ^ 0x5c for key bytes; 0x5c for pad bytes (hmac.ts:69, 73) |
The values 0x36 and 0x5c are correct. They are applied across the full 64-byte (B) key block. Both loops together cover indices 0 through 63, producing 64 bytes each for HMAC256_IPAD_OFFSET and HMAC256_OPAD_OFFSET.
The pad-byte branch (hmac.ts:72–73) writes 0x36 and 0x5c directly. This is equivalent to 0x00 ^ 0x36 = 0x36 and 0x00 ^ 0x5c = 0x5c, since the zero-padded key bytes are 0x00. The optimization avoids an unnecessary XOR with zero.
1.3 Inner and Outer Hash
Inner hash: H((K' ^ ipad) || message)
hmac256Init (hmac.ts:76–78):
sha256Init() // reset SHA-256 state
memory.copy(SHA256_INPUT_OFFSET, HMAC256_IPAD_OFFSET, 64) // copy ipad key block
sha256Update(64) // process ipad block
After hmac256Init, the SHA-256 state contains the intermediate hash of the 64-byte ipad block. The caller then feeds message data via hmac256Update, which passes through directly to sha256Update (hmac.ts:83–85).
Outer hash: H((K' ^ opad) || inner_hash)
hmac256Final (hmac.ts:88–101):
sha256Final() // finalize inner hash → SHA256_OUT_OFFSET
memory.copy(HMAC256_INNER_OFFSET, SHA256_OUT_OFFSET, 32) // save inner hash (32 bytes)
sha256Init() // reset for outer hash
memory.copy(SHA256_INPUT_OFFSET, HMAC256_OPAD_OFFSET, 64) // copy opad key block
sha256Update(64) // process opad block
memory.copy(SHA256_INPUT_OFFSET, HMAC256_INNER_OFFSET, 32) // load inner hash
sha256Update(32) // process inner hash
sha256Final() // finalize outer hash → SHA256_OUT_OFFSET
The outer hash processes exactly B + L = 64 + 32 = 96 bytes:
- 64 bytes:
K' ^ opad(the opad key block) - 32 bytes: the inner hash output
This matches the RFC 2104 §2 definition exactly.
[!NOTE] Step 2 (
memory.copytoHMAC256_INNER_OFFSET) is essential.sha256Init()in step 3 clears the SHA-256 hash state atSHA256_H_OFFSET, andsha256Final()writes its output toSHA256_OUT_OFFSET. Without saving the inner hash to a separate buffer first, the outer hash setup would overwrite it. The implementation correctly usesHMAC256_INNER_OFFSET(a dedicated 32-byte buffer at offset 588) as temporary storage.
No accidental truncation or extension: The inner hash is always 32 bytes (the output of sha256Final), and the outer hash always processes exactly those 32 bytes. The memory.copy operations use explicit lengths (32 bytes). No variable-length copies that could silently truncate or extend.
1.4 Test Vector Verification
All test vectors are sourced from RFC 4231 §4. The implementation passes all vectors including the key-longer-than-block edge case:
| Test Case | Key | Message | Expected HMAC-SHA256 | Status |
|---|---|---|---|---|
| TC1 (§4.2): short key | 0x0b × 20 |
"Hi There" | b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7 |
PASS |
| TC2 (§4.3): key shorter than block | "Jefe" (4 bytes) | "what do ya want for nothing?" | 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964a86910 |
PASS |
| TC3 (§4.4): data longer than block | 0xaa × 20 |
0xdd × 50 |
773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe |
PASS |
| TC4 (§4.5): combined lengths | 0102030405060708090a0b0c0d0e0f10111213141516171819 (25 bytes) |
0xcd × 50 |
82558a389a443c0ea4cc819899f2083a85f0faa3e578f8077a2e3ff46729665b |
PASS |
| TC5 (§4.6): truncation (not used) | 0x0c × 20 |
"Test With Truncation" | a3b6167473100ee06e0c796c2955552bfa6f7c0a6a8aef8b93f860aab0cd20c |
PASS |
| TC6 (§4.7): key longer than block | 0xaa × 131 |
"Test Using Larger Than Block-Size Key - Hash Key First" | 60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54 |
PASS |
| TC7 (§4.8): key longer than block + long data | 0xaa × 131 |
"This is a test using a larger than block-size key and a larger than block-size data. The key needs to be hashed before being used by the HMAC algorithm." | 9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2 |
PASS |
TC6 is the most commonly broken edge case. It requires the key to be pre-hashed with SHA-256 before the HMAC construction. TC7 exercises the same long-key path and a multi-block message simultaneously, making it the most comprehensive single vector. The TypeScript wrapper's k.length > 64 guard (index.ts:172) correctly triggers SHA-256 pre-hashing, producing a 32-byte derived key that is then zero-padded to 64 bytes by hmac256Init.
The test suite (test/unit/sha2/hmac.test.ts) runs all seven HMAC-SHA256 vectors plus a cross-check vector generated from the leviathan TypeScript reference implementation. Gate test 5 is HMAC-SHA256 TC1 (hmac.test.ts:45–53).
1.5 Buffer Layout and Memory Safety
The HMAC-SHA256 buffers reside in the SHA-2 WASM module's linear memory (src/asm/sha2/buffers.ts):
| Offset | Size | Name | Purpose |
|---|---|---|---|
| 384 | 64 | SHA256_INPUT_OFFSET |
Key staging (hmac256Init) / message staging (hmac256Update) |
| 460 | 64 | HMAC256_IPAD_OFFSET |
K' ^ ipad, inner key material |
| 524 | 64 | HMAC256_OPAD_OFFSET |
K' ^ opad, outer key material |
| 588 | 32 | HMAC256_INNER_OFFSET |
Inner hash saved before outer pass |
| 352 | 32 | SHA256_OUT_OFFSET |
Final HMAC output (shared with SHA-256 digest output) |
| 0 | 32 | SHA256_H_OFFSET |
SHA-256 hash state H0–H7 (shared, overwritten per pass) |
No aliasing between inner and outer hash states: The inner hash result is saved to HMAC256_INNER_OFFSET (offset 588) before sha256Init() resets SHA256_H_OFFSET (offset 0) for the outer pass. These buffers are 588 bytes apart with no overlap.
Buffer non-overlap verification:
| Buffer | Start | End | Next buffer start |
|---|---|---|---|
SHA256_INPUT_OFFSET |
384 | 447 | SHA256_PARTIAL_OFFSET = 448 |
HMAC256_IPAD_OFFSET |
460 | 523 | HMAC256_OPAD_OFFSET = 524 |
HMAC256_OPAD_OFFSET |
524 | 587 | HMAC256_INNER_OFFSET = 588 |
HMAC256_INNER_OFFSET |
588 | 619 | SHA512_H_OFFSET = 620 |
All buffers are contiguous and non-overlapping.
Key material wiping: wipeBuffers() (src/asm/sha2/index.ts:41–43) performs memory.fill(0, 0, 1976), which zeroes the entire SHA-2 module memory from offset 0 to 1975. This covers all HMAC buffers including:
HMAC256_IPAD_OFFSET(K' ^ ipad, contains key-derived material)HMAC256_OPAD_OFFSET(K' ^ opad, contains key-derived material)HMAC256_INNER_OFFSET(inner hash, derived from key)SHA256_INPUT_OFFSET(may contain key bytes duringhmac256Init)SHA256_H_OFFSET(hash state, contains key-dependent intermediate values)
[!NOTE] The
wipeBuffersimplementation uses a singlememory.fill(0, 0, 1976)call rather than individually zeroing each buffer. This is correct and sufficient. It zeroes the entire module memory, a superset of all sensitive buffers. There are no buffers that live outside the 0–1975 byte range.
Stack-allocated vs heap-allocated: All buffers are static offsets in WASM linear memory. There is no heap allocation (memory.grow() is not used). No intermediate values are stored in local variables that could leak to WASM shadow stack. All accumulator state lives in the fixed-offset buffers.
1.6 TypeScript Wrapper Layer
HMAC_SHA256 (src/ts/sha2/index.ts:163–191):
init() gate: The constructor calls getExports() → getInstance('sha2'), which throws if init('sha2') has not been called. No class silently auto-initializes.
Input validation: The hash(key, msg) method accepts arbitrary-length Uint8Array inputs for both key and message. There is no minimum key length enforced. This is discussed in §2.3. The long-key path (k.length > 64) is validated by the TypeScript guard.
Message feeding: The feedHash helper (index.ts:86–96) writes message data to SHA256_INPUT_OFFSET in 64-byte chunks, calling hmac256Update for each chunk. The Math.min(msg.length - pos, 64) bound ensures no write exceeds the 64-byte staging buffer.
Output: out.slice(...) creates a copy of the 32 bytes at SHA256_OUT_OFFSET. The .slice() call returns an independent Uint8Array. The caller cannot observe subsequent WASM memory writes. The output is always exactly 32 bytes (HMAC-SHA256 output length L).
dispose(): Calls this.x.wipeBuffers(), which zeroes all SHA-2 module memory including all HMAC key-derived material.
2. Security Analysis
2.1 Length Extension Immunity
SHA-256 is vulnerable to length extension attacks: given H(m) and len(m), an attacker can compute H(m || padding || m') without knowing m. This is because SHA-256's Merkle-Damgard construction exposes the internal state in the digest output.
HMAC is specifically designed to be immune to this attack. The outer hash wraps the inner hash output:
HMAC(K, m) = H((K' ^ opad) || H((K' ^ ipad) || m))
An attacker who knows HMAC(K, m) knows the output of the outer hash, but:
- The outer hash input is
(K' ^ opad) || inner_hash. The attacker does not knowK' ^ opad. - Even if the attacker could extend the outer hash, they would need to produce
H((K' ^ ipad) || m || padding || m')for the inner hash. But they don't know the inner hash's input prefixK' ^ ipad. - The two-layer structure ensures that extending either hash requires knowledge of
K'.
Structural verification: In the implementation, the outer hash input is constructed as:
memory.copy(SHA256_INPUT_OFFSET, HMAC256_OPAD_OFFSET, 64) // K' ^ opad
sha256Update(64)
memory.copy(SHA256_INPUT_OFFSET, HMAC256_INNER_OFFSET, 32) // inner hash
sha256Update(32)
sha256Final()
The outer hash processes exactly opad_block || inner_hash. There is no mechanism to extend this input from outside the function. The hmac256Final function is not a streaming API that accepts additional data between the opad block and the inner hash. Length extension immunity holds structurally.
2.2 Security Bound
HMAC security rests on two pillars (Bellare, Canetti, Krawczyk 1996; Bellare 2006):
-
PRF security: HMAC-SHA256 is a secure PRF if the SHA-256 compression function is a PRF. Under this assumption, the advantage of any adversary in distinguishing HMAC-SHA256 from a random function is negligible.
-
MAC security (forgery resistance): An adversary making
qqueries of total lengthlblocks can forge with probability at most:
$$\epsilon_{\text{forge}} \leq \frac{q^2}{2^{256}} + \epsilon_{\text{PRF}}(q, l)$$
The q^2 / 2^{256} term comes from the birthday bound on the outer hash. For any practical query volume, this is negligible.
Concrete bounds:
- Key recovery: 2^256 (brute force against the 256-bit key space when
len(K) ≥ 32) - Forgery: Approximately 2^128 security (limited by SHA-256's collision resistance)
- Distinguishing from random: 2^256 (PRF bound, assuming SHA-256 compression function is a PRF)
These bounds assume the key is uniformly random and at least L = 32 bytes.
2.3 Key Size Recommendations
| Key length | Security implication | RFC 2104 guidance |
|---|---|---|
< L (< 32 bytes) |
Security degrades below 128-bit forgery resistance | "The key for HMAC can be of any length. However, less than L bytes is strongly discouraged" (§3) |
= L (32 bytes) |
Full 128-bit forgery resistance, 256-bit key recovery | Recommended minimum |
> L, ≤ B (33–64 bytes) |
No additional security (key space is 256 bits regardless) | Acceptable. Zero-padded to B. |
> B (> 64 bytes) |
Effective key reduced to H(K) = 32 bytes |
"Keys longer than B bytes are first hashed using H" (§3) |
API enforcement: leviathan-crypto does not enforce a minimum key length at the API level. HMAC_SHA256.hash() accepts keys of any length including zero bytes. This is a deliberate design choice. The library is a low-level cryptographic primitive, and key length enforcement is the caller's responsibility.
[!NOTE] Using a key longer than 64 bytes does not improve security. The key is pre-hashed to 32 bytes. Applications that derive HMAC keys from HKDF or similar KDFs should target 32-byte output, not longer.
2.4 Usage Context in leviathan-crypto
HMAC-SHA256 is used as a building block in three contexts within leviathan-crypto:
Seal with SerpentCipher (STREAM construction, Encrypt-then-MAC)
SerpentCipher (src/ts/serpent/cipher-suite.ts) implements the CipherSuite interface consumed by Seal, SealStream, and OpenStream. Each call to sealChunk / openChunk performs SerpentCBC + HMAC-SHA256 in Encrypt-then-MAC configuration. Key material is derived per-stream by HKDF, not supplied directly by the caller. See the next section.
SerpentCipher (CBC + HMAC + HKDF)
SerpentCipher (src/ts/serpent/cipher-suite.ts) uses HKDF-SHA256 to derive three keys from a master key at stream construction:
const derived = hkdf.derive(masterKey, nonce, INFO, 96);
// bytes[0:32]=enc_key, bytes[32:64]=mac_key, bytes[64:96]=iv_key
The three keys provide:
- Key separation:
enc_key,mac_key, andiv_keyare derived from disjoint portions of the HKDF output (T(1), T(2), T(3)) - Position binding: The HMAC covers
counterNonce ‖ u32be(aad_len) ‖ aad ‖ ciphertext, binding authentication to chunk position and associated data - IV derivation: Per-chunk CBC IV is
HMAC-SHA-256(iv_key, counterNonce)[0:16], deterministically derived on both sides
HKDF-SHA256
HKDF_SHA256 uses HMAC-SHA256 as both the extract and expand PRFs (RFC 5869). The HMAC implementation's correctness is a prerequisite for HKDF's security. See hkdf_audit.md. A post-audit hardening pass zeroed all intermediate key material (buf, prev blocks, and PRK) in expand() and derive() to minimise heap residue; see hkdf_audit.md §1.5.
Assessment: Key separation is correctly implemented in all usage contexts. The encryption key and MAC key are always derived from independent key material, either by splitting a longer key or by HKDF derivation with distinct info strings.
Cross-References
| Document | Description |
|---|---|
| index | Project Documentation index |
| architecture | architecture overview, module relationships, buffer layouts, and build pipeline |
| sha2_audit | SHA-256 implementation audit (HMAC builds on SHA-256) |
| hkdf_audit | HKDF builds on HMAC-SHA256 |
| serpent_audit | HMAC-SHA256 used in SerpentCipher §2.4 |
| chacha_audit | XChaCha20-Poly1305 uses a different MAC (Poly1305) |
| sha3_audit | SHA-3 companion audit |