Conversation
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/hmacmachinery from PR #11319 (eFuse HMAC keys) to also cover the DS peripheral, and wiring the result intosslfor mutual-TLS client certificate authentication, the actual motivating use case in the issue (Azure IoT DPS / Adafruit IO style X.509 device identity).API
HardwareKey.purposegainshardwarekey.Purpose.HMAC_DOWN_DIGITAL_SIGNATURE, alongside the existingHMAC_UP/UNUSED, reported automatically for any eFuse block burned that way.HardwareKey.load_ds_params(ds_params: ReadableBuffer) -> Nonemakes a DS-purpose key usable.ds_paramsis 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 onCIRCUITPY.HardwareKey.sign(data, padding, algorithm) -> bytesandHardwareKey.decrypt(ciphertext, padding) -> bytesmirrorcryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey'ssign()/decrypt()shape: explicit padding/algorithm arguments (all keyword-capable) rather than baking one combination into a method name.crypto_primitivesmodule holds those padding/algorithm objects:crypto_primitives.PKCS1v15andcrypto_primitives.SHA256are plain constants (there's nothing to configure, so there's nothing to construct; compare withis, likehardwarekey.Purpose's own values), mirroringcryptography'spadding.PKCS1v15/hashes.SHA256for the one combination the peripheral actually supports today.crypto_primitives.OAEP(algorithm)is a real class since it takes a real parameter, also available fordecrypt()(see "OAEP / TLS 1.3" below for why it needs a non-default build). This lives in its own module rather than underhardwarekey, since these are algorithm and padding descriptors, not hardware keys, and the name and grouping are taken directly fromcryptography.hazmat.primitives, the real shared parent ofpaddingandhashesin the library this shape already mirrors.HardwareKey.rsa_key_bits: the RSA modulus size (e.g. 2048), read fromds_params.ssl.SSLContext.load_cert_chain(certfile, keyfile)accepts ahardwarekey.HardwareKeyaskeyfilein addition to a file path. When it does,wrap_socket()signs the TLS handshake (CertificateVerify) viambedtls_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 duplicateadafruit_rsa(the Bundle library) or a realcryptographyinstall 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 aHardwareKeycommits to whichever algorithm it's first used with afterload_ds_params()(sign(),decrypt()with a specific padding, orssl.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 nextload_ds_params(). A later call requesting a different algorithm raisesValueErrorrather than silently reusing the key unsafely:OAEP / TLS 1.3
crypto_primitives.OAEPdecrypt padding is more modern and secure thanPKCS1v15(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 whereCONFIG_MBEDTLS_SSL_PROTO_TLS1_3is 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 clearNotImplementedErrorforOAEPrather than a confusing generic PSA error, so the limitation is explicit rather than a silent trap.To build and test the OAEP path yourself:
Note the clean rebuild: an incremental rebuild after editing
sdkconfigdid not reliably pick up the change when we tested this (decrypt()kept raisingNotImplementedErrorfrom a stale partial build even though the flag was set), so always do a full rebuild after changing this flag. With that build flashed:works the same as
PKCS1v15.Provisioning (not part of this PR)
ds_paramsis produced entirely by existing, external Espressif vendor tooling:esp-secure-cert-toolgenerates the RSA key pair and the encrypted parameter blob, andespefuse.py burn-key(already used for the HMAC path in #11319) burns the paired HMAC key into an eFuse block with purposeHMAC_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 withopenssl dgst -verifyagainst the paired public key.decrypt(), both paddings: each round-trips against an independently-generated (openssl pkeyutl -encrypt) ciphertext back to the original plaintext.OAEPverified in a locally built TLS-1.3-enabled image (see above);PKCS1v15in 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.sign()afterdecrypt(),decrypt()aftersign(), either afterssl.load_cert_chain(), and vice versa all raiseValueError; a freshload_ds_params()clears it and allows recommitting to a different algorithm).decrypt()/sign()beforeload_ds_params(), an unsupported padding object, callingcrypto_primitives.PKCS1v15()(correctly raisingTypeError, since it's a constant rather than a callable class), and a non-DS-purpose key all raise clear, specific errors.authmode REQUIREDand a pinned CA accepts a DS-key-signed clientCertificateVerifyand completes the handshake; the same server never completes the handshake for a client presenting no certificate.hmac.new()/ eFuse HMAC path from hardwarekey: Add board-exposed keys usable via hmac.new() #11319..potregeneration and.pyistub extraction both pass cleanly.