Skip to content

Pqc engines - #3

Merged
apayne185 merged 15 commits into
mainfrom
pqc-engines
Jul 2, 2026
Merged

Pqc engines#3
apayne185 merged 15 commits into
mainfrom
pqc-engines

Conversation

@apayne185

Copy link
Copy Markdown
Owner

No description provided.

apayne185 added 15 commits June 25, 2026 13:20
Implements Keccak-f[1600] and the sponge construction from scratch —
no OpenSSL, no external dependencies.

Provides SHA3-256, SHA3-512, SHAKE128, SHAKE256. All four are verified
against 9 NIST FIPS 202 known-answer vectors (block-boundary cases
included). SHA-3 is a dependency of SPHINCS+ and is the pre-image
of Ethereum's keccak256.

- cpp/sha3/sha3.h   — public API (Digest256/512, SHAKE as vector<uint8_t>)
- cpp/sha3/sha3.cpp — Keccak-f[1600] permutation + sponge absorb/squeeze
- cpp/sha3/sha3_test.cpp — 9 NIST vectors across SHA3-256/512, SHAKE128/256
- CMakeLists.txt    — sha3 static library + sha3_test ctest target
- CI label updated to reflect both FIPS 180-4 and FIPS 202 coverage
Post-quantum hash-based digital signatures built directly on the SHA-3
module added in the previous commit. Implements the full SLH-DSA-SHAKE-128s
parameter set (n=16, h=63, d=7, k=14, a=12, w=16) from scratch with no
external crypto library.

Scheme layers:
  - WOTS+: one-time signatures via LEN=35 hash chains of length w=16
  - XMSS:  Merkle tree of 512 WOTS+ public keys per layer
  - HT:    7-layer hypertree; each layer signs the root below
  - FORS:  14 few-time trees (height 12) signing the message digest indices

All tweakable hash functions call SHAKE256(PK.seed || ADRS || input) with
a 32-byte address struct (ADRS) for domain separation. Signing is
randomised by default (/dev/urandom) or deterministic via opt_rand.

Also optimises Keccak-f[1600] (eliminates %5 modulo in theta/chi) and
adds a word-level absorb fast-path in the sponge, reducing SHAKE256
call overhead by ~3x. SHA-3 KAT vectors continue to pass.

Signature size: 7856 bytes. Round-trip sign/verify: ~4.5s single-threaded.
11/11 tests pass (keygen, sign, verify, tamper rejection, determinism).
- fors_indices: uint8_t -> uint16_t — 12-bit FORS indices (A=12) were
  silently truncated to 8 bits, collapsing the reachable leaf space from
  4096 to 256 per tree and reducing effective FORS security from 168 to
  112 bits (below the 128-bit target). Sign/verify remained consistent
  so tests passed, but the security margin was wrong.

- prf_msg, h_msg: add size_t overflow guard before vector allocation —
  a near-SIZE_MAX msg_len wraps the addition, allocates a tiny buffer,
  and the subsequent memcpy writes past it.

- random_bytes: check gcount() after read() — a short read from
  /dev/urandom left the tail of opt_rand as zeros without any error.

- thash: assert(in_len <= LEN*N) — the 608-byte stack buffer is sized
  for the largest valid input; make that contract explicit.

- fors_sign_and_pk: set_tree_height(0) explicitly on FORS_PRF ADRS —
  previously relied on zero-initialised base_adrs; defensive explicit set
  matches what fors_leaf already does.

- sha3.cpp: static_assert little-endian — the fast-absorb word-XOR path
  is only correct on LE hosts; make this a compile-time failure on BE.
std::endian is C++20; use __BYTE_ORDER__ preprocessor macros instead,
guarded by #if so the check is skipped on compilers that don't define them.
Implements the ML-KEM-512 parameter set (k=2, q=3329, n=256, η1=3, η2=2,
du=10, dv=4) — the 128-bit post-quantum KEM standardised as FIPS 203.

Core components:
- NTT and InvNTT over Z_q[X]/(X^256+1) with precomputed zeta table
- BaseCaseMul for NTT-domain pointwise multiplication via 128 quadratic
  factors; GAMMAS[i] = 17^{2·BitRev7(i)+1}
- ByteEncode/ByteDecode (little-endian bit packing, any d)
- Compress/Decompress (rounding to d bits)
- SampleNTT (SHAKE128 rejection sampling), SamplePolyCBD, PRF (SHAKE256)
- K-PKE.KeyGen, K-PKE.Encrypt, K-PKE.Decrypt (Algorithm 13-15)
- ML-KEM.KeyGen, ML-KEM.Encaps, ML-KEM.Decaps with implicit rejection
  (Algorithm 16-18)

SHA-3 changes:
- Add shake128_into() and shake256_into() — allocation-free XOF helpers
  needed by SampleNTT and the implicit-rejection path

12/12 round-trip and rejection tests pass.
FIPS 203 Algorithm 13 (K-PKE.KeyGen) defines the public matrix as
A_hat[i][j] = SampleNTT(XOF(ρ, j, i)) — j before i.  Our code had
the indices swapped, so the generated matrix was the transpose of the
correct one.  Algorithm 14 (K-PKE.Encrypt) must use A^T, which under
the fixed convention is SampleNTT(XOF(ρ, i, j)) — i before j.

Both sides were consistently wrong, so round-trips passed, but outputs
did not match NIST ACVP vectors.  Verified fix against NIST ACVP
ML-KEM-keyGen-FIPS203 tcId=1: ek[0..31] now matches expected value.

Also adds a full KAT test block: keygen ek is validated against NIST
ACVP tcId=1; encaps ct/ss are golden values for regression coverage.
All 16 tests pass.
The function comment said "(i, j)" in a way that implied param i = FIPS row
and param j = FIPS col. After the matrix index fix, keygen calls (j, i) and
encrypt calls (i, j) — a maintainer reading the old comment would conclude
the keygen call site looks transposed and "fix" it, silently reverting the
FIPS 203 Alg 13 correction. New comment makes the byte-order contract
explicit and names both call sites inline.
'A'-'F' were routed to the lowercase branch, producing c-'a'+10 = -22 cast
to uint8_t = 234 — silently corrupting any uppercase nibble. Adds an
uppercase branch so NIST ACVP JSON vectors (which use uppercase A-F) can be
pasted in directly without silent corruption.
@apayne185
apayne185 merged commit 4f81412 into main Jul 2, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant