Skip to content

NXP EdgeLock (ELS + PKC) crypto callback port - #48

Closed
Frauschi wants to merge 16 commits into
zephyr_rw612from
els_pkc
Closed

Frauschi wants to merge 16 commits into
zephyr_rw612from
els_pkc

Conversation

@Frauschi

@Frauschi Frauschi commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Offloads wolfCrypt to the NXP EdgeLock secure subsystem (ELS) and its PKC
coprocessor on the RW612, through the crypto callback interface.

Review-only PR against cryptocb_keystore, so the diff is the port itself.
The WC_ALGO_TYPE_KEYSTORE facility it builds on is under review separately as
wolfSSL#11336; basing here keeps that one commit out of this diff.

What lands

  • ELS tier - SHA-256/384/512, AES (ECB/CBC/CTR), AES-GCM, CMAC, the DRBG,
    and ECDSA/ECDH on P-256.
  • PKC tier - RSA at the raw primitive, X25519, and ECDSA on the curves
    beyond P-256.
  • Key store - five operations over the ELS slots, plus wc_ecc_init_id() /
    wc_AesInit_Id() / wc_InitCmac_Id() bindings so AES, GCM and CMAC run
    against a key whose value never leaves the hardware.
  • Provisioning - els_pkc_keyblob.c builds the wrapped-key container in
    plain wolfCrypt, so a factory-side tool needs no NXP code, and
    wc_ElsPkc_DeriveDieKek() derives NXP_DIE_KEK_SK rather than requiring a
    provisioned wrapping key.
  • Builds two ways - as a Zephyr module (CONFIG_WOLFSSL_ELS_PKC=y) and
    from autoconf (--with-els-pkc), with an arm-none-eabi cross-compile job
    and a bare-metal m33mu job in CI.

Two contracts worth checking

Declining and failing are different. CRYPTOCB_UNAVAILABLE means "not
served, do it in software". A hardware failure returns WC_HW_E on purpose, so
a caller that ignores the return cannot walk away with a confident bad result.

A rejected request resets the SoC. ELS answers an invalid key permission by
signalling the tamper controller, which drives a chip reset rather than
returning an error. Every slot reference is validated in software first, and an
in-flight operation is never cancelled. See "Hardware behaviour worth knowing"
in wolfcrypt/src/port/nxp/README.md.

Testing

134 checks on a physical frdm_rw612 (zephyr/samples/wolfssl_els_pkc), each
asserting an engine counter moved rather than only that the answer was right -
a disabled accelerator agrees with software perfectly. The m33mu lane covers
the software surface and the fallback contract with the vendor calls stubbed to
fail; it does not exercise the offload, which stays hardware-validated.

The vendor library (NXP/els_pkc) is fetched at build time, never vendored: it is
under a proprietary license whose terms forbid subjecting it to one requiring
source disclosure.

@Frauschi
Frauschi force-pushed the els_pkc branch 2 times, most recently from 32157ac to 4572988 Compare September 3, 2026 11:41
Comment thread IDE/MCUEXPRESSO/RW612/README.md Outdated
Comment thread IDE/MCUEXPRESSO/RW612/README.md Outdated
Comment thread wolfcrypt/src/port/nxp/README.md Outdated
Comment thread wolfcrypt/src/port/nxp/README.md Outdated
Comment thread wolfcrypt/src/port/nxp/README.md Outdated
Comment thread zephyr/CMakeLists.txt Outdated
Comment thread zephyr/Kconfig.tls-generic Outdated
Comment thread zephyr/Kconfig
Comment thread zephyr/Kconfig Outdated
Comment thread zephyr/Kconfig Outdated
@Frauschi
Frauschi force-pushed the els_pkc branch 6 times, most recently from 568428c to 86b02bd Compare September 10, 2026 06:38
@Frauschi
Frauschi changed the base branch from cryptocb_keystore to zephyr_rw612 September 10, 2026 06:49
Comment thread wolfcrypt/src/cryptocb.c Outdated
Comment thread wolfcrypt/src/wc_port.c Outdated
Comment thread zephyr/CMakeLists.txt
Comment thread zephyr/CMakeLists.txt Outdated
Comment thread zephyr/user_settings.h Outdated
Comment thread zephyr/zephyr_init.c Outdated
Comment thread zephyr/zephyr_init.c Outdated
Comment thread zephyr/zephyr_init.c Outdated
Comment thread wolfssl/wolfcrypt/port/nxp/els_pkc_port.h Outdated
Comment thread wolfcrypt/src/port/nxp/els_pkc_port.c Outdated
Comment thread zephyr/zephyr_init.c Outdated
Comment thread zephyr/zephyr_init.c
Comment thread wolfcrypt/src/port/nxp/README.md
Comment thread ChangeLog.md
Comment thread ChangeLog.md
Comment thread zephyr/samples/wolfssl_benchmark/sample.yaml
Comment thread zephyr/Kconfig Outdated
Comment thread zephyr/Kconfig Outdated
Routes wolfCrypt through the on-chip EdgeLock subsystem (ELS) on RW612 via the
crypto callback interface, with SHA-256 as the first offloaded primitive.
Anything the hardware does not implement declines to software, so enabling the
port never removes functionality.

Three properties of the hardware shape the design.

ELS is one peripheral with global busy state: every operation is an _Async call
followed by a wait, and nothing may start between the two, so one lock is held
across each pair.

ELS answers a rejected request by signalling the tamper controller, which
resets the SoC, rather than by returning an error. Validation therefore happens
in software before the call: getting it wrong reboots the device.

mcuxClEls_WaitForOperation() busy-spins with no timeout, and would do so
holding that lock. Completion is taken from the ELS interrupt instead, wired
with IRQ_CONNECT because the IRQ has no devicetree node, with polling as the
fallback for ISR context and for anything before the IRQ is armed. A late
interrupt degrades to the synchronous wait rather than cancelling: measured,
MCUXCLELS_RESET_CANCEL on an in-flight operation reboots the SoC.

For SHA-256 the engine round-trips its intermediate state (hashoe writes it,
hashld reloads it), so the state lives in the caller's wc_Sha256 and the lock
covers one call. Holding it from update to final deadlocks TLS 1.3, which keeps
several transcript hashes alive at once. ELS never pads, so the port carries the
residual block and appends the padding itself.

Registration happens inside wolfCrypt_Init(), which is also what zeroes the
callback device table. Zephyr gains CONFIG_WOLFSSL_SYS_INIT to run that at boot
from POST_KERNEL rather than leaving it to whichever library call happens to be
first, which starts to matter once initialization brings up a peripheral. It
defaults on when the port is enabled and stays available on its own, since
nothing about it is specific to this hardware. The hook calls wolfSSL_Init() so
the TLS layer is initialized too, and wolfCrypt_Init() in a WOLFCRYPT_ONLY
build, where wolfSSL_Init() is not compiled.

The port's Kconfig symbol is restricted to SOC_SERIES_RW6XX, the only series for
which els_pkc exports src/platforms/<soc>, where mcux_els.h lives.
ELS serves SHA-2 in all four widths. SHA-384 and SHA-512 share the engine's
512-bit state and 1024-bit block, so they differ from SHA-256 only in the mode
selector, a 128-bit padding length field, and SHA-384 taking the leading 48
bytes of the final state.

HMAC benefits without an arm of its own: HmacKeyInitHash passes the Hmac's devId
to the inner hash, so HMAC-SHA384, HMAC-SHA512, HKDF and the SHA-384 TLS PRF all
compress on the engine.

The ELS HMAC command stays unused. It is one shot, with no equivalent of the
hash command's state load, while the wolfCrypt callback is incremental, so
serving it would mean buffering whole messages on parts routinely built without
an allocator.
Adds the AES cipher modes, and with them the slot reference that lets a
wolfCrypt key name a key living inside the ELS key store.

A callback sees only key->id[], its length and the devId, so the reference is
self-describing and travels as the key's id blob: sixteen bytes of magic,
version, key class, slot, flags and eight bytes binding it to a public point.
wolfPSA stores the same bytes for a vendor-location key. Fields are never
redefined; the version bumps and the format appends. Each key class maps onto
exactly one ELS permission bit and one entry point, and every entry point reads
the slot's properties back and checks that permission before issuing a command,
because a permission violation resets the part instead of returning an error.

AES-192 and any trailing partial block decline to software: ELS knows only 128-
and 256-bit keys and only whole blocks.

CBC chaining is maintained here. The documentation says ELS "will always read
and write" pIV, but it only reads it, so aes->reg still held the original IV
after a one-block encrypt: correct for a single call and wrong from the second
onward. The next IV is the last ciphertext block either way, saved before an
in-place decrypt overwrites it. cphsie/cphsoe are set only for CTR; for CBC they
are documented as ignored, but setting them made ELS treat pIV as an internal
state blob and produce output that was self-consistent yet disagreed with
software.
The whole Init/UpdateAad/UpdateData/Finalize sequence runs under one lock, which
it can because the callback hands over the entire message at once.

Every stage consumes whole blocks: the final partial data block is zero-padded
with msgendw carrying its real byte count, and the AAD length reaches the
hardware only through Finalize. An exact block multiple needs no padding and so
comes out correct either way, which means a test exercising only 16, 32 and 64
bytes passes against a port that is wrong for every other length.

J0 is IV || 0x00000001, the single-block form Aead_Init takes; other IV lengths
need the GHASH-based derivation through Aead_PartialInit and are declined.

Finalize outputs the expected tag on decrypt rather than checking it, so the
comparison is done here in constant time, and a mismatch clears the plaintext so
a caller that ignores the return value is not handed unauthenticated data.
The input must arrive already padded to a whole block per SP 800-38B, 0x80 then
zeros, while inputLength stays the true pre-padding count, because that count is
what selects subkey K1 or K2.

Only the last block needs holding back, since only it takes the other subkey, so
everything before it goes in a single command. Every ELS command costs a fixed
~8us for AES, measured by sweeping buffer size on frdm_rw612, so a command per
16-byte block is what throughput is actually made of: AES-128-CMAC over 1KB runs
at 7.2 MiB/s this way against 775 KiB/s one block at a time.

pMac is [in, out] and carries the intermediate state, so it lives in the caller's
Cmac rather than in a pool.
Serves WC_ALGO_TYPE_SEED as well as WC_ALGO_TYPE_RNG, so wolfCrypt's own
Hash-DRBG is seeded from the hardware rather than only the direct generate path
being accelerated.

The engine takes at least four bytes and only whole words. With the driver's
parameter checks compiled out, a sub-word length would program the DMA past the
end of the caller's buffer, and odd sizes are ordinary (wc_RNG_GenerateByte), so
the tail comes from a word-sized scratch.
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 is reachable through the keystore
derive path.

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.
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 instantiates mcuxClRandomModes_Mode_ELS_Drbg, which carries 128-bit
strength and is instantiated in microseconds, and takes the full SP800-90A
CTR_DRBG only above RSA-4096 and P-384, which is the split the vendor's own PSA
driver makes. DRG.3 unconditionally costs about 90ms, enough to make RSA public
ten times slower than software.

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. Private
operations use CRT when the key carries its factors.

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.

Nothing here is vaulted: the PKC tier accelerates keys in ordinary memory, which
is precisely what the ELS key store cannot hold.
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.

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 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.
Derive is the only key store operation that creates a key without any way to
say how big it should be. Every other one has an answer: ImportPlain has the
plaintext's length, ImportWrapped reads it from the blob, the exports and
GetInfo report a property of a key that already exists. Derive has keyType,
which is a purpose rather than a length - AES is 128 or 256 either way -
derivSz, which measures the input rather than the output, and attrs, which is
three flag bits. So the device picks, the caller cannot ask, and GetInfo then
reports a size the API gave no way to request.

Add keySz, in bytes, beside the keyType it qualifies. Zero keeps the current
behaviour and leaves the choice to the device, which is the right answer for a
key store whose derivation has only one output size.

PKCS#11 carries this in C_DeriveKey's template as CKA_VALUE_LEN and PSA in the
attributes passed to psa_key_derivation_output_key(); this is the same
parameter under a name that matches wc_KeyStore_ImportPlain().

The key store API reached master without a ChangeLog entry. Add one here, since
this is the last change to that API and the entry can describe it as it ships.
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.
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.

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.
The port was reachable only through the Zephyr module, on the assumption that
NXP's els_pkc is a Zephyr artifact. It is not: els_pkc is NXP's CLNS middleware,
shipped as an MCUXpresso SDK component that additionally carries a Zephyr module,
so a project using the SDK directly should be able to build the port too.

Add --with-els-pkc=DIR alongside --with-mcux-sdk=DIR for the SoC device headers
its platform layer includes, and --with-els-pkc-platform=SOC defaulting to rw61x.
The component include directories are globbed rather than enumerated: the list
runs to roughly fifty entries and moves with each vendor release.

The port states its prerequisites as #error in els_pkc_port.h, so promote
WOLF_CRYPTO_CB_COPY and WOLF_CRYPTO_CB_FREE here the way --enable-rtl8735b does.
A wrong or missing path is diagnosed at configure time, naming the option, rather
than several hundred lines into the build with a missing mcuxClEls.h.

The vendor library stays link-only, so the archive carries unresolved mcuxCl*
references by design, as the SE050 port does.
An IDE/MCUEXPRESSO/RW612 directory with a known-good user_settings.h and the
steps to build the port against the SDK rather than Zephyr, plus the port
README's pointer to it. The README itself grew alongside the offloads it
documents.
Nothing committed exercised the port through wolfCrypt's own suites. Give both
samples an frdm_rw612 board overlay that turns the offload on; settings.h already
maps WC_USE_DEVID at WOLFSSL_ELS_PKC_DEVID, which is what the unmodified test and
benchmark read, and defining it also defines BENCH_DEVID, so the benchmark
measures every algorithm twice and labels the rows HW and SW.

Both suites pass on frdm_rw612.
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.

1 participant