Skip to content

espressif: Digital Signature peripheral — RSA sign/decrypt through hardwarekey, mutual TLS via ssl - #11425

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

mmabey wants to merge 8 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
ds_key = hardwarekey.load_digital_signature_key(
    key, ds_params=open("/ds_params.bin", "rb").read()  # see "Provisioning" below
)

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

key = board.EFUSE_KEY4
ds_key = hardwarekey.load_digital_signature_key(key, 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=ds_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

This revision reorganizes the DS API in response to review: HardwareKey stays a pure generic handle (key_slot, purpose, exportable, name/__repr__, __bool__, nothing else), the same type shared with hmac.new(). All DS-specific state and behavior lives on a new, separate type instead.

  • HardwareKey.purpose gains hardwarekey.Purpose.HMAC_DOWN_DIGITAL_SIGNATURE, alongside the existing HMAC_UP/UNUSED, reported automatically for any eFuse block burned that way. This is the only DS-flavored thing left on HardwareKey itself.
  • hardwarekey.load_digital_signature_key(key: HardwareKey, ds_params: ReadableBuffer) -> DigitalSignatureKey makes a DS-purpose key usable, mirroring cryptography.hazmat.primitives.serialization.load_pem_private_key(): a blob of key material in, a ready-to-use key object out. key must have purpose == Purpose.HMAC_DOWN_DIGITAL_SIGNATURE; the check happens once here rather than on every later call. 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.DigitalSignatureKey holds key_size (the RSA modulus size in bits, e.g. 2048) and sign(data, padding, algorithm) -> bytes / decrypt(ciphertext, padding) -> bytes, mirroring cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey's method shape (sign, decrypt, key_size), not its full interface: there is intentionally no public_key(), private_numbers(), or private_bytes(), since the key material never leaves the DS peripheral and there is no honest way to implement export. Calling load_digital_signature_key() again returns a new, independent DigitalSignatureKey; an older one held across a later load for the same slot correctly raises ValueError on next use instead of silently signing or decrypting with the new load's key material (see "Testing" below).
  • A new crypto_primitives module holds 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). This deliberately doesn't mirror cryptography's padding.PKCS1v15()/hashes.SHA256(), which are instantiable classes there, since the peripheral only supports one concrete combination today and a stateless marker has nothing to configure. 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.
  • ssl.SSLContext.load_cert_chain(certfile, keyfile) accepts a hardwarekey.DigitalSignatureKey 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 DigitalSignatureKey commits to whichever algorithm it's first used with (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 for the rest of its lifetime. A later call requesting a different algorithm raises ValueError rather than silently reusing the key unsafely:

ds_key = hardwarekey.load_digital_signature_key(key, ds_params=ds_params)
ds_key.sign(data=msg, padding=crypto_primitives.PKCS1v15, algorithm=crypto_primitives.SHA256)
ds_key.decrypt(ciphertext=ciphertext, padding=crypto_primitives.PKCS1v15)  # ValueError: already committed to signing
ds_key2 = hardwarekey.load_digital_signature_key(key, ds_params=ds_params)  # a fresh, independent key
ds_key2.decrypt(ciphertext=ciphertext, padding=crypto_primitives.PKCS1v15)  # fine, commits ds_key2 to decryption

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 = ds_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):

  • load_digital_signature_key(): correctly refuses a key whose purpose isn't HMAC_DOWN_DIGITAL_SIGNATURE, and refuses a non-HardwareKey argument with a clear TypeError.
  • 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_digital_signature_key() returns an independent key with no commitment).
  • Generation-counter guard: loaded two DigitalSignatureKeys for the same slot, used the second one, then confirmed the first (now stale) raises ValueError on sign() instead of silently signing with the second load's key material.
  • Error paths: decrypt()/sign() on an unsupported padding object, 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. This revision's translatable-string count for the DS-related code is net lower than before it: the old "load_ds_params() has not been called on this key" check is gone entirely (the type's existence is now the proof), and load_digital_signature_key()'s type check reuses the existing mp_arg_validate_type() string rather than adding a new one.

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.

@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.

How would you do this functionality in CPython today? That's the API to add. Don't expand HardwareKey except in it's type definition.

Comment thread shared-module/hardwarekey/HardwareKey.h Outdated
Comment on lines +73 to +98
// DS purpose only. Port-specific: caches ds_params and computes rsa_key_bits, but
// does NOT import a PSA key yet -- see ensure_algorithm() below for why. Raises
// ValueError on a malformed blob or a non-DS-purpose key. Safe to call more than
// once: replaces the cached blob and clears any committed algorithm, so the key
// can be recommitted to a (possibly different) algorithm afterward.
void common_hal_hardwarekey_hardwarekey_load_ds_params(hardwarekey_hardwarekey_obj_t *self, const uint8_t *ds_params, size_t ds_params_len);

// DS purpose only, after load_ds_params(). Port-specific: on the first call since
// load_ds_params(), imports self->key_id under exactly `alg` and `usage` (only the
// port knows how to turn the cached ds_params into a PSA key reference). On a later
// call, either confirms `alg` matches what was already committed (no-op) or raises
// ValueError -- this key already committed to a different algorithm. Callers
// (sign(), decrypt() below) call this before their PSA operation so a HardwareKey
// is never used under two different algorithms over its lifetime.
void common_hal_hardwarekey_hardwarekey_ensure_algorithm(hardwarekey_hardwarekey_obj_t *self, psa_algorithm_t alg, psa_key_usage_t usage);

// DS purpose only, and only after load_ds_params(). Portable: psa_sign_message()
// against self->key_id. sig_out must be rsa_key_bits / 8 bytes.
void common_hal_hardwarekey_hardwarekey_sign(hardwarekey_hardwarekey_obj_t *self, const uint8_t *data, size_t data_len, uint8_t *sig_out, size_t sig_out_len);

// DS purpose only, and only after load_ds_params(). Portable: psa_asymmetric_decrypt()
// against self->key_id. plaintext_out must be at least rsa_key_bits / 8 bytes; the
// actual plaintext length (after padding removal) is returned via *output_len.
void common_hal_hardwarekey_hardwarekey_decrypt(hardwarekey_hardwarekey_obj_t *self,
psa_algorithm_t alg, const uint8_t *ciphertext, size_t ciphertext_len,
uint8_t *plaintext_out, size_t plaintext_out_size, size_t *output_len);

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.

All of this "DS purpose only" is a big red flag suggesting you should have a different API.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I split DS-specific state and behavior out of HardwareKey entirely. HardwareKey is now back to a pure generic handle (key_slot, purpose, exportable, name/__repr__, __bool__), the same type shared unchanged with hmac.new(). The only DS-flavored thing left on it is Purpose.HMAC_DOWN_DIGITAL_SIGNATURE itself, which I read as the "type definition" you said was fine to touch.

For "how would you do this in CPython today": the new hardwarekey.load_digital_signature_key(key, ds_params) -> DigitalSignatureKey mirrors cryptography.hazmat.primitives.serialization.load_pem_private_key() (a blob of key material in, a ready-to-use key object out), and DigitalSignatureKey.sign()/.decrypt()/.key_size mirror RSAPrivateKey's method shape. It's not a full RSAPrivateKey implementation (no public_key(), private_numbers(), or private_bytes(), since the key material never leaves the peripheral and there's no honest way to export it), and the docstring says so explicitly.

One place I did deliberately not follow cryptography literally: crypto_primitives.PKCS1v15/SHA256 stay non-callable constants rather than becoming instantiable classes like cryptography's own padding.PKCS1v15()/hashes.SHA256(). The peripheral only supports one concrete combination today and there's nothing to configure, so I didn't see a reason to make callers write () on something stateless. Flagging it here in case you'd rather I match cryptography exactly even for the stateless case.

The purpose check now happens once in load_digital_signature_key() instead of on every sign()/decrypt() call, so a DigitalSignatureKey's existence is itself the proof it's usable. That did introduce one new thing to get right: load_digital_signature_key() returns a new object each call, but the port's decrypted-params cache is still a single, physical-slot-indexed value. I added a generation counter so an older, not-yet-used DigitalSignatureKey correctly raises ValueError if a later call loads a new key for the same slot, instead of silently importing the wrong key material. Hardware-verified this specifically (see the updated PR description's Testing section).

Everything is re-verified end to end on hardware with the new shape: sign, both decrypt paddings, the algorithm-commitment guard, the new generation-counter guard, mutual TLS via ssl.load_cert_chain(keyfile=ds_key), and no regression to the existing HMAC path. Full details in the updated PR description.

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.

Thank you for splitting it out. Let's not add this to hardwarekey though. Instead, let's start adding our own cryptography module hierarchy with the pieces we want. This would be just like how usb is done but with more nesting. I'd expect it to target psa_crypto just like hmac does. Introducing cryptography will help make code portable to CPython.

… HardwareKey

HardwareKey.rsa_key_bits/committed_alg and its
load_ds_params()/sign()/decrypt() methods were a "DS purpose only"
bolt-on that didn't belong on a type meant to be a generic key handle
shared with hmac.new().

HardwareKey goes back to a pure generic handle: key_slot, purpose,
exportable, name/__repr__, __bool__, and nothing else. The only
DS-flavored thing left on it is Purpose.HMAC_DOWN_DIGITAL_SIGNATURE
itself, which is fine since it's the type definition tannewt said not
to touch. This mirrors the same correction this codebase already made
once: commit 965a59e removed HardwareKey.hmac_sha256()/
verify_hmac_sha256() in favor of hmac.new() type-checking a generic
HardwareKey and doing its own PSA wiring.

New type hardwarekey.DigitalSignatureKey, obtained via
hardwarekey.load_digital_signature_key(key, ds_params), mirrors
cryptography.hazmat.primitives.serialization.load_pem_private_key():
a blob of key material in, a ready-to-use key object out. The purpose
check now happens once at load time instead of on every sign()/
decrypt() call; after that, the type's existence is itself the proof
the key is DS-capable. rsa_key_bits is renamed to key_size to match
cryptography.hazmat...RSAPrivateKey.key_size exactly. sign()/decrypt()
move here verbatim from the old HardwareKey methods, unchanged in
behavior.

    key = board.EFUSE_KEY4
    ds_key = hardwarekey.load_digital_signature_key(
        key, ds_params=open("/ds_params.bin", "rb").read()
    )
    signature = ds_key.sign(
        data=b"...", padding=crypto_primitives.PKCS1v15, algorithm=crypto_primitives.SHA256
    )
    plaintext = ds_key.decrypt(ciphertext=ciphertext, padding=crypto_primitives.PKCS1v15)
    ctx.load_cert_chain(certfile="/client.pem", keyfile=ds_key)

Generation-counter guard: load_digital_signature_key() returns a new
object each call, but the port's per-slot ds_params cache is still a
single, physical-slot-indexed array. Without a guard, an older,
not-yet-used DigitalSignatureKey held across a second load for the
same slot would silently import the new load's key material on first
use instead of failing loudly. Fixed with a generation counter on the
port's cache, snapshotted at construction and checked before the first
PSA import: a stale key now raises ValueError instead of silently
signing/decrypting with the wrong key material.

load_digital_signature_key()'s key argument is validated with the
existing mp_arg_validate_type() helper rather than a hand-rolled
raise, reusing an already-translated string instead of adding a new
one.

ssl.SSLContext.load_cert_chain()'s keyfile type check and docstring
move to hardwarekey.DigitalSignatureKey; the manual purpose check is
removed since the type itself is now the proof.

Hardware-reverified end to end on an ESP32-S3-DevKitC-1-N8R8
(BLOCK_KEY4): sign() output independently verified with openssl dgst
-verify; decrypt() (both PKCS1v15 and OAEP paddings) round-trips
against independently-generated openssl pkeyutl ciphertext; the
one-algorithm-per-lifetime commitment guard and the new
generation-counter guard both raise ValueError exactly as designed;
mutual TLS via ssl.load_cert_chain(keyfile=ds_key) completes an
on-device SoftAP loopback handshake with a DS-key client certificate
and correctly times out with no certificate; hmac.new()'s existing
eFuse HMAC path (BLOCK_KEY5) shows no regression.
@mmabey mmabey changed the title espressif: Digital Signature peripheral (RSA sign/decrypt, mTLS) espressif: Digital Signature peripheral — RSA sign/decrypt through hardwarekey, mutual TLS via ssl Sep 28, 2026
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

2 participants