Skip to content

Add hmac module: CPython-compatible HMAC backed by PSA Crypto - #11341

Open
mmabey wants to merge 2 commits into
adafruit:mainfrom
mmabey:mabey/hmac-module
Open

Add hmac module: CPython-compatible HMAC backed by PSA Crypto#11341
mmabey wants to merge 2 commits into
adafruit:mainfrom
mmabey:mabey/hmac-module

Conversation

@mmabey

@mmabey mmabey commented Sep 10, 2026

Copy link
Copy Markdown

Adds an hmac module: keyed-hash message authentication (HMAC), following the CPython hmac API and backed by PSA Crypto.

This is a small, self-contained piece split out of the discussion on #11319. In that review @tannewt suggested that HMAC should go through a CPython-standard hmac module rather than living on the key object. This PR is that module. It is useful on its own with bytes keys; #11319 will be reworked so that its hardware-held key object is accepted by hmac.new() in place of bytes (a small, additive change on top of this).

What's added

import hmac

h = hmac.new(key, b"message", digestmod="sha256")
h.update(b" more")
h.hexdigest()                       # -> str
h.digest()                          # -> bytes
h.digest_size, h.block_size, h.name # 32, 64, "hmac-sha256"
h.copy()

hmac.digest(key, b"message", "sha256")        # one-shot -> bytes
hmac.compare_digest(mac_a, mac_b)             # constant-time
  • digestmod accepts "sha256" or "sha1". (CPython also accepts a hashlib constructor or module; those are not supported here — a name string only.)
  • key is any bytes-like object.
  • hmac.HMAC is exposed for isinstance checks.

Implementation notes

  • Same three-layer layout as hashlib: shared-bindings/hmac/ + shared-module/hmac/, with no common-hal — every operation is psa_mac_compute() / a PSA key import, so any port with a PSA Crypto backend gets it unchanged.
  • The key is kept as an owned copy and imported into PSA as a volatile PSA_KEY_TYPE_HMAC key for the duration of each digest() call, then destroyed. There is no finaliser and no way to leak a PSA key slot.
  • Bytes fed via update() are buffered, so digest() is a single one-shot psa_mac_compute(). That keeps copy(), calling digest() more than once, and update() after digest() all behaving like CPython (PSA Crypto has no psa_mac_clone() for a streaming MAC operation).
  • compare_digest() mirrors CPython's _tscmp: the work done depends only on len(a), and a length mismatch still runs the loop before returning False.
  • Keys longer than the hash block size are reduced per RFC 2104 by PSA internally; no special-casing here.

Build scope

New CIRCUITPY_HMAC flag, on by default wherever a full PSA Crypto build that already includes HMAC is present — i.e. CIRCUITPY_HASHLIB_MBEDTLS and not CIRCUITPY_HASHLIB_MBEDTLS_ONLY. That covers espressif (ESP-IDF's mbedtls) and SSL builds.

The CIRCUITPY_HASHLIB_MBEDTLS_ONLY subset (e.g. nordic) does not currently ship the PSA MAC driver, and ports/zephyr-cp would need CONFIG_PSA_WANT_ALG_HMAC. Enabling those is left for a follow-up so this PR stays small.

Testing

  • tests/circuitpython/hmac.py — RFC 4231 (SHA-256) and RFC 2202 (SHA-1) test vectors, plus empty message, block-size key reduction, incremental vs. one-shot, copy() independence, update() after digest(), digest()/hexdigest() agreement, hmac.digest(), metadata, an unsupported-algorithm ValueError, and compare_digest cases (equal / unequal / different length / bytes vs bytearray vs memoryview). The module isn't in the unix coverage variant, so this test SKIPs under run-tests; it runs on any build that has the module.
  • Built and run on an ESP32-S3-DevKitC-1-N8R8. Every digest matches host CPython hmac / openssl dgst -sha256 -mac HMAC. As a cross-check, hmac.new() with the raw bytes of a key that was burned into an eFuse HMAC_UP block produces exactly the MAC that the ESP32-S3 HMAC peripheral produces for the same key in Add securekey module for hardware-held cryptographic keys #11319 — so the two approaches interoperate.
  • pre-commit passes.

Docs

API docs are generated from the //| docstrings; the module appears under the shared-bindings reference automatically.

New shared-bindings/shared-module module `hmac`, mirroring the CPython
`hmac` API and the three-layer split already used by `hashlib` (bindings +
shared-module on the PSA Crypto interface, no common-hal).

  - hmac.new(key, msg=b"", digestmod) -> HMAC
  - hmac.digest(key, msg, digest) -> bytes   (one-shot)
  - hmac.compare_digest(a, b) -> bool        (constant time)
  - HMAC: update, digest, hexdigest, copy, digest_size, block_size, name

`digestmod` accepts "sha256" or "sha1". The key is a bytes-like object;
it is kept as an owned copy and imported into PSA as a volatile
PSA_KEY_TYPE_HMAC key for each digest() call, then destroyed -- so there
is no finaliser and no PSA key-slot leak. The message fed via update() is
buffered so digest() is a single psa_mac_compute(), which keeps copy(),
repeated digest(), and update()-after-digest() all CPython-compatible
(PSA has no psa_mac_clone()).

Build flag CIRCUITPY_HMAC, enabled where a full PSA crypto build with
HMAC is already present (espressif's ESP-IDF mbedtls, SSL builds). The
CIRCUITPY_HASHLIB_MBEDTLS_ONLY subset does not yet ship the PSA MAC
driver, so those ports (and zephyr-cp) are left for a follow-up.

Verified on an ESP32-S3-DevKitC-1-N8R8 against RFC 4231 / RFC 2202
vectors and host openssl.

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I'd prefer to not support copy() in favor of streaming digest. That will remove allocations that happen as update() is called. Just have copy() raise not implemented error.

You'll need to switch to the PSA crypto's multipart API. The hash object should be able to store the multipart state.

Comment thread shared-module/hmac/__init__.c Outdated
@mmabey

mmabey commented Sep 11, 2026

Copy link
Copy Markdown
Author

Thanks for the review! Digging into the multipart API, I ran into a design snag I want to flag before picking a direction.

PSA's multipart MAC API (psa_mac_sign_setuppsa_mac_updatepsa_mac_sign_finish) is one-shot: once psa_mac_sign_finish() is called, that operation is done; there's no way to resume it or peek at an intermediate MAC and keep updating.

That conflicts with digest()'s current contract here (and with CPython's own documented hmac.HMAC.digest() behavior): "The object can still be updated after this call." CPython's hashlib-backed implementations support that because the underlying hash state can be cheaply duplicated (e.g. OpenSSL's EVP_MD_CTX_copy): digest() clones the running state, finalizes the clone, and leaves the original untouched.

PSA does have an equivalent for plain hashes, psa_hash_clone(), but there's no psa_mac_clone() for MAC operations specifically. So switching update()/digest() straight onto psa_mac_* multipart, as described, would mean giving up repeatable digest() plus continued update(), a real behavior change from what's there now.

Three ways I can see to resolve this. I wanted your take before implementing one:

  1. Build HMAC by hand on psa_hash_* instead of psa_mac_*: implement the inner/outer-pad construction directly, using psa_hash_clone() for repeatable digest(). Keeps CPython-compatible semantics and gets the streaming/no-buffer win, at the cost of more code to write and verify ourselves instead of leaning on PSA's built-in MAC construction.
  2. Use psa_mac_* multipart as-is, and change digest() to be callable once: a second call, or update() after digest(), would raise. Simpler, matches your suggestion literally, but is a compatibility deviation from CPython's hmac module.
  3. Some other approach I haven't considered, happy to hear it.

Let me know which direction you'd prefer, or if I'm missing something about the multipart API that resolves this more cleanly.

Addresses tannewt's review comment on PR adafruit#11341: the hand-rolled
constant-time loop was fine in source but not guaranteed constant-time
after compiler optimization. Switch to mbedtls_ct_memcmp(), which uses
volatile accesses (and assembly on some platforms) specifically to
defeat that. It takes a single length, so the existing a_len/b_len
mismatch handling (compare a against itself, force a mismatch) stays
in place around it.

Verified this compiles and links on espressif_esp32s3_devkitc_1_n8r8.
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.

2 participants