The ccache that keeps this workflow fast is written only by the weekday
seed job, which runs on a schedule against the default branch. Nothing
had seeded it since 2026-07-24, because the workflow itself was failing
to load for that whole stretch, and the Actions cache evicts entries
untouched for seven days. The first run after the fix therefore reported
"Cache not found for input keys" on all four shards.
That cold run measured ~102 thread-minutes per shard, or 24-27 minutes
of wall including checkout, deps and autogen, against a 30 minute
timeout. Three minutes of headroom on the slowest shard is not enough,
and a shard killed by the timeout presents as a test failure rather than
as a cold cache. Raise it to 40. The comment above it claimed ~68
thread-minutes and ~20 minutes of wall, which the measurement above
contradicts, so replace it with the measured figures. macOS and Windows
are left alone: they came in at 11.6 minutes against 45 and 2.0 against
6.
Add workflow_dispatch so the seed can be run on demand rather than
waiting up to a day for the next cron, which matters exactly in the
situation above, where every PR run stays cold until something refills
the cache.
Adding the trigger alone would not have been enough. The seed behaviour
hangs off `github.event_name == 'schedule'` in five places (CCACHE_RECACHE,
--build-only on linux and macOS, the cache save, and skipping the Windows
job), so a manual run would have gone through the full test path and
saved nothing. All five now treat a dispatch as a seed as well.
The condition is written against `github.event.inputs.seed` rather than
`inputs.seed`, because the `inputs` context is only documented as
available on workflow_dispatch and workflow_call, whereas `github.event`
always exists. That yields strings, so it is compared explicitly rather
than for truthiness, where the string 'false' would read as true. It
tests `!= 'false'` and not `== 'true'` so that a dispatch which sends no
input at all still seeds: a declared default is not reliably reflected
into `github.event.inputs`, and keying on the positive would have made
`gh workflow run` quietly skip the seeding it was invoked to do.
Comments are brought in line with all of this, including one that was
already wrong before the trigger existed: the macOS ccache step is
read-only purely on pull_request, so every non-PR run writes that cache,
where the note claimed only the seed did. The two platforms seed
differently in a second way as well - linux sets CCACHE_RECACHE and so
rebuilds from scratch, macOS never does and only accumulates deltas.
Neither behaviour is changed here, but both are now written down at the
top of the file rather than left to be rediscovered from a surprising
cache.
A workflow file GitHub cannot load does not fail loudly. Its runs end
within 0s with zero jobs, no logs, no annotations and no check runs, and
the workflow re-registers under its bare path instead of its `name:`
field. Among the few hundred checks on a PR that reads as unrelated
flake, so the coverage just disappears: os-check.yml was in this state on
master for ten days in July 2026 before anyone noticed, and no open PR
reported a problem the whole time.
Add two guards.
Pre-merge, check-workflows.py measures every `run:` step against
GitHub's 21000 character cap and fails the build past it, with a warning
from 18000 so a growing step is noticed while there is still runway.
Sizes come from the parsed YAML, which is what the Actions service
evaluates, so block-scalar indentation needs no guessing. It runs from
check-source-text.yml over every workflow and composite action rather
than only PR-changed files: the cap applies per file, the whole sweep
takes well under a second, and a file can be pushed over the line by a
change elsewhere in the PR. Note that this cap is enforced by the
service and not by the workflow schema, so neither a YAML validator nor
actionlint reports it.
Post-merge, workflow-health.yml runs check-workflow-health.py daily and
looks for the symptom rather than any particular cause, so a workflow
that stops loading for a reason nobody anticipated is still caught. Two
signals: an active workflow whose registered name equals its path, and a
completed run that failed with zero jobs (prefiltered on
created_at == updated_at, so only a handful need a jobs lookup). Against
the live repository the first signal flags os-check.yml and nothing else
across 107 workflows, and reports clean on wolfTPM and wolfMQTT. It
exits 1 on a finding and 2 when the check could not be carried out at
all, because a missing token and a broken workflow call for different
responses.
Findings go into a single reused issue rather than another red check
that would blend into the noise: the body is rewritten on each run, a
comment is posted only when the set of affected workflows changes, and
the issue closes itself once everything loads again.
Finding that issue reliably turned out to be the fiddly part, and the
approach here is the one that survived testing against a live
repository. The issue is identified by both a dedicated label and its
title, and looked up through the REST issues endpoint. Both halves of
that identity matter: the label alone is a normal repository label that
anyone can apply, and an adopted issue has its body overwritten and is
then closed, so matching on the label alone would destroy a mislabelled
issue. Searching by title instead is unusable, because search ignores
--state and returns closed issues, which had the monitor re-closing an
already closed issue on every clean run. `gh issue list` reads a GraphQL
replica that can lag. The REST endpoint lags too, by about 2.4s for a
newly created issue, so the lookup re-checks a few times before
concluding nothing is open - without that, consecutive runs each open a
duplicate, and a clean run right after an outage fails to close the
issue it just opened.
Verified against the commit that caused the outage: check-workflows.py
fails on acff4d62a (21813 characters) and passes on its parent
f5ace71dd, which it flags at 20662 - already inside the warning band,
338 characters short of breaking. The full issue lifecycle (open,
repeat with no comment, comment on change, close, stay closed, reopen a
fresh issue for a new outage) was exercised end to end against a live
repository.
GitHub caps a single `run:` step at 21000 characters. os-check.yml
embedded its 109-entry Linux config list as a heredoc inside that step,
and commit c00e7260b ("Add AES-GCM DEM, CryptoCb support, and devId
threading to ECIES") pushed it from 20662 to 21813 characters. Since
that merge on 2026-07-24 GitHub has refused to load the file at all:
every run of the workflow ends in failure within 0s with zero jobs, on
master and on every PR branch.
The failure is easy to miss. The run registers under the literal path
`.github/workflows/os-check.yml` rather than its `name:` field, its
check suite carries no check runs so there are no logs or annotations,
and because GitHub cannot read the file it cannot apply the `on:`
filters either - hence the master push runs for a workflow whose push
trigger is restricted to release/**. Meanwhile `gh pr checks` still
reports hundreds of green checks from the other workflows.
Move the config lists to checked-in JSON under .github/configs/;
parallel-make-check.py already accepts the JSON path as its positional
argument. Splitting the step in two would not have been enough: the
heredoc alone was 21387 characters once de-indented.
os-check-linux.json 109 configs
os-check-macos.json 7 configs
pq-all.json 31 configs
multi-arch.json 23 configs
smoke-test.json 10 configs
The os-check lists are the fix; the other three are preventive - pq-all
and multi-arch were the next largest run steps at 14076 and 11011
characters. The largest remaining run step is now 6285 characters.
Each list was compared object-for-object against the version it
replaces, so this is a pure relocation with no coverage change.
wc_ed448_check_key()'s Y-range check is a walk over the bytes above the 0xFE
position followed by a single compare of that byte, so the stanza aimed at the
low-byte scan and its final byte compare no longer reaches a decision.
The three stanzas now cover both surviving decisions and both of their operands:
- every byte above the 0xFE position 0xff, that byte 0xfe: the loop runs to
exhaustion, so its index operand goes false and the compare below is
reached and taken.
- the same value with one byte inside the walked range cleared: the loop
breaks with ret == 0, covering the byte compare's true side and the
following (ret == PUBLIC_KEY_E) guard's false side.
- every byte 0xff, including the 0xFE position: no small-order table row
matches, the walk finds no byte below 0xff and the 0xFE compare is false,
so the key is rejected as out of range -- the compare's false side, and the
one stanza whose result is exact rather than curve-decode dependent.
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.
Ed448 verification rejects a non-canonical signature scalar S (S >= L)
per RFC 8032, and that range check is the only guard against a malleated
signature: because L times the base point is the identity, (R, S + L)
recomputes the same R and would otherwise verify. The check had no
negative coverage, so a deletion or boundary mutation passed the suite
while all canonical KAT signatures kept working.
Add a test that signs a message, then verifies crafted signatures whose
S half equals the order, exceeds it in a high or low byte, and equals
S + L, asserting BAD_FUNC_ARG, plus an in-range wrong S asserting
SIG_VERIFY_E.
Fixes F-6777.
wc_ecc_check_key validates a public key's coordinate range, that the
point is on the curve, and its order, but the software path had no
negative coverage: the existing test only exercised a valid key and
NULL, and the off-curve case lived in the crypto-callback test, which
validates the device path rather than the software on-curve check. A
deletion of either the on-curve check or the coordinate-range checks
therefore passed the suite.
Add a test that imports secp256r1 public keys that are off the curve
and out of coordinate range, asserting IS_POINT_E and ECC_OUT_OF_RANGE_E
respectively, exercising the software validation path.
Fixes F-6620.
wc_DhSetKey_ex loads DH parameters as untrusted and validates that the
modulus is prime, but it passed no RNG, so the check fell back to a
Miller-Rabin test using the fixed small-prime bases 2 through 19. That
test is defeatable: a composite crafted as a strong pseudoprime to those
known bases passes as prime, letting an attacker supply a composite
modulus with a smooth factorization for small-subgroup recovery of the
private exponent and shared secret.
When no RNG is supplied on the untrusted path, create a temporary RNG so
mp_prime_is_prime_ex runs with random witnesses, which such crafted
composites cannot reliably pass. Named FFDHE primes still short-circuit
the check, and builds without an RNG keep the deterministic test.
Fixes F-6776.
wc_ecc_shared_secret_gen_sync ran the scalar multiplication and then
copied the x-coordinate to the output without checking whether the
result was the point at infinity. Both math backends report success for
the identity: ecc_map_ex sets x, y to zero and z to one and returns
success, and the single precision generators serialize the identity as
an all-zero x-coordinate. Either way a shared secret that computed to
infinity was handed back as an all-zero secret with a success code,
where SP 800-56Ar3 5.7.1.2 requires an error and stop.
Check the mapped point on the software path, and detect the all-zero
output after the single precision generators, returning ECC_INF_E in
both cases. The scan accumulates over the whole buffer so it does not
branch on the secret.
A key whose private value is resident in an SE050 carries no software
scalar, so the software multiply legitimately yields the identity for
it. Skip the check for those keys specifically, rather than for a zero
scalar: on a prime-order curve a zero scalar is the one way the identity
can arise, so exempting it would disable the check for the case it
exists to catch.
Fixes F-6770.
wc_Chacha_Process validated only its pointer arguments and then produced
keystream directly from the context state. A zero-initialized ChaCha
context, common for static or global storage, that received a nonce via
wc_Chacha_SetIV but never had wc_Chacha_SetKey called would encrypt with
an all-zero, attacker-predictable key and still return success. This is
the same fail-open class already guarded against in wc_Arc4Process.
Add a keySet flag to the ChaCha struct, set it in wc_Chacha_SetKey, and
return MISSING_KEY from wc_Chacha_Process when the key was never set.
Fixes F-6893.
psoc6_ecc_verify_hash_ex serialized the signature r and s components
into a fixed 132-byte stack buffer using mp_to_unsigned_bin without
checking their sizes. The values come from attacker-supplied ASN.1 in
DecodeECC_DSA_Sig with no magnitude cap beyond sp_int capacity, so an
oversized r or s wrote past signature_buf, a pre-authentication stack
overflow reachable during TLS signature verification. The generic path
guards this with wc_ecc_check_r_s_range, but that check is compiled out
on WOLFSSL_PSOC6_CRYPTO builds and the port function performed no r/s
validation of its own. Reject any r or s whose serialized size exceeds
the key size before writing into the buffer.
Fixes F-6778.
Several DTLS tests drive a connection with reads alone and then assert that
something was sent, an ACK in most cases. With WOLFSSL_RW_THREADED that only
happens once the application asks for it, so stand in for such an application
and pump where the send is expected. The helper is a no-op elsewhere, so
builds whose read path sends for itself are unchanged.
test_dtls13_ack_overflow needs the same treatment in its setup, where the ACK
the first reads scheduled would otherwise be left in the seen-record list and
counted by the assertions that follow. It sits in the dtls13 group rather than
dtls, so a run of the dtls group alone does not cover it.
Add a test for the new API that runs in every build rather than only the
threaded one. It schedules a key update the way the AEAD failure limit does
and requires the predicate to report it, the pump to perform it and put a
record on the wire, and the wait for the peer's acknowledgement not to be
reported as work. It then drives the state that would wedge a drain loop, a
peer requesting a KeyUpdate while ours is unacknowledged, and requires pump
and predicate to agree that nothing can be sent and the request to be kept
until it can. It also covers the bad argument cases, a DTLS 1.2 object being
refused rather than quietly succeeding, and that refusing an object records no
error against the connection and leaves it usable.
The AEAD limit test excludes its second key update and its hard limit check
from threaded builds. Both need the acknowledgement processing that stays off
the write path: without it the decrypting epoch stops matching the one the
drop counter is placed on, so the read never reaches the limit and the test
hangs rather than failing.
With WOLFSSL_RW_THREADED the read path performs no scheduled work, because
transmitting from the reader would race the write thread over the output
buffer and the sending key schedule, neither of which is covered by a lock.
Post-handshake the only remaining consumers are on the write path, and
wolfSSL_dtls_retransmit() only helps while the handshake is unfinished. An
application that reads without writing therefore never acknowledges a
NewSessionTicket, KeyUpdate or connection ID message, and the peer keeps
retransmitting what it is waiting to have acknowledged. RFC 9147 relies on
those ACKs, so this is a protocol level break rather than a missed
optimisation.
Add wolfSSL_dtls13_do_scheduled_work() so such an application can send that
work from its write thread, and wolfSSL_dtls13_pending_work() so it can tell
when there is any.
Both entry points ask the same helpers rather than each testing conditions of
their own, so they cannot drift into the predicate promising work the pump
then declines or silently discards, which would leave a drain loop spinning or
mislead the caller about what happened. That covers key updates in particular:
none is sent while one of ours is unacknowledged, since DTLS must not have two
in flight and Tls13UpdateKeys() drops a locally scheduled one in that state,
and a peer request is kept rather than dropped until it can be answered. The
predicate also errs towards reporting work when it cannot tell, so a loop
surfaces the error rather than stopping silently.
Refusing an object is treated as a usage error and leaves ssl->error alone.
That field is sticky, since SendData() only clears it for WANT_WRITE, pending
async work and the DTLS MAC and decrypt cases, and wolfSSL_write() skips its
write-dup drain while it is set, so recording one would disable the very drain
a write-dup application depends on.
Write-dup pairs are out of scope on both sides. They park the read side's work
in the shared WriteDup struct, which only wolfSSL_write() reconciles, so they
already have a drain. Completing a key update we started ourselves is out of
scope too: that needs the peer's acknowledgement processed, which rotates the
sending keys and creates an epoch, and the epoch table has no locking while
the read thread mutates it as well.
The declaration is gated to match where the definitions live, so a lean build
is not promised a symbol it does not get.
In a small stack build wc_SrpComputeKey allocates six objects up front and
checks them together afterwards, so a failure of any one of them jumps to the
cleanup with the others allocated but not yet passed through mp_init_multi.
The cleanup decided whether to zeroize the four temporaries by testing the
return code against MP_INIT_E, which does not hold on the allocation failure
path, so it called mp_forcezero on uninitialized memory. That takes its length
from the size field of the object being zeroized, so an unset field turns into
a write of arbitrary length past the end of the allocation.
Track whether mp_init_multi succeeded and gate the zeroize on that instead.
The flag is only set once the objects really are initialized, so it covers the
init failure case the return code test was aiming at as well.
Add a regression test that fails the last of the six allocations through a
custom allocator and requires the call to report a memory error without
touching the objects it never initialized.
Fixes F-7084.
wc_rng_bank_init derives each instance's personalization string from
&ctx->rngs[i], which is already the address of the instance, so the DRBG read
the leading bytes of the instance struct rather than the address itself.
Those bytes had just been cleared by the memset over the whole array and
nothing writes to instance i before it is initialized, so every DRBG in the
bank was instantiated with the same all zero personalization string.
wc_rng_bank_inst_reinit does this correctly by taking the address of a local
pointer variable, which yields the pointer value and so a distinct nonce per
instance. Take the instance pointer into a local here as well and pass its
address, so both paths agree and each instance gets its own value.
Fixes F-7085.
The encap and decap paths create a temporary RNG, install it into a key that
belongs to the caller so the shared secret computation can blind, and then
free it without taking it back out. Both wc_ecc_set_rng and
wc_curve25519_set_rng only record the pointer, and nothing else ever writes
that field, so the caller's key was left pointing at freed memory. Encap does
this to the ephemeral key and decap to the receiver key, including the
curve25519 branch. Repeated HPKE calls hide it because each one installs a
fresh RNG first, but any other use of the key that consults it, such as an
ECDH or a signature under timing resistance, reads the freed object. ECH
holds exactly such a long lived key.
Save whatever RNG the key already had and put it back before freeing the
temporary one, so the key is handed back to the caller unchanged. Clearing
the field instead would silently drop an RNG the caller had installed for
blinding of their own.
Extend the HPKE round trip test to give both keys an RNG of its own and to
require them to still have it once the seal and open have finished.
Fixes F-7083.
wc_ecc_ctx_new_ex records the caller's heap hint in the context and then
calls wc_ecc_ctx_reset, which goes through ecc_ctx_init. That function opens
by clearing the whole context and only restores the algorithm choices, the
protocol role and the RNG, so the heap hint was lost on every context the
_ex variant produced, and on every later call to the public reset. The
context was then freed with a null hint, so it went to the default allocator
rather than the heap it came from, and the temporary buffers that
wc_ecc_encrypt_ex and wc_ecc_decrypt take from the same hint went to the
default allocator too.
A default build hides this because XMALLOC discards the hint, but with static
memory the block belongs to the caller's pool and handing it to the system
allocator is a free of memory that was never allocated there.
Save the hint before ecc_ctx_init and restore it afterwards. Doing it in the
reset covers both the constructor and the public reset. The other callers of
ecc_ctx_init pass an uninitialized context on the stack, so the hint must not
be read there.
Add a regression test that creates a context from a static heap and requires
the heap to be whole again once the context is freed.
Fixes F-7082.
SSL_clear recycles a WOLFSSL object for a new connection, which is the usual
pattern in connection pooling servers, and wolfSSL_shutdown calls it on
success as well. It reset the option and state fields but left every piece of
key material from the previous connection in place. The teardown path in
SSL_ResourceFree is careful here and force zeroes the keys struct and the TLS
1.3 traffic secrets, so a reused object ended up holding material that a
freed one would not.
The keys struct keeps the write keys, MAC secrets and IVs, clientSecret and
serverSecret keep the TLS 1.3 traffic secrets, the DTLS 1.3 epoch table keeps
traffic keys, IVs and sequence number keys for every epoch, and the handshake
arrays keep the master secret, the pre master secret, the PSK key and the TLS
1.3 key schedule secret. The tls-unique fields keep the Finished values of the
connection that just ended, so the next caller could bind to the wrong
session. The buffers are sized for the largest supported algorithm, so a later
handshake that negotiates something smaller only overwrites a prefix and the
tail survives.
Force zero all of it. A freshly created object has these zeroed already, with
two exceptions that are put back after the wipe: the multicast peer identifier
sentinel, and the unprotected DTLS 1.3 epoch 0 together with the epoch
pointers aimed at it, which only InitSSL sets up and without which the next
handshake has no valid epoch.
Wipe the handshake arrays in place rather than releasing them. They have to
stay allocated because wolfSSL_set_secret, the exporter and the accessors that
run after a connection all read from them on an object that is being recycled
rather than freed, and because the key agreement routines take preMasterSz as
the size of the buffer they may write, so that is restored to what a freshly
allocated Arrays carries. An application that asked to keep the arrays still
gets back everything the API can hand it, so the master secret and the
exporter secret only go when it did not ask, while the pre master secret, the
PSK key and the key schedule secret always do because nothing reads those
back.
wolfSSL_set_secret and wolfSSL_make_eap_keys both reached into the arrays
without checking that they are there, which the ordinary handshake teardown
can already leave them not to be, so both now report a bad argument instead.
Add a regression test that runs a handshake, clears the object with the arrays
kept, and requires the write keys, both traffic secrets and the pre master
secret to be gone while the master secret, the exporter secret and the client
random survive. It then takes that request back, clears again, and requires
the master and exporter secrets to be gone with the arrays themselves still
present.
Fixes F-7258.
WOLFSSL_HMAC_CTX keeps a copy of the inner and outer pads outside the
embedded wolfCrypt HMAC object so that a later init with a NULL key can
restore the key. Those pads are the key combined with the fixed padding, so
for any key no longer than the hash block size the key falls out of a single
exclusive or. Cleanup only called wc_HmacFree on the embedded object, which
zeroes what it is given but cannot reach the enclosing context, so the saved
pads survived. HMAC_CTX_free then returned that heap block to the allocator
with the key material still in it, where it stayed until some later
allocation happened to overwrite it.
Wipe both saved pads in wolfSSL_HMAC_cleanup, which HMAC_CTX_cleanup,
HMAC_CTX_reset and HMAC_CTX_free all reach. Do the same on the set key
failure path in the init function, since the context is reported as unkeyed
there while the previous key's pads would otherwise remain.
The session ticket key callback had the same leak for the same reason. It
holds a WOLFSSL_HMAC_CTX on the stack, hands it to the application to be
keyed with the long lived ticket HMAC key, and then only freed the embedded
object, leaving the pads on the stack after every ticket encrypt and every
ticket decrypt including the error paths. Have it clean up through
wolfSSL_HMAC_CTX_cleanup so it picks up the wipe.
Add a regression test that keys a context, checks the pads were populated,
runs cleanup and requires both arrays to be zero.
Fixes F-7256 and F-7257.
wolfSSL_X509_check_host takes an explicit length and its own validation
accepts a buffer with no NUL terminator, since only an embedded NUL is
rejected and a trailing one is merely stripped when present. The iPAddress
check then called CheckIPAddr, which drops the length and measures the
buffer with XSTRLEN, reading past the end of a caller supplied buffer that
is length delimited rather than terminated. This ran on every call, not
only when checking an IP address, and is compiled in whenever
WOLFSSL_IP_ALT_NAME is defined, which OPENSSL_ALL and WOLFSSL_QT enable.
Call CheckHostName directly with the caller's length and the IP flag set.
That is what CheckIPAddr does internally, minus the length being recomputed.
Behaviour is unchanged for NUL terminated input, because the normalization
above already leaves chklen equal to the string length in that case. It
also fixes a matching bug, since a length delimited IP address sitting in a
longer buffer no longer fails to match an iPAddress entry.
Add a regression test covering an interior slice of a longer buffer and a
buffer sized exactly to the name with no terminator.
Fixes F-7248.
X509PrintDirType derived the length of the alt name payload with XSTRLEN.
A directoryName entry holds raw DER, which routinely contains zero bytes,
and under WC_ASN_NO_HEAP the buffer is not NUL terminated at all, so the
computed length was wrong in general and reading it was already out of
bounds in the no-heap case. The length then fed an unsigned loop bound of
src_len - 5, so any entry that XSTRLEN measured as shorter than five bytes
wrapped the bound to nearly UINT32_MAX and the tag scan read far past the
end of the entry. A certificate with a short or empty directoryName alt
name reaches this from the ordinary certificate printing path.
Take the length from entry->len, which the parser already fills in, and
return early when the entry is too short to hold an OID, a tag and a
length. The scan is unchanged for entries of five bytes or more, and the
ASN parsing helpers now receive the true buffer length as their bound.
Add a regression test that prints a certificate carrying a directoryName
with an embedded zero byte before the common name, and one carrying a
directoryName too short for the tag scan.
Fixes F-7223.
wolfSSL_dtls_set_pending_peer acquired the DTLS peer lock for reading and
then mutated the shared state under it. It frees and clears pendingPeer,
calls SockAddrSet which frees and reallocates the address buffer, and
writes processingPendingRecord. A read lock allows several holders at once,
so two threads in this function can both free pendingPeer.sa, and a reader
in wolfSSL_dtls_get_peer or the wolfio send path can observe a dangling
pointer.
Acquire the lock for writing instead, matching wolfSSL_dtls_set_peer, which
already does so for the same class of mutation. Only affects builds that
define WOLFSSL_RW_THREADED, since the locking is compiled out otherwise.
Fixes F-7222.