Repository-wide audit and 5.0.0 completion: SIGILL in shipped wheels, KyberSlash divisions, broken SVE2 kernels, and the gates and lanes that could not fail - #394
Open
Steel-SecAdv-LLC wants to merge 322 commits into
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Repository-wide security/correctness audit fixes across the native C core, Python API, CI gates, and documentation to ensure shipped artifacts don’t crash on real hardware, constant‑time properties hold for secret-derived operations, and “fail-closed” controls actually fail when broken.
Changes:
- Fixes multiple native-crypto correctness and side-channel issues (notably ML‑KEM compression, SVE2 SHA3/Kyber, batch Ed25519 RNG fail-open) and tightens parameter validation / zeroization.
- Hardens operational controls: continuous RNG test concurrency + non-retention, CI/static-analysis gates that previously could not fail, and adds missing CI coverage for SVE2 builds.
- Aligns benchmarks and documentation with actual implementations/claims (native SHA3 instead of
hashlib, clearer non‑FIPS‑validated statements, updated metrics).
Reviewed changes
Copilot reviewed 47 out of 49 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| wiki/Architecture.md | Clarifies standards conformance vs FIPS validation status in architecture doc. |
| tools/check_c_secret_zeroization.py | New CI gate to detect bare memset(..., 0, ...) on secret-named C buffers. |
| THREAT_MODEL.md | Updates mitigations/risks to reflect shipped AES-NI/VAES/ARMv8-CE and stricter configure behavior. |
| tests/test_post_failclosed.py | Adds regression tests for continuous RNG test atomicity and digest-based health state. |
| tests/test_equations.py | Strengthens σ-quadratic enforcement tests to assert real correction and unreachable thresholds. |
| tests/test_c_secret_zeroization_gate.py | Tests the new C zeroization gate in both detection and non-detection directions. |
| tests/test_adaptive_posture.py | Pins posture rotation cooldown semantics and rejects unknown algorithms. |
| src/c/sve2/ama_sha3_sve2.c | Fixes SVE2 Keccak theta parity bug by switching to scalar parity computation. |
| src/c/sve2/ama_kyber_sve2.c | Fixes SVE2 Kyber NTT reduction/canonicalization to match other backends. |
| src/c/ed25519_donna_shim.c | Fails closed on batch-verify RNG failure via thread-local latch. |
| src/c/ama_sha3.c | Guards cross-family misuse of shared streaming context to prevent OOB/stack smash. |
| src/c/ama_platform_rand.c | Hardens RNG: removes stdio fallback patterns; fixes Windows length truncation via chunking. |
| src/c/ama_kyber.c | Replaces secret-dependent division in Compress_d with reciprocal multiply; scrubs x4 sponge ctx. |
| src/c/ama_hkdf.c | Adds NULL/length validation symmetry for salt/info to avoid NULL reads. |
| src/c/ama_dilithium.c | Scrubs x4 sponge ctx in sampling paths to avoid retaining secret-derived absorbed state. |
| src/c/ama_chacha20poly1305.c | Scrubs Poly1305 clamped r key on stack for 32-bit/radix-2^26 branch. |
| src/c/ama_argon2.c | Rejects >2^32-1 pwd/salt lengths to avoid RFC 9106 length-binding truncation. |
| SECURITY.md | Clarifies “FIPS-strict” posture wording for strict mode behavior. |
| README.md | Updates invariants range and test-count claims to match metrics. |
| fuzz/seed_corpus/fuzz_ed25519/selector2_batch | Adds/repairs fuzz seed corpus entry for batch path. |
| fuzz/seed_corpus/fuzz_ed25519/selector1_verify | Adds/repairs fuzz seed corpus entry for verify path. |
| fuzz/python/fuzz_key_formats.py | Fixes atheris seed writing and ensures selector byte is included in seeds. |
| ENHANCED_FEATURES.md | Updates test naming/references and corrects breaking-change count in release table. |
| docs/METRICS_REPORT.md | Updates LoC/test-count figures and keeps narrative consistent with new totals. |
| CONSTANT_TIME_VERIFICATION.md | Corrects AES S-box description to masked full-table scan (not algebraic bitslice). |
| CMakeLists.txt | Removes global AVX2 flags; documents why ISA flags must be per-file. |
| benchmarks/validation_suite.py | Validates and measures AMA native SHA3-256 instead of OpenSSL hashlib. |
| benchmarks/benchmark_suite.py | Benchmarks AMA native SHA3-256 instead of OpenSSL hashlib. |
| ARCHITECTURE.md | Updates invariants range, test-count claims, and breaking-change count. |
| ama_cryptography/secure_channel.py | Uses health-tested secure_token_bytes for AEAD nonces (INVARIANT-41). |
| ama_cryptography/pqc_backends.py | Adjusts mypy ignore annotations for optional Cython bindings. |
| ama_cryptography/monitoring.py | Adds bounds/locking to alert structures to prevent lost alerts and memory exhaustion. |
| ama_cryptography/legacy_compat.py | Uses health-tested secure_token_bytes for salts/master secrets. |
| ama_cryptography/key_management.py | Uses health-tested secure_token_bytes for encryption keys, salts, and GCM nonces. |
| ama_cryptography/equations.py | Implements real σ-quadratic correction via dominant eigenvector + minimal blend. |
| ama_cryptography/crypto_api.py | Uses health-tested secure_token_bytes for nonces and key material. |
| ama_cryptography/adaptive_posture.py | Rejects unknown algorithms; fixes cooldown arming semantics around failed rotations. |
| ama_cryptography/_self_test.py | Seeds continuous RNG health state using digest (matches runtime behavior). |
| ama_cryptography/_module_state.py | Adds digest-based, locked continuous RNG test to avoid races and key retention. |
| ama_cryptography/_integrity_signature.py | Updates integrity digests/signature constants for the new tree state. |
| ama_cryptography/_integrity_digest.txt | Updates integrity digest to match new tree state. |
| AMA_CRYPTOGRAPHY_ETHICAL_PILLARS.md | Removes/qualifies overclaims about validation/formal verification and coverage confidence. |
| .semgrep.yml | Documents why the C memset rule is unenforced by semgrep and points to the new gate. |
| .github/workflows/static-analysis.yml | Ensures cppcheck pipeline can fail (pipefail) while still emitting artifacts. |
| .github/workflows/ci.yml | Uploads Semgrep report even on failure; adds new C-zeroization gate step. |
| .github/workflows/ci-build-test.yml | Fixes build failure propagation (captures build rc correctly). |
| .github/workflows/arm-qemu.yml | Adds SVE2 CI job at VL=128/256 and gates workflow on both AArch64 jobs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 123 out of 225 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/c/ama_kyber.c:2600
kyber_compress_d()'s comment/spec says the result is reducedmod 2^d("& (2^d - 1)"), but the implementation returns the unmasked reciprocal-multiply quotient. Today the call sites mask (or uset & 1), but leaving the masking out here makes the helper itself non-conformant and easy to misuse later (e.g., d=1 can return 2 for x=q-1, which only becomes correct aftermod 2).
tools/check_c_secret_zeroization.py:120- The gate output suggests
ama_secure_memzero({self.dst}, LEN), butself.dstis the trailing identifier only (e.g.hmac_keyfromctx->hmac_key). That recommendation can be incorrect / not in scope, which makes the failure output harder to act on and risks someone applying the wrong fix. Consider deriving the full destination expression from the matched text for the suggestion while still using the extracted identifier for the secret-name check.
tools/check_c_secret_zeroization.py:153 scan_text()matchesmemset(...)line-by-line (for ... in text.splitlines()), so a trivially reformatted call likememset(secret_key,\n 0,\n 32);will not be detected. Since this tool is intended as a fail-closed security gate, it should be robust against multi-line formatting (or explicitly document that it only catches single-line calls and enforce formatting in CI).
… documentation claims Findings from an independent audit of the branch at f51e10f. The gated numbers in this repository were all accurate; every false claim below is one that no gate reads. release.yml — SLSA provenance could attest to nothing (HIGH) ----------------------------------------------------------- The "Compute base64 subjects" step declared no `shell:`, so it ran under the default `bash -e {0}` — which has `-e` but NOT `pipefail`. In sha256sum *.whl *.tar.gz | LC_ALL=C sort > ../subjects.txt the pipeline's status is `sort`'s, and `sort` exits 0 on empty input. If either download-artifact step yielded nothing, sha256sum failed, `-e` did not fire, subjects.txt was empty, and base64 of an empty file was handed to slsa-github-generator as the subject set: green provenance attesting to nothing, on a release tag. Reproduced: default shell rc=0 with empty subjects; with pipefail rc=1. Fixed with `shell: bash` plus an explicit emptiness assertion, so an empty subject set can never reach the generator for any reason. Verified the step now fails on an empty dist/ and still passes with real artefacts. ci.yml — dead rc check in the AVX-512 KAT lane ---------------------------------------------- Same class: `"$SDE" ... | tee ... || rc=$?` captured tee's status, so the `[ "$rc" -ne 0 ]` branch could never be taken. The "Results: ... 0 failed" grep still caught a crash, so the lane was not vacuous — but a guard that reads as live protection must be live. `shell: bash` added. Documentation corrections (each verified against the code at this head) ---------------------------------------------------------------------- * ARCHITECTURE.md said `OPENSSL_FOUND` additionally gates `test_kat`. Nothing in this build consumes OPENSSL_FOUND (CMakeLists.txt:903-908 documents the deliberate non-probing); test_kat is gated on AMA_USE_NATIVE_PQC and AMA_AES_CONSTTIME, and drives its KAT DRBG from AMA's own constant-time AES-256. The claim contradicted INVARIANT-1 in the architecture document. * SECURITY.md and ARCHITECTURE.md claimed NIST SP 800-108 conformance. There are zero occurrences of 800-108 in any .c/.h/.py and no Counter/Feedback/ Double-Pipeline KDF exists; CSRC_STANDARDS.md, which promises no aspirational entries, correctly omits it and files HKDF under RFC 5869. Claim removed rather than swapped for another standard. * SECURITY.md listed RFC 3161 flatly under "designed to comply with", closing with "non-compliance ... should be reported as a high-severity security issue" — while rfc3161_timestamp.py declares tsa_signature, tsa_certificate_chain and gen_time all False, and item 6 of the same document's layer list says so. Moved to an explicit "Partial, and deliberately so" entry. * wiki/Secure-Memory.md documented a pure-Python XOR fallback as the constant-time path and described padding both operands to equal length. 5.0.0 REMOVED that fallback (INVARIANT-7) because it was constant-time only in shape, and replaced max()-padding with min(len(a), len(b)) because the padding was memory amplification on unauthenticated input. The wiki was making precisely the claim the code repudiates. * wiki/Post-Quantum-Cryptography.md claimed "Full NIST KAT validation: 10/10". The corpora hold 100 vectors each; tests/c/test_kat.c caps at `kat_tested < 10` and tests/test_nist_kat.py defaults to max_vectors=10. Reworded to state what is exercised and where the cap lives. * wiki/Adaptive-Posture.md's first example printed `evaluation.recommended_action`; PostureEvaluation exposes `.action`, as the same page notes 26 lines later. The documented example raised AttributeError. * wiki/Architecture.md claimed all crypto operations exceed 1,000 ops/sec; SLH-DSA-SHAKE-128s sign is ~1,250 ms (under 1 op/s) by design. * README.md said the ACVP gate runs on "every push". It runs on pushes to main/develop/feature/**/fix/** and v* tags, PRs to main/develop, and weekly. * docs/METRICS_REPORT.md promised its published reproduction commands match the gate's measurement "exactly", but omitted the gate's _LOC_BUILD_REWRITTEN exclusion, so the document did not reproduce its own table. Exclusion added to the published helper and the whole-project command; verified the commands now reproduce Library Python (28/38,252) and Library total (137/91,722) exactly. Stale 2026-08-16 header dates corrected. Verified: ruff, black, check_documented_counts, check_workflow_commands, check_verification_claim_honesty, check_suppression_hygiene, check_headers, check_gate_coverage, check_action_pins — all exit 0. Both edited workflows parse as YAML. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rKPJZW5dimcEC9k7o3sms
…alar NTT post-condition Three findings from independent audits of this branch, each fixed and each verified to fail before the fix. 1. Two CTest cases reported "Passed" while executing zero checks --------------------------------------------------------------- tests/c/test_aes_gcm_vaes_equiv.c has four skip paths (no AES-NI/PCLMULQDQ, NULL dispatch slot, dispatcher chose the AES-NI reference rather than VAES, non-AVX2 build) and tests/c/test_dilithium_rej_equiv.c has one. All five returned 0, and neither test was registered with SKIP_RETURN_CODE 77 — while their immediate siblings in the same file (test_aes_gcm_neon_equiv, test_dilithium_ntt_equiv) both are. CTest therefore reported "Passed" for a run that compared nothing. This was not occasional: dispatch_table.dilithium_rej_uniform is assigned in exactly one place, the AVX2 block of ama_dispatch.c, so on EVERY AArch64 build that skip is permanent. Both tests were counted green in arm-qemu-ctest, arm-qemu-no-crypto-ext, arm-qemu-sve2, arm-qemu-ubsan and both macOS c-library lanes — so every AArch64 "all tests passed" tally in this project included two tests that did no work, on AES-GCM and on ML-DSA rejection sampling. Skip exits now return 77 and both are registered with SKIP_RETURN_CODE 77. Verified both directions: on this VAES-capable host both still genuinely run and pass (67/67); in a -DAMA_ENABLE_SIMD=OFF build both now report Skipped rather than Passed. 2. The shipped scalar Kyber NTT disagreed with every SIMD kernel --------------------------------------------------------------- kyber_ntt_scalar/kyber_invntt_scalar omitted the trailing canonicalising Barrett sweep that ama_kyber_ntt_avx2, _neon and _sve2 all apply. Measured, the shipped fallback disagreed with AVX2 on ~58% of coefficients by exact multiples of q, and left coefficients in [-9344, +12863] instead of the documented [0, q]. No wrong bytes ever reached a caller — every serialization path funnels through poly_reduce/coeff_normalize — but ama_kyber_ntt_generic_ref is the implementation the dispatcher installs whenever a SIMD slot is NULL, the one the auto-tune benches the SIMD slots against (comparing unequal work), and the one this file calls a "single source of truth". A future consumer of dt->kyber_ntt relying on the canonical range would have been wrong. The sweep is now applied in both directions. Verified: scalar and AVX2 are byte-identical over 5,000 random polynomials (previously ~58% differed), the scalar output is within [0, q] in every trial, and the NTT/invNTT round-trip is a single constant Montgomery factor explaining all 256 coefficients. 3. The equivalence test could not have caught (2) ------------------------------------------------- tests/c/test_kyber_ntt_equiv.c compared the SIMD kernels against a private copy of the scalar NTT living in the test file — one that already added the sweep, with a comment noting the production path did not. The shipped fallback was compared to nothing. A reference that is not the shipped code cannot detect the shipped code drifting away from it. Added lane 4: ama_kyber_ntt_generic_ref / ama_kyber_invntt_generic_ref against that reference, plus an explicit [0, q] post-condition assertion, running on every build rather than only where SIMD is wired. Proven non-vacuous — with the sweep removed again in a scratch copy the test exits 1 (lanes 1-3 still pass, which is exactly the blind spot); with it present, exit 0. Also moved ama_ascon_permutation_for_test out of the installed public header into src/c/internal/ama_testing_exports.h. It deliberately carries no AMA_API and cmake/ama_exports.map localises it, so it is absent from the shared library (nm -D finds nothing) — a downstream consumer that included the public header and called what it declared got an unresolved-symbol link failure. And recorded two constant-time coverage gaps in CONSTANT_TIME_VERIFICATION.md, which acknowledged the SVE2 gap but not these: the AVX-512 sha3-avx512x4 slot has no timing measurement (bounded — AMA_ENABLE_AVX512 defaults OFF and setup.py never sets it, so no shipped wheel contains the kernel), and the NEON ML-KEM NTT, ML-DSA NTT and Argon2-G kernels cannot be measured at all because no kyber-ntt-neon / dilithium-ntt-neon / argon2-g-neon slot exists in AMA_DISPATCH_ONLY_SLOTS — not a hardware limit, since the hosted arm runners already execute the three NEON slots that do exist. Verified on this tree: C suite 67/67 (Release+AVX2) and 67/67 under ASan+UBSan with -fno-sanitize-recover=all and zero sanitizer diagnostics; test_kat, test_kyber_cpa and test_kyber_ntt_equiv all pass; exported symbols unchanged at 240, all ama_* (check_export_allowlist exit 0); check_avx_scoping, check_secret_division and check_ghash_constant_time (ghash/consttime/ ascon-hash, cross-class delta 0) all exit 0; ruff, black, check_documented_counts, check_verification_claim_honesty, check_workflow_commands, check_suppression_hygiene, check_gate_coverage, check_c_secret_zeroization, check_headers all exit 0. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rKPJZW5dimcEC9k7o3sms
…rt map withholds
Two paths that put unverified or deliberately-withheld code into a shipped
artefact. Each was reproduced before it was fixed and is pinned by evidence a
functional test could not have produced.
1. A refused native library was mapped anyway, by the next import
-----------------------------------------------------------------
`_find_native_library` opens the candidate, hashes the bytes it holds open, and
loads it through /proc/self/fd/N so the bytes mapped are the bytes hashed. On a
mismatch it refuses and returns None WITHOUT mapping, and its refusal message
states why: "refused before mapping ... a shared object executes its
constructors the moment it is mapped."
That guarantee was defeated a few statements later by this package's own import
sequence. All five Cython binding extensions carry
DT_NEEDED [libama_cryptography.so.5] and RUNPATH [$ORIGIN:...] (readelf -dW),
and `pqc_backends` probed all five at module scope unconditionally — on the
refusal path as well as the healthy one. Importing ed25519_binding made the
dynamic loader map the very object the digest check had just rejected, out of
the package directory via $ORIGIN, with no check of any kind.
Reproduced, not reasoned. One byte flipped in the in-package
libama_cryptography.so.5.0.0, the other candidates removed so only the tampered
copy resolves, and the import traced against /proc/self/maps at each submodule
boundary:
ama_cryptography.pqc_backends library mapped? no
ama_cryptography._finalizer_health no
ama_cryptography.ed25519_binding no <- this import maps it
ama_cryptography.dilithium_binding yes
The import raises CryptoModuleError exactly as designed, and /proc/self/maps
nevertheless contains the tampered library with its constructors already run. An
attacker able to replace that file obtained code execution in the victim's
process despite the integrity check correctly detecting the tampering — which is
the entire purpose of a pre-load refusal.
Fixed by gating every probe on `_binding_imports_permitted()`, i.e. on the
native library having been verified and loaded. Nothing is lost: a binding
cannot function without the library, because it is a hard DT_NEEDED. The rule is
written once, in one predicate, and applied inside each of the five probes
rather than at their call sites, so a probe cannot be reached without it.
Healthy path re-verified: import OPERATIONAL, `_native_lib` loaded, and all five
of _cy_ed25519_sign_fn / _cy_dilithium_sign_fn / _cy_hkdf_fn / _cy_sha3_fn /
_cy_hmac_fn still resolve with all five binding modules in sys.modules — no
accelerator lost to the gate.
tests/test_native_library_never_mapped_unverified.py pins it end to end: a
tampered copy of the package, imported in a subprocess, must be refused AND must
leave /proc/self/maps free of the library. It carries a positive control — an
intact copy must import and MUST show the mapping — so it cannot pass because
the /proc probe stopped working. Proven discriminating: with the five guards
removed the mapping assertion fails while the positive control still passes.
2. The test-only Ascon permutation shipped in the static archive
----------------------------------------------------------------
cmake/ama_exports.map localises `ama_ascon_permutation_for_test` by exact name
and says why: "a raw permutation in a FIPS-aligned module's public surface
invites non-approved constructions." 496f80e moved its declaration out of the
installed public header for the same reason.
Both controls govern the shared object. The shipped STATIC archive has no export
control at all, and `nm libama_cryptography_static.a` found the function there as
a defined T symbol — on x86-64 and AArch64 alike. A consumer linking the static
library, which this project installs and its pkg-config file names, could call
the raw permutation directly.
cmake/ama_exports.macos.sym is the same decision expressed a third time, and
wrongly: a Mach-O exported-symbols list is an ALLOW-list with no exclusion form,
so its single `_ama_*` entry matches `_ama_ascon_permutation_for_test` and
publishes from the .dylib precisely what the version script withholds from
the .so.
An -unexported_symbols_list would have patched the macOS side and left the class
standing — one decision in three platform-specific mechanisms that can each
drift. The function is now compiled only under AMA_TESTING_MODE, so there is
nothing for any of them to publish. The `local:` entry stays as defence in depth.
Verified per artefact rather than per platform, which is what makes the
conclusion portable: before, `nm ..._static.a` reported the symbol on both
architectures; after, `nm -D` on the shared object and `nm` on both static
archives report zero, while libama_cryptography_test.a still defines it and
test_ascon passes. Exported symbols unchanged at 240.
Verification: C suite 71/71 (x86-64) and 72/72 (AArch64 under QEMU); ASan+UBSan
71/71 with -fno-sanitize-recover=all and zero diagnostics, instrumentation
confirmed by nm -D; Python suite 6,315 passed / 24 skipped / 0 failed;
mypy --strict clean over 313 files; black, ruff and all 29 argument-free gate
scripts exit 0.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…ime gap Four findings in the native crypto tree, each measured rather than argued. 1. The inverse NTT ran outside the bound that keeps it inside int32 ------------------------------------------------------------------- `dil_invntt_scalar`, and every SIMD kernel the dispatcher installs in its place, performs no modular reduction on the additive half of its butterfly. At each of its 8 levels `a[j] = a[j] + a[j + len]` adds two values that were themselves sums at the level below, so the bound on the accumulating position doubles per level and the structural worst case is 2^8 = 256x the input bound. With |input| < q that is 256q = 2,145,386,752 — under INT32_MAX by 0.1%. The FIPS 204 reference states this as poly_invntt_tomont's precondition in as many words and places a polyveck_reduce before each such call to establish it. Three call sites fed it an l-fold accumulator without that reduction: keygen's t = A*s1, the secret-key consistency check in dil_pubkey_from_sk (reached from ama_ml_dsa_pubkey_from_privkey and ama_ml_dsa_privkey_check), and w = A*NTT(y) inside signing's rejection loop. Each is a sum of l Montgomery products, each in (-q, q) by dil_montgomery_reduce's bound and added without reduction, so those inputs were bounded by nothing tighter than l*q — 5q for ML-DSA-65. 256 * 5q = 10,726,933,760 exceeds INT32_MAX by roughly 5x, and signed overflow is undefined behaviour, not a wrap this code could rely on. Verification was already correct; so were the three single-pointwise-product sites in signing, whose inputs are < q by construction. Measured with a 64-bit shadow of both additive results, so the probe reports the true magnitude even where the int32 expression would wrap. Over 36,990 inverse- NTT calls from 400 keygen/sign/verify cycles: entry reached 2.415q and the largest intermediate 0.167 * INT32_MAX — a 6x observed headroom that is sign cancellation in the sampled data, not a bound. After the three reductions: entry 0.510q, intermediates 0.0796 * INT32_MAX, headroom 12.6x, and the worst case becomes provable — dil_reduce32's image was enumerated over a >= 6q-wide band as [-4235259, 4235258], so 256 * 4235259 = 1,084,226,304 with a 1.98x margin. No output changes, verified rather than asserted: the SHA3-256 digest over the public and secret keys of 64 distinct seeds is byte-identical with and without the three calls, and sign/verify round-trips 64/64 either way. Pinned by tests/c/test_dilithium_invntt_bound.c. Nothing functional could have caught this — the transform is linear mod q and its results are reduced downstream, so signatures still verify and every KAT still passes with a reduction removed; only the overflow margin changes. The bound is read through an AMA_TESTING_MODE counter maintained at the DISPATCH wrapper, so it covers whichever kernel the host runs. Proven discriminating by mutation: removing each reduction in turn fails exactly its own phase (keygen 2.273q, sign 2.349q, privkey-check 2.130q) while the other three phases pass. The privkey-check phase exists because the first version of the test passed with that site's reduction removed — it is reached from neither keygen nor signing. The counter is DISARMED until reset() is called, and that is not tidiness: tests/c/test_dudect.c links the same archive and has an ML-DSA-65 sign lane, so an unconditional accumulator would put a branch on a secret-derived magnitude inside a lane that exists to prove there is none. 2. Three NEON kernels had no timing measurement, because no name reached them ------------------------------------------------------------------------------ 496f80e recorded this as an open gap and called it "the gap on this list most cheaply closed". AMA_DISPATCH_ONLY_SLOTS carried no name resolving to the NEON ML-KEM NTT, ML-DSA NTT or Argon2-G kernels, so the nightly sweep could not pin them even in principle — though they are wired by default on every AArch64 host, ship in every arm64 wheel, and the dudect suite already has the lanes that exercise them. Not a hardware limit: the hosted ubuntu-24.04-arm runners execute NEON natively and already run the three NEON slots that did exist. kyber-ntt-neon, dilithium-ntt-neon and argon2-g-neon now exist, in all five places the inventory's own source-of-truth comment requires to stay in step. They resolve the way sha3-neon does rather than the way the AVX2 and SVE2 branches do: on an SVE2 build running on SVE2 silicon the higher tier has already overwritten kyber_ntt and dilithium_ntt, so a `saved ==` comparison would answer UNSUPPORTED and blame the CPU — the defect already recorded for sha3-neon. Verified under QEMU: all three resolve HONORED, test_kat passes under each pin, AArch64 ctest 68/68 -> 71/71, and on x86-64 the same names report UNSUPPORTED (exit 77) rather than UNRECOGNISED, 67/67 -> 70/70. On an SVE2 host at VL=128 and VL=256 all three still resolve alongside the SVE2 slots — the configuration the resolution style exists for. Non-vacuity by mutation: making the kyber-ntt-neon branch unreachable turns that one test Skipped while its sibling still passes. 3. Three reduction routines documented a range they do not have --------------------------------------------------------------- Each enumerated rather than re-quoted. dil_reduce32 was documented as "[0, q)" but returns the CENTRED representative, negative for roughly half of all inputs — which is what makes the dil_caddq in dil_freeze necessary rather than decorative, and any bound derived from the old claim was wrong by a factor of two in the wrong direction. ama_kyber.c's barrett_reduce was headed "for values up to 2^26", a domain its int16_t parameter cannot express (2^26 is the reciprocal's scaling constant, not an input bound). The AVX2 and NEON copies still bounded their result at (-2q, 2q) — the loose, sign-admitting form ama_kyber.c's own body comment records as replaced. Re-verified exhaustively over all 65,536 int16 inputs: quotient in [-10, 9], image [0, 3329], zero negative outputs, q attained at exactly nine inputs. 4. A dead NEON reduction that was not a reduction -------------------------------------------------- barrett_reduce_dil_neon had zero callers anywhere — static inline with no caller, the one shape neither gcc nor clang warns about, which is why it survived the dead-NEON-kernel sweep in d96fb08. It was also wrong: `t = a >> 23; a - t*q`, omitting the + (1 << 22) rounding term dil_reduce32 carries. Against that reference over 400,000 values from [-5q, 5q] it disagreed on 50.0% and returned |result| >= q on 0.1%, up to 1.004q. Dead-but-plausible arithmetic is worse than none: the next author needing a vector reduction would have wired it. Verification: C suite 71/71 (x86-64) and 72/72 (AArch64 under QEMU, and at both SVE2 vector lengths); ASan+UBSan 71/71, zero diagnostics; all 18 deterministic instruction-count constant-time targets exit 0; check_secret_division reports zero divides in the ML-KEM decaps path; Python suite 6,315 passed / 24 skipped; strict-warning AArch64 NEON and SVE2 builds clean against the frozen allowlist; exported symbols unchanged at 240. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
… workflows could not run on the change that breaks them Principal finding 1 of this release is that -mavx2 was applied to CMAKE_C_FLAGS for every translation unit, so the compiler auto-vectorised ordinary C and a shipped wheel could SIGILL on pre-AVX2 x86-64 inside the very portable path the CPUID dispatcher selects BECAUSE the CPU lacks AVX2. The remedy was per-file scoping plus tools/check_avx_scoping.py, which fails on a YMM/ZMM operand outside an AVX2/AVX-512 kernel. CMakeLists.txt scopes SEVEN families of CPUID-gated instructions per file, not one: AVX/AVX2/AVX-512, AES-NI (-maes, -mvaes), PCLMULQDQ (-mpclmul, -mvpclmulqdq), SHA-NI (-msha), BMI1/BMI2/ADX (-mbmi -mbmi2, -mbmi2 -madx), SSSE3 (-mssse3) and SSE4.1 (-msse4.1). Every one raises the same hazard in the same way. The gate checked YMM/ZMM only, so six of the seven were left to a CMake comment — which is exactly the state the audit found -mavx2 in. Measured. A build with -maes -mpclmul -msha -mssse3 -msse4.1 -mbmi -mbmi2 -madx applied globally puts those instructions in 172 non-kernel symbols — ama_ascon_hash256, ama_ed25519_point_add, ama_ge25519_restore_extended_t, x25519_scalarmult and 168 more — split BMI/ADX 100, SSE4.1 52, SSSE3 20. Run against that object the AVX-only gate printed "OK: every YMM/ZMM operand in the object is inside an AVX2/AVX-512 kernel" and EXITED 0: a clean report over the precise regression class it exists to catch, because none of the leaks were YMM. The extended gate exits 1 and names all 172. On the shipped object it accounts for 5,360 CPUID-gated instructions across 7 families, 0 outside a kernel. Two exclusions stated rather than silent: tzcnt/lzcnt/popcnt are not flagged (tzcnt is encoded as `rep bsf` and executes as bsf without BMI1 — wrong for a zero input, never a fault; lzcnt/popcnt sit behind ABM/POPCNT, not behind any flag this build scopes per file), and XMM stays excluded because SSE2 is baseline. SSSE3 and SSE4.1 ARE checked: -march=x86-64 is SSE2, so neither is baseline. Matching against the mnemonic column rather than the whole line is load-bearing and has its own test: objdump renders a call as `call ... <ama_aes256_gcm_encrypt_avx2>`, so a whole-line match would turn every CALLER of a kernel into a reported leak — and the callers are, by construction, exactly the non-kernel symbols. tests/test_avx_scoping_gate.py grows from 18 tests to 53, parametrised over the real family table: each family's pattern matches its own instruction, it owns its own required kernels, and a planted occurrence in a non-kernel symbol fails the gate. The synthetic "clean object" fixture was itself a casualty — it described a build with no AES-NI, SHA-NI or BMI kernel at all, which the gate must now reject rather than pass. Five workflows could not run on the change that breaks them ------------------------------------------------------------ A paths: filter decides whether a workflow runs at all, and a gate that is correct, non-vacuous and green is worth nothing on a change that never triggers it — silent in the most misleading way available, because the pull request shows no red check, it shows no check. The sharpest case guards the finding above. dudect.yml is the only workflow that runs check_avx_scoping.py, and the property that gate enforces is set entirely by set_source_files_properties(... COMPILE_FLAGS ...) in the root CMakeLists.txt — which appeared in NEITHER of that workflow's filters. A pull request reintroducing a library-wide -mavx2, the exact audit-M3 regression, touches only that file and so would not have run the gate written for it. tools/check_avx_scoping.py was not listed either, though its two sibling gates were, under a comment stating precisely why they had to be. Four more, found by checking the property across every workflow rather than stopping at the reported one: arm-qemu.yml is the only place check_secret_division.py runs against an AArch64 object and did not list it; baseline-guard.yml did not list the script that adjudicates the two baselines it watches; corpus-provenance.yml did not list the gate it runs over its corpora; integrity-anchor-check.yml builds through CMake without listing CMakeLists.txt. And dudect.yml's two filters had drifted apart — six patterns on push against nine on pull_request — so three gate scripts were re-verified when a change arrived as a pull request and skipped when the same change was pushed to main, develop or a feature branch. The same change, gated on how it arrived. tests/test_workflow_path_filters.py pins the property: for every path-filtered workflow, every repository script named in a run: block is matched by that workflow's own patterns, a workflow that drives CMake lists CMakeLists.txt, and push and pull_request filters are identical where both exist. Only paths that resolve to a tracked file are required, so a shell word that merely looks like a path cannot fail it. Proven discriminating by reverting each fix in turn: dropping CMakeLists.txt from dudect.yml, dropping check_avx_scoping.py, dropping check_secret_division.py from arm-qemu.yml, and re-introducing the push/pull_request asymmetry each fail the test. dudect.yml also gains the three NEON sweep cells for the dispatch slots added in the previous commit. They are MANDATORY on the AArch64 side, not optional: AdvSIMD is architecturally guaranteed there, so a 77 is a dispatch-wiring regression rather than silicon, and the confirm step fails the lane on it. tests/test_dudect_simd_sweep_gate.py parametrises over the real matrix and executes the real shell classification logic, so it picked the three up automatically — 15 tests before, 18 after. Also resolves CodeQL alert 647 (py/import-and-import-from) in tests/test_avx_scoping_gate.py at source, no suppression, using the form tests/test_benchmark_baseline_infra.py and tests/test_timing_detector_calibration.py already settled on. Swept the class rather than the one flagged site: an AST pass over all tracked .py files found exactly one more, tools/wheel_smoke_test.py, whose call-time deferral is preserved. The sweep now reports zero sites tree-wide. Verification: 53 gate tests + 12 path-filter tests + 18 sweep-gate tests pass; check_workflow_commands, check_gate_coverage and all 29 argument-free gates exit 0; every workflow YAML parses; mypy --strict, black and ruff clean. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…tead of inheriting it
The MSVC branch of CMakeLists.txt has carried /guard:cf since the file was
written. On ELF the property was arriving by accident where it arrived at all.
Ubuntu patches GCC to enable -fcf-protection by default, so every AMA x86-64
object happened to carry IBT + shadow stack. Measured: readelf -nW on the built
libama_cryptography.so reports "x86 feature: IBT, SHSTK", and so does a flagless
`int main(void){return 0;}` compiled by the same gcc — the flag is the
distribution's, not this project's. A toolchain without that patch (upstream GCC,
or the musl/Alpine image this repository ships a Dockerfile for) produces the
same sources with no CET at all, and nothing here would have noticed.
AArch64 had no such accident to inherit. The same probe against
aarch64-linux-gnu-gcc emits no AArch64 GNU property, and neither did the AArch64
libama_cryptography.so: every arm64 wheel shipped with no branch-target
identification and no return-address signing while the x86-64 wheel shipped with
full CET. That asymmetry was not a decision recorded anywhere; it was the absence
of one.
Both are now requested explicitly, per architecture, probed for support the way
the RELRO/noexecstack linker flags above them already are:
-fcf-protection=full on x86, -mbranch-protection=standard (bti + pac-ret) on
AArch64.
Free hardening rather than a portability trade, by construction: ENDBR64 decodes
as a multi-byte NOP on pre-CET x86, and bti/paciasp/autiasp sit in the AArch64
hint (NOP) space on pre-Armv8.3/8.5 cores. A binary built with them runs
unchanged on hardware that has neither. Neither flag is secret-dependent — the
inserted instructions are unconditional and data-independent — and all 18
deterministic instruction-count constant-time targets pass with them enabled.
BTI is all-or-nothing at link time, so it must stay a global flag rather than a
per-file one. This tree has no hand-written assembly translation unit
(git ls-files '*.S' '*.s' is empty), so no object is unable to carry the marking,
and all 37 objects of the AArch64 shared library do.
What is verifiable here, stated exactly. The AArch64 shared object built by this
container's CROSS toolchain gains 133 bti landing pads and 612 paciasp/autiasp
return-address-signing instructions where it had none — and PAC-RET is effective
on its own, because signing and authentication are self-contained in each
function's prologue and epilogue and need no loader property. The linked image
nevertheless carries no GNU_PROPERTY_AARCH64_FEATURE_1_BTI, and that is a
property of this sysroot rather than of the change: crti.o and crt1.o from
Ubuntu's libc6-dev-arm64-cross carry no BTI/PAC property, and a trivial
one-function .so built with the same flag loses the property identically. BTI
enforcement therefore follows the CRT objects of whichever sysroot links the
artefact; the release arm64 wheels are built natively on ubuntu-24.04-arm rather
than cross-compiled, and confirming the property on that image is a CI
observation this container cannot make. The landing pads are emitted either way,
and are inert where unenforced.
Verified: AArch64 ctest 72/72 under QEMU with the flag on; x86-64 ctest 71/71
with 386 endbr64 landing pads and the IBT, SHSTK property intact; ASan+UBSan
71/71 with zero diagnostics; both strict-warning AArch64 configurations (NEON and
SVE2) build clean with the frozen warning allowlist unchanged at 74 allowlisted
int128-extension diagnostics; the ISA-scoping gate still reports every
CPUID-gated instruction inside a kernel scoped for it (ENDBR64 and bti are
baseline-safe and belong to no gated family); exported symbols unchanged at 240;
Python suite 6,315 passed / 24 skipped / 0 failed.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…nted count
CHANGELOG.md gains "Maintenance pass, thirteenth", covering the seven changes in
the four commits above, each with the measurement behind it rather than a
summary of it.
Counts re-measured last, after every other edit in the pass, because the
documented-counts gate reads the tree it is run against:
* C test suites 62 -> 63 and translation units 65 -> 66
(tests/c/test_dilithium_invntt_bound.c), corrected in README.md,
ARCHITECTURE.md, docs/METRICS_REPORT.md and the CHANGELOG passage that
states the figure in the present tense.
* Static test functions 4,645 -> 4,647 across 195 -> 196 files.
* Lines-of-code tables in docs/METRICS_REPORT.md.
check_documented_counts.py passes at 66 checks across 14 families.
One correction the gate cannot make: ARCHITECTURE.md's 5.0.0 revision row said
"BREAKING x4". The CHANGELOG glance table has 22 rows of which 10 are marked
**Breaking**, and the pull-request description already says ten. This is the same
stale-count class this branch withdrew from its own description in an earlier
pass; the ARCHITECTURE row was missed then and is corrected now.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…package edit ama_cryptography/pqc_backends.py changed in this pass (the binding-import gate), so the committed .py integrity digest is stale and ci-build-test.yml's "Verify committed .py integrity digest is current" step would fail on every platform: it snapshots ama_cryptography/_integrity_digest.txt before any build re-signs it and compares against _compute_module_digest(). Re-signed with AMA_BUILD_PIPELINE=1 python -m ama_cryptography.integrity --update --sign. INTEGRITY_DIGEST_HEX and _integrity_digest.txt now hold the current source digest (31d20641). Verified: committed == computed. Signed with the binding extensions moved aside, so INTEGRITY_BINDING_DIGESTS_HEX stays the empty map this file carries at HEAD rather than acquiring six linux-x86_64-cpython-311 filenames. A populated map naming extensions that do not exist on another platform is inventory drift — a logged warning that drops integrity strength on a developer build, and a hard failure on an anchored one. The native digest, pubkey and signature are per-build ephemeral (INVARIANT-17) and are re-signed by CI for each build; the reproducible-build gate compares two builds to each other, not to this committed value. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
The three dil_*_reduce calls added before invNTT in 74855ff moved code in ama_dilithium.c and tripped two gates that pin against it. .cppcheck-suppressions pins by exact line. The +137 lines shifted the w1_packed uninitvar site from 2219 to 2355, so the suppression stopped matching and cppcheck went red. Re-pinned and verified both ways: the full CI invocation now exits 0 with no findings, and with the dilithium line removed cppcheck reports the warning at 2355:51 exactly, so the pin is live rather than merely harmless. benchmarks/check_baseline_justification.py refuses an extended validity window when floored code changed after the calibration commit, unless each file is named in metadata.floor_drift_acknowledged. Both baselines now carry an entry for src/c/ama_dilithium.c. The acknowledgement measures rather than asserts, as the existing ascon and kyber entries do. Wall-clock could not resolve the change on this host: an interleaved best-of-6 A/B put dilithium_verify -- which the diff does not touch -- at -4.09%, against a 9.9%-12.9% run-to-run spread. So the figures come from callgrind instruction counts, with the per-op cost isolated as (Ir at n=20 minus Ir at n=4) / 16 so that process startup and the FIPS 140-3 POST cancel exactly: dilithium_keygen 1,552,881 -> 1,557,464 Ir (+4,583, +0.295%) dilithium_sign 5,413,012 -> 5,435,056 Ir (+22,044, +0.407%) dilithium_verify 1,577,424 -> 1,577,424 Ir (-0.6 instructions) dilithium_verify is the control: its invNTT already carried this reduce, so its -0.6 instructions per op is the instrument's resolution. The sign-to-keygen delta ratio is 4.81, matching the mean ML-DSA-65 rejection-attempt count, so the cost scales exactly as the one hot site predicts and nothing else moved. For AArch64, no valgrind was available here, so that entry carries the x86-64 dynamic figures plus static evidence: per-symbol disassembly under aarch64-linux-gnu-gcc -O3 -mbranch-protection=standard leaves 42 of 47 symbols instruction-identical after branch-target normalisation, and the four that changed are the three functions holding the new call sites plus gcc's constprop clone of the signing one. The fifth delta is a single alignment nop dropped from ama_dilithium_ntt_generic_ref. The benchmarked operation itself is unchanged: over 64 deterministic seeds the public key, secret key, signature and pubkey-from-privkey output are byte-identical between the two builds and every signature verifies -- dil_reduce32 returns the centred representative of the same residue and the invNTT is linear mod q. Same work per op. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…t it, and the docs that described a different library
Two package defects and a documentation surface that had drifted far enough
to be dangerous to a reader. Every claim below was measured before it was
written.
secure_memzero sized every native back-end's wipe with len(data). len() on a
memoryview counts ITEMS, not bytes, and ctypes.from_buffer accepts a length
smaller than the buffer, so nothing raised. Measured on
memoryview(array('I', [0xDEADBEEF] * 8)) -- 8 items, 32 bytes -- the wipe
cleared 8 bytes, returned normally, and left 24 of 32 secret bytes intact.
secure_mlock/secure_munlock had the same defect: locking len() bytes of a
wider buffer leaves the rest swappable. A _byte_length helper sizes all six
sites from .nbytes. The pure-Python fallback was already correct and is
untouched -- it indexes items -- which means the wipe's completeness used to
depend on which back-end was selected. Non-contiguous views are now refused
with SecureMemoryError rather than mis-wiped, and refused uniformly: the
native path already rejected them with TypeError while the fallback handled
them, so that too was back-end-dependent.
SecureChannelInitiator.complete_handshake cleared _shared_secret and
_handshake_hash only on success. After a rejected handshake,
initiator._shared_secret is not None -- including on the two paths that raise
HandshakeError deliberately. A wrong-length responder_public_key also
escaped as a raw ValueError from dilithium_verify's length check, not the
documented HandshakeError; reproduced end to end against a real responder,
one byte short gives "Invalid public key length: expected 1952, got 1951".
Completion now runs inside a handler that drops the state on every exit and
re-types the two peer-data cases, and the channel moves to CLOSED so a failed
handshake cannot be completed by a second response. The responder has no
counterpart defect: its shared secret is a local, never stored on the object.
wiki/C-API-Reference.md had 33 of 38 declarations wrong. Four functions and
ten macros do not exist, so its examples could not compile -- that fails
loudly. The ones that compiled are the finding: ama_ed25519_keypair was
documented with a 32-byte secret key against a real 64, overflowing the
caller's buffer on every keypair and sign; both AEADs were documented
payload-first when the real order is (key, nonce, payload), and every one of
those parameters is a const uint8_t *; ML-DSA and SLH-DSA verify were
documented signature-first when the real order is message-first; and
consttime swap/copy were documented with `condition` last when it is first.
Every prototype is regenerated from the header.
SECURITY.md still asserted that the repair flow "binds none" and that a
source tree's binding coverage "is not an attestation claim at all". The
CHANGELOG's own 5.0.0 entry says the opposite and the code agrees with the
CHANGELOG -- integrity.py sets --bind-extensions unconditionally. The
identical stale claim was corrected in setup.py's comment and pinned there;
SECURITY.md was simply not covered.
Also corrected: four AVX2 kernel filenames no file has ever had, an
ama_ed25519_avx2.c credited to an algorithm with no AVX2 translation unit at
all, a docker.yml workflow that is a job inside ci-build-test.yml, Docker
multi-arch and image-scanning claims the job does not implement, a
license-compliance check that exists nowhere, an alpine:3.18 base against a
digest-pinned 3.23, docker-compose invocations that cannot find their compose
file, an AMA_DISPATCH_NO_VAES opt-out that does not exist, a
benchmarks/regression_results.json nothing writes, and three source paths
cited by INVARIANTS.md, PROVENANCE.md and the benchmarks wiki that resolve to
nothing.
Four gates so none of it can come back, each verified to fail on the reverted
text:
tests/test_documented_c_prototypes_match_headers.py -- every prototype in a
```c fence, in every tracked .md, must match a header declaration
verbatim; a second test feeds them to a C compiler after the real header
tests/test_documented_source_paths_exist.py -- every cited source path and
every bare filename must resolve, with allowlists whose entries name the
command that writes the path or which kind of non-file it is
tests/test_setup_signer_contract.py -- extended to SECURITY.md
tests/test_docker_pins_gate.py -- every FROM line a document shows must
name a base a tracked Dockerfile uses
Verification on this tree: pytest 6551 passed / 24 skipped / 0 failed; ctest
71/71 and ASan+UBSan 71/71 with 0 sanitizer diagnostics; black, ruff and
mypy --strict clean over 305 files; 35 of 36 argument-free gates exit 0 (the
36th needs a semgrep report this environment cannot produce, and fails
closed); documented counts re-measured; the integrity artefact re-signed with
the binding map in its committed empty shape.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
… it is pushed The baseline guard went red on 77bf648 for two files that commit moved under the floored paths: ama_cryptography/secure_memory.py and src/c/PROVENANCE.md. The second is a Markdown file — _FLOORED_CODE_PATHS matches the src/c prefix, so every file beneath it is reported, documentation included. Both acknowledgements measure rather than assert. For secure_memory.py the measurement is that the edited functions are on no benchmarked path at all. A live call counter wrapped around secure_memzero, secure_mlock and secure_munlock, run over every one of the 19 benchmarked operations — every entry of benchmark_runner.py's benchmark_functions and pqc_benchmark_functions — recorded 0 calls to each. The counter is not vacuous: the same instrumentation records 2 secure_memzero calls during SecureSession.close(). Were the helper ever on such a path, it costs 67 ns per call over the len() it replaced (best of 7 x 2,000,000 timeit rounds on x86-64 CPython 3.11: 101.18 ns against 34.05 ns for a bytearray). For PROVENANCE.md the measurement is that it reaches no compiled artefact: it appears in no CMake source list and no build script, and the five matches for its name under src/ are prose inside comments, not #include directives. The reason CI found these and the local suite did not is worth fixing rather than noting. test_the_current_tree_satisfies_the_rule compares origin/main to HEAD, so it sees only drift that is already committed — a push too late, which is exactly how this branch learned about ama_dilithium.c and then about these two. A companion test runs the same rule against the working tree: `git diff <calibration-commit> --` with no second ref includes uncommitted changes, and the acknowledgement list is read from the file on disk rather than from HEAD, so the failure arrives while the edit is still local. In CI, where the working tree is clean, the two are the same assertion. Verified to fail when either new acknowledgement is removed, and to pass when it is restored. Verification: the guard now exits 0 against this HEAD with the same invocation CI runs; 71 floored files have changed since calibration commit 26df305 and 71 are acknowledged; black, ruff and mypy --strict clean on the changed test; documented counts re-measured. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…its mutation test forced The gate added in 77bf648 failed on all six Windows lanes at 47ce5ef, three tests each. Both causes were in the gate, not in the documentation it checks. First, the path separator. test_a_documented_internal_entry_point_is_declared_as_such compared str(header.relative_to(REPO_ROOT)) against an allowlist written with forward slashes; on Windows str() spells that src\c\ama_platform_rand.h, so the comparison failed there and passed everywhere else. Now compared through Path.as_posix(), with a test that pins the allowlist's spelling so the next entry cannot reintroduce it. This was avoidable twice over: the repository already knew the hazard. tests/test_docker_pins_gate.py compares its own paths through as_posix(), and tests/test_benchmark_baseline_infra.py carries a test about str(Path(...)) yielding backslashes on Windows. The pattern was in the tree; the new gate did not follow it. Second, the dllimport decoration. On Windows AMA_API expands to __declspec(dllimport) for an external consumer of the DLL, and the compile probe redeclares each function without it -- correctly, because the documentation prints the signature a reader types, not the linkage macro -- so gcc reported "redeclared without dllimport attribute" and -Werror promoted it. The probe now defines AMA_BUILDING_STATIC, which is the header's own arm for exactly this ("#elif defined(AMA_BUILDING_STATIC)"), verified by preprocessing the macro block both ways: DLLIMPORT without the define, empty with it. Dropping -Wall -Wextra -Werror was tried first and reverted, by the gate's own mutation test. Without them the mutated ama_ed25519_keypair was accepted: a conflicting return type is not always an error, because ama_error_t carries a negative enumerator and gcc therefore makes it compatible with int, and the parameter bounds decay to pointers. The diagnostic that actually catches that case is a warning -- -Warray-parameter, "argument 2 of type uint8_t[32] with mismatched bound" -- which is precisely the Ed25519 secret-key defect this gate was written for. Silencing it would have left the compile half unable to see the bug it exists to catch. The flags stay, and the include path is now -isystem rather than -I so that -Werror covers the probe's own redeclarations and not whatever the header might emit under a compiler this repository does not otherwise build with. Verified in both directions under gcc and clang: the correct prototypes compile clean, the mutated bound is rejected with -Werror=array-parameter, under both -I and -isystem. Verification: 93/93 in the gate; 6553 passed / 24 skipped / 0 failed in the full suite; black, ruff and mypy --strict clean. The two test_trust_anchor_pinning failures seen mid-work were an incoherent local state of my own making -- the committed empty binding map against built extensions present in the package directory -- and pass in both coherent states: bindings present with the artefact re-signed over them, and bindings absent with the committed artefact, which is the shape this commit ships. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…1 failure in 8
The macOS Python 3.14 lane failed tests/test_c_secret_zeroization_gate.py's
linearity check at 2.94x against its 2.8x ceiling, on a commit that touched
neither tools/check_c_secret_zeroization.py nor the gate. The same lane was
green one commit earlier, so the code under test was bit-identical across the
two outcomes.
Not accepted as a flake. Reproduced here at 1 failure in 8 runs under full
synthetic saturation (8 spinners on 4 cores), against 0 in 6 unloaded.
The residual bias is structural, and interleaving by round does not remove it:
interleaving equalises WHEN each size is sampled, not how LONG each sample is
exposed. The largest payload's scan is the longest, so a contention burst is
likeliest to land on it, and that inflates exactly the numerator of the ratio.
One-sided noise, one-sided error, in the direction that breaks the test.
The whole measurement is now repeated up to three times, and the test passes as
soon as one comes back under the ceiling. This sharpens the check rather than
loosening it, and the same one-sided model is why: noise can only inflate a
sample, the per-round floor already discards all but the least disturbed one,
and a further measurement can only move each size's floor DOWN toward its true
cost. Both floors converge on truth, so the ratio converges on the pattern's
real growth. Reaching the ceiling from a genuine 4x would require the SMALLER
size's floor to be overestimated by 30% in every attempt, which is the one
thing the model forbids. The ceiling is unchanged at 2.8x and the rounds per
attempt are unchanged at seven.
Measured under the same saturation that failed the single-shot form: 0 failures
in 10 runs.
The other direction was checked by planting nested quantifiers in the value
group -- 0+ as 0*0*, and the bounded (?:0{1,40})+ -- and neither produced a
ratio any number of retries could rescue, because neither completes: both ran
past a 300-second timeout at the sizes this test uses, and past it again at
2^8-2^10. That is the honest shape of the discrimination, and it is now
written down in the test: a pattern that has lost linearity does not land just
over the ceiling where a retry might reach it, it hangs, and what catches it is
the 1-second absolute bound over 200,000 characters at the top of the same
class. This ratio test guards the narrower case of growth that is superlinear
but still fast, and there the retry costs nothing and removes the false
positives.
Verification: 137/137 in the gate's own file; 6551 passed / 24 skipped in the
full suite, with the only two failures the known incoherent local artefact
state (committed empty binding map against built extensions present in the
package directory), which pass in both coherent states and are untouched by
this commit; black, ruff and mypy --strict clean.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…TY.md did An independent audit of this branch caught the half of the repair-flow correction that 77bf648 missed. ARCHITECTURE.md's 5.0.0 release row states "wheel pipeline binds, repair flow binds none" -- the identical assertion that commit withdrew from SECURITY.md, compressed into a single clause. So the repository went on contradicting itself in the document a reader reaches first, against both the code (integrity.py sets --bind-extensions unconditionally) and the CHANGELOG's own 5.0.0 entry. The row now says what the code does. The gate is parametrised over SECURITY.md and ARCHITECTURE.md rather than checking one file, and matches the short clause as well as the long paragraph -- a SECURITY.md-only check could not see this wording, which is why it survived. Verified to fail when the clause is restored in ARCHITECTURE.md and to pass when it is corrected. Verification: 280 passed across the four documentation gates; black and ruff clean; documented counts re-measured. No package file changed, so the committed integrity artefact is untouched. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…e that could not fail
nistp_use_mulx4() cached its CPUID verdict in a plain `int`, lazily, on a path
any thread doing P-curve arithmetic can reach first — the "lockless flag +
plain variable" shape INVARIANT-15 and src/c/internal/ama_once.h prohibit
outright, in a file whose other one-time state already goes through
AMA_CALL_ONCE. The comment above it argued for leaving it that way and both
halves were wrong: an idempotent value does not stop concurrent unsynchronised
read and write from being a data race — C11 5.1.2.4p25 makes it UB whatever the
store does in hardware — and the CPUID getters' pthread_once orders nothing
about this separate object. It also called a plain `int` access "a single
relaxed load", which it is not.
Its invisibility was an accident of ordering. On keygen/sign/verify the first
write happens inside nistp_comb_build() under NISTP_COMB_ONCE, so nothing
races. ama_nistp_point_decode and ama_nistp_pubkey_validate — both
attacker-input paths — reach the gate through nistp_load_point with no once in
the way.
The lane that exists to catch this class could not. The thread-sanitizer job's
own comment names it, but TSan reports a race only when two threads touch a
location at once, and no C test ever had two threads running at the same time:
of all the C test files exactly one created a thread, and it joined on the next
statement. The lane instrumented correctly and watched a single-threaded
program.
tests/c/test_concurrent_init.c gives it something to observe — eight threads
released together on a barrier, entering the dispatch table, the CPUID probes,
the NIST-P and secp256k1 combs and the Ed25519 tables at once, and first in each
round, deliberately from cold, the two P-256 decode/validate paths that bypass
the comb's once. That ordering is the finding: the first version of this test
called ama_nistp_keypair and TSan reported nothing.
Measured both directions on the shipped code:
before 2 data races, exit 66 — nistp_use_mulx4 ama_nistp.c:400 under
ama_nistp_point_decode -> ama_nistp_pubkey_validate ->
nistp_load_point -> nistp_to_mont -> nistp_mont_mul
after 0 data races, exit 0
The gate is now _Atomic int with memory_order_relaxed on both accesses, which
is the correct order (it publishes no other data, and a reader that misses the
write recomputes the same answer) and is what makes the comment true. No
portability shim: the block sits inside AMA_HAVE_NISTP_MONT_MULX_IMPL, which
CMakeLists.txt defines only for x86-64 GCC/Clang (AND NOT MSVC), and both
provide C11 <stdatomic.h>.
The hot path does not pay for it, measured rather than asserted. Under gcc -O3
all 46 symbols in the translation unit have identical instruction counts before
and after, and the only difference anywhere in .text is five bytes: the same
five `mov` instructions with two stack spill slots exchanged. No extra
instruction, no fence. Cross-compiled for AArch64 the objects are
byte-identical before and after, confirming the block compiles out there.
The lane is non-vacuous structurally too: it now asserts test_concurrent_init
is registered before trusting its own green, the shape the Valgrind lane
already uses for its target count.
Also re-pinned .cppcheck-suppressions: the +32 net lines shifted all three
ama_nistp.c entries (667/769/1136 -> 699/801/1168), each verified live by
running cppcheck without them and confirming it reports at exactly those lines.
That is the second such shift this pass, so the hygiene gate now checks it:
scoped to the three files carrying pinned entries, it runs cppcheck without the
suppressions list and asserts every pinned (id, file, line) is among what is
reported, with a non-vacuity assertion and a skip where cppcheck is absent.
Three seconds, and it fails when any pin is reverted to its pre-shift line.
Verification: ctest 72/72 x86, 72/72 ASan+UBSan with 0 sanitizer diagnostics,
72/72 under ThreadSanitizer with 0 races; pytest 6556 passed / 24 skipped / 0
failed; cppcheck exits 0 with 0 findings; gcc and clang both clean on the
changed TU under -Wall -Wextra -Wpedantic -Wconversion -Werror; black, ruff and
mypy --strict clean; documented counts re-measured (C suite 63 -> 64 files,
66 -> 67 translation units).
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…ild with the check deleted tests/test_nistp_curves.py asserted that a coordinate >= p is "rejected, never reduced" using x = p, x = p+1 and x = 2^(8*nbytes)-1. None of the three can make that claim: each reduces to a value that is not on the curve, so an implementation that reduced its input first would decline the point anyway, for the other reason, and the test cannot tell the two apart. The same three vectors back the ECDH invalid-curve test and the SEC 1 decode test. Measured, not argued. Against a build with the `xs < p || ys < p` guard in nistp_load_point deleted: shipped library 97 passed guard deleted, shipped test module 97 passed <- defect invisible guard deleted, with the new test 3 failed, 94 passed test_second_encodings_of_a_valid_point_are_rejected builds a coordinate that is both >= p and reduces onto a real point, which is the only shape that separates rejection from reduction. That needs a small coordinate (2^256 - p is about 2^224 on P-256), so the module now derives two reference points per curve from the curve equation alone: the lowest x by square root, and the lowest y as a root of t^3 - 3t + (b - y^2) over F_p, read off gcd(t^p - t, f) when that gcd is linear. P-256 gives x=5, y=5; P-384 x=2, y=1; P-521 x=1, y=1. 56 ms for all six, cached. Both coordinates go to ama_nistp_pubkey_validate, and the x case also to ama_nistp_point_decode and ama_nistp_ecdh, each beside the canonical encoding of the same point as the paired accepting control — the rule tests/test_ed25519_canonical_y.py already states and these curves lacked. Sweeping the class found the same gap on secp256k1. ama_secp256k1_pubkey_ decompress is what key_formats uses to import compressed public keys, and the header promises "a value >= p is rejected, never reduced". With its secp256k1_fe_bytes_canonical call deleted it accepted x = p+1 and returned a 64-octet key whose X half was the non-canonical encoding, and 591 Python tests plus the whole test_secp256k1 C suite passed anyway. The module's note that such a control is not constructible for secp256k1 is true of verify (that would need the ECDLP) but not of decompress, which needs no signature: 1^3 + 7 = 8 is a residue, so x = 1 and x = 1 + p are a usable twin pair. Added, note corrected to say which entry point it was about. Also fixes the two lanes 5171ef2 turned red, both verified on Linux only: - tests/c/test_concurrent_init.c used pthread_barrier_t. Barriers are the _POSIX_BARRIERS option, not base POSIX; macOS ships pthreads without them and both macOS C lanes failed at "unknown type name". The rendezvous is now a mutex and a condition variable. Same power: against the pre-fix nistp_use_mulx4, TSan still reports 2 data races and exit 66 at the same stack; against the shipped gate, 0 and exit 0. - All six Windows lanes failed on the suppression-liveness check. The runners carry a cppcheck inside Strawberry Perl whose std.cfg path was baked to a directory that exists only on the machine that built it, so it analyses nothing — being on PATH is not being usable. It now skips on cppcheck's own words and nothing else, so a cppcheck that really runs and reports nothing still fails. Verified in all three states with a stub on PATH. Verification: ctest 72/72 x86, 72/72 ASan+UBSan, 72/72 TSan with 0 races; pytest 6561 passed / 24 skipped / 0 failed; black, ruff clean; documented counts OK. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…64 backend
include/ama_cryptography.h states exactly one requirement of ama_ed25519_verify's
buffers -- "exactly 64 readable bytes" and "exactly 32 readable bytes". It says
nothing about alignment, and const uint8_t * imposes none, so
verify(sig, msg, len, packet + 3) is a call the API promises to serve.
It did not. On x86-64 the default backend is ed25519-donna (AMA_ED25519_ASSEMBLY
defaults ON there, so src/c/ama_ed25519.c is dropped from the source list), and
donna's curve25519_expand read the key with *(uint64_t *)(in + 0) -- a load
requiring 8-byte alignment from a pointer that carries none. UB under C11
6.3.2.3p7. Reproduced through the public API:
curve25519-donna-64bit.h:293:8: runtime error: load of misaligned address
... for type 'uint64_t', which requires 8 byte alignment
#0 curve25519_expand
#1 ge25519_unpack_negative_vartime
#2 ed25519_sign_open
#3 ama_ed25519_verify
The AddressSanitizer + UBSan job runs UBSAN_OPTIONS=halt_on_error=1, so for such
a caller this was not a diagnostic to read later: a signature check became a
process abort. The batch path reaches the same expand twice more, on pk[i] and
on the RS[i] signature halves.
Nothing saw it because nothing tried. Every existing test hands the library a
uint8_t[32] local or static, which every compiler in use aligns to at least 8, so
the whole suite exercised the one alignment the contract does not promise.
tests/c/test_ed25519_unaligned_input.c walks 16 consecutive offsets and asserts
the verdict both ways at each -- a good signature must verify, a corrupted
signature and a corrupted key must not -- so it is a behavioural check in an
ordinary build and the UB tripwire under the sanitizers. At CI's own setting it
exits 1 on the unpatched library and passes on the patched one.
The fix is memcpy, in all three curve25519_expand variants (64-bit, 32-bit,
SSE2), marked AMA-PATCH: and recorded in src/c/PROVENANCE.md beside the existing
donna patch -- which was the same class of finding, a UBSan-reported UB in
vendored code. donna's byte-wise else branch was already correct and is
untouched.
It costs nothing, measured rather than asserted: libama_cryptography.so.5.0.0 is
BYTE-IDENTICAL before and after under the Release build (md5 831a0934 both). The
control matters as much as the result -- an edit at the same site that does
change behaviour (x0 ^= in[31]) moves the digest to 48cd36a1, so the identical
digest is evidence about the change, not about a build that failed to notice it.
An earlier control (x0 ^= (in[0] & 0)) was folded away by the optimiser and
proved nothing; it was replaced rather than believed.
Class swept: grep for cast-based multi-byte loads across first-party src/c finds
zero. The AVX2 X25519 kernel names donna in its comments but was already
byte-wise.
Verification: ctest 73/73 x86; 73/73 ASan+UBSan at halt_on_error=1; 73/73 TSan;
pytest 6559 passed / 24 skipped / 0 failed; strict-warning sweep (gcc and clang,
unoptimised and Release+LTO) plus tools/check_compiler_warnings.py exit 0;
benchmarks/check_baseline_justification.py exit 0; documented counts OK.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
…nothing
Every `continue` in run_all_benchmarks -- a baseline that does not name the
benchmark, a primitive absent from the build -- is silent to the exit code.
With all of them taken, main() printed "All benchmarks within acceptable range"
and returned 0. The job is green because it stopped measuring.
Reproduced against a copy of the shipped baseline with every key renamed, which
passes --require-populated-baseline (that flag rejects only zero
baseline_values, never a name nothing answers to):
Total benchmarks: 0
Passed: 0
Failed: 0
All benchmarks within acceptable range.
$ echo $?
0
Nineteen populated floors, none compared against anything, exit 0.
main() now answers the question the loop cannot, which needs the two dispatch
tables at module scope (BENCHMARK_FUNCTIONS, PQC_BENCHMARK_FUNCTIONS) rather
than as locals. Three states, separated because they mean different things:
- A baseline name no benchmark function answers to is a RENAME: the floor is
still in the JSON, still justified, and can never fire again. Fatal on any
host, with or without flags.
- Zero rows measured is fatal unconditionally. A run that compared nothing
against anything is not a pass, whatever it was invoked with.
- A name whose function exists but produced no measurement is the documented
"primitive absent from this build" skip. Legitimate locally, never true of
the CI job, so it is fatal exactly under --require-populated-baseline -- the
flag CI already passes to mean "this run must be worth trusting" -- and a
plain local run names the floors it did not cover instead of quietly covering
less than it claims.
No measurement path changes: the new code runs after every rate is recorded,
and no sampling constant, batch size, warmup or timing window is touched.
Against the real baseline the run still reports 19 of 19 rows, the same set as
before. Both shipped baselines name the same 19, all runnable, and a test pins
that as a standing property rather than something CI discovers the day a rename
lands. Both floor-drift acknowledgements are extended with that measurement.
Nine tests cover it; six fail when the check is removed. The other three are
the controls that keep them honest -- a healthy run must still exit 0, the
tables must be non-empty, a partial local build must stay usable -- because a
main() that had simply become unable to return 0 would satisfy the first six.
Verification: pytest 6568 passed / 24 skipped / 0 failed;
tests/test_benchmark_baseline_infra.py 89 passed (80 before);
the real baseline run measures 19/19 with no coverage complaint;
black, ruff, mypy --strict clean; documented counts OK.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
check_baseline_justification.py enforces that a moved benchmark floor carries a
line-item justification: the primitive named, a measured number, a CI runner
identified. It gathered that evidence by concatenating EVERY commit message on
the branch that touched a baseline JSON and scanning the blob for those three
things anywhere in it.
On a long branch that requirement cannot fail. Measured on this one, before
any new commit was written:
25 commits touch a baseline JSON
commit-text bytes: 86892
measurement token present: True
runner token present: True
primitive names (19) already present: 19 missing: []
Every requirement already satisfied by text written for unrelated changes, so a
commit that moves a floor and says nothing inherits all of it. Demonstrated end
to end on the real branch -- one commit, message "wip", halving ed25519_sign's
floor, empty PR body:
- ed25519_sign: 33000 -> 26942.5
OK: every changed baseline is named, a measurement value is cited, and a CI
runner is identified.
$ echo $?
0
That is the guard reporting success on the exact pattern
docs/BENCHMARK_HISTORY.md records it as existing to prevent.
Each change is now attributed: a floor is justified only by a single text that
names it, cites a number AND identifies a runner -- the PR body, or the message
of the commit that last wrote that number.
"Last", not "any", and the distinction is not theoretical: the first version of
this fix accepted any commit that had ever touched the key, and the "wip" commit
still passed, riding on the recalibration commit that had set the previous value
and named the primitive. An earlier commit justifies the number IT wrote; a
later commit moving the same floor is a new claim needing its own evidence. A
merge is credited only with what it introduces itself -- a key differing from
every parent -- so a floor merged in from the base branch is attributed to the
commit that wrote it, not to the merge that carried it past.
Strictly stronger, and it costs this branch nothing: against the real history
with an empty PR body the guard still exits 0, because the one commit that moved
floors (37d8b3b, all 38 across both files) names every one, cites its numbers and
identifies the runner. Of the 25 commits touching a baseline JSON that is the
only one that moved a value; the other 24 changed metadata, which needs no
measurement.
Eight tests build real throwaway git repositories rather than stubbing git, and
one reproduces the replaced algorithm alongside the new one on the same history:
it calls that history justified and the guard does not. Pinning the comparison
rather than describing it is the point. The two helpers it left dead
(_collect_commit_text, _check_justification) are deleted, so the file describes
one algorithm.
Verification: pytest 6576 passed / 24 skipped / 0 failed;
tests/test_benchmark_baseline_infra.py 97 passed (89 before);
the guard exits 0 on the real branch and 1 on the "wip" mutant;
reverting the attribution to "any commit" fails the ride-along test;
black, ruff, mypy --strict clean; documented counts OK.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
Reverts, newest first: 9811476 fix(benchmarks): the baseline guard was unfalsifiable on a long branch 7cd8fc7 fix(benchmarks): the regression gate exited 0 on a run that measured nothing d1e831e fix(ed25519): a misaligned load on caller memory, in the default x86-64 backend 45379f6 test(curves): every non-canonical-coordinate test passed against a build with the check deleted 5171ef2 fix(nistp): a live data race in the shipped library, under a TSan lane that could not fail Requested by the repository owner. Applied with `git revert --no-commit` for each of the five and committed once; no reset, no rebase, no force-push. All five applied without conflict, and the resulting tree is byte-identical to 249571b, the parent of the first reverted commit. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HwrxgyipKRW3kRMzd1APE
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Repository-wide audit and 5.0.0 completion
A full-repository audit at v4.0.0 (post-#391) and the completion passes that followed.
CHANGELOG.md's[5.0.0]section is the authoritative record: its glance table lists every behaviour change with its migration. This description indexes that table rather than duplicating it.Principal findings
1 — Shipped wheels could SIGILL on pre-AVX2 x86-64.
-mavx2was applied toCMAKE_C_FLAGSfor every translation unit, so the compiler auto-vectorised ordinary C: 34 YMM instructions in ML-KEM's portable keygen/encaps/decaps, 18 in the dispatcher, 2 in the constant-time AES fallback that exists specifically for CPUs without AES-NI. The fault occurs inside the portable path the CPUID dispatcher correctly selects. Fixed by scoping AVX2 to the per-file kernels that require it; verified afterwards that no AVX/AVX2 opcode appears outsidesrc/c/avx2andsrc/c/avx512in the linked library.2 — ML-KEM used the KyberSlash division pattern on secret operands. Present in the Compress_1 message decode during decapsulation and in
poly_compressinside the FO re-encryption. Replaced with an exact Granlund–Montgomery reciprocal multiply (M = ceil(2^40/q),S = 40), proven byte-identical to the division form over all 16,645 (coefficient, width) pairs.3 — The SVE2 backend was wired into dispatch but built by no CI configuration. Two defects were behind that gap: a Keccak theta step reading uninitialised stack on every shipping SVE2 vector length, and a Kyber NTT disagreeing with every other backend at all vector lengths. Both fixed; the configuration is now built and tested.
Breaking changes
Ten, at glance-table rows 1, 2, 3, 7, 14, 15, 16, 17, 18, 21 of
CHANGELOG.md, each with its migration:importand inhibits every cryptographic surfacex = 0with the sign bit set (RFC 8032 §5.1.3) at every public-key decode, both backendsCryptoPostureControllerraises for an unrankable algorithm instead of mapping it to the weakest rungama_ed25519_batch_verifyrejects a non-canonical R (detail below)jwk_thumbprintaccepts six SHA-2/SHA-3 names rather than everythinghashlib.new()resolvescreate_crypto_packageraises fornum_derived_keys < 1tsa_moderaises rather than falling through to the ONLINE pathama_dispatch_table_tdrops its unreadsha3_256memberAMA_BUILD_PIPELINE=1Row 14 is the only change in this release where the library was accepting input it should have rejected, and reviewers should read it first.
ama_ed25519_batch_verifynow rejects a signature whose R half is a non-canonical point encoding (RFC 8032 §5.1.7 step 1 → §5.1.3), in both backends. The donna batch path decoded R rather than re-encoding and comparing it, so atcount >= 4— where donna leaves its per-entry fallback for the multi-scalar routine — it returned VALID for a signatureama_ed25519_verifyrejects. Two verifiers in the same build disagreed on the same 64 bytes, reachable with the signer's own key and no forgery. Migration: none for conformant callers, since R is emitted only by canonical encoders; a caller that batch-verified attacker-supplied signatures should re-check anything accepted atcount >= 4.Twelve further changes are behavioural — the observable answer is unchanged, the work, timing or failure mode is not: rows 4, 5, 6, 8, 9, 10, 11, 12, 13, 19, 20, 22. Rows 19 and 20 are the source-install fixes:
pip install .signs the integrity artefact and binds the extensions it ships, andintegrity --update --signbinds what it repairs.Every breaking change is a fail-closed correction. None removes a capability, and none alters a wire format, a key format or a C API signature. For C consumers the SONAME moves
.so.4→.so.5, so existing binaries require relinking.Verification
Executed against head
7432e0d:-fno-sanitize-recover=all), MSan, TSannm -Dbefore each runERROR SUMMARY: 0 errorseachmypy --strict(303 files) ·black·ruff· every gate scriptpip install ./AMA_BUILD_PIPELINE=1/AMA_NO_CYTHON=1, each imported underAMA_FIPS_STRICT=1OPERATIONAL, 13/13 POST tests, 0 skippedVendor isolation was re-verified against the built object rather than through the gate:
lddresolves only libc, the loader and the vdso; the dynamic symbol table contains noEVP_,SSL_,CRYPTO_,sodium_,wc_,botan_,nettle_,gcry_ormbedtls_symbol; and noSHA512_*orRAND_bytesappears among the undefined symbols, which establishes at object level that the vendored ed25519-donna tree's OpenSSL arms were not compiled.Each behavioural fix is pinned by a test verified to fail without it. Per-pass records, with the command and exit code behind each measurement, are in the PR comments and in
CHANGELOG.md.A maintenance pass at head
f8873ae(2026-08-23) closed the four lanes that were red at7432e0d— the benchmark runner's full-window qualification revoking already-measured batches on noisy runners, the PyKCS11 sdist build broken by the runner images' move to SWIG >= 4.3, sixteen-Wconversiondiagnostics intests/c/test_field_bench.c, and a documented line count that depended on whether a build had re-signed the integrity artefact — and brought the sub-floor, trust-bootstrap and competitive-benchmark claims in the tree to what the code and data support.CHANGELOG.md§"Maintenance pass, twelfth" is the record. Four deterministic constant-time gates (consttime-lookup/consttime-swap/consttime-copy/secure-memzero) joined the callgrind inventory, measured at zero cross-class delta on all four metrics under both compilers and each verified to fail on a planted defect before being trusted. Local validation atf8873ae: C suite 68/68, Python suite 6,016 passed / 96 skipped / 0 failed,mypy --strict/black/ruff/ every touched gate script clean.Prerequisites for release
Each requires hardware, a protected credential, or a workflow dispatch. None is blocked by a defect in this branch.
7432e0d(SVE2 at VL=128 and VL=256, jobs 97221691798 / 97221691921). The SVE2 dudect slots are not a re-run item at all: hosted AArch64 runners lack SVE2 silicon, the measurement step has exited 77 (Skipped) on every recorded run, and that verification requires SVE2 hardware —CONSTANT_TIME_VERIFICATION.mdnow states this explicitly.README.mdmarks the published figures as 4.x-era.release.ymlchanged — it is byte-identical to the recorded green run 31988592972 atb524d3ed— but becausesetup.py(the post-build integrity signer cibuildwheel invokes) andtools/wheel_smoke_test.py(theCIBW_TEST_COMMAND) changed after that run. The operator runbook inrelease.ymlnow names the exact file set whose change invalidates a recorded dry run.v5.0.0tag and publication, under the owner's signing key and thereleaseenvironment thatrelease.ymlnow declares on thegithub-releasejob. Referencing the environment creates it without protection rules; configuring required reviewers under Settings → Environments is a one-time repository-settings action that makes the gate hold.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache License 2.0.
Copyright (C) 2025-2026 Steel Security Advisors LLC