Implement SLH-DSA (SPHINCS+, FIPS 205) as an entity authentication
algorithm for the TLS 1.3 and DTLS 1.3 handshake, following
draft-reddy-tls-slhdsa. All twelve parameter sets (SHAKE and SHA2 families,
128/192/256 in the f and s variants) are wired into the handshake for
signing and verifying the CertificateVerify message; test certificates and
configs cover the 128f and 128s sets.
Handshake integration:
- Map the SLH-DSA signature schemes to and from the wire in the
signature_algorithms extension and CertificateVerify. The mapping,
advertisement, and OID handling are gated per parameter set so a build
only offers, accepts, and maps the variants actually compiled in
(including partial SHA2 builds).
- Sign and verify the CertificateVerify with an SLH-DSA entity key, and
load SLH-DSA private keys and certificates (ssl_load.c, ssl.c,
ssl_api_pk.c, asn.c).
- Preserve the verify return code on a failed SLH-DSA CertificateVerify
rather than flattening every non-zero result to SIG_VERIFY_E.
wc_SlhDsaKey_Verify already returns SIG_VERIFY_E on a real mismatch, so
the failure semantics are unchanged while WC_PENDING_E (async crypto
callbacks) and hard errors now propagate, matching ML-DSA and Falcon.
Protocol version gating:
- SLH-DSA is defined for TLS 1.3 only, so the schemes are no longer offered
to a TLS 1.2 peer, and MatchSigAlgo and PickHashSigAlgo pin an SLH-DSA
certificate both to the scheme for its exact parameter set and to
TLS 1.3.
- Reject a Falcon, ML-DSA or SLH-DSA key in the TLS 1.2 CertificateVerify
with SIG_TYPE_E. No signature scheme below TLS 1.3 covers a post-quantum
key, the record is reserved for a classic signature, and the signing
switches have no post-quantum case, so continuing would have sent the
reserved buffer's uninitialized tail.
Streamed CertificateVerify send:
- SLH-DSA signatures are large (up to ~50 KB). When the CertificateVerify
body exceeds a single record, generate the signature into a
connection-level buffer and emit it one record at a time so the output
buffer never has to hold the whole signature. This keeps peak memory near
one signature plus a single fragment and resumes correctly across a
non-blocking WANT_WRITE without recomputing the randomized signature.
Gated by WOLFSSL_TLS13_STREAM_CERT_VERIFY (TLS 1.3, non-async, PQC
signatures); DTLS and WOLFSSL_ASYNC_CRYPT keep the existing in-place
fragmented path.
- Drop a half-sent streamed CertificateVerify in wolfSSL_clear. Left in
place, the resume guard would fire on the next handshake and re-send the
previous one's signature into a different transcript.
- Dual-algorithm (WOLFSSL_DUAL_ALG_CERTS, BOTH) CertificateVerify bodies are
streamed as well. The combined two-signature body may include a
variable-length signature, so the body buffer is sized from the
per-signature upper bounds and the exact length is recorded after signing;
the small trailing slack is never sent.
Buffer sizing:
- Keep MAX_X509_SIZE a fixed 9 KB for post-quantum builds. It sizes a
static per-certificate slot embedded by value in every cached session, so
it must not scale with a post-quantum signature; nor may it derive from
the enabled ML-DSA level, or a level-restricted build would silently drop
certificates that a full build keeps.
- Add MAX_CERT_WIRE_SZ for the largest certificate that may appear in a
handshake message, sized from the enabled post-quantum signatures, and
derive MAX_CERTIFICATE_SZ from it instead of from MAX_X509_SIZE.
- Add MAX_CERT_MSG_DEPTH for the chain depth assumed when sizing the
certificate message. MAX_CHAIN_DEPTH bounds how deep a chain may be
verified, while this sizes a buffer an unauthenticated peer can make us
allocate, so it is trimmed to 5 when a post-quantum certificate has
inflated the per-certificate size. Classic builds are unchanged.
- Size the CertificateVerify buffers from the actual signature length
instead of the worst-case WC_MAX_CERT_VERIFY_SZ, which balloons with
SLH-DSA. WC_MAX_CERT_VERIFY_SZ is retained for API compatibility and its
growth is documented in README.md.
- Order Scv13Args widest member first so it carries no interior padding and
still fits ssl->async->args under WOLFSSL_ASYNC_CRYPT together with
WOLFSSL_DUAL_ALG_CERTS.
Dual-algorithm certificates:
- Reserve the two signature length prefixes in the in-place
CertificateVerify sizing that the streamed path already accounted for.
- Build the PreTBS for an alternative signature check from the certificate
size minus both signatures, and retry once at a size the canonical
re-encode cannot exceed when that estimate turns out short. The estimate
keeps the allocation small on constrained targets, and wc_GeneratePreTBS
reports an encoder failure as WOLFSSL_FAILURE, which is zero, so a
non-positive result is now an error instead of silently skipping
ConfirmSignature and reading as a verified signature.
Device held private keys:
- Support an SLH-DSA private key that lives in a device and is referenced
by id or label. The parameter set cannot be recovered from a device side
identifier, so it is carried from the key type down to
wc_SlhDsaKey_Init_id and wc_SlhDsaKey_Init_label, and the key is released
with wc_SlhDsaKey_Free once the certificate and key pair is checked.
Robustness:
- Check the SlhDsaParamToType, wc_SlhDsaKey_PublicSizeFromParam and
wc_SlhDsaParamToOid results in the certificate and key load paths.
- Zeroize an SLH-DSA key before wc_SlhDsaKey_Init, which can return
NOT_COMPILED_IN before it clears the object, in both the certificate load
path and AllocKey.
- Take the alternative key's parameter set from the certificate's sapkiOID
rather than keyOID, which describes the native key.
- Re-initialise across hash families in wc_SlhDsaKey_PublicKeyDecode as
wc_SlhDsaKey_PrivateKeyDecode already does. The hash objects share a union
selected by family, so importing across families writes the new family's
state over the old one's and orphans it.
- Copy pkCurveOID in SetSSL_CTX when only SLH-DSA is enabled, matching the
struct member guard. Without it the field stayed zero and the signature
scheme matching above was dead in exactly that build.
- Derive the per parameter set WOLFSSL_SLHDSA_PARAM_NO_* macros from the
group level exclusions, and select WC_SLHDSA_DEFAULT_PARAM with those
same macros, so the parameter table and the TLS mappings cannot disagree.
- Add SLH-DSA to the lean build WOLFSSL_MAX_SIGALGO carve-out, since twelve
more entries no longer fit the small list.
- Prefix the new SLHDSA_ALL_NO_* macros in the installed header with WC_.
Tests and certificates:
- Add SLH-DSA entity (client and server) certificates for the SHAKE and
SHA2 128f and 128s parameter sets, and update the generation script.
- Add TLS 1.3 and DTLS 1.3 entity-cert CertificateVerify test configs
covering the fragmented (128f) and single-record (128s) send paths for
both hash families, wired into suites.c. These sign with the entity key,
so they are excluded from verify-only builds.
- Interrupt the streamed CertificateVerify with one WANT_WRITE and with
several on the same record, and assert the handshake still completes and
re-emits identical bytes, which the blocking .conf handshakes never
exercise. The record to interrupt is counted first, because the server's
record batching differs between builds. Where the flight is flushed as a
single write the send is retried below SendTls13CertificateVerify, so
these do not by themselves cover the fragOffset resume path.
- Drive the streamed path with an ML-DSA leaf under a negotiated
max_fragment_length, covering it for a non SLH-DSA algorithm.
- Reject a TLS 1.2 handshake that presents an SLH-DSA client certificate.
- Map every compiled-in scheme from its wire code point to the key OID, and
extend the exhaustive SaToNid coverage with the twelve new algorithms.
- Accept an SLH-DSA private key referenced by id and by label.
Build configuration:
- configure.ac: --enable-slhdsa now keeps the certificate/ASN code enabled
(as --enable-mldsa does), so an SLH-DSA-only build with RSA, ECC and DH
disabled configures instead of erroring that ASN is off.
- Guard the WOLFSSL, WOLFSSL_CTX and WOLFSSL_X509 pkCurveOID members for
WOLFSSL_HAVE_SLHDSA, so an SLH-DSA-only build declares the field the
handshake and CopyDecodedToX509 already reference under an SLH-DSA guard.
- Mark checkKeySz used in the SLH-DSA branch of ProcessBufferCertPublicKey;
SLH-DSA is the only certificate signature algorithm with no minimum-size
check, so an SLH-DSA-only build otherwise tripped -Wunused-parameter.
- Propagate haveSlhDsaSig in wolfSSL_set_SSL_CTX, which copied the Falcon
and ML-DSA flags but not the SLH-DSA one.
- CI: add a SHA2-only SLH-DSA build (--enable-slhdsa=sha2) so the
SHAKE-disabled combined-maxima guards are exercised, and an async crypto
build with dual-algorithm certificates, which is the only configuration
that compiles the in-place fragmented CertificateVerify send.
The applet >= 7.2 arm of se050_curve25519_shared_secret uses
CURVE25519_KEYSIZE directly, leaving keySize unused there and failing
-Werror maintainer builds with unused-variable.
The direct ECDH APDU carries the peer public point in the command, so
uploading the peer key to the SE050 on the applet >= 7.2 path wasted
APDU round trips, consumed a persistent object slot per distinct peer
in the default build, and added a failure path the derive does not
need. Confine the upload, the keyId bookkeeping and the keyCreated
cleanup to the pre-7.2 arm; on 7.2 builds a reference object is only
taken when the peer public key is already SE050-resident.
Also from review: validate the ECC direct-APDU response length against
the curve size, mirroring the Curve25519 arm; scope the derive-key
state (deriveKey, ctx_derive_key, deriveKeyCreated and their init and
cleanup) into the pre-7.2 arm instead of voiding it; and reword the CI
workflow comment to describe SE050_SIM_STRICT_ECDH as a regression
guard, noting the pre-7.2 arm is hardware-verified (SE050C applet
3.1.1) until an 03_XX matrix leg exists.
Verified: wolfCrypt suite passes against the strict simulator on the
07_02 build; the pre-7.2 arm compiles clean against an 03_XX SDK.
wc_strlcat measured dst with XSTRLEN(), which keeps reading until it finds
a NUL regardless of dstSize. When dst holds no NUL within dstSize -- the
case strlcat(3) explicitly bounds to prevent security problems in
incorrect code -- that read runs off the end of the buffer. Under
AddressSanitizer an unterminated 16-byte dst with dstSize 8 is a
stack-buffer-overflow READ of size 17.
Scan for the end of dst without passing dstSize. If no NUL is found the
length of dst is taken to be dstSize, nothing is appended and dst is left
un-terminated because there is no room for the NUL, matching strlcat(3).
The return value is now the total length attempted in every case: the
initial length of dst plus the length of src. The dstSize == 0 case
returns the length of src rather than 0, since nothing can be appended and
that is still the length the call tried to create.
Verified against the OpenBSD implementation over a matrix of dst values,
src values and destination sizes, including dst buffers containing no NUL
at all: 196 cases, identical return values and identical destination
buffers throughout.
wc_strlcpy returned the number of bytes it copied, so a truncating call
reported dstSize - 1 and a caller could not tell a truncated copy from an
exact fit. strlcpy(3) instead returns the length of src -- the length the
copy would have needed -- which is what makes the documented truncation
check (ret >= dstSize) work.
The doxygen comment in doc/dox_comments/header_files/types.h already
specifies "Length of source string", so this brings the implementation in
line with its own documentation rather than changing the contract.
Walking the remainder of src also gives wc_strlcat the length it attempted
in its truncating path, matching strlcat(3). No in-tree caller uses either
return value.
Verified against the OpenBSD implementation over a matrix of source
strings and destination sizes (including dstSize 0): identical return
values and identical destination buffers in all cases.
The applet refuses to export a symmetric key object regardless of the
policy attached at its creation: on SE051 applet 7.2.0 hardware,
ReadObject on an HMACKey object whose attributes confirm an attached
POLICY_OBJ_ALLOW_READ still fails with SW 0x6986, so a derived secret
stored in an SE05x object can never be read back and the previous
attach-a-read-policy approach cannot work (ZD 22212).
Se05x_API_ECDHGenerateSharedSecret, which returns the shared secret
directly in the APDU response, is accepted by the same applet. It is
also what sss_se05x_derive_key_dh itself uses whenever the derived key
object lives in a host keystore, so use it for the applet >= 7.2 ECDH
offload instead of deriving into an SE05x object: pass the private key
id and the peer public point, taken from the wolfSSL key when the peer
is a software key or read back from the resident public key object
otherwise. Montgomery points and secrets are byte swapped around the
call, matching the middleware's own handling.
The pre-7.2 flow is restructured but behaviorally unchanged (Binary
derive target created by the middleware during the derive, read back
afterwards), and drops fewer APDUs per derive on 7.2 since no target
object is created, read or deleted.
Verified on SE051 applet 7.2.0 hardware: ECC P-256 and X25519 shared
secrets derive successfully in both directions, where the previous
approach failed with SW 0x6986. The pre-7.2 path remains as validated
on SE050C applet 3.1.1 hardware.
Real SE050 hardware (applet 3.1.1, JCOP4) refuses ReadObject on a
symmetric key object created without a read policy just like applet 7.2
does, and pre-7.2 middleware has no way to grant that policy:
sss_policy_common_u can_Read maps to POLICY_OBJ_ALLOW_READ only for
SSS_HAVE_SE05X_VER_GTE_07_02 builds and the symmetric key policy union
has no read flag at all. Switching the derive target to an HMACKey
object unconditionally therefore broke ECDH offload on applet 3.x parts
with SW 0x6986 at the shared secret export.
Restrict the HMACKey target and its attached read policy to
SSS_HAVE_SE05X_VER_GTE_07_02 builds and restore the original Binary
object flow otherwise: no pre-created target, erase before derive, and
the middleware creates the object when storing the derived secret.
Binary objects are readable without an attached policy.
Verified on SE050C (applet 3.1.1) hardware: the ECC and CURVE25519
wolfCrypt tests fail with SW 0x6986 without this change and pass with
it, matching master behavior on the same part. The applet 7.2 path is
unchanged.
Applet 7.2 denies ReadObject on a symmetric key object that was created
with no policy attached, so the shared secret written into the derive
target by Se05x_API_ECDHGenerateSharedSecret_InObject could not be
exported: sss_key_store_get_key failed with SW 0x6986 (command not
allowed) on SE05x applet >= 7.2 hardware.
Create the derive target with an attached common policy granting read,
write and delete. An attached policy replaces the applet default
entirely, so write (the ECDH engine storing the result) and delete (the
cleanup path) must be granted explicitly alongside read. Guarded by
SSS_HAVE_SE05X_VER_GTE_07_02 so builds against older middleware keep
creating the object with no policy attached.
Align se050_curve25519_shared_secret with the ECC path: set
deriveKeyCreated only after sss_key_store_set_key succeeds, so the
cleanup path cannot erase or free a derive key object whose handle
was never allocated.
Middleware built for SE05x applet >= 07_02 (required for SE052) derives
the ECDH shared secret with Se05x_API_ECDHGenerateSharedSecret_InObject,
which requires TLV[TAG_7] to reference an existing HMACKey object sized
exactly to the shared secret; otherwise the applet returns SW 0x6985
(conditions not satisfied). The port never created this object, so ECDH
offload failed with WC_HW_E on such builds.
Create the derive target as an HMACKey object of the exact secret size
before the derive, for both ECC and Curve25519 shared secrets. Read the
result back as AES type since sss_se05x_key_store_get_key has no HMAC
read case.
wc_PKCS7_ResetStream()/wc_PKCS7_FreeStream() freed stream->aad, tag,
nonce, buffer, and key but never stream->bufferPt, the encryptedContent
buffer wc_PKCS7_DecodeAuthEnvelopedData() stashes across WANT_READ
re-entries. When the decode is abandoned mid-stream (e.g. malformed
input drives the parser into a pending WANT_READ state and the caller
then calls wc_PKCS7_Free()) that allocation was never reclaimed.
Found under LeakSanitizer by feeding wc_PKCS7_DecodeAuthEnvelopedData
single-byte-corrupted and truncated copies of a valid AES128GCM
AuthEnvelopedData message built with wc_PKCS7_EncodeAuthEnvelopedData;
a corrupted length field reliably drove it into the leaking state.
Fix: free/null stream->bufferPt in wc_PKCS7_ResetStream() alongside the
other stream buffers. This also required nulling stream->bufferPt right
after wc_PKCS7_DecodeAuthEnvelopedData's own successful-decrypt path
frees the same pointer, since that path used to leave the stream
pointer dangling and rely on the caller not resetting the stream again
before it was overwritten - otherwise ResetStream would double-free it.
Verified: the same corruption/truncation sweep under
-fsanitize=address,undefined,leak now reports no leaks and no
double-frees. ./wolfcrypt/test/testwolfcrypt and
./tests/unit.test --group pkcs7 --group pkcs7_sd --group pkcs7_ed
--group pkcs7_sed --group pkcs7_cd pass on the ASan build. A full
./configure --enable-all && make && ./wolfcrypt/test/testwolfcrypt &&
./tests/unit.test --api build also passes (0 failed, 1758 passed,
338 skipped).
Premise: DoPKCS12Hash is static with exactly two call sites, in the two
mutually exclusive WC_PKCS12_PBKDF_USING_MP_API variants of
wc_PKCS12_PBKDF_ex -- pwdbased.c:555 (buffer, Ai) and :771 (buffer, B).
Claim: pwdbased.c:376 `if ((buffer == NULL) || (Ai == NULL))
return BAD_FUNC_ARG;`
Proof: four paths reach the function (2 call sites x WOLFSSL_SMALL_STACK on
and off) and none can pass a NULL:
Ai / B -- SMALL_STACK: XMALLOC at :479 / :741, each immediately
followed by `if (... == NULL) return MEMORY_E;`. Otherwise a
stack array (`byte Ai[WC_MAX_DIGEST_SIZE]` :440,
`ALIGN8 byte B[WC_MAX_BLOCK_SIZE]` :685).
buffer -- initialised to staticBuffer (:431 / :687) and only
replaced by the XMALLOC at :513 / :748, each immediately followed
by `if (buffer == NULL) { ...free...; return MEMORY_E; }`.
Both operands are therefore invariantly false and neither
independence pair is reachable.
Scope: covers both WOLFSSL_SMALL_STACK settings and both
WC_PKCS12_PBKDF_USING_MP_API arms; the whole area is inside
HAVE_PKCS12.
Evidence: llvm-cov MC/DC records both conditions' pairs as uncovered
(reports/pwdbased/GAPS.md rows 358:9:358:41:0 and :1, pre-drift
numbering).
The caller obligation the guard used to restate is recorded in the function's
header comment instead.
pwdbased.c is inside the FIPS module boundary for v6 and v7+. Released FIPS
flavours pin pwdbased.c to a tag and are unaffected; fips-dev/fips-ready build
from master and recompute the in-core hash.
Compiler cross-check: gcc -O2 emits byte-identical code for this file before
and after this commit -- the optimiser had already folded the removed
condition, independently confirming it was dead.
Premise: kdf.c:1609 `int ret = 0;` and kdf.c:1666 `if (ret != 0) { break; }`,
an unconditional statement on every path through the loop body.
Claim: kdf.c:1636 `while (ret == 0 && len_rem >= WC_AES_BLOCK_SIZE)` -- the
`ret == 0` operand.
Proof: first evaluation: ret is its initialiser 0. Nothing between :1609 and
:1636 writes ret -- the four argument-check paths (:1612-1627) and the
WOLFSSL_SMALL_STACK allocation failure (:1630) all `return` instead.
Re-evaluations: the body contains no `continue` and no `goto`, so
every iteration reaches :1666, which breaks out whenever ret != 0.
Control therefore returns to the loop condition only with ret == 0:
the operand is invariantly true and its independence pair is
unreachable.
Scope: the only #if inside the body is WOLFSSL_DEBUG_KDF (:1639-1642),
containing a WOLFSSL_MSG_EX and nothing else; WOLFSSL_SMALL_STACK
only chooses heap vs stack for cmac, above the loop.
Evidence: llvm-cov MC/DC records this condition as never false
(reports/kdf/GAPS.md row 1616:12:1616:52:0, pre-drift numbering).
The `break` is what terminates the loop on error and is kept; ret is still
tested at :1672 and returned.
kdf.c is inside the FIPS module boundary (v5, v5-RC12, v6, v7+). Released FIPS
flavours pin kdf.c to a tag and are unaffected; fips-dev/fips-ready build from
master and recompute the in-core hash.
Compiler cross-check: gcc -O2 emits byte-identical code for this file before
and after this commit -- the optimiser had already folded the removed
condition, independently confirming it was dead.
Premise: ed448.c:1511 `if ((ret == 0) && ed448_is_small_order(key->p)) {
...; ret = PUBLIC_KEY_E; }`, and ed448_is_small_order (:245-312),
which zeroes byte 56 of a copy of the input and memcmps it against a
table that explicitly includes the non-canonical y = p encoding
(:283-291: 0xff x28, 0xfe, 0xff x27, 0x00).
Claim: ed448.c:1554 `if ((ret == PUBLIC_KEY_E) && (key->p[0] < 0xff))` --
the `key->p[0] < 0xff` operand, whose false half is the Y == p case.
Proof: reaching :1554 requires the preceding range walk to have found
p[56..29] all 0xff (loop at :1533), p[28] == 0xfe (:1543) and
p[27..1] all 0xff (loop at :1546). If p[0] were also 0xff the input
would be exactly the y = p encoding modulo byte 56 -- which
ed448_is_small_order masks -- so :1511 would have set
ret = PUBLIC_KEY_E and the else-if arm containing :1554 would never
have been entered. The operand can only ever evaluate true, so its
independence pair is unreachable.
With that operand fixed true, the remaining `ret == PUBLIC_KEY_E`
operand no longer decides anything: when it is false the bottom loop
has already set ret = 0. Both paths end with ret == 0, so the whole
conditional collapses to `ret = 0;`.
Scope: neither wc_ed448_check_key nor ed448_is_small_order is conditionally
compiled, so this holds in every configuration that builds ed448.c.
Evidence: llvm-cov MC/DC records no covered pair for this condition in any
ed448 variant.
Rejection semantics are unchanged: y = p is still refused, by
ed448_is_small_order rather than by this byte walk, and every other encoding
takes the same path as before. The table's y = p row is therefore what enforces
the upper end of the Y range; ed448_is_small_order carries a note saying so, so
the dependency is stated where the invariant is produced as well as where it is
consumed.
ed448.c is inside the FIPS module boundary. Released FIPS flavours pin ed448.c
to a tag and are unaffected; fips-dev/fips-ready build from master and
recompute the in-core hash.
With the last-byte test gone, the loop over p[27..1] can no longer affect the
outcome -- its only effects were setting ret = 0 and breaking -- so it goes too,
and the two surviving arms both yield ret = 0, collapsing to a single
`p[ED448_PUB_KEY_SIZE/2] <= 0xfe` test. The only input for which this differs
from the original is exactly the y == p encoding, which the proof below shows
never reaches here.
Proof check: gcc does not fold this (it cannot reason about the small-order
table). Machine-checked exhaustively instead: the byte walk pins 56 of the 57
public-key bytes before the final test, leaving p[0] as the only free variable,
so all 256 values were enumerated against the real ed448_is_small_order(). Two
values (p[0] = 0xff, y = p; and p[0] = 0xfe, y = p-1) are rejected early by the
table; the other 254 reach the final test and all satisfy p[0] < 0xff. Zero
counterexamples over the complete state space.
Also checked with clang's deadcode.DeadStores analyzer (the checker behind the
clang-tidy CI legs): clean on this file, with the two pre-existing sp_int.c
core.* findings unchanged from master.
Premise: ed448.c:1517 `if ((ret == 0) && key->privKeySet) { ... }` -- the
sibling arm of the if/else this condition belongs to.
Claim: ed448.c:1524 `else if ((ret == 0) && (!key->privKeySet))` -- the
`!key->privKeySet` operand; and, as a direct consequence, the nested
`if (ret == 0)` that opened the arm's body.
Proof: entering the else arm means the sibling condition was false, i.e.
(ret != 0) || (!privKeySet). && short-circuits, so the else arm's own
`key->privKeySet` read happens only after `ret == 0` evaluated true --
and with ret == 0 the sibling's falsity forces !privKeySet. The
operand is therefore invariantly true. privKeySet is a plain bitfield,
not volatile, and no statement runs between the two conditions.
The nested `if (ret == 0)` follows from the same short-circuit: the
arm is entered only with ret == 0 and nothing precedes the nested test.
Scope: wc_ed448_check_key contains no #if/#ifdef, so this holds in every
configuration that compiles ed448.c.
Evidence: llvm-cov MC/DC records this condition's pair as uncovered
(reports/ed448/GAPS.md row 1503:14:1503:46:1, pre-drift numbering).
Reviewed with `git diff -w`: apart from the two removed lines the change is
pure de-indentation.
ed448.c is inside the FIPS module boundary. Released FIPS flavours pin ed448.c
to a tag and are unaffected; fips-dev/fips-ready build from master and
recompute the in-core hash.
Compiler cross-check: gcc -O2 emits byte-identical code for this file before
and after this commit -- the optimiser had already folded the removed
condition, independently confirming it was dead.
Premise: aes.c:16444 `int bit = 7;` and the single update site inside the loop,
aes.c:16492-16498 `bit--; if (bit < 0) { ...; bit = 7U; ... }`.
Claim: aes.c:16507 `if (bit >= 0 && bit < 7)` -- the `bit >= 0` operand.
Proof: bit is written nowhere else in the function, and the decrement is
immediately normalised back to 7 whenever it would go negative, so
0 <= bit <= 7 holds at every loop-iteration boundary. The loop's only
other exit is the `break` taken when the block transform fails, which
happens before the `bit--` and leaves bit unchanged (and sets ret != 0,
so :16506 short-circuits anyway). sz == 0 is rejected at entry, so the
zero-trip case still has bit == 7. `bit >= 0` is therefore invariantly
true and its independence pair is unreachable.
Scope: there is exactly one definition of wc_AesFeedbackCFB1 in the tree
(:16438, called only from wc_AesCfb1Encrypt/Decrypt), all of it inside
#ifndef WOLFSSL_NO_AES_CFB_1_8. The only #ifdef in the function,
WC_AES_HAVE_PREFETCH_ARG at :16446, declares did_prefetches and
touches neither bit nor the control flow.
Evidence: llvm-cov MC/DC records this condition's pair as uncovered
(reports/aes/GAPS.md row 16469:13:16469:32:0, pre-drift numbering).
`bit < 7` stays and is load-bearing: when sz is a whole number of bits the loop
exits with bit == 7 and the trailing partial byte must not be written.
aes.c is inside the FIPS module boundary. Released FIPS flavours pin aes.c to a
tag and are unaffected; fips-dev/fips-ready build from master and recompute the
in-core hash.
Compiler cross-check: gcc -O2 emits byte-identical code for this file before
and after this commit -- the optimiser had already folded the removed
condition, independently confirming it was dead.
Premise: mp_init (integer.c:145-166) has exactly one non-MP_OKAY return,
`if (a == NULL) return MP_VAL;` at :150. Everything after it is
unconditional field initialisation ending in `return MP_OKAY;`.
Claim: the six guards at integer.c:114, 117, 122, 127, 132, 137 of the shape
if (x && ((res = mp_init(x)) != MP_OKAY)) { ...mp_clear...; return res; }
-- specifically the `!= MP_OKAY` operand of each, and the mp_clear
cascades they guard.
Proof: the left operand of each && establishes x != NULL, which excludes
mp_init's only failure path, so mp_init(x) == MP_OKAY and the second
operand is invariantly false. None of the six bodies can execute, so
the mp_clear cascades and the early `return res` are unreachable and
the function's only possible return value is MP_OKAY.
Scope: the sole preprocessor construct in mp_init is HAVE_WOLF_BIGINT
(:161-163), which adds the void call wc_bigint_init. The
`#define mp_init sp_init` in sp_int.h:1359 does not apply -- integer.c
is compiled only when integer.h is the math backend, and integer.h
declares mp_init as a plain prototype (:315).
Evidence: llvm-cov MC/DC records the `!= MP_OKAY` operand of all six as
uncovered (reports/bigint-integer/GAPS.md rows 114/117/122/127/132/137,
index 1).
The other two math backends already implement this API exactly this way, so the
resulting shape is the tree's majority convention rather than a new one:
sp_int.c's sp_init_multi (mp_init_multi under the SP backend, sp_int.h:1361)
NULL-guards each operand, calls the void _sp_init_size and returns MP_OKAY
unconditionally, and tfm.c:4390 does the same with fp_init. The call sites that
test the result -- integer.c:1089, :1233 and the rest -- are therefore already
testing a constant in every default and fastmath build.
Caveat, since this is the one proof here that rests on the callee's body rather
than a guard in the same function: mp_init is public API whose contract permits
failure, so mp_init_multi now depends on integer.c's mp_init staying
failure-free. Kept as the last commit of the non-FIPS group so it can be dropped
on its own. Note that sp_init has the same shape today -- MP_VAL for a NULL
argument, MP_OKAY otherwise -- so no backend in the tree has an init that can
fail on a non-NULL operand.
The mp_init calls themselves are kept although the XMEMSET above them already
produces the state mp_init writes: dp = NULL, used = 0, alloc = 0,
sign = MP_ZPOS (0), and under HAVE_WOLF_BIGINT wc_bigint_init only zeroes raw.
Compiler cross-check: gcc -O2 emits byte-identical code for this file before
and after this commit -- the optimiser had already folded the removed
condition, independently confirming it was dead.
Premise: wc_lms_impl.c:3593 `priv_key->inited = 0;`, sitting under the SAME
#ifndef WOLFSSL_WC_LMS_SMALL gate as the claimed condition -- wherever
the check is compiled, so is the reset.
Claim: wc_lms_impl.c:3603 `if ((ret == 0) && (!priv_key->inited))` -- the
`!priv_key->inited` operand.
Proof: the only call between the reset and the check is
wc_hss_expand_private_key(state, priv_key->priv, priv_raw, 0), which
receives the priv buffer, not the key struct, and so cannot write the
`word8 inited:1` bitfield (wc_lms.h:741). inited is therefore still 0
and the operand is invariantly true; its independence pair is
unreachable.
Scope: the WOLFSSL_WC_LMS_SERIALIZE_STATE `if (pub_root != NULL)` at :3596
only decides whether the block runs at all; it does not touch inited.
Evidence: llvm-cov MC/DC records both of this decision's conditions as
uncovered (reports/lms/GAPS.md rows 3592:13:3592:46:0 and :1, recorded
against wc_lms.c before the impl-file split).
The `priv_key->inited = 0;` at :3593 goes with it: it was only ever observed by
this operand, and :3620 assigns inited unconditionally on every path out.
Proof check: gcc does not fold this -- it must assume the call may
alias the bitfield. It cannot: HssPrivKey declares 'byte* priv' as a POINTER
member (wc_lms.h:726) alongside 'word8 inited:1' (wc_lms.h:741), and
wc_hss_expand_private_key receives the pointer value, not the struct address.
Writes through it target the pointed-to buffer, a distinct object; reaching
inited that way would be an out-of-bounds write, i.e. undefined behaviour.
Premise: each of the two inner loops is entered only from an outer loop whose
own condition already established ret == 0 --
wc_mldsa.c:8097 `for (r = 0; (ret == 0) && (r < params->k); r++)`
wc_mldsa.c:8873 `for (; (ret == 0) && valid && (r < params->k); r++)`
-- and nothing between the outer condition and the inner one assigns
ret. (At :8884 the WC_MLDSA_FAULT_HARDEN check does write ret, but it
`break`s out of the outer loop, so it never reaches the inner one.)
Claim: the `ret == 0` operand of
wc_mldsa.c:8103 `for (s = 0; (ret == 0) && (s < params->l); s++)`
wc_mldsa.c:8895 `for (s = 0; (ret == 0) && (s < params->l); s++)`
Proof: first evaluation: ret == 0 by the premise.
Re-evaluations: every write to ret inside either body is immediately
followed by an unconditional `break` --
:8107 `ret = mldsa_rej_ntt_poly_ex(...)` / :8108 `if (ret != 0) break;`
:8899 the same, and the WC_MLDSA_FAULT_HARDEN write at :8907, also
followed by `break`.
A scan of both bodies finds no other assignment to ret. So the loop
condition is never re-evaluated with ret != 0, the operand is
invariantly true, and its independence pair is unreachable.
Scope: checked with and without WC_MLDSA_FAULT_HARDEN,
WOLFSSL_MLDSA_SMALL_MEM_POLY64, WOLFSSL_MLDSA_SMALL and
WOLFSSL_MLDSA_SIGN_SMALL_MEM_PRECALC_A -- every one of those either
adds a break-terminated write or none at all.
Evidence: llvm-cov MC/DC records both conditions as never false
(reports/mldsa/GAPS.md rows for 8103 and 8850, pre-drift numbering).
The two enclosing outer loops keep their `ret == 0` operand: they DO re-evaluate
after the inner `break`, so their false half is reachable.
Compiler cross-check: gcc -O2 emits byte-identical code for this file before
and after this commit -- the optimiser had already folded the removed
condition, independently confirming it was dead.
Premise: wc_mldsa.c:8684 `int ret = 0;` in the WOLFSSL_MLDSA_SIGN_SMALL_MEM
arm of the sign path.
Claim: wc_mldsa.c:8730 `if ((ret == 0) && (*sigLen < params->sigSz))` -- the
`ret == 0` operand.
Proof: ret is not assigned anywhere between its initialiser and :8730 -- a
grep of the span for `ret` yields only the declaration and this
condition. The intervening code is the WOLFSSL_CHECK_MEM_ZERO
bookkeeping at :8724-8728 (an XMEMSET and a wc_MemZero_Add). This is
the operand's only evaluation, and it is invariantly true, so its
independence pair is unreachable.
Scope: the site is inside the #else arm selected by
WOLFSSL_MLDSA_SIGN_SMALL_MEM; the WOLFSSL_CHECK_MEM_ZERO block in the
span writes neither ret nor sigLen.
Evidence: llvm-cov MC/DC records this condition as never false
(reports/mldsa/GAPS.md row 8685:9:8685:48:0, pre-drift numbering).
Compiler cross-check: gcc -O2 emits byte-identical code for this file before
and after this commit -- the optimiser had already folded the removed
condition, independently confirming it was dead.
Premise: wc_mldsa.c:11544 `if (ret == 0) {` opens the block containing the
claimed condition.
Claim: wc_mldsa.c:11589 `if ((ret == 0) && (x != 0))` -- the `ret == 0`
operand.
Proof: :11544 dominates :11589 and nothing in between assigns ret: the block
decodes, NTTs, matrix-multiplies and XOR-accumulates into x through
mldsa_vec_decode_*, mldsa_vec_ntt_small_full, mldsa_matrix_mul,
mldsa_vec_red, mldsa_vec_invntt_full, mldsa_vec_add/sub/make_pos --
all void-returning. A grep of the span for `ret` finds only the two
conditions themselves. The operand is invariantly true and its
independence pair is unreachable.
Scope: the only preprocessor construct in the span is WOLFSSL_MLDSA_SMALL at
:11566, which adds another void call.
Evidence: llvm-cov MC/DC records this condition as never false
(reports/mldsa/GAPS.md row 11479:13:11479:35:0, pre-drift numbering).
The nearby `ret == 0` guards at :11485 and :11488 stay: prvKeySet / pubKeySet
are user-controlled and those decisions are live.
Compiler cross-check: gcc -O2 emits byte-identical code for this file before
and after this commit -- the optimiser had already folded the removed
condition, independently confirming it was dead.
Premise: `int ret = 1;` at wc_mldsa.c:5062, and mldsa_check_low (:5028-5046)
returns only 0 or 1.
Claim: wc_mldsa.c:5066 `for (i = 0; (ret == 1) && (i < l); i++)` -- the
`ret == 1` operand.
Proof: first evaluation: ret is its initialiser 1.
Re-evaluations: the body's only write is `ret = mldsa_check_low(...)`,
immediately followed by `if (ret == 0) { break; }` -- so the loop
condition is re-evaluated only on paths where ret is not 0, and
mldsa_check_low returns nothing but 0 or 1. Hence ret == 1 whenever
the operand is evaluated; its false half is unreachable and the
`break` alone terminates the loop.
Scope: the function is compiled under
!WOLFSSL_MLDSA_NO_VERIFY || (!WOLFSSL_MLDSA_NO_SIGN &&
!WOLFSSL_MLDSA_SIGN_SMALL_MEM); there is no #if inside it. The AVX2
alternative at :5089 is a separate function and is untouched.
Evidence: llvm-cov MC/DC records this condition as never false
(reports/mldsa/GAPS.md row 5048:17:5048:38:0, pre-drift numbering).
The guard is dropped rather than the `break`: keeping both would leave the same
dead operand.
Compiler cross-check: gcc -O2 emits byte-identical code for this file before
and after this commit -- the optimiser had already folded the removed
condition, independently confirming it was dead.
Premise: wc_MlKemKey_EncodePublicKey ends with
if (ret == 0) { key->flags |= MLKEM_FLAG_H_SET; }
(wc_mlkem.c:2796-2799). It is the only definition of that function in
the tree and is unconditional inside WOLFSSL_HAVE_MLKEM.
Claim: wc_mlkem.c:1428-1431,
if ((ret == 0) && ((key->flags & MLKEM_FLAG_H_SET) == 0))
ret = BAD_STATE_E;
Proof: two cases at :1428.
(a) MLKEM_FLAG_H_SET was already set on entry: the block at :1398 is
skipped entirely and nothing clears the flag, so the second
operand is false.
(b) The flag was clear: reaching :1428 with ret == 0 requires
wc_MlKemKey_PublicKeySize to have succeeded, the (malloc build)
allocation to have succeeded, and wc_MlKemKey_EncodePublicKey to
have returned 0 -- which sets the flag. Again the second operand
is false.
{ret == 0, H unset} is therefore unreachable, the condition can never
be true, and BAD_STATE_E can never be returned from here.
Scope: both the WOLFSSL_NO_MALLOC and !WOLFSSL_NO_MALLOC arms of the block
funnel through the same EncodePublicKey call; the size-failure and
allocation-failure paths both leave ret != 0. The enclosing
!WOLFSSL_MLKEM_NO_ENCAPSULATE || !WOLFSSL_MLKEM_NO_DECAPSULATE gate
selects whether the function exists at all.
Evidence: llvm-cov MC/DC records both of this decision's conditions as
uncovered (reports/mlkem/GAPS.md rows 1373:9:1373:61:0 and :1, at the
pre-drift line numbers) -- they are the only two residuals holding
wc_mlkem.c at 66/68.
The comment called it an "implementation issue" check, i.e. an assertion
against a future encoder that forgets the flag; it is being removed rather than
retained because no call path can reach it.
Proof check: gcc does not fold this (the encoder call is not inlined).
Verified instead by enumerating every write to key->flags in the translation
unit -- all nine, at wc_mlkem.c:453, 621, 852, 1001, 1003, 2283, 2327, 2430 and
2794, living in Init, Free, MakeKeyWithRandom, DecodePrivateKey,
DecodePublicKey and EncodePublicKey. wc_mlkemkey_check_h calls only
wc_MlKemKey_PublicKeySize, XMALLOC, wc_MlKemKey_EncodePublicKey and XFREE, so
the sole flag write reachable from it is :2794, which SETS MLKEM_FLAG_H_SET on
ret == 0. No reachable path clears it.
The little-endian large-code path of mlkem_vec_compress_10_c cast the
output byte buffer to word32* and issued five 32-bit stores through it.
That buffer is the caller-supplied ML-KEM ciphertext, which has no
alignment guarantee, so on strict-alignment targets the store bus-faults
and the cast violates strict aliasing. Write each word with
writeUnalignedWord32, which does an alignment-safe byte copy, matching
mldsa_encode_w1_88_c and the neighboring ML-KEM sampling code.
Fixes F-6782.
The little-endian large-code path of mlkem_cbd_eta3 cast the
caller-supplied byte buffer to word32* and read through it. The buffer
has no alignment guarantee, so on strict-alignment targets such as
Cortex-M3 and M4 with unaligned-access trapping enabled the read faults
or returns wrong values, and the cast violates strict aliasing. Read
each word with readUnalignedWord32, which does an alignment-safe byte
copy, matching the neighboring mlkem_cbd_eta2.
Fixes F-6781.
wc_InitRsaKey_Id cast the byte array key->id to word32* and dereferenced
it to recover the SE050 key id. key->id is a byte array with no alignment
guarantee inside RsaKey, so the dereference is an unaligned 32-bit read
that faults or mis-reads on strict-alignment targets, and it violates
strict aliasing. Read the value with readUnalignedWord32 instead, which
does an alignment-safe byte copy, matching wc_ecc_init_id.
Fixes F-6624.
The ESP32-C3 deactivation path in esp_mp_hw_unlock passed its arguments
to DPORT_REG_CLR_BIT and DPORT_REG_SET_BIT in the wrong order and added
a stray DR_REG_RSA_BASE offset. The macros take (register address, bit
mask), but the code used the bit mask as part of the register address
and the register as the bit mask. As a result the RSA clock-enable and
memory power-down bits were never updated at deactivation, and reads and
writes landed at a bogus peripheral address. The accelerator was left
powered up after every bignum and RSA operation.
Pass the register address first and the bit mask second and drop the
offset, mirroring the activation path and the other targets.
Fixes F-6779.
IntelQaSymCipher ran cpaCySymPerformOp synchronously but never
translated its completion status into the return code. Only the AES-GCM
decrypt auth check, driven by verifyResult, could set an error. For
AES-CBC, AES-GCM encrypt, and 3DES-CBC a non-success status left ret at
zero, so the exit path copied the working buffer to the output and
returned success. On encrypt that buffer still holds the plaintext copy,
so a hardware failure returned success with plaintext written to the
ciphertext output.
Translate a non-success perform-op status into ASYNC_OP_E, matching the
asynchronous port, so hardware failures are surfaced instead of
returning zero with unprocessed output.
Fixes F-6623.