Skip to content
Prev Previous commit
Next Next commit
hardwarekey, ssl, crypto_primitives: Split DigitalSignatureKey out of…
… 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.
  • Loading branch information
mmabey committed Sep 28, 2026
commit 680ae633637feb135214a6aa1445ba64207b1a9c
151 changes: 151 additions & 0 deletions ports/espressif/common-hal/hardwarekey/DigitalSignatureKey.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// This file is part of the CircuitPython project: https://circuitpython.org
//
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Mabey
//
// SPDX-License-Identifier: MIT

#include <string.h>

#include "py/runtime.h"

#include "common-hal/hardwarekey/board.h"

#include "shared-module/hardwarekey/DigitalSignatureKey.h"
#include "shared-module/hardwarekey/HardwareKey.h"

#include "esp_heap_caps.h"

// Pulls in MBEDTLS_CONFIG_FILE (esp_config.h), which is what defines
// ESP_RSA_DS_DRIVER_ENABLED on chips that have the Digital Signature
// peripheral. Including only <psa/crypto.h> goes through the tf-psa-crypto
// config path and does NOT define it, so the opaque-driver headers below
// would compile to nothing.
#include "mbedtls/build_info.h"
#include "psa/crypto.h"

// The Digital Signature peripheral driver is genuinely optional:
// CIRCUITPY_HARDWAREKEY is on for every HMAC-capable chip, but not every one
// of those also has SOC_DIG_SIGN_SUPPORTED. Where it's absent,
// ESP_RSA_DS_DRIVER_ENABLED is undefined and DS purpose is simply never
// reported by hardwarekey_efuse_slot_load() -- no build-time #error.
#if defined(ESP_RSA_DS_DRIVER_ENABLED)
#include "esp_ds.h"
#include "psa_crypto_driver_esp_rsa_ds.h"
#include "psa_crypto_driver_esp_rsa_ds_contexts.h"

// Persistent (non-GC) storage for a DS slot's imported key. Allocated lazily
// on first load and reused (overwritten in place) on a later call for the
// same slot -- the PSA RSA-DS driver only supports volatile keys (IDF-15427),
// so there is no psa_destroy_key() to pair a replacement with; the old PSA
// key id is simply abandoned along with its one HMAC-key eFuse block's worth
// of state.
//
// `generation` is bumped every time a slot is (re)loaded. It has nothing to
// do with the PSA key lifecycle above -- it exists purely so a
// DigitalSignatureKey object that snapshot an earlier generation can detect
// it was superseded before it ever committed to a PSA key. See the
// `generation` field comment in shared-module/hardwarekey/DigitalSignatureKey.h.
typedef struct {
esp_ds_data_t *data;
esp_ds_data_ctx_t *ctx;
esp_rsa_ds_opaque_key_t *opaque_key;
uint32_t generation;
} ds_slot_cache_t;
static ds_slot_cache_t ds_slot_cache[HARDWAREKEY_EFUSE_SLOT_COUNT];

void common_hal_hardwarekey_digitalsignaturekey_construct(hardwarekey_digitalsignaturekey_obj_t *self,
hardwarekey_hardwarekey_obj_t *source_key, const uint8_t *ds_params, size_t ds_params_len) {
if (ds_params_len != sizeof(esp_ds_data_t)) {
mp_raise_ValueError(MP_ERROR_TEXT("ds_params has the wrong length"));
}

mp_int_t slot = common_hal_hardwarekey_hardwarekey_get_key_slot(source_key);
ds_slot_cache_t *cache = &ds_slot_cache[slot];
if (cache->data == NULL) {
cache->data = heap_caps_malloc(sizeof(esp_ds_data_t), MALLOC_CAP_8BIT);
cache->ctx = heap_caps_malloc(sizeof(esp_ds_data_ctx_t), MALLOC_CAP_8BIT);
cache->opaque_key = heap_caps_malloc(sizeof(esp_rsa_ds_opaque_key_t), MALLOC_CAP_8BIT);
if (cache->data == NULL || cache->ctx == NULL || cache->opaque_key == NULL) {
m_malloc_fail(sizeof(esp_ds_data_t));
}
}
memcpy(cache->data, ds_params, sizeof(esp_ds_data_t));

// rsa_length is stored as (bits / 32) - 1 (see esp_digital_signature_length_t).
mp_int_t rsa_bits = ((mp_int_t)cache->data->rsa_length + 1) * 32;

*cache->ctx = (esp_ds_data_ctx_t) {
.esp_ds_data = cache->data,
.efuse_key_id = (uint8_t)slot,
.rsa_length_bits = (uint16_t)rsa_bits,
};
*cache->opaque_key = (esp_rsa_ds_opaque_key_t) {
.ds_data_ctx = cache->ctx,
};

// A new load for this slot invalidates any not-yet-committed
// DigitalSignatureKey previously obtained for it -- see ensure_algorithm().
cache->generation++;
self->generation = cache->generation;

// The actual PSA import is deferred to ensure_algorithm(), on the first
// sign()/decrypt() call -- see its declaration in shared-module for why.
self->key_id = 0;
self->committed_alg = PSA_ALG_NONE;
self->key_size = rsa_bits;
}

void common_hal_hardwarekey_digitalsignaturekey_ensure_algorithm(hardwarekey_digitalsignaturekey_obj_t *self,
psa_algorithm_t alg, psa_key_usage_t usage) {
if (self->key_id != 0) {
if (self->committed_alg != alg) {
mp_raise_ValueError(MP_ERROR_TEXT(
"This key already committed to a different algorithm; call load_digital_signature_key() again to use a different one"));
}
return;
}

hardwarekey_hardwarekey_obj_t *source_key = MP_OBJ_TO_PTR(self->source_key);
mp_int_t slot = common_hal_hardwarekey_hardwarekey_get_key_slot(source_key);
ds_slot_cache_t *cache = &ds_slot_cache[slot];
if (self->generation != cache->generation) {
mp_raise_ValueError(MP_ERROR_TEXT(
"This key's ds_params were replaced by a later load_digital_signature_key() call for the same key slot"));
}

psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT;
psa_set_key_type(&attr, PSA_KEY_TYPE_RSA_KEY_PAIR);
psa_set_key_bits(&attr, self->key_size);
psa_set_key_usage_flags(&attr, usage);
psa_set_key_algorithm(&attr, alg);
psa_set_key_lifetime(&attr, PSA_KEY_LIFETIME_ESP_RSA_DS_VOLATILE);

psa_key_id_t key_id = 0;
psa_status_t status = psa_import_key(&attr,
(const uint8_t *)cache->opaque_key, sizeof(*cache->opaque_key), &key_id);
if (status != PSA_SUCCESS) {
mp_raise_ValueError(MP_ERROR_TEXT("ds_params is invalid for this key slot"));
}

self->key_id = key_id;
self->committed_alg = alg;
}

#else

void common_hal_hardwarekey_digitalsignaturekey_construct(hardwarekey_digitalsignaturekey_obj_t *self,
hardwarekey_hardwarekey_obj_t *source_key, const uint8_t *ds_params, size_t ds_params_len) {
// Unreachable in practice: a Digital Signature purpose is never reported
// by hardwarekey_efuse_slot_load() on a chip without the driver, and
// shared-bindings checks `purpose` before calling this. Kept as a body
// (not a build error) so this file still compiles on those chips.
mp_raise_NotImplementedError(MP_ERROR_TEXT("Digital Signature peripheral not available on this chip"));
}

void common_hal_hardwarekey_digitalsignaturekey_ensure_algorithm(hardwarekey_digitalsignaturekey_obj_t *self,
psa_algorithm_t alg, psa_key_usage_t usage) {
// Also unreachable: construct() above always raises first.
mp_raise_NotImplementedError(MP_ERROR_TEXT("Digital Signature peripheral not available on this chip"));
}

#endif
124 changes: 4 additions & 120 deletions ports/espressif/common-hal/hardwarekey/HardwareKey.c
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@

// The one port-specific step: turn an eFuse key block into a PSA key id.
// Everything after that -- hmac.new()'s use of the key id, sign() -- lives in
// shared-module/hardwarekey/HardwareKey.c.

#include <string.h>
// shared-module/hardwarekey/HardwareKey.c. Digital Signature key handling
// lives in DigitalSignatureKey.c alongside this file.

#include "py/runtime.h"

Expand All @@ -18,7 +17,6 @@
#include "shared-module/hardwarekey/HardwareKey.h"

#include "esp_efuse.h"
#include "esp_heap_caps.h"

// board.h hardcodes the slot count (enum values can't be used in #if); make sure
// it still matches this chip's eFuse layout.
Expand All @@ -40,17 +38,6 @@ _Static_assert(HARDWAREKEY_EFUSE_SLOT_COUNT == EFUSE_BLK_KEY_MAX - EFUSE_BLK_KEY
#error "hardwarekey requires the ESP-IDF PSA opaque HMAC driver (SOC_HMAC_SUPPORTED targets only)"
#endif

// The Digital Signature peripheral driver, by contrast, is genuinely optional:
// CIRCUITPY_HARDWAREKEY is on for every HMAC-capable chip, but not every one
// of those also has SOC_DIG_SIGN_SUPPORTED. Where it's absent,
// ESP_RSA_DS_DRIVER_ENABLED is undefined and DS purpose is simply never
// reported by hardwarekey_efuse_slot_load() below -- no build-time #error.
#if defined(ESP_RSA_DS_DRIVER_ENABLED)
#include "esp_ds.h"
#include "psa_crypto_driver_esp_rsa_ds.h"
#include "psa_crypto_driver_esp_rsa_ds_contexts.h"
#endif

// The ESP HMAC peripheral consumes a 256-bit eFuse key.
#define HMAC_KEY_BITS 256

Expand Down Expand Up @@ -81,15 +68,15 @@ bool hardwarekey_efuse_slot_load(mp_int_t slot, hardwarekey_hardwarekey_obj_t *k
key->key_id = 0;
key->purpose = HARDWAREKEY_PURPOSE_UNUSED;
key->exportable = false;
key->rsa_key_bits = 0;

esp_efuse_block_t block = (esp_efuse_block_t)(EFUSE_BLK_KEY0 + slot);
esp_efuse_purpose_t block_purpose = esp_efuse_get_key_purpose(block);

if (block_purpose == ESP_EFUSE_KEY_PURPOSE_HMAC_DOWN_DIGITAL_SIGNATURE) {
// No PSA import yet: unlike HMAC_UP, this purpose alone doesn't name a
// full key -- the caller still has to supply ds_params via
// load_ds_params(). Just record that the slot is provisioned for it.
// hardwarekey.load_digital_signature_key(). Just record that the slot
// is provisioned for it.
#if defined(ESP_RSA_DS_DRIVER_ENABLED)
key->purpose = HARDWAREKEY_PURPOSE_DS;
key->exportable = !esp_efuse_get_key_dis_read(block);
Expand Down Expand Up @@ -119,106 +106,3 @@ bool hardwarekey_efuse_slot_load(mp_int_t slot, hardwarekey_hardwarekey_obj_t *k
key->exportable = !esp_efuse_get_key_dis_read(block);
return true;
}

#if defined(ESP_RSA_DS_DRIVER_ENABLED)

// Persistent (non-GC) storage for a DS slot's imported key. Allocated lazily
// on first load_ds_params() and reused (overwritten in place) on a later
// call for the same slot -- the PSA RSA-DS driver only supports volatile
// keys (IDF-15427), so there is no psa_destroy_key() to pair a replacement
// with; the old PSA key id is simply abandoned along with its one HMAC-key
// eFuse block's worth of state.
typedef struct {
esp_ds_data_t *data;
esp_ds_data_ctx_t *ctx;
esp_rsa_ds_opaque_key_t *opaque_key;
} ds_slot_cache_t;
static ds_slot_cache_t ds_slot_cache[HARDWAREKEY_EFUSE_SLOT_COUNT];

void common_hal_hardwarekey_hardwarekey_load_ds_params(hardwarekey_hardwarekey_obj_t *self,
const uint8_t *ds_params, size_t ds_params_len) {
if (ds_params_len != sizeof(esp_ds_data_t)) {
mp_raise_ValueError(MP_ERROR_TEXT("ds_params has the wrong length"));
}

ds_slot_cache_t *cache = &ds_slot_cache[self->key_slot];
if (cache->data == NULL) {
cache->data = heap_caps_malloc(sizeof(esp_ds_data_t), MALLOC_CAP_8BIT);
cache->ctx = heap_caps_malloc(sizeof(esp_ds_data_ctx_t), MALLOC_CAP_8BIT);
cache->opaque_key = heap_caps_malloc(sizeof(esp_rsa_ds_opaque_key_t), MALLOC_CAP_8BIT);
if (cache->data == NULL || cache->ctx == NULL || cache->opaque_key == NULL) {
m_malloc_fail(sizeof(esp_ds_data_t));
}
}
memcpy(cache->data, ds_params, sizeof(esp_ds_data_t));

// rsa_length is stored as (bits / 32) - 1 (see esp_digital_signature_length_t).
mp_int_t rsa_bits = ((mp_int_t)cache->data->rsa_length + 1) * 32;

*cache->ctx = (esp_ds_data_ctx_t) {
.esp_ds_data = cache->data,
.efuse_key_id = (uint8_t)self->key_slot,
.rsa_length_bits = (uint16_t)rsa_bits,
};
*cache->opaque_key = (esp_rsa_ds_opaque_key_t) {
.ds_data_ctx = cache->ctx,
};

// The actual PSA import is deferred to ensure_algorithm(), on the first
// sign()/decrypt() call -- see its declaration in shared-module for why.
self->key_id = 0;
self->committed_alg = PSA_ALG_NONE;
self->rsa_key_bits = rsa_bits;
}

void common_hal_hardwarekey_hardwarekey_ensure_algorithm(hardwarekey_hardwarekey_obj_t *self,
psa_algorithm_t alg, psa_key_usage_t usage) {
if (self->key_id != 0) {
if (self->committed_alg != alg) {
mp_raise_ValueError(MP_ERROR_TEXT(
"This key already committed to a different algorithm; call load_ds_params() again to use a different one"));
}
return;
}

ds_slot_cache_t *cache = &ds_slot_cache[self->key_slot];
if (cache->opaque_key == NULL) {
mp_raise_ValueError(MP_ERROR_TEXT("load_ds_params() has not been called on this key"));
}

psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT;
psa_set_key_type(&attr, PSA_KEY_TYPE_RSA_KEY_PAIR);
psa_set_key_bits(&attr, self->rsa_key_bits);
psa_set_key_usage_flags(&attr, usage);
psa_set_key_algorithm(&attr, alg);
psa_set_key_lifetime(&attr, PSA_KEY_LIFETIME_ESP_RSA_DS_VOLATILE);

psa_key_id_t key_id = 0;
psa_status_t status = psa_import_key(&attr,
(const uint8_t *)cache->opaque_key, sizeof(*cache->opaque_key), &key_id);
if (status != PSA_SUCCESS) {
mp_raise_ValueError(MP_ERROR_TEXT("ds_params is invalid for this key slot"));
}

self->key_id = key_id;
self->committed_alg = alg;
}

#else

void common_hal_hardwarekey_hardwarekey_load_ds_params(hardwarekey_hardwarekey_obj_t *self,
const uint8_t *ds_params, size_t ds_params_len) {
// Unreachable in practice: a Digital Signature purpose is never reported
// by hardwarekey_efuse_slot_load() on a chip without the driver, and
// shared-bindings checks `purpose` before calling this. Kept as a body
// (not a build error) so this file still compiles on those chips.
mp_raise_NotImplementedError(MP_ERROR_TEXT("Digital Signature peripheral not available on this chip"));
}

void common_hal_hardwarekey_hardwarekey_ensure_algorithm(hardwarekey_hardwarekey_obj_t *self,
psa_algorithm_t alg, psa_key_usage_t usage) {
// Also unreachable: load_ds_params() above always raises first.
mp_raise_NotImplementedError(MP_ERROR_TEXT("Digital Signature peripheral not available on this chip"));
}

#endif
2 changes: 2 additions & 0 deletions py/circuitpy_defns.mk
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ SRC_COMMON_HAL_ALL = \
sdioio/SDCard.c \
sdioio/__init__.c \
hardwarekey/HardwareKey.c \
hardwarekey/DigitalSignatureKey.c \
hardwarekey/__init__.c \
socketpool/__init__.c \
socketpool/SocketPool.c \
Expand Down Expand Up @@ -853,6 +854,7 @@ SRC_SHARED_MODULE_ALL = \
sdcardio/SDCard.c \
sdcardio/__init__.c \
hardwarekey/HardwareKey.c \
hardwarekey/DigitalSignatureKey.c \
sharpdisplay/SharpMemoryFramebuffer.c \
sharpdisplay/__init__.c \
socket/__init__.c \
Expand Down
4 changes: 3 additions & 1 deletion shared-bindings/crypto_primitives/__init__.c
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
//| no cryptography itself
//| and holds no key material; it exists only so those operations can take explicit
//| ``padding``/``algorithm`` arguments instead of baking one fixed combination into
//| a method name. Named and organized after
//| a method name. `hardwarekey.DigitalSignatureKey.sign()` /
//| `hardwarekey.DigitalSignatureKey.decrypt()` are the operations these
//| parameterize today. Named and organized after
//| :py:mod:`cryptography.hazmat.primitives`, the equivalent shared home for
//| :py:mod:`~cryptography.hazmat.primitives.asymmetric.padding` and
//| :py:mod:`~cryptography.hazmat.primitives.hashes` in the ``cryptography`` package.
Expand Down
Loading