Skip to content

NXP EdgeLock: PKC offload (RSA, X25519, ECDSA), P-256 key slots and the ELS key store - #11944

Open
Frauschi wants to merge 9 commits into
wolfSSL:masterfrom
Frauschi:els_pkc_pr2
Open

Frauschi wants to merge 9 commits into
wolfSSL:masterfrom
Frauschi:els_pkc_pr2

Conversation

@Frauschi

Copy link
Copy Markdown
Member

Description

Second of three PRs for the NXP RW612 EdgeLock port (WOLFSSL_ELS_PKC), following #11703. The third adds a CI workflow under the m33mu emulator.

  • ELS, P-256: every ECDSA verify, plus signing and key generation with a key in a slot, including from an EdgeLock 2GO seed.
  • PKC: raw RSA (beneath all padding), X25519 agreement, and ECDSA on every other curve, including P-256 with ordinary keys.
  • Key store: the five WC_ALGO_TYPE_KEYSTORE operations, plus wc_ElsPkc_DeriveDieKek(), so an RFC 3394 blob can be imported without a provisioned wrapping key.
  • Slot keys in TLS: a key id from wolfSSL_CTX_use_PrivateKey_Id() signs on ELS, and wc_ecc_check_key() checks a slot key against its slot.

Anything the hardware does not serve still runs in software. Each commit message carries the reasoning for its part.

Design notes

  • ELS reports a bad signature only through R, so the port compares the recomputed R itself, in constant time.
  • Slots are validated before every command, since a bad permission or a failed unwrap resets the SoC.
  • Results match software, including the error codes for inputs wolfCrypt checks only on its software path (RSA bounds, ECDSA r and s, malformed DER).
  • A slot key never falls back: its ecc_key holds no scalar, so a request ELS cannot serve is an error.
  • Stays in software: key generation outside slots, ECDH, EdDSA, deterministic or set-k ECDSA.

Building

WOLF_CRYPTO_CB_KEYSTORE is now required alongside WOLFSSL_ELS_PKC and WOLF_CRYPTO_CB; Zephyr and --with-els-pkc set it. The MCUXpresso user_settings.h and the frdm_rw612 test and benchmark scenarios turn on the offloaded curves and RSA.

Testing

  • frdm_rw612: wolfCrypt test suite 54/54 (with the RNG test fix in Keep the DRBG-state RNG tests off devices that serve generate #11943), and a separate hardware-check application comparing every offloaded operation and error path against software, 308/308, no tamper resets.
  • Every commit cross-compiles for Cortex-M33 with -Werror in 12 configurations (RSA, ECC sign/verify/import/export and curve variants).

Benchmark

frdm_rw612, Zephyr 4.4, one run of about a second per row. Software is the same build with the port off.

Algorithm Software EdgeLock Speedup
RSA-2048 private 2 ops/s 24 ops/s 12x
RSA-2048 public 92 ops/s 314 ops/s 3.4x
ECDSA P-256 sign 16 ops/s 96 ops/s 6.0x
ECDSA P-256 verify 10 ops/s 192 ops/s 19x
ECDSA P-384 sign 4 ops/s 30 ops/s 7.5x
ECDSA P-384 verify 4 ops/s 54 ops/s 13x
ECDSA P-521 sign 2 ops/s 20 ops/s 10x
ECDSA P-521 verify 1 ops/s 22 ops/s 22x
X25519 agree 132 ops/s 134 ops/s 1.0x

@Frauschi Frauschi self-assigned this Oct 30, 2026
Copilot AI balanced review requested due to automatic review settings October 30, 2026 16:29
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The RSA accelerator path accepts unsupported operation types and rejects raw decrypt inputs that the software API allows, breaking compatibility with existing behavior.

2 open issues
What changed in this PR

Extends the RW612 EdgeLock crypto callback port introduced in #11703 with public-key acceleration and hardware key-store support.

Changes:

  • Adds RSA, X25519 and ECDSA offload through PKC and ELS.
  • Adds P-256 slot-key generation, signing, validation and wrapped-key management.
  • Updates build settings, Zephyr sample configurations and integration documentation.
File Description
zephyr/​user_settings.h Enables and checks the required key-store callback define.
zephyr/​samples/​wolfssl_test/​sample.yaml Enables additional offloaded curves for RW612 tests.
zephyr/​samples/​wolfssl_benchmark/​sample.yaml Enables additional offloaded curves for benchmarks.
zephyr/​Kconfig Documents required settings-file defines.
wolfssl/​wolfcrypt/​port/​nxp/​els_pkc_port.h Declares slot-key APIs and clarifies reference fields.
wolfcrypt/​src/​port/​nxp/​README.md Documents acceleration, slot permissions and key-store behavior.
wolfcrypt/​src/​port/​nxp/​els_pkc_port.c Implements public-key offload, key-store operations and PKC lifecycle handling.
IDE/​MCUEXPRESSO/​RW612/​user_settings.h Enables key-store callbacks, ECC, X25519 and RSA settings.
IDE/​MCUEXPRESSO/​RW612/​README.md Updates build instructions and slot-key guidance.
configure.ac Enables key-store callbacks with the EdgeLock port.
.wolfssl_known_macro_extras Registers the vendor internal-public-key feature macro.

Review effort: Balanced

Comment thread wolfcrypt/src/port/nxp/els_pkc_port.c Resolved
Comment thread wolfcrypt/src/port/nxp/els_pkc_port.c Resolved
The three P-256 operations that can use a slot reference: in-slot generation,
signing and verification.

Key generation asks for as little as it can. The usage bit, the key size and the
slot kind are set by the engine from kgtypedh, and a property word ELS disagrees
with is a tamper event rather than an error return, so only the two
access-protection bits are passed, plus wrpok when the reference asks for an
exportable key. The properties are read back before first use.

Verification carries the sharpest trap in this port: ELS does not report a bad
signature through its return status. It recomputes R and hands it back, and the
signature is valid only if that equals the R the caller supplied. A port that
trusted the status would pass every positive test and accept every forgery, so
the comparison is done here, in constant time.

An all-zero digest is declined. wolfCrypt rejects one too, but below the
callback, so an offload would otherwise accept what software refuses.

ECDH is absent by design. mcuxClEls_EccKeyExchange_Async deposits the shared
secret in a slot, which cannot be read back in the clear, while the ecdh callback
must hand a buffer back. An in-slot agreement would need a key store operation,
which this port does not provide.

A generated key stays typed ECC_PUBLICKEY, as wc_ecc_import_unsigned() leaves
it: the private half is in the slot, so export and software ECDH refuse the
key instead of using the zero scalar its ecc_key holds. For the same reason
key generation does not decline once a slot reference parses: a wrong class or
curve is BAD_STATE_E. wolfCrypt already refuses a software fallback for a key
with an id, but not under WOLF_CRYPTO_CB_KEYGEN_SW_FALLBACK, where a decline
would let software fill the key while its id still names the slot.

Generation also needs a free pair of general purpose slots. Slots 0 and 1 are
retention and slot 2 is hardware-out, and the key store refuses them as targets
for the same reason.

ELS speaks raw X9.62 throughout, points as X||Y and signatures as R||S, while
the callback boundary is DER, so the conversion happens here.

A P-256 key can also arrive as a key generation seed, which is how EdgeLock 2GO
delivers one. The seed is the private scalar, and ELS will not sign with it until
a deterministic key generation has turned it into a key pair in the same slots.
wc_ElsPkc_EccKeyGenFromSeed() takes a reference to the seed, runs that
generation, binds the ecc_key to the result and loads the public point, since
that is again the only moment the hardware gives it out. It refuses a slot that
does not hold a whole 256-bit seed before issuing anything. The engine sets the
generated key's properties itself, and marks it wrap-exportable whatever the
seed carried.

The reference format reserves bytes 8 to 15 for a binding to the public point,
but nothing computes or checks one, and ELS cannot hand out a stored key's
public point to check it against. Until that exists, wc_ElsPkc_MakeKeyRef()
and wc_ElsPkc_ParseKeyRef() refuse the BIND flag, so the field stays zero
instead of carrying a value nobody verifies. wc_ecc_check_key() is what shows
that a slot still holds the key a reference was made for.

Every slot path requires the reference to name the first slot of a key over 128
bits, and every ECC key is one; the engine marks that slot with its base-slot
property bit, so a reference to the second slot of a pair is refused before any
command is issued. Verification range-checks r and s itself, since wolfCrypt
does so only on its software path, and fails with the MP_ZERO_E or MP_VAL
software would give. ELS answers an off-curve public key with a mismatched R, so
that fails as it does in software. Verification declines a DER signature with a
negative r or s, or with bytes after its SEQUENCE, both of which software
rejects (the latter unless NO_STRICT_ECDSA_LEN), and reads the public point
straight from the key, so it needs no key export support. If the public point
cannot be loaded after an in-slot generation, the pair is deleted again so a
retry finds it free, and a build without key import refuses a slot key's
generation with NOT_COMPILED_IN.
Adds the second engine on this part. Where ELS is a fixed-function block driven
by a single async command, the PKC is a general modular-arithmetic coprocessor
reached through a session owning two workareas.

Only the CPU half is the caller's to allocate. The PKC half is a fixed hardware
region that mcuxClSession_init() takes as a pointer, which makes it look like
memory the caller chose. That shared region is also why PKC work runs under the
same lock as ELS: two concurrent operations would corrupt each other's
intermediates rather than merely race.

The session uses mcuxClRandomModes_Mode_ELS_Drbg, which carries 128-bit
strength and is instantiated in microseconds, and the full SP800-90A CTR_DRBG
only for RSA-4096 and curves over 256 bits, which is the split the vendor's
own PSA driver makes. DRG.3 costs about 90ms to instantiate, so one session
serves every PKC operation: it is opened on first use, the CTR_DRBG is
instantiated the first time it is needed and then kept, and
mcuxClSession_setRandom() picks the DRBG per operation. Each operation ends
with mcuxClSession_cleanup(), which wipes the work areas. Some CLNS error
exits keep their work area claimed (the MontDH small-subgroup exit, for one),
so the session is torn down after any operation that leaves more claimed than
it found, and after an engine failure; wc_ElsPkc_Cleanup() closes it, and init
forgets any session a failed cleanup left behind, since the PKC reset
invalidates it. Tearing a session down also resets the PKC, because the same
error exits leave it mid-operation, and init skips that reset while the port
is already up, where it would wedge an operation in flight.

RSA is claimed at the raw primitive, since the callback sits in wc_RsaFunction_ex
beneath all padding: NoVerify is RSAVP1 and NoEncode is RSASP1. That is also the
well-formed corner of the vendor API, as every encrypt and decrypt workarea macro
on this part still carries an unsubstituted template placeholder.

CLNS takes a CRT key's modulus length as bits(p) + bits(q), and its raw
private operation assumes a modulus of whole bytes. A modulus that is not a
multiple of 8 bits is therefore declined; on the board an RSA-2047 key gave
a wrong private result. A private operation uses CRT only when every CRT
member fits in half the modulus and bits(p) + bits(q) equals bits(n), and
any other key takes the plain form. A shorter p is fine: the engine reads it
correctly from its zero-padded entry, as a 2040-bit key built from 1016-bit
and 1024-bit primes shows.

The raw public operation reports VERIFYPRIMITIVE_OK, not VERIFY_OK; the latter
belongs to the padded modes. Accepting only VERIFY_OK rejects every successful
RSAVP1.

wolfCrypt bounds a decrypt input to 1 < c < n - 1 only on its software path, and
there only for its own padded callers, which the callback cannot tell apart from
a raw wc_RsaFunction() call. The port therefore runs RsaFunctionCheckIn() and
declines an input that fails it, so software applies its own rule:
RSA_OUT_OF_RANGE_E for a padded caller, a result for a raw call. An unknown
operation type gives RSA_WRONG_TYPE_E, as in software, instead of running as a
public operation.

ELS_PowerDownWakeupInit() leaves the PKC alone, so init also runs
PKC_PowerDownWakeupInit(), which clocks and resets the PKC and clears its RAM.
Without it the port relies on the boot ROM having left the PKC clocked, which
does not hold after a power-down.

A session whose DRBG fails to come up is torn down without
mcuxClRandom_uninit(), which would read the mode that the failed init never
set.

Nothing here is vaulted: the PKC tier accelerates keys in ordinary memory, which
is precisely what the ELS key store cannot hold.

The public path copies the exponent into the room the scratch block leaves
after the modulus, so an exponent longer than the modulus is declined instead
of overrunning it. Where WOLF_CRYPTO_CB_ONLY_RSA or WOLFSSL_NO_RSA_SW compiles
RsaFunctionCheckIn() out, the port applies the same 1 < c < n - 1 bound
itself, since no software path would.

An even modulus is declined, so software rejects it with MP_VAL as it does on
its own path.
The agreement only. Key generation stays in software so the private key keeps
whatever provenance wolfCrypt gave it, and the agreement is where the scalar
multiplication, and so all of the time, actually is. A big-endian request is
declined rather than byte-swapped behind the caller.

The engine performs the RFC 7748 all-zero check itself and reports a small-order
peer key as ERROR_SMALL_SUBGROUP, which maps to ECC_OUT_OF_RANGE_E, the error
software returns for it. CLNS reports a fault it detects during the
multiplication through the same status, so such a fault surfaces as
ECC_OUT_OF_RANGE_E as well.

EdDSA is not claimed because it cannot be: the Ed25519 entry points are present
as symbols but their Twisted-Edwards internals are compiled out for this part, so
a build that calls them fails to link, and Ed448 has no domain parameters at all.
Symbol presence is not capability here; only a link test settles it.
The complement to the ELS tier, which verifies only on P-256 and signs only
with a P-256 key that lives in a slot. The two do not overlap, so the dispatch
tries ELS first and falls through on CRYPTOCB_UNAVAILABLE, which is exactly
the set ELS declines.

Unlike ELS, the PKC has no built-in notion of a curve: a, b, p, G and n arrive as
octet strings on every call. wolfCrypt keeps those as hex text, so they are
converted into scratch held under the existing lock rather than onto the stack,
which would otherwise cost around half a kilobyte per signature.

The PKC draws its own nonce, so an RFC 6979 or wc_ecc_sign_set_k() signature
is declined and stays in software.

secp160r1 and secp160r2 also stay in software: their order is a byte longer
than the prime, and the domain parameters are carried at the prime's width.

The PKC verify path applies the same DER checks to the signature as the ELS
one.

The converted constants of a curve from wolfCrypt's own table are kept for the
next operation on it, so the hex parsing runs once per curve change rather
than once per signature, inside the lock. A caller-supplied curve set may live
anywhere and is converted every time, and the precomputed point derived for a
custom curve is keyed on p, a, b and G together, since every
explicit-parameter curve reports ECC_CURVE_CUSTOM. An off-curve public key
makes mcuxClEcc_Verify() return INVALID_PARAMS, which is declined so software
gives its own answer, instead of being reported as a hardware failure, and
the same status from the generator precomputation is declined too. As on
ELS, r and s are range-checked against the curve order with software's
MP_ZERO_E and MP_VAL.
Wires WC_ALGO_TYPE_KEYSTORE to EdgeLock: import a wrapped blob straight into a
slot, wrap a stored key back out, derive one slot from another, delete, and
report what a slot holds. None of these can be expressed by an algorithm
callback, since import and export carry a container that never becomes plaintext
on this side and derive moves a key slot to slot without it entering memory.

Validation matters more here than anywhere else in the port: these calls name two
slots rather than one, and a permission mistake resets the SoC. Delete checks the
slot is occupied first for the same reason.

The wrapping key's two permissions are not interchangeable. ukwk both wraps and
unwraps while ukuok only unwraps, so an export demands more than an import does,
which is the entire point of an unwrap-only key. A derived wrapping key is given
ukwk and nothing else.

Exportability is decided at creation and cannot be added afterwards, so a key
made without wrpok is refused an export rather than attempted. Only the vendor
container is accepted: a bare RFC 3394 blob has no property word for ELS to set
the imported key's permissions from.

Slots 0 and 1 are retention and slot 2 is hardware-out, holding the die key and
the flash encryption key, so an ordinary key is required to name a general
purpose slot, both for reserve and delete and as an import or derive target.
An ECC key or seed always takes a slot pair.

An imported key's permissions come from the property word inside the
container, which is only readable after the unwrap. Import therefore checks
the new key against the target reference's class afterwards and deletes it
again on a mismatch. Derive refuses an ECC key pair class as its target, since
those only come out of key generation, refuses a seed under 256 bits, and
accepts only WC_KDF_TYPE_NONE: the engine runs the SP800-108 CMAC KDF, and
WC_KDF_TYPE_TWOSTEP_CMAC names the different SP 800-56C two-step construction.

ELS feeds the requested property word into every derivation, so the same parent
and derivation data give a different key when the protection bits or wrpok
differ. The derive path asks for the same bits NXP's own key chains do, which is
what lets an application reproduce keys like the EdgeLock 2GO import keys.

A wrap-capable key may only be derived from the die unique key. From any derived
parent the engine trips the tamper controller, measured on hardware, so derive
refuses that case up front. An unwrap-only key derives from any source. The
README records both rules and what an EdgeLock 2GO import needs from the port.

Export, delete, GetInfo and the wrapping key also require a base slot, and
delete, export and GetInfo also require the slot to carry the permission of
the reference's class, so a stale reference cannot destroy or export a key
that has since reused its slot. An ECC import needs a 256-bit container, and a
seed target derives at 256 bits when keySz is 0. A 256-bit target that would
run past the last slot gives BAD_FUNC_ARG, the same as key generation, and
so does an export whose wrapping key is the key being exported.
Importing an RFC 3394 key blob needs a wrapping key the application had to
arrange for itself, which is awkward: the blob is wrapped at provisioning time
under NXP_DIE_KEK_SK, and the boot ROM deletes its own copy when SB file loading
finishes, so nothing is resident by the time the application runs.

It does not have to be. NXP_DIE_KEK_SK is a CKDF child of the die master key and
NXP publishes both the parent and the twelve byte derivation constant, so
wc_ElsPkc_DeriveDieKek() derives a fresh copy into a free slot pair and hands
back a reference. Import against that reference and delete the slot afterwards.
Slot 0 has to hold an active key with CKDF and DUK before anything is derived
from it, since deriving from an unfit parent resets the SoC instead of failing.

What is not verified is that this is the same key the provisioning tooling used,
which would need a blob programmed into OTP on a provisioned part. The header
says so rather than implying more than was tested.

Slot 0 holds NXP_DIE_INT_MK_SK, and the macro naming it now says so. The header
points at the README for the EdgeLock 2GO import keys, which descend from a
different root and are not this key.

Now that slot 0 has a name, derive's rule that only the die unique key may
parent a wrap-capable KWK checks the slot as well as the DUK property bit, so
another ROM key carrying DUK cannot reach the derivation that would trip the
tamper controller.
An ecc_key built by wc_ecc_init_id() carries a key id and nothing else: no
curve, because an id says nothing about one. ElsEccCurveOk() required
key->dp->id == ECC_SECP256R1, so the port declined every signature from such
a key, the operation fell through to the PKC path that has no private scalar,
and the handshake failed with ECC_BAD_ARG_E. That is the path
wolfSSL_CTX_use_PrivateKey_Id() takes, so an application could reference a
slot key from TLS but never sign with it. ElsEccPinCurve() now pins P-256
when the key has no curve, which the key reference already implies, the same
way wolfPKCS11's Pkcs11GetEccParams() fills the curve in from the token.
Once the key reference parses, nothing declines: the PKC and software paths
would sign with the zero scalar the ecc_key holds, so a wrong key class, a
curve other than P-256, a curve-table miss or an RFC 6979 or set-k request,
which ELS cannot honour, is returned as an error.

ElsEccSign() and ElsEccVerify() also rejected any digest that was not exactly
32 bytes, on the grounds that padding or truncating would be guesswork. It is
not: FIPS 186-4 6.4 and RFC 6979 bits2int take the leftmost min(N, outlen)
bits, which over a 256-bit order is a plain byte truncation with no bit
shift, and ecc.c does exactly that at its own truncation sites. ElsEccDigest()
now truncates a longer digest and zero-extends a shorter one, so SHA-384 and
SHA-512 ECDSA reach the hardware instead of being declined.

Both functions gain a single exit, so the error returns after the digest
buffer is populated reach the ForceZero the success path already performed.
The MCUXpresso user_settings.h and README now turn on ECC, X25519 and RSA
with the SP math they need and ECC timing resistance, and the key store
operations, and the autoconf
example passes the matching options. The frdm_rw612 test and benchmark
scenarios add P-384, P-521 and X25519, the curves the PKC offloads beyond the
module's default P-256.
A P-256 key generated in or referenced from an ELS slot holds no scalar
in its ecc_key, so the software check says nothing about the slot, and a
key bound with wc_ElsPkc_EccUseSlot() has no public point either and
fails it outright.

The port now answers WC_PK_TYPE_EC_CHECK_PUB_KEY for keys that carry a
slot reference. A public point, when the key has one, goes through
wc_ecc_point_is_on_curve(), which also bounds both coordinates below p;
P-256 has cofactor 1, so that covers the order check as well. With
checkPriv, the slot has to be occupied with the usage bit its class
claims, and a signing slot with a public point also signs a fixed digest and
verifies the result against the public point. That is the only way to
show the slot and the point belong together, since ELS cannot recompute
the public key of a stored key. A key agreement slot cannot sign and
gets the slot check alone, at the cost of one ELS sign and one verify
for a signing slot.

The self-test signs whatever deterministic or set-k state the key is in,
since that state concerns the caller's signatures, not the test's. With
no public point and no checkPriv the slot has nothing to answer, so that
case goes back to software.

If the self-test cannot run at all, the check fails with ECC_PRIV_KEY_E
instead of declining, since a decline would let wc_ecc_check_key() fall
back to a software check that cannot see the slot.

Keys without a slot reference still go to software.
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.

2 participants