Skip to content

espressif: Digital Signature peripheral (RSA sign/decrypt, mTLS) - #11425

Open
mmabey wants to merge 5 commits into
adafruit:mainfrom
mmabey:mabey/esp32-ds-peripheral
Open

mmabey wants to merge 5 commits into
adafruit:mainfrom
mmabey:mabey/esp32-ds-peripheral

Conversation

@mmabey

@mmabey mmabey commented Sep 19, 2026

Copy link
Copy Markdown

Closes #3341.

Summary

The ESP32-S2/S3 (and C3/C5/C6/H2/P4) Digital Signature (DS) peripheral lets an RSA private key be used for signing or decryption without the key ever existing in readable form in software: it's decrypted internally by the peripheral, using a separate eFuse-held HMAC key, and the raw private key never appears in RAM. This closes #3341 by extending the hardwarekey/hmac machinery from PR #11319 (eFuse HMAC keys) to also cover the DS peripheral, and wiring the result into ssl for mutual-TLS client certificate authentication, the actual motivating use case in the issue (Azure IoT DPS / Adafruit IO style X.509 device identity).

import board, hardwarekey, crypto_primitives

key = board.EFUSE_KEY4  # any eFuse block burned with purpose HMAC_DOWN_DIGITAL_SIGNATURE
key.load_ds_params(ds_params=open("/ds_params.bin", "rb").read())  # see "Provisioning" below

signature = key.sign(
    data=b"message",
    padding=crypto_primitives.PKCS1v15,
    algorithm=crypto_primitives.SHA256,
)
plaintext = key.decrypt(ciphertext=ciphertext, padding=crypto_primitives.PKCS1v15)
import board, ssl, socketpool, wifi

key = board.EFUSE_KEY4
key.load_ds_params(ds_params=open("/ds_params.bin", "rb").read())

pool = socketpool.SocketPool(wifi.radio)
ctx = ssl.create_default_context()
ctx.load_verify_locations(cadata=open("/ca.pem").read())
ctx.load_cert_chain(certfile="/client_cert.pem", keyfile=key)  # private key never leaves the DS peripheral

sock = pool.socket(pool.AF_INET, pool.SOCK_STREAM)
sock.connect(("iot.example.com", 8443))
tls_sock = ctx.wrap_socket(sock, server_hostname="iot.example.com")

API

  • HardwareKey.purpose gains hardwarekey.Purpose.HMAC_DOWN_DIGITAL_SIGNATURE, alongside the existing HMAC_UP/UNUSED, reported automatically for any eFuse block burned that way.
  • HardwareKey.load_ds_params(ds_params: ReadableBuffer) -> None makes a DS-purpose key usable. ds_params is the encrypted parameter block produced at provisioning time by vendor tooling (see "Provisioning" below). It is not itself secret (it's only usable together with this specific eFuse block), so it's fine to keep in a plain file on CIRCUITPY.
  • HardwareKey.sign(data, padding, algorithm) -> bytes and HardwareKey.decrypt(ciphertext, padding) -> bytes mirror cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey's sign()/decrypt() shape: explicit padding/algorithm arguments (all keyword-capable) rather than baking one combination into a method name.
  • A new crypto_primitives module holds those padding/algorithm objects: crypto_primitives.PKCS1v15 and crypto_primitives.SHA256 are plain constants (there's nothing to configure, so there's nothing to construct; compare with is, like hardwarekey.Purpose's own values), mirroring cryptography's padding.PKCS1v15/hashes.SHA256 for the one combination the peripheral actually supports today. crypto_primitives.OAEP(algorithm) is a real class since it takes a real parameter, also available for decrypt() (see "OAEP / TLS 1.3" below for why it needs a non-default build). This lives in its own module rather than under hardwarekey, since these are algorithm and padding descriptors, not hardware keys, and the name and grouping are taken directly from cryptography.hazmat.primitives, the real shared parent of padding and hashes in the library this shape already mirrors.
  • HardwareKey.rsa_key_bits: the RSA modulus size (e.g. 2048), read from ds_params.
  • ssl.SSLContext.load_cert_chain(certfile, keyfile) accepts a hardwarekey.HardwareKey as keyfile in addition to a file path. When it does, wrap_socket() signs the TLS handshake (CertificateVerify) via mbedtls_pk_wrap_psa(): the private key is never parsed into mbedtls, never leaves the DS peripheral.

Deliberately not included: signature/plaintext verification against the public key. Verification only needs the public key, which needs no hardware protection and is already available wherever the application already has it (the X.509 cert file passed to load_cert_chain(), or a peer's own TLS stack), so reimplementing verification here would duplicate adafruit_rsa (the Bundle library) or a real cryptography install for no benefit.

Security: one key, one algorithm, enforced

A single RSA key must not be used under more than one algorithm for its lifetime. This isn't just caution, it's explicitly called out in the PSA Crypto API itself (psa_set_key_enrollment_algorithm()'s doc comment: "using the same key with different algorithms can allow some attacks based on arithmetic relations between different computations made with the same key"). So a HardwareKey commits to whichever algorithm it's first used with after load_ds_params() (sign(), decrypt() with a specific padding, or ssl.load_cert_chain(), which commits it to signing, since that's what a TLS handshake does), and stays committed to that one algorithm until the next load_ds_params(). A later call requesting a different algorithm raises ValueError rather than silently reusing the key unsafely:

key.load_ds_params(ds_params=ds_params)
key.sign(data=msg, padding=crypto_primitives.PKCS1v15, algorithm=crypto_primitives.SHA256)
key.decrypt(ciphertext=ciphertext, padding=crypto_primitives.PKCS1v15)  # ValueError: already committed to signing
key.load_ds_params(ds_params=ds_params)  # clears the commitment
key.decrypt(ciphertext=ciphertext, padding=crypto_primitives.PKCS1v15)  # now fine

OAEP / TLS 1.3

crypto_primitives.OAEP decrypt padding is more modern and secure than PKCS1v15 (which is susceptible to padding-oracle-style attacks). It's implemented and hardware-verified in this PR (see "Testing" below), but it only works in a build where CONFIG_MBEDTLS_SSL_PROTO_TLS1_3 is enabled: the ESP-IDF DS driver's OAEP un-padding code is compiled out otherwise. That flag is off by default in this port, and turning it on for every board is a real cost (extra flash and RAM for TLS 1.3 handshake machinery most boards don't otherwise need) and a decision that affects this whole port, not just this feature. We're deliberately leaving that call to the maintainers rather than making it ourselves as a side effect of this PR. Until (or unless) that flag is enabled, decrypt() raises a clear NotImplementedError for OAEP rather than a confusing generic PSA error, so the limitation is explicit rather than a silent trap.

To build and test the OAEP path yourself:

cd ports/espressif
make BOARD=<your_board>                                            # build once
echo "CONFIG_MBEDTLS_SSL_PROTO_TLS1_3=y" >> build-<your_board>/esp-idf/sdkconfig
rm -rf build-<your_board> && make BOARD=<your_board>                # clean rebuild, see note

Note the clean rebuild: an incremental rebuild after editing sdkconfig did not reliably pick up the change when we tested this (decrypt() kept raising NotImplementedError from a stale partial build even though the flag was set), so always do a full rebuild after changing this flag. With that build flashed:

plaintext = key.decrypt(
    ciphertext=ciphertext,
    padding=crypto_primitives.OAEP(algorithm=crypto_primitives.SHA256),
)

works the same as PKCS1v15.

Provisioning (not part of this PR)

ds_params is produced entirely by existing, external Espressif vendor tooling: esp-secure-cert-tool generates the RSA key pair and the encrypted parameter blob, and espefuse.py burn-key (already used for the HMAC path in #11319) burns the paired HMAC key into an eFuse block with purpose HMAC_DOWN_DIGITAL_SIGNATURE. This fork intentionally adds no write/burn/provisioning API, consistent with the HMAC path's existing design, so a botched build can't brick a key block, and the diff stays small and reviewable.

Testing

Hardware-verified on an ESP32-S3-DevKitC-1-N8R8 with a real DS-purpose eFuse block (RSA-2048):

  • sign(): output independently verified with openssl dgst -verify against the paired public key.
  • decrypt(), both paddings: each round-trips against an independently-generated (openssl pkeyutl -encrypt) ciphertext back to the original plaintext. OAEP verified in a locally built TLS-1.3-enabled image (see above); PKCS1v15 in the default build.
  • sign()/decrypt() keyword arguments: verified with arguments passed out of order, to confirm they're genuinely keyword-capable rather than just positional with labels.
  • Algorithm commitment: verified in every direction (sign() after decrypt(), decrypt() after sign(), either after ssl.load_cert_chain(), and vice versa all raise ValueError; a fresh load_ds_params() clears it and allows recommitting to a different algorithm).
  • Error paths: decrypt()/sign() before load_ds_params(), an unsupported padding object, calling crypto_primitives.PKCS1v15() (correctly raising TypeError, since it's a constant rather than a callable class), and a non-DS-purpose key all raise clear, specific errors.
  • Mutual TLS, on-device SoftAP loopback (the dev board has no antenna for a real network): server with authmode REQUIRED and a pinned CA accepts a DS-key-signed client CertificateVerify and completes the handshake; the same server never completes the handshake for a client presenting no certificate.
  • No regression to the existing hmac.new() / eFuse HMAC path from hardwarekey: Add board-exposed keys usable via hmac.new() #11319.

.pot regeneration and .pyi stub extraction both pass cleanly.

Enable CONFIG_MBEDTLS_HARDWARE_RSA_DS_PERIPHERAL for builds that
include the hardwarekey module. ESP-IDF only compiles its PSA
opaque-key driver for the Digital Signature peripheral when this
option is set, and it defaults to off upstream.

The option lives in a new sdkconfig-hardwarekey.defaults that is
appended to the sdkconfig list only when CIRCUITPY_HARDWAREKEY=1,
mirroring the existing sdkconfig-ble.defaults handling. On chips
without SOC_DIG_SIGN_SUPPORTED the option has an unmet dependency and
is ignored.

Groundwork for a following commit, which uses the driver to expose
RSA signing and decryption through hardwarekey.HardwareKey.
…ture peripheral

hardwarekey.HardwareKey gains sign() and decrypt(), using the DS
peripheral's PSA opaque-key driver from the previous commit.
purpose gains hardwarekey.Purpose.HMAC_DOWN_DIGITAL_SIGNATURE,
reported automatically for any eFuse block burned that way.

    key = board.EFUSE_KEY4
    key.load_ds_params(ds_params=open("/ds_params.bin", "rb").read())
    signature = key.sign(
        data=b"message",
        padding=crypto_primitives.PKCS1v15,
        algorithm=crypto_primitives.SHA256,
    )
    plaintext = key.decrypt(ciphertext=ciphertext, padding=crypto_primitives.PKCS1v15)

sign() and decrypt() mirror cryptography.hazmat.primitives.asymmetric.
rsa.RSAPrivateKey's sign()/decrypt() shape: explicit padding/algorithm
arguments (keyword-capable, not just positional) rather than baking
one combination into a method name. The padding/algorithm objects
themselves live in a new crypto_primitives module rather than under
hardwarekey, since they're algorithm descriptors, not hardware keys:
named and organized after cryptography.hazmat.primitives, the real
shared parent of padding and hashes in the library this shape already
mirrors. crypto_primitives.PKCS1v15 and .SHA256 are plain constants
(nothing to configure, so nothing to construct, compared with `is`
like hardwarekey.Purpose's own values); crypto_primitives.OAEP is a
real class since it takes a real parameter (algorithm=).

The RSA private key is never present in readable form: it is AES
encrypted inside ds_params and recovered only inside the Digital
Signature peripheral, keyed by a read-protected eFuse HMAC key. There
is still no API to read key material or to burn keys. Deliberately
not included: signature/plaintext verification against the public
key, since that only needs the public key (no hardware protection
needed) and already has a home in adafruit_rsa or a real cryptography
install.

A single RSA key must not be used under more than one algorithm for
its lifetime: reusing it for both signing and decryption, or for two
different decrypt paddings, is a real cryptographic risk (see the
warning on psa_set_key_enrollment_algorithm() in the PSA Crypto API),
not just an inconvenience. So a HardwareKey commits to whichever
algorithm it is first used with after load_ds_params(), for as long
as that ds_params stays loaded; a later call under a different
algorithm raises ValueError, and a fresh load_ds_params() clears the
commitment. The actual PSA import happens in a new
common_hal_hardwarekey_hardwarekey_ensure_algorithm(), shared by
sign() and decrypt(), rather than eagerly in load_ds_params().

crypto_primitives is shared-bindings only (no common-hal, no
shared-module: these are pure, portable marker types with zero
hardware dependency), built the same way as any other shared-bindings-
only module (SRC_PATTERNS plus an entry in circuitpy_defns.mk's
hand-maintained SRC_BINDINGS_ENUMS list, since a module with no
common-hal counterpart needs that too), gated identically to
CIRCUITPY_HARDWAREKEY per espressif chip since it's only useful
alongside it today.

Hardware-verified on an ESP32-S3-DevKitC-1-N8R8 with a real DS-purpose
eFuse block (RSA-2048): sign() output verified against the paired
public key with openssl dgst -verify; decrypt() with both PKCS1v15
and OAEP (the latter in a locally built TLS-1.3-enabled image, needed
because the ESP-IDF DS driver's OAEP un-padding path is compiled out
otherwise -- ports/espressif/mpconfigport.mk / CONFIG_MBEDTLS_SSL_
PROTO_TLS1_3 is deliberately left off by default, that is a whole-port
decision out of scope here, so decrypt() raises NotImplementedError
for OAEP without it) round-trip against independently-generated
ciphertexts; the algorithm-commitment rule enforced in every
direction, and cleared by a fresh load_ds_params(); decrypt()/sign()
before load_ds_params(), an unsupported padding object, calling
crypto_primitives.PKCS1v15() (correctly raising TypeError, since it
is a constant rather than a callable class), and a non-DS-purpose key
all raising clear, specific errors; keyword arguments confirmed
genuinely keyword-capable by passing them out of order; and no
regression to the existing hmac.new() / eFuse HMAC path from adafruit#11319.
SSLContext.load_cert_chain()'s keyfile argument now also accepts a
hardwarekey.HardwareKey with purpose HMAC_DOWN_DIGITAL_SIGNATURE, in
addition to a file path. When it does, the client-certificate private
key is never parsed into mbedtls: the SSLContext carries the key's
PSA id, and wrap_socket() calls mbedtls_pk_wrap_psa() so the handshake
signature (TLS 1.2 CertificateVerify) is produced by the hardware, on
espressif the Digital Signature peripheral.

    key = board.EFUSE_KEY4
    key.load_ds_params(ds_params=open("/ds_params.bin", "rb").read())
    ctx.load_cert_chain(certfile="/client.pem", keyfile=key)

Using a key this way commits it to signing, participating in the same
one-algorithm-per-loaded-key rule as HardwareKey.sign()/decrypt(): it
calls common_hal_hardwarekey_hardwarekey_ensure_algorithm() itself
(the same function sign() uses), so a key already used for decrypt()
(or vice versa) is correctly refused with a clear error rather than
silently reused unsafely.

The HardwareKey path is gated behind CIRCUITPY_HARDWAREKEY; ports
without it are unaffected. keyfile also becomes optional in the
signature to match the long-standing behavior (omitted -> key read
from certfile).

With this, the ESP32 Digital Signature peripheral
(adafruit#3341) is usable from Python end to end:
provision-time key burn (espefuse), sign/decrypt via hardwarekey, and
mutual TLS via ssl.

Hardware-verified with an on-device SoftAP loopback mutual-TLS
handshake (the dev board has no antenna for a real network): a server
with authmode REQUIRED and a pinned CA accepts the DS-key-signed
client CertificateVerify and completes the handshake with app data
flowing both ways; the same server never completes the handshake for
a client presenting no certificate.
PKCS1v15 and SHA256 are constant instances, not classes, so using them
as type annotations (crypto_primitives.PKCS1v15, etc.) is invalid and
fails mypy's check-stubs target. Use object instead, matching the
existing precedent in OAEP.__init__'s algorithm parameter. The
:param TypeName name: docstring prose is left as-is since it still
documents the expected values for humans and Sphinx.

Also fix a broken bare cross-reference in the crypto_primitives module
docstring (`.decrypt()` -> `hardwarekey.HardwareKey.decrypt()`), found
by running the full sphinx-build -W pipeline locally.
@dhalbert
dhalbert requested a review from tannewt September 19, 2026 17:24
…rror strings

Merge near-duplicate error messages into shared %q-parameterized
strings to reduce the translation burden this PR adds: the
padding/algorithm-mismatch messages in HardwareKey.sign()/decrypt()
and the algorithm check in crypto_primitives.OAEP() now share one
"Only %q supported" message (a compound value like "PKCS1v15 and
SHA256" passed as a single qstr, following the existing
MP_QSTR_report_id_space_0-style precedent), and the purpose-mismatch
checks in hmac.new() and SSLContext.load_cert_chain() share one
message instead of each having their own. Regenerate
locale/circuitpython.pot to match.
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.

Support the ESP32-S2's Digital Signature Peripheral

1 participant