Skip to content

Add securekey module for hardware-held cryptographic keys - #11319

Open
mmabey wants to merge 2 commits into
adafruit:mainfrom
mmabey:mabey/esp32-hmac-efuse-planning
Open

Add securekey module for hardware-held cryptographic keys#11319
mmabey wants to merge 2 commits into
adafruit:mainfrom
mmabey:mabey/esp32-hmac-efuse-planning

Conversation

@mmabey

@mmabey mmabey commented Sep 5, 2026

Copy link
Copy Markdown

Summary

Adds esphmac, a new Espressif-port-only module exposing the ESP32-S3's on-chip HMAC
peripheral as a compute-only primitive: esphmac.HMACKey(key_block) binds to one of the
six eFuse key blocks (BLOCK_KEY0-BLOCK_KEY5), and its only operations are
hmac(data) -> bytes and a read-only read_protected status check.

This is related to #3341 (Support the ESP32-S2's Digital Signature
Peripheral), but deliberately scoped to HMAC only, not the Digital Signature / RSA-signing
peripheral - that's a separate, larger surface this PR doesn't attempt.

Why

Once an eFuse key block's RD_DIS bit is set (which espefuse burn-key does by default),
the eFuse controller enforces in hardware that the raw key can never be read back by any
software, regardless of API design. The HMAC peripheral has an internal hardware path to
the key that bypasses that block-out, so it can still compute a correct HMAC-SHA256 using
a key that is otherwise permanently unreadable. This gives the chip a "use the secret,
never expose it" capability similar to what a discrete secure element (e.g. an ATSHA204A)
provides, without needing extra hardware.

This module is intentionally compute-only:

  • No API reads key material back, from this module or any other - that's enforced by the
    eFuse hardware itself once RD_DIS is set, not by this module's design.
  • No API burns or writes eFuse keys. That remains the job of the stock espefuse.py tool
    at manufacturing time, which already supports burning a key with the HMAC_UP purpose.
    Adding eFuse write access from CircuitPython is a materially different, higher-risk
    change than this PR, and is not included here.

Design notes

  • HMACKey.__init__ fails closed: it checks the target block's actual eFuse purpose via
    esp_efuse_get_key_purpose() and raises ValueError unless it's already HMAC_UP.
    This matters because esp_hmac_calculate() itself does not validate the purpose of
    the key block it's given - without this check, HMACKey could be pointed at a block
    reserved for flash encryption, secure boot, JTAG re-enable, or the DS peripheral by
    mistake.
  • read_protected reports whether RD_DIS is actually set on the bound block. It's
    informational only and does not gate whether hmac() can be called, since espefuse
    already sets RD_DIS by default when burning an HMAC_UP key - this is meant for
    manufacturing-time self-test code to confirm a key block was burned as expected.
  • Enabled by default on every Espressif target with SOC_HMAC_SUPPORTED (all except the
    original ESP32, which predates this peripheral).
  • No new ESP-IDF component wiring was needed: esp_security (which provides
    esp_hmac_calculate()) is already an unconditional component in this port's
    CMakeLists.txt, and is already a hard dependency of mbedtls, which the port needs
    for TLS regardless.

Testing

Built and flash-tested on a real ESP32-S3-DevKitC-1-N8R8
(espressif_esp32s3_devkitc_1_n8r8 board target):

  1. Built this branch locally with the ESP-IDF toolchain and flashed the resulting firmware
    to the board.
  2. Generated a throwaway 32-byte random test key and burned it into BLOCK_KEY5 with
    purpose HMAC_UP, using the stock espefuse burn-key BLOCK_KEY5 <keyfile> HMAC_UP
    (default read-protect left enabled, i.e. RD_DIS set).
  3. In the CircuitPython REPL on-device:
    import esphmac
    key = esphmac.HMACKey(5)
    print(key.read_protected)          # -> True
    print(key.hmac(b"test message").hex())
  4. Independently, on a host machine, computed hmac.new(key_bytes, b"test message", hashlib.sha256).hexdigest() using Python's standard library hmac module and the same
    key bytes used in the burn.
  5. The two hex digests matched exactly.

This confirms the full path end-to-end: the eFuse-bound key is not independently readable
(read_protected is True), yet hmac() still produces a correct, standard
FIPS-198/RFC-2104 HMAC-SHA256 result using it.

No automated test suite entries were added. Sibling Espressif-only modules with a similar
hardware dependency (espnow, espulp, espcamera) have none either, since tests/ runs
against the unix port and this functionality is inherently dependent on a real, manually
pre-burned eFuse key block.

Also verified as part of this PR:

  • tools/codeformat.py -c (Uncrustify) passes with no reformatting needed.
  • make check-translate passes (locale/circuitpython.pot updated with the three new
    MP_ERROR_TEXT() strings this adds).
  • pre-commit run passes.
  • tools/extract_pyi.py extracts a clean .pyi stub from the module's inline
    documentation with no errors, confirming the docs build cleanly.

@dhalbert dhalbert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for working on this.

Given our effort to be more port-agnostic, do you think you could come up with a more general API that would cover cryptography peripherals on other chips as well, and also eventually cover the other functionality on espressif? Then we would have more portable API's.

What is your use case for this functionality? Is it for Matter?

As we are moving toward Zephyr in the long run, looking at its API's may be helpful.

There is cryptography, the CPython library, but it is quite complicated, and the hardware-specific parts of it may be buried.

mmabey added a commit to mmabey/circuitpython that referenced this pull request Sep 5, 2026
Reworks PR adafruit#11319 per maintainer review (dhalbert): make the eFuse-bound
HMAC API port-agnostic instead of an espressif-only module.

- Remove esphmac / esphmac.HMACKey entirely (nothing merged depends on it).
- Add securekey.HardwareKey in shared-bindings + shared-module:
    HardwareKey(key_slot)              key_slot is port-defined
    .hmac_sha256(data) -> bytes        PSA psa_mac_compute
    .verify_hmac_sha256(data, mac)     PSA psa_mac_verify, constant time (new)
    .key_slot, .exportable            (exportable = old read_protected, inverted)
- Split follows os/hashlib: shared-module owns the PSA operations on a
  stored psa_key_id_t; the only per-port file is common-hal construct(),
  which maps the key slot to a psa_key_id_t. A second port (Zephyr's PSA
  build included) only needs that shim.
- espressif construct() imports an esp_hmac_opaque_key_t reference through
  ESP-IDF's vendored PSA opaque-key driver (built for every
  SOC_HMAC_SUPPORTED chip), caching one import per eFuse block so repeated
  construction does not leak PSA key slots. Still fail-closed on the
  HMAC_UP eFuse purpose; still no raw-key read and no burn/write.
- CIRCUITPY_SECUREKEY: default off, on for espressif HMAC-capable chips
  (off for esp32 / esp32c2 / esp32c61). Drop the CIRCUITPY_ESPHMAC wiring
  and the esphmac SRC block in ports/espressif/Makefile.
- Update locale/circuitpython.pot.
@mmabey

mmabey commented Sep 5, 2026

Copy link
Copy Markdown
Author

Thanks for working on this.

My pleasure! CircuitPython has given me a lot of joy over the years, so I'm always happy when a chance arises to contribute back.

What is your use case for this functionality? Is it for Matter?

No, I'm helping build a medical device that needs to compute an HMAC using a key that we obviously don't want to be readable. So, being able to use the ESP32-S3's own eFuse-bound HMAC key instead of a second chip is a real BOM win.

Given our effort to be more port-agnostic, do you think you could come up with a more general API that would cover cryptography peripherals on other chips as well, and also eventually cover the other functionality on espressif? Then we would have more portable API's.

As we are moving toward Zephyr in the long run, looking at its API's may be helpful.

@dhalbert I really like this idea, thank you for pushing back. Since ESP-IDF ships a PSA Crypto opaque-key driver for eFuse HMAC keys, the eFuse block shows up as a normal psa_key_id_t and psa_mac_compute() works against it (HMAC_UP purpose enforced in the driver). So this can sit on PSA instead of raw esp_hmac_calculate(), which also lines up with Zephyr.

I've reworked the approach and put an example below. The working name is securekey, but bikeshedding is welcome.

import securekey

# Handle to a key held in hardware. The argument that selects WHICH key is
# port-defined (same pattern as board pins): on espressif it is the eFuse
# key block index; other ports would use their own key-slot identifier.
key = securekey.HardwareKey(0)

key.hmac_sha256(b"message")            -> bytes          # -> psa_mac_compute
key.verify_hmac_sha256(b"msg", mac)    -> bool           # -> psa_mac_verify (constant time)
key.exportable                         -> bool           # informational (RD_DIS on espressif)

Operations live in shared-module on psa_mac_compute / psa_mac_verify; the only per-port file is a common-hal construct() mapping the key-slot id to a psa_key_id_t, which is the same split as hashlib, so a Zephyr backend is just that shim. Handle has room for .sign() later (#3341).

@grgrant

grgrant commented Sep 5, 2026

Copy link
Copy Markdown

@mmabey Mike, this is really cool. I had never used this function before or really looked at the efuse stuff at all. I flashed/burned a random 32byte key (saved locally as hmac_key.bin) to BLOCK5 on a QtPy ESP32-S3 NOPSRAM and signed "Hello World" on my Mac and then ran a few commands shown below using the built CircuitPython artifact with your changes and everything works great.

Bob

CircuitPython Test Output

Edit: Added output from off by one bit incorrect digest showing False for verify

Adafruit CircuitPython 10.3.0-33-gc7097082e1 on 2026-09-05; Adafruit QT Py ESP32-S3 no psram with ESP32S3
>>> import securekey
>>> dir(securekey)
['__class__', '__name__', 'HardwareKey', '__dict__']
>>> dir(securekey.HardwareKey)
['__class__', '__name__', '__bases__', '__dict__', 'exportable', 'hmac_sha256', 'key_slot', 'verify_hmac_sha256']
>>> h = securekey.HardwareKey(5)
>>> h.key_slot
5
>>> h.hmac_sha256("Hello World")
b'\xeb\xde\x05AJ\xd6\x1a\xc8\xcdS\xcb\xa3Bc\xb3VY\x9b\xd8[\x9fC\x86r\xd03r\x89\x8e:\xbe\x0f'
>>> h.hmac_sha256("Hello World").hex()
'ebde05414ad61ac8cd53cba34263b356599bd85b9f438672d03372898e3abe0f'
>>> h.verify_hmac_sha256("Hello World",b'\xeb\xde\x05AJ\xd6\x1a\xc8\xcdS\xcb\xa3Bc\xb3VY\x9b\xd8[\x9fC\x86r\xd03r\x89\x8e:\xbe\x0f')
True
>>> # Change one character 'w' -- expect False
>>> h.verify_hmac_sha256("Hello world",b'\xeb\xde\x05AJ\xd6\x1a\xc8\xcdS\xcb\xa3Bc\xb3VY\x9b\xd8[\x9fC\x86r\xd03r\x89\x8e:\xbe\x0f')
False
>>> # Final digest bit off by one 0x0e instead of 0x0f -- expect False
>>> h.verify_hmac_sha256("Hello World",b'\xeb\xde\x05AJ\xd6\x1a\xc8\xcdS\xcb\xa3Bc\xb3VY\x9b\xd8[\x9fC\x86r\xd03r\x89\x8e:\xbe\x0e')
False
>>>

Validation on Mac OSX

hmac_key.bin contains the key that was burned into the efuse BLOCK5

Mac$ printf 'Hello World' | openssl dgst -sha256 -mac HMAC -macopt hexkey:$(xxd -p -c 64 hmac_key.bin | tr -d '\n')
ebde05414ad61ac8cd53cba34263b356599bd85b9f438672d03372898e3abe0f

securekey exposes keys that live in a hardware key store -- eFuse, a key
manager, a secure element -- and can be used but never read back. Python
code can compute or verify an HMAC-SHA256 with the key; there is no API to
read the raw key bytes and no API to write or burn keys. Provisioning is a
manufacturing-time step done with vendor tools (e.g. espefuse.py).

The module is portable, split across the usual three layers:

  - shared-bindings/securekey/  -- portable arg parsing + docstrings
  - shared-module/securekey/    -- HardwareKey object and the
    psa_mac_compute / psa_mac_verify operations, port-independent
  - ports/espressif/common-hal/securekey/ -- only construct(): validates
    the eFuse key block and imports a PSA opaque-key reference

securekey.HardwareKey(key_slot) takes a port-defined identifier. On
espressif it is the eFuse key block index 0-5; the block must be burned
with purpose HMAC_UP or construction fails (fail-closed), so a HardwareKey
can never be pointed at a block reserved for flash encryption, secure
boot, or the Digital Signature peripheral. Methods: hmac_sha256(data),
verify_hmac_sha256(data, mac) (constant-time). Properties: key_slot,
exportable (informational; False once RD_DIS is burned).

Build wiring: CIRCUITPY_SECUREKEY, default 0, enabled on espressif for
all chips with the HMAC peripheral (off for esp32 / esp32c2 / esp32c61,
which lack it).
@mmabey
mmabey force-pushed the mabey/esp32-hmac-efuse-planning branch from ca84d5f to 6a2e0ab Compare September 5, 2026 22:52
@mmabey mmabey changed the title espressif: Add esphmac module for eFuse-bound HMAC-SHA256 Add securekey module for hardware-held cryptographic keys Sep 5, 2026
@mmabey

mmabey commented Sep 5, 2026

Copy link
Copy Markdown
Author

@grgrant That is very reassuring to know you were also able to confirm this works on actual hardware. Thank you! And thanks for being willing to permanently give up one of your key slots!

@grgrant

grgrant commented Sep 5, 2026

Copy link
Copy Markdown

Was happy to dedicate a slot. Let me know if/when any changes should be retested. I would really like to have more crypto available -- especially as I use things like RFM95.

That is very reassuring to know you were also able to confirm this works on actual hardware. Thank you! And thanks for being willing to permanently give up one of your key slots!

@mmabey

mmabey commented Sep 9, 2026

Copy link
Copy Markdown
Author

@dhalbert Dan, have you had a chance to look at the new structure of my approach? Do you have any additional changes you want to see me make before this is ready to merge?

Over the weekend, I took the liberty of implementing the remainder of the security features requested in #3341 that I planned to put in a fast-follow PR once this was merged in. I don't know what your preference is between smaller, more incremental PRs and larger PRs with more related code packed in. So, if it would be easier to review all of that work at once, just let me know.

@dhalbert
dhalbert requested a review from tannewt September 9, 2026 19:39
@dhalbert

dhalbert commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

@mmabey: @tannewt was away for a few days and I am interested in his opinion as well.

Smaller PR's are easier to review, so a follow-on PR is fine.

@tannewt

tannewt commented Sep 9, 2026

Copy link
Copy Markdown
Member

Ok, I've taken a look at this and think we should keep the idea of the securekey module and object but reduce it being a placeholder for a key. It'll be more like a pin in board. The name and objects are fixed at startup and added to board. This will be the trickiest part.

Once created, the HardwareKey object implementation should use psa_crypto only so it is port-agnostic. This can be done by placing names and None values in board, making them mutable and replacing them in board_init() for keys that have been set. I don't think we have a way of adding entries at runtime to board unfortunately.

Let's rename the module to hardwarekey too so that we don't claim to be secure.

You could allow export by implementing buffer conversion to get a bytes object out but I doubt it is worth it.

HMAC should be done through the CPython standard hmac module API and backed by PSA crypto. The only difference will be that it can take in a securekey.HardwareKey in place of bytes (but make bytes work too.) That'll make prototyping almost identical to "production" use.

For any other crypto extensions we should create CPython cryptography library subsets that are backed by PSA Crypto. (HKDF and AESCCM would be useful for CircuitMatter)

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.

4 participants