Skip to content

test(tweak-aead): run the crate in CI and pin AVX2 against portable byte-for-byte - #75

Open
Nexlab-One wants to merge 4 commits into
mainfrom
test/tweak-aead-coverage
Open

test(tweak-aead): run the crate in CI and pin AVX2 against portable byte-for-byte#75
Nexlab-One wants to merge 4 commits into
mainfrom
test/tweak-aead-coverage

Conversation

@Nexlab-One

Copy link
Copy Markdown
Contributor

lib-q-tweak-aead dispatches between an AVX2 keystream (xor_keystream_avx2) and a portable one, but the crate was never a cargo test target in CIgrep -rn "tweak-aead" .github/workflows/ returned exactly one hit, a cargo publish --dry-run package list in cd.yml. Its own tests/roundtrip.rs never ran, and nothing ever checked that the AVX2 path agrees with the portable one.

To be precise about what was and was not true: the AVX2 code was compiled — ci.yml:76 runs cargo clippy --all-targets --all-features ungated. Compiled is not tested.

What this adds

  • tests/simd_equivalence.rs — AVX2 vs portable, byte-for-byte, over varied lengths, keys, nonces and tweaks.
  • A multi-block KAT (293 bytes = 9 full blocks + 5-byte remainder) that drives every loop of the AVX2 keystream in one shot: two iterations of the 4-way batched loop, one single-block tail iteration, and the sub-block remainder. The pre-existing 4-byte vector only ever reached the remainder path.
  • CI wiring so the crate runs with and without simd-avx2.

The KAT expected bytes are generated from the portable path (cargo run -p lib-q-tweak-aead --example dump_tweak_kat, no simd-avx2), so they are an independent oracle rather than a recording of the AVX2 path. It is also branch-independent — xor_body picks AVX2 at runtime, and the differential test can only run on a host with AVX2, so the KAT pins fixed bytes whichever branch executes.

Two holes found by adversarial review, both closed

Every output buffer was zeroed, so the suite could not distinguish ct[i] = pt[i] ^ ks[i] from ct[i] ^= ... — 0 is the XOR identity. Changing both AVX2 write sites to ^= left all 12 tests green. This is not contrived: crypto::encrypt is pub and writes into a caller-supplied out, so a caller reusing a buffer would silently get wrong ciphertext. Buffers are now pre-filled 0xAA; expected bytes unchanged, which is the point.

The vector table topped out at 4096 bytes (block indices 0..127), so a divergence first appearing at block >= 128 was invisible. Lengths now reach past the batched loop 256th block.

A third issue was introduced by that second fix and is also corrected: the meta-guard counted (key-set x length) pairs against a threshold of exactly 8 — zero margin — so trimming the pseudorandom key sets from 6 to 2, a pure runtime reduction leaving the deep-counter property intact, tripped it with a misleading message. It now asserts the property it actually cares about: LENGTHS contains at least one length exercising block index > 256, independent of how many key sets exist.

Verified

cargo test -p lib-q-tweak-aead -> 10 passed. With --features simd-avx2 -> 13 passed (the 3 simd_equivalence tests now genuinely run). Each hole was reproduced first (mutation applied, suite green), then closed, then the same mutation shown failing.

Production source is untouched: the only non-test change is inside #[cfg(test)] mod kat_tests.

Not checked

  • Whether xor_keystream_avx2 is correct on a non-AVX2 host — the differential test returns early there; the KAT is the fallback guard.
  • Divergences first appearing beyond the new ceiling.
  • lib-q-duplex-aead declares a simd-avx2 feature too; its avx2.rs reportedly forwards straight to Portable, so it is likely a no-op rather than a real split. Not confirmed.

for (i, k) in key.iter_mut().enumerate() {
*k = i as u8;
}
let mut nonce = [0u8; NONCE_BYTES];
let nonce = [0u8; 16];
let mut out = [0u8; 4 + 32];
let key = [0u8; KEY_BYTES];
let nonce = [0u8; NONCE_BYTES];
for (i, k) in key.iter_mut().enumerate() {
*k = i as u8;
}
let mut nonce = [0u8; NONCE_BYTES];
@github-actions

Copy link
Copy Markdown

🔒 Security Validation Report

Generated: Wed Jul 29 12:54:49 UTC 2026

📊 Summary

  • NIST compliance: success
  • Cryptographic validation: success
  • Constant-time operations: success
  • Memory safety: success
  • Dependency security: success
  • WASM security: success

✅ Overall Security Status: PASSED

All critical security validations passed successfully.

🔍 Details

This report covers:

  • NIST post-quantum algorithm compliance
  • Constant-time operation verification
  • Memory safety and zeroization checks
  • Dependency vulnerability scanning
  • WASM build artifact validation

📋 Next Steps

✅ Security validation passed. Code is ready for deployment.


Security validation passed! This code meets all security requirements.

@github-actions

Copy link
Copy Markdown

🔍 Pull Request Summary

Generated: Wed Jul 29 12:58:41 UTC 2026

📋 Validation Results

  • Core Validation: success
  • Security Validation: success
  • Test Coverage: success
  • WASM Compatibility: success
  • Documentation: success

✅ Overall Status: PASSED

🔒 Security Checklist

  • No classical cryptographic algorithms
  • Only SHA-3 family hash functions
  • Constant-time operations
  • Proper memory zeroization
  • Input validation
  • Error handling

📝 Review Notes

Please review the security implications of this change carefully.
All cryptographic changes require security team review.


Automated validation passed! This PR is ready for review.

…r-byte

`lib-q-tweak-aead`'s `simd-avx2` keystream was COMPILED on every PR (the
workspace `cargo clippy --all-targets --all-features` pass in core-validation)
but EXECUTED by nothing: no Cargo.toml forwards `lib-q-tweak-aead/simd-avx2`,
no workflow enables it, and the repo's only `cargo test --all-features` sits
behind rust-build's `run-tests` input, which every caller sets to "false".

Adding a feature row alone would not have helped. Every test in
tests/roundtrip.rs encrypts and then decrypts through the SAME `xor_body`
branch, so CTR's self-inverse cancels any keystream error and the roundtrip
still passes. The one test that pinned actual bytes,
`kat_encrypt_libq_empty_ad`, has a 4-byte plaintext -- below BLOCK_BYTES=32 --
so it only ever reached the sub-block remainder path, never the AVX2 4-way
batched loop. Verified by mutation: changing `block_idx + i as u64` to
`block_idx + (i as u64 ^ 1)` in the batched loop left all 8 roundtrip tests
AND the 4-byte KAT green.

Three changes:

- tests/simd_equivalence.rs (new): differential `xor_keystream_avx2` vs
  `Portable::xor_keystream` (plus an independent naive CTR reference) over 26
  lengths x 8 key/nonce sets, all from fixed seeds so failures reproduce.
  Modelled on lib-q-saturnin/tests/simd_equivalence.rs, including the
  `if !has_avx2() { return; }` guard -- that is a SAFETY requirement, not
  style: `xor_keystream_avx2` is `#[target_feature(enable = "avx2")]` and
  calling it without the CPU feature is UB. Non-vacuity assertions (keystream
  not all-zero, counter advances across the batch boundary, output depends on
  key and on nonce) stop it passing on a degenerate keystream, and a
  `batched_vectors` floor stops a future edit trimming the length table back
  below 128 bytes, which would silently remove the only coverage that matters.

- src/crypto.rs `kat_encrypt_multi_block_293`: 293 bytes = 9 full blocks + 5,
  which drives two batch iterations, the single-block tail and the remainder in
  one vector. Unlike the differential test this is branch-INDEPENDENT, so it
  also covers the case the differential test cannot: on a runner whose CPU
  lacks AVX2, simd_equivalence.rs returns early, but this KAT still pins the
  bytes whichever branch `xor_body` took. Expected bytes are generated from the
  portable path via examples/dump_tweak_kat.rs (extended here), so the KAT is
  an independent oracle for the AVX2 path rather than a recording of it.

- ci.yml: two `test-matrix` rows so the crate is a real `cargo test` target on
  every PR, with and without `simd-avx2`. The plain row duplicates what the
  whole-workspace "std" shard already covers, deliberately: that shard names no
  package, so `grep tweak-aead .github/workflows/` found nothing and the crate
  read as untested. That invisibility is what produced this audit.

No production code changed. Severity is latent, not live: `simd-avx2` is
opt-in and nothing in the workspace enables it, so today's blast radius is
zero. This is regression insurance on a path that was shipping unexercised.
…6th block, and stop testing XOR against zeroed buffers

d5aee03 made `xor_keystream_avx2` a real test target and pinned it against the
portable keystream. That claim holds, but the suite it added had two blind spots
that mutation testing walks straight through. Both were reproduced before being
fixed, and each fix was re-checked against the same mutation.

HOLE 1 -- the vector table had no ceiling.

`LENGTHS` topped out at 4096 bytes = 128 blocks, and the 293-byte KAT reaches
block 9, so no test drove the block counter above index 127. Truncating the
batched loop's counter to 8 bits (`block_idx + i as u64` ->
`((block_idx + i as u64) as u8) as u64`, src/simd/avx2.rs:44) left all 12 tests
green. That exact mutation is contrived -- the counter is a plain `u64` handed to
a scalar setup fn -- but the property is not: a vectorised-counter rewrite
(broadcasting four lanes of `block_idx` into a `__m256i` instead of calling
`setup_state_pre_f1600` four times) is precisely where a narrowed counter would
appear, and the table could not have caught it.

The obvious fix is wrong, which is why the constant carries the arithmetic in a
doc comment. "Add a length above 8192 so block index >= 256 is exercised" does
not work: at 8256 bytes (258 blocks) the batched loop runs
`while block_idx + 4 <= 258`, so it exits at block_idx = 256 and hands blocks 256
and 257 to the SCALAR TAIL -- which calls the unmutated `keystream_block`. The
counter reaches 257 and the mutation still hides. Verified: with 8256 in the
table the whole suite stayed green under the mutation.

The batched loop only touches index 256 once `full_blocks >= 260`. So the added
vector is 8357 = 261 blocks + 5 (batch covers 256..259, tail takes 260, remainder
takes 261), plus 8192 for the boundary itself (highest batched index exactly 255).
The new `deep_counter_vectors` guard is phrased as `batched_blocks(len) > 256`
rather than a byte threshold, so a future edit cannot satisfy it with a length
that only reaches 256 via the tail -- the trap this commit just fell into.

HOLE 2 -- every output buffer started zeroed, so nothing pinned assignment.

Zero is the identity for XOR, so `ct[i] = pt[i] ^ ks[i]` and `ct[i] ^= ...` are
byte-identical on a fresh buffer. Changing BOTH AVX2 write sites to `^=` left all
12 tests green. Unlike hole 1 this is not contrived: `crypto::encrypt` is `pub` in
a `pub mod crypto` and writes into a caller-supplied `out`, so a caller reusing
one buffer across messages -- the obvious way to avoid reallocating -- would get
each ciphertext XORed with the previous one. For a stream cipher that hands the
attacker the XOR of two plaintexts.

Three separate pins, deliberately overlapping:
  - the differential loop now prefills each of the three buffers with a DIFFERENT
    pattern (0xAA / 0x55 / 0x33), so an accumulate defect in any one of them
    carries its own pattern out and diverges from the other two;
  - `avx2_output_is_assigned_not_xor_accumulated` states the property directly and
    without reference to the portable path -- same input into a clean and a dirty
    buffer must give the same bytes -- over 0xAA/0x55/0xFF and a length that hits
    all three write sites;
  - both KATs in src/crypto.rs now encrypt into 0xAA-filled buffers. Expected bytes
    unchanged; that IS the assertion. These run through the public `encrypt`, so
    they hold whichever branch `xor_body` takes -- including on runners without
    AVX2, where tests/simd_equivalence.rs returns early.

Also widened the plaintext seed stride from 8192 to 100_000: with lengths now
exceeding 8192, `set_idx * 8192 + len` was no longer injective, so a reported
"set N, len L" could have named two different plaintexts.

Test-side only. src/simd/avx2.rs is untouched -- the mutations above were
temporary probes, reverted before commit; the src/crypto.rs change is confined to
`#[cfg(test)] mod kat_tests`.
deep_counter_vectors counted (key/nonce set, length) PAIRS, not lengths in
LENGTHS: with 8 key/nonce sets and the single qualifying length (8357, the
only entry whose batched_blocks() exceeds 256), the count was exactly 8
against an `>= 8` threshold — zero margin. Any unrelated trim of
key_nonce_sets()'s loop range (fewer key/nonce sets, nothing to do with which
lengths are tested) would trip the guard, and its message ("only N vectors
reach it... needs len >= N bytes") would misdescribe the cause as lost
length/coverage rather than fewer key/nonce sets.

Assert directly on LENGTHS instead: at least one entry drives
batched_blocks(len) past 256. This is the actual property the guard exists
to protect, and it no longer depends on key_nonce_sets()'s cardinality.
Verified the reduction (0..6u64 -> 0..2u64) still passes under the new
assertion.
@Nexlab-One
Nexlab-One force-pushed the test/tweak-aead-coverage branch from 48016dc to 021314e Compare July 29, 2026 13:04
CodeQL's rust/hard-coded-cryptographic-value fired at critical severity on
three sites added by this branch: the multi-block KAT's nonce in crypto.rs
and the two in the regeneration example. All three are known-answer test
vectors -- a KAT pins fixed inputs to fixed outputs, so a fresh nonce would
make the expected bytes unverifiable, which defeats the purpose. None is
reachable from a production path; the example only regenerates the expected
bytes and its inputs must match the test exactly or the two drift apart.

Suppress narrowly, per site, each with the reason stated inline rather than
silencing the query repo-wide or excluding tests from analysis -- the query is
right in general and should keep firing everywhere else.

Note the pre-existing kat_encrypt_libq_empty_ad vector does the same thing
with `let nonce = [0u8; 16]`; CodeQL flags the loop-constructed form but not
the bare literal, which is why this surfaced only now.

Verified: 13 passed with --features simd-avx2, fmt clean, clippy clean.
@github-actions

Copy link
Copy Markdown

🔍 Pull Request Summary

Generated: Wed Jul 29 13:46:15 UTC 2026

📋 Validation Results

  • Core Validation: success
  • Security Validation: success
  • Test Coverage: success
  • WASM Compatibility: success
  • Documentation: success

✅ Overall Status: PASSED

🔒 Security Checklist

  • No classical cryptographic algorithms
  • Only SHA-3 family hash functions
  • Constant-time operations
  • Proper memory zeroization
  • Input validation
  • Error handling

📝 Review Notes

Please review the security implications of this change carefully.
All cryptographic changes require security team review.


Automated validation passed! This PR is ready for review.

@github-actions

Copy link
Copy Markdown

🔒 Security Validation Report

Generated: Wed Jul 29 13:49:00 UTC 2026

📊 Summary

  • NIST compliance: success
  • Cryptographic validation: success
  • Constant-time operations: success
  • Memory safety: success
  • Dependency security: success
  • WASM security: success

✅ Overall Security Status: PASSED

All critical security validations passed successfully.

🔍 Details

This report covers:

  • NIST post-quantum algorithm compliance
  • Constant-time operation verification
  • Memory safety and zeroization checks
  • Dependency vulnerability scanning
  • WASM build artifact validation

📋 Next Steps

✅ Security validation passed. Code is ready for deployment.


Security validation passed! This code meets all security requirements.

@github-actions

Copy link
Copy Markdown

🔒 Security Validation Report

Generated: Wed Jul 29 14:10:34 UTC 2026

📊 Summary

  • NIST compliance: success
  • Cryptographic validation: success
  • Constant-time operations: success
  • Memory safety: success
  • Dependency security: success
  • WASM security: success

✅ Overall Security Status: PASSED

All critical security validations passed successfully.

🔍 Details

This report covers:

  • NIST post-quantum algorithm compliance
  • Constant-time operation verification
  • Memory safety and zeroization checks
  • Dependency vulnerability scanning
  • WASM build artifact validation

📋 Next Steps

✅ Security validation passed. Code is ready for deployment.


Security validation passed! This code meets all security requirements.

@github-actions

Copy link
Copy Markdown

🔍 Pull Request Summary

Generated: Wed Jul 29 14:11:51 UTC 2026

📋 Validation Results

  • Core Validation: success
  • Security Validation: success
  • Test Coverage: success
  • WASM Compatibility: success
  • Documentation: success

✅ Overall Status: PASSED

🔒 Security Checklist

  • No classical cryptographic algorithms
  • Only SHA-3 family hash functions
  • Constant-time operations
  • Proper memory zeroization
  • Input validation
  • Error handling

📝 Review Notes

Please review the security implications of this change carefully.
All cryptographic changes require security team review.


Automated validation passed! This PR is ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants