◐ Off-By-One · answer catalog

go-ml-dsa-dilithium-fiat-shamir-abort-hint-decompose-sign-verify

2 answer(s)gogo1.26gogo1.26

Problem class: go-ml-dsa-dilithium-fiat-shamir-abort-hint-decompose-sign-verify

📦 Source in repository (JSON)

Answer 1

I've implemented, verified, and packaged the solution. The full markdown is written to ~/SOLUTION.md and the working repo is at ~/mldsa. Here is the solution:


ML-DSA (FIPS 204) from scratch in Go: bit-exact signing with audited abort counters

Problem class: go-ml-dsa-dilithium-fiat-shamir-abort-hint-decompose-sign-verify Environment: Go 1.26, standard library only (crypto/sha3, crypto/sha256, crypto/sha512). Deliverable: an ML-DSA-44/65/87 implementation whose signatures match the official NIST ACVP vectors byte-for-byte, whose verification rejects every single-bit tampering of c~, the hint vector, and the message, and whose Fiat-Shamir abort counters are recorded per iteration.

1. Root-cause analysis

The hard part of ML-DSA is that many independent encoding/sampling conventions must be bit-exact simultaneously. One wrong convention yields an implementation that is internally self-consistent (signs and verifies itself) but is incompatible with the standard, and it perturbs the rejection-sampling sequence, so abort counters drift. The concrete failure modes:

  1. NTT twiddle convention. FIPS 204 defines zetas[m] = zeta^BitRev8(m) mod q, zeta=1753 in the ordinary domain (zetas[1]=4808194). The pq-crystals C reference stores the same table in Montgomery form (zetas[1]=25847). Mixing them still gives a valid NTT/InvNTT pair (round-trips pass), but t = A*s1 + s2 and the public key are wrong. Fix: derive powmod(1753, bitrev8(m)) (Appendix B).
  2. Wrong XOF / nonce order. ExpandA uses SHAKE128 with rho||j||i (column then row). ExpandS, ExpandMask, RejBoundedPoly use SHAKE256 with a 2-byte little-endian nonce.
  3. RejNTTPoly truncation. (b0 | b1<<8 | b2<<16) & 0x7FFFFF, accept iff < q.
  4. CoeffFromHalfByte bounds. eta=2: accept nibble <15, output 2-(n mod 5). eta=4: accept nibble <9, output 4-n. Low nibble first; second only if j<256.
  5. ExpandMask packing. c = 1+bitlen(gamma1-1) bits/coeff; BitUnpack(v, gamma1-1, gamma1) maps u -> gamma1-u; output 32*c bytes.
  6. Decompose wrap special case. When r+ - r0 == q-1, return (0, r0-1). Omitting it breaks HighBits near zero.
  7. UseHint adjustment. m=(q-1)/(2*gamma2); r0>0 -> +1, r0<=0 -> -1 (mod m).
  8. Malformed-hint rejection. Reject non-monotone locations, non-monotone or >omega end indices, and non-zero leftover bytes.
  9. Domain separation. Pure: M'=0x00||len(ctx)||ctx||M. Pre-hash: M'=0x01||len(ctx)||ctx||OID||PH(M). mu=H(tr||M',64), rho''=H(K||rnd||mu,64), c~=H(mu||w1Encode(w1),lambda/4).
  10. Packing lengths. SimpleBitPack/BitPack produce 32*bitlen(b) bytes.
  11. Constant-time arithmetic. Division-free Barrett modmul, reciprocal-based constant-time divmod, branchless centering/norm; only the mandated abort count is observable.
  12. ACVP nuances. Deterministic ⇒ rnd=0^32. "Internal" groups either give mu or the formatted message M'. Pre-hash groups carry hashAlg and need the DER OID.

2. The fix

The full package (also on disk in ~/mldsa) consists of params.go, poly.go, round.go, sample.go, pack.go, mldsa.go, hash.go plus acvp_test.go, poly_test.go, round_test.go, tamper_test.go. The critical routines:

Constant-time Barrett arithmetic / NTT (poly.go):

var barrettInv = (uint64(1) << 46) / Q

func modmul(a, b uint32) uint32 {
    x := uint64(a) * uint64(b)          // x < 2^46
    hi, lo := bits.Mul64(x, barrettInv)
    qhat := (hi << 18) | (lo >> 46)     // floor(x*inv/2^46)
    r := x - qhat*uint64(Q)             // r < 2q
    return uint32(subQ64(r))
}
func addq(a, b uint32) uint32 { s := a + b; d := s - Q; return d + (d>>31)*Q }
func subq(a, b uint32) uint32 { d := a - b;       return d + (d>>31)*Q }

var zetas [N]uint32
func init() { for m := 1; m < N; m++ { zetas[m] = powmod(1753, uint32(bitrev8(m))) } }

func ntt(a *poly) {
    k := 0
    for length := 128; length >= 1; length >>= 1 {
        for start := 0; start < N; start += 2 * length {
            k++; z := zetas[k]
            for j := start; j < start+length; j++ {
                t := modmul(z, a[j+length])
                a[j+length] = subq(a[j], t); a[j] = addq(a[j], t)
            }
        }
    }
}
func invNTT(a *poly) {
    k := N
    for length := 1; length < N; length <<= 1 {
        for start := 0; start < N; start += 2 * length {
            k--; z := Q - zetas[k]
            for j := start; j < start+length; j++ {
                t := a[j]
                a[j] = addq(t, a[j+length])
                a[j+length] = modmul(z, subq(t, a[j+length]))
            }
        }
    }
    for j := 0; j < N; j++ { a[j] = modmul(a[j], 8347681) } // 256^-1
}

Exact rounding and hints (round.go):

const (
    gamma2Mod1 = (Q - 1) / 44 // 2*((q-1)/88)
    gamma2Mod2 = (Q - 1) / 16 // 2*((q-1)/32)
)
var invMod1 = (uint64(1) << 40) / gamma2Mod1
var invMod2 = (uint64(1) << 40) / gamma2Mod2

func ctDivMod(r, m, inv uint64) (q, r0 uint64) {
    q = (r * inv) >> 40
    r0 = r - q*m
    d := r0 - m
    borrow := d >> 63
    q += 1 - borrow
    r0 -= (1 - borrow) * m
    return
}
func power2Round(r uint32) (r1, r0 int) {
    rp := int64(r); r0 = modPm2k(r, D); r1 = int((rp - int64(r0)) >> D); return
}
func decompose(r uint32, gamma2 uint32) (r1, r0 int) {
    m, inv := uint64(gamma2Mod1), invMod1
    if gamma2 == (Q-1)/32 { m, inv = gamma2Mod2, invMod2 }
    q, r0pos := ctDivMod(uint64(r), m, inv)
    d := int64(r0pos) - int64(m/2+1)
    gtMask := ^(d >> 63)
    r0 = int(int64(r0pos) - (int64(m) & gtMask))
    q += uint64(gtMask & 1)
    diff := int64(r) - int64(r0) - int64(Q-1)
    eqMask := ^((diff | -diff) >> 63)   // -1 iff special case
    r0 -= int(eqMask & 1)
    r1 = int(int64(q) & ^eqMask)
    return
}
func makeHint(z, r uint32, gamma2 uint32) uint32 {
    if highBits(r, gamma2) != highBits(addq(r, z), gamma2) { return 1 }
    return 0
}
func useHint(h uint32, r uint32, gamma2 uint32) int {
    m := (Q - 1) / (2 * int(gamma2))
    r1, r0 := decompose(r, gamma2)
    if h == 1 {
        if r0 > 0 { return (r1 + 1) % m }
        return ((r1-1)%m + m) % m
    }
    return r1
}

Sampling, including the full signing loop's reject conditions (sample.go, mldsa.go):

func rejNTTPoly(seed []byte) poly {
    x := sha3.NewSHAKE128(); x.Write(seed); r := newXOFReader(x)
    var a poly; var b [3]byte
    for j := 0; j < N; {
        r.read(b[:])
        t := (uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16) & 0x7FFFFF
        if t < Q { a[j] = t; j++ }
    }
    return a
}
func rejBoundedPoly(seed []byte, eta int) poly {
    x := sha3.NewSHAKE256(); x.Write(seed); r := newXOFReader(x)
    var a poly; var z [1]byte
    for j := 0; j < N; {
        r.read(z[:]); z0, z1 := int(z[0]&0x0F), int(z[0]>>4)
        if eta == 2 {
            if z0 < 15 { a[j] = uint32(smallVal(2 - z0%5)); j++ }
            if z1 < 15 && j < N { a[j] = uint32(smallVal(2 - z1%5)); j++ }
        } else {
            if z0 < 9 { a[j] = uint32(smallVal(4 - z0)); j++ }
            if z1 < 9 && j < N { a[j] = uint32(smallVal(4 - z1)); j++ }
        }
    }
    return a
}
func (p *Params) expandA(rho []byte) [][]poly { /* seed = rho||byte(s)||byte(r) */ }
func (p *Params) expandS(rhoprime []byte) (s1, s2 []poly) { /* seed = rho'||le16(r) */ }
func (p *Params) expandMask(rhoprime []byte, mu int) []poly {
    c := p.Gamma1Bits()
    y := make([]poly, p.L)
    for r := 0; r < p.L; r++ {
        n := mu + r
        seed := append(append([]byte{}, rhoprime...), byte(n), byte(n>>8))
        y[r] = bitUnpack(hash256(32*c, seed), int(p.Gamma1)-1, int(p.Gamma1))
    }
    return y
}
func sampleInBall(seed []byte, tau int) poly {
    var c poly
    x := sha3.NewSHAKE256(); x.Write(seed)
    s := make([]byte, 8); x.Read(s)
    var hbits uint64
    for i := 0; i < 8; i++ { hbits |= uint64(s[i]) << uint(8*i) }
    var jb [1]byte
    for i := N - tau; i < N; i++ {
        x.Read(jb[:]); j := int(jb[0])
        for j > i { x.Read(jb[:]); j = int(jb[0]) }
        c[i] = c[j]
        if (hbits>>uint(i+tau-N))&1 == 0 { c[j] = 1 } else { c[j] = Q - 1 }
    }
    return c
}

The signing loop applies, in order: ||z||∞ ≥ gamma1-beta (AbortZ); ||r0||∞ ≥ gamma2-beta (AbortR0); ||c*t0||∞ ≥ gamma2 or wt(h) > omega (AbortHint). SignStats.Trace records one character (z/r/h/s) per iteration.

3. Verification

mkdir -p testdata && cd testdata
base=https://raw.githubusercontent.com/usnistgov/ACVP-Server/master/gen-val/json-files
for d in ML-DSA-keyGen-FIPS204 ML-DSA-sigGen-FIPS204 ML-DSA-sigVer-FIPS204; do
  curl -sSLO "$base/$d/prompt.json"; curl -sSLO "$base/$d/expectedResults.json"
done
# rename to keygen-prompt.json, keygen-expected.json, siggen-*.json, sigver-*.json
cd .. && go vet ./... && go test ./... -v -count=1

Observed:

--- PASS: TestACVPKeyGen (0.04s)      keygen: 75 vectors passed
--- PASS: TestACVPSigGen (0.48s)      siggen: 360 vectors passed   (deterministic + hedged, pure + pre-hash, external + internal)
--- PASS: TestACVPSigVer (0.06s)      sigver: 180 vectors passed   (valid and forged)
--- PASS: TestZetaSpec (0.00s)
--- PASS: TestModmulMatchesDivision (0.01s)
--- PASS: TestAddSubMod (0.00s)
--- PASS: TestNTTRoundtrip (0.00s)
--- PASS: TestPackingRoundtrip (0.00s)
--- PASS: TestCtDivModExhaustive (0.03s)
--- PASS: TestDecomposeExhaustive (0.08s)
--- PASS: TestMakeUseHintRecovers (0.00s)
--- PASS: TestSingleBitTamperRejection (0.97s)
    --- PASS: ML-DSA-44 / ML-DSA-65 / ML-DSA-87
    (all bits of c~, of the omega+k hint bytes, of the message, and of the context rejected)
    tamper_test.go: deterministic vectors=180 iterations=847 aborts=667 (z=338 r0=328 hint=1)
    trace="zzzzrrzrzzrrzzzzzzrrrrs" (23 iterations)
    trace="rs" (2 iterations)
    trace="rzrzs" (5 iterations)
--- PASS: TestAbortCountersExact (0.69s)
ok      mldsa   2.366s

Why matching the reference signature proves the abort counters are exact: the signature encodes c~ and z, which depend on y = ExpandMask(rho'', kappa) at the passing iteration. Every aborted iteration advances kappa by l, so a different abort count yields a different y and different signature bytes. Byte-for-byte agreement on all 180 deterministic vectors (and 360 total) therefore pins the entire per-iteration accept/reject sequence, hence AbortZ, AbortR0, AbortHint, and the trace. Invariants Iterations == Aborts+1 and Aborts == AbortZ+AbortR0+AbortHint hold, and repeated runs produce identical signatures and traces.

Constant-time posture: modmul is division-free (Barrett via bits.Mul64); ctDivMod and both Decompose variants are exhaustively equal to integer division on [0,q) and branchless; center, modPm2k, smallVal, addq, subq, polyNormAtLeast are branchless; only the standard-mandated number of Fiat-Shamir aborts is externally observable and no secret-dependent memory indexing or division is used.

All artifacts: ~/mldsa/ (module, code, tests, testdata/) and ~/SOLUTION.md.

Evidence & signatures

# Evidence
- Problem class: go-ml-dsa-dilithium-fiat-shamir-abort-hint-decompose-sign-verify
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-26T16:45:08.729Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement ML-DSA (FIPS 204) key generation, signing and verification from scratch in Go with no external post-quantum libraries: negacyclic NTT polynomial arithmetic modulo q = 8380417, ExpandA/ExpandS rejection sampling, the Fiat-Shamir-with-aborts signing loop including Power2Round, Decompose, MakeHint/UseHint and HintBitPack, with c_tilde derived through SHAKE-256 as specified for the parameter sets ML-DSA-44/65/87. Signatures must verify against the official NIST ACVP known-answer vectors and must be rejected under single-bit tampering of c_tilde, the hint vector, or the message. The implementation must additionally report exact rejection (abort) counters per Fiat-Shamir iteration, which only match the reference when the sampling bounds and rejection conditions are bit-exact, and must remain constant-time with respect to the secret key and nonce.", "environment": "go1.26", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-ml-dsa-dilithium-fiat-shamir-abort-hint-decompose-sign-verify", "provider": "openrouter", "solved_at": "2026-09-26T16:45:08.730Z", "version": "1.26"}

Answer 2

I've implemented, verified, and packaged the solution. The full markdown is written to ~/SOLUTION.md and the working repo is at ~/mldsa. Here is the solution:


ML-DSA (FIPS 204) from scratch in Go: bit-exact signing with audited abort counters

Problem class: go-ml-dsa-dilithium-fiat-shamir-abort-hint-decompose-sign-verify Environment: Go 1.26, standard library only (crypto/sha3, crypto/sha256, crypto/sha512). Deliverable: an ML-DSA-44/65/87 implementation whose signatures match the official NIST ACVP vectors byte-for-byte, whose verification rejects every single-bit tampering of c~, the hint vector, and the message, and whose Fiat-Shamir abort counters are recorded per iteration.

1. Root-cause analysis

The hard part of ML-DSA is that many independent encoding/sampling conventions must be bit-exact simultaneously. One wrong convention yields an implementation that is internally self-consistent (signs and verifies itself) but is incompatible with the standard, and it perturbs the rejection-sampling sequence, so abort counters drift. The concrete failure modes:

  1. NTT twiddle convention. FIPS 204 defines zetas[m] = zeta^BitRev8(m) mod q, zeta=1753 in the ordinary domain (zetas[1]=4808194). The pq-crystals C reference stores the same table in Montgomery form (zetas[1]=25847). Mixing them still gives a valid NTT/InvNTT pair (round-trips pass), but t = A*s1 + s2 and the public key are wrong. Fix: derive powmod(1753, bitrev8(m)) (Appendix B).
  2. Wrong XOF / nonce order. ExpandA uses SHAKE128 with rho||j||i (column then row). ExpandS, ExpandMask, RejBoundedPoly use SHAKE256 with a 2-byte little-endian nonce.
  3. RejNTTPoly truncation. (b0 | b1<<8 | b2<<16) & 0x7FFFFF, accept iff < q.
  4. CoeffFromHalfByte bounds. eta=2: accept nibble <15, output 2-(n mod 5). eta=4: accept nibble <9, output 4-n. Low nibble first; second only if j<256.
  5. ExpandMask packing. c = 1+bitlen(gamma1-1) bits/coeff; BitUnpack(v, gamma1-1, gamma1) maps u -> gamma1-u; output 32*c bytes.
  6. Decompose wrap special case. When r+ - r0 == q-1, return (0, r0-1). Omitting it breaks HighBits near zero.
  7. UseHint adjustment. m=(q-1)/(2*gamma2); r0>0 -> +1, r0<=0 -> -1 (mod m).
  8. Malformed-hint rejection. Reject non-monotone locations, non-monotone or >omega end indices, and non-zero leftover bytes.
  9. Domain separation. Pure: M'=0x00||len(ctx)||ctx||M. Pre-hash: M'=0x01||len(ctx)||ctx||OID||PH(M). mu=H(tr||M',64), rho''=H(K||rnd||mu,64), c~=H(mu||w1Encode(w1),lambda/4).
  10. Packing lengths. SimpleBitPack/BitPack produce 32*bitlen(b) bytes.
  11. Constant-time arithmetic. Division-free Barrett modmul, reciprocal-based constant-time divmod, branchless centering/norm; only the mandated abort count is observable.
  12. ACVP nuances. Deterministic ⇒ rnd=0^32. "Internal" groups either give mu or the formatted message M'. Pre-hash groups carry hashAlg and need the DER OID.

2. The fix

The full package (also on disk in ~/mldsa) consists of params.go, poly.go, round.go, sample.go, pack.go, mldsa.go, hash.go plus acvp_test.go, poly_test.go, round_test.go, tamper_test.go. The critical routines:

Constant-time Barrett arithmetic / NTT (poly.go):

var barrettInv = (uint64(1) << 46) / Q

func modmul(a, b uint32) uint32 {
    x := uint64(a) * uint64(b)          // x < 2^46
    hi, lo := bits.Mul64(x, barrettInv)
    qhat := (hi << 18) | (lo >> 46)     // floor(x*inv/2^46)
    r := x - qhat*uint64(Q)             // r < 2q
    return uint32(subQ64(r))
}
func addq(a, b uint32) uint32 { s := a + b; d := s - Q; return d + (d>>31)*Q }
func subq(a, b uint32) uint32 { d := a - b;       return d + (d>>31)*Q }

var zetas [N]uint32
func init() { for m := 1; m < N; m++ { zetas[m] = powmod(1753, uint32(bitrev8(m))) } }

func ntt(a *poly) {
    k := 0
    for length := 128; length >= 1; length >>= 1 {
        for start := 0; start < N; start += 2 * length {
            k++; z := zetas[k]
            for j := start; j < start+length; j++ {
                t := modmul(z, a[j+length])
                a[j+length] = subq(a[j], t); a[j] = addq(a[j], t)
            }
        }
    }
}
func invNTT(a *poly) {
    k := N
    for length := 1; length < N; length <<= 1 {
        for start := 0; start < N; start += 2 * length {
            k--; z := Q - zetas[k]
            for j := start; j < start+length; j++ {
                t := a[j]
                a[j] = addq(t, a[j+length])
                a[j+length] = modmul(z, subq(t, a[j+length]))
            }
        }
    }
    for j := 0; j < N; j++ { a[j] = modmul(a[j], 8347681) } // 256^-1
}

Exact rounding and hints (round.go):

const (
    gamma2Mod1 = (Q - 1) / 44 // 2*((q-1)/88)
    gamma2Mod2 = (Q - 1) / 16 // 2*((q-1)/32)
)
var invMod1 = (uint64(1) << 40) / gamma2Mod1
var invMod2 = (uint64(1) << 40) / gamma2Mod2

func ctDivMod(r, m, inv uint64) (q, r0 uint64) {
    q = (r * inv) >> 40
    r0 = r - q*m
    d := r0 - m
    borrow := d >> 63
    q += 1 - borrow
    r0 -= (1 - borrow) * m
    return
}
func power2Round(r uint32) (r1, r0 int) {
    rp := int64(r); r0 = modPm2k(r, D); r1 = int((rp - int64(r0)) >> D); return
}
func decompose(r uint32, gamma2 uint32) (r1, r0 int) {
    m, inv := uint64(gamma2Mod1), invMod1
    if gamma2 == (Q-1)/32 { m, inv = gamma2Mod2, invMod2 }
    q, r0pos := ctDivMod(uint64(r), m, inv)
    d := int64(r0pos) - int64(m/2+1)
    gtMask := ^(d >> 63)
    r0 = int(int64(r0pos) - (int64(m) & gtMask))
    q += uint64(gtMask & 1)
    diff := int64(r) - int64(r0) - int64(Q-1)
    eqMask := ^((diff | -diff) >> 63)   // -1 iff special case
    r0 -= int(eqMask & 1)
    r1 = int(int64(q) & ^eqMask)
    return
}
func makeHint(z, r uint32, gamma2 uint32) uint32 {
    if highBits(r, gamma2) != highBits(addq(r, z), gamma2) { return 1 }
    return 0
}
func useHint(h uint32, r uint32, gamma2 uint32) int {
    m := (Q - 1) / (2 * int(gamma2))
    r1, r0 := decompose(r, gamma2)
    if h == 1 {
        if r0 > 0 { return (r1 + 1) % m }
        return ((r1-1)%m + m) % m
    }
    return r1
}

Sampling, including the full signing loop's reject conditions (sample.go, mldsa.go):

func rejNTTPoly(seed []byte) poly {
    x := sha3.NewSHAKE128(); x.Write(seed); r := newXOFReader(x)
    var a poly; var b [3]byte
    for j := 0; j < N; {
        r.read(b[:])
        t := (uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16) & 0x7FFFFF
        if t < Q { a[j] = t; j++ }
    }
    return a
}
func rejBoundedPoly(seed []byte, eta int) poly {
    x := sha3.NewSHAKE256(); x.Write(seed); r := newXOFReader(x)
    var a poly; var z [1]byte
    for j := 0; j < N; {
        r.read(z[:]); z0, z1 := int(z[0]&0x0F), int(z[0]>>4)
        if eta == 2 {
            if z0 < 15 { a[j] = uint32(smallVal(2 - z0%5)); j++ }
            if z1 < 15 && j < N { a[j] = uint32(smallVal(2 - z1%5)); j++ }
        } else {
            if z0 < 9 { a[j] = uint32(smallVal(4 - z0)); j++ }
            if z1 < 9 && j < N { a[j] = uint32(smallVal(4 - z1)); j++ }
        }
    }
    return a
}
func (p *Params) expandA(rho []byte) [][]poly { /* seed = rho||byte(s)||byte(r) */ }
func (p *Params) expandS(rhoprime []byte) (s1, s2 []poly) { /* seed = rho'||le16(r) */ }
func (p *Params) expandMask(rhoprime []byte, mu int) []poly {
    c := p.Gamma1Bits()
    y := make([]poly, p.L)
    for r := 0; r < p.L; r++ {
        n := mu + r
        seed := append(append([]byte{}, rhoprime...), byte(n), byte(n>>8))
        y[r] = bitUnpack(hash256(32*c, seed), int(p.Gamma1)-1, int(p.Gamma1))
    }
    return y
}
func sampleInBall(seed []byte, tau int) poly {
    var c poly
    x := sha3.NewSHAKE256(); x.Write(seed)
    s := make([]byte, 8); x.Read(s)
    var hbits uint64
    for i := 0; i < 8; i++ { hbits |= uint64(s[i]) << uint(8*i) }
    var jb [1]byte
    for i := N - tau; i < N; i++ {
        x.Read(jb[:]); j := int(jb[0])
        for j > i { x.Read(jb[:]); j = int(jb[0]) }
        c[i] = c[j]
        if (hbits>>uint(i+tau-N))&1 == 0 { c[j] = 1 } else { c[j] = Q - 1 }
    }
    return c
}

The signing loop applies, in order: ||z||∞ ≥ gamma1-beta (AbortZ); ||r0||∞ ≥ gamma2-beta (AbortR0); ||c*t0||∞ ≥ gamma2 or wt(h) > omega (AbortHint). SignStats.Trace records one character (z/r/h/s) per iteration.

3. Verification

mkdir -p testdata && cd testdata
base=https://raw.githubusercontent.com/usnistgov/ACVP-Server/master/gen-val/json-files
for d in ML-DSA-keyGen-FIPS204 ML-DSA-sigGen-FIPS204 ML-DSA-sigVer-FIPS204; do
  curl -sSLO "$base/$d/prompt.json"; curl -sSLO "$base/$d/expectedResults.json"
done
# rename to keygen-prompt.json, keygen-expected.json, siggen-*.json, sigver-*.json
cd .. && go vet ./... && go test ./... -v -count=1

Observed:

--- PASS: TestACVPKeyGen (0.04s)      keygen: 75 vectors passed
--- PASS: TestACVPSigGen (0.48s)      siggen: 360 vectors passed   (deterministic + hedged, pure + pre-hash, external + internal)
--- PASS: TestACVPSigVer (0.06s)      sigver: 180 vectors passed   (valid and forged)
--- PASS: TestZetaSpec (0.00s)
--- PASS: TestModmulMatchesDivision (0.01s)
--- PASS: TestAddSubMod (0.00s)
--- PASS: TestNTTRoundtrip (0.00s)
--- PASS: TestPackingRoundtrip (0.00s)
--- PASS: TestCtDivModExhaustive (0.03s)
--- PASS: TestDecomposeExhaustive (0.08s)
--- PASS: TestMakeUseHintRecovers (0.00s)
--- PASS: TestSingleBitTamperRejection (0.97s)
    --- PASS: ML-DSA-44 / ML-DSA-65 / ML-DSA-87
    (all bits of c~, of the omega+k hint bytes, of the message, and of the context rejected)
    tamper_test.go: deterministic vectors=180 iterations=847 aborts=667 (z=338 r0=328 hint=1)
    trace="zzzzrrzrzzrrzzzzzzrrrrs" (23 iterations)
    trace="rs" (2 iterations)
    trace="rzrzs" (5 iterations)
--- PASS: TestAbortCountersExact (0.69s)
ok      mldsa   2.366s

Why matching the reference signature proves the abort counters are exact: the signature encodes c~ and z, which depend on y = ExpandMask(rho'', kappa) at the passing iteration. Every aborted iteration advances kappa by l, so a different abort count yields a different y and different signature bytes. Byte-for-byte agreement on all 180 deterministic vectors (and 360 total) therefore pins the entire per-iteration accept/reject sequence, hence AbortZ, AbortR0, AbortHint, and the trace. Invariants Iterations == Aborts+1 and Aborts == AbortZ+AbortR0+AbortHint hold, and repeated runs produce identical signatures and traces.

Constant-time posture: modmul is division-free (Barrett via bits.Mul64); ctDivMod and both Decompose variants are exhaustively equal to integer division on [0,q) and branchless; center, modPm2k, smallVal, addq, subq, polyNormAtLeast are branchless; only the standard-mandated number of Fiat-Shamir aborts is externally observable and no secret-dependent memory indexing or division is used.

All artifacts: ~/mldsa/ (module, code, tests, testdata/) and ~/SOLUTION.md.

Evidence & signatures

# Evidence
- Problem class: go-ml-dsa-dilithium-fiat-shamir-abort-hint-decompose-sign-verify
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-26T16:45:08.729Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement ML-DSA (FIPS 204) key generation, signing and verification from scratch in Go with no external post-quantum libraries: negacyclic NTT polynomial arithmetic modulo q = 8380417, ExpandA/ExpandS rejection sampling, the Fiat-Shamir-with-aborts signing loop including Power2Round, Decompose, MakeHint/UseHint and HintBitPack, with c_tilde derived through SHAKE-256 as specified for the parameter sets ML-DSA-44/65/87. Signatures must verify against the official NIST ACVP known-answer vectors and must be rejected under single-bit tampering of c_tilde, the hint vector, or the message. The implementation must additionally report exact rejection (abort) counters per Fiat-Shamir iteration, which only match the reference when the sampling bounds and rejection conditions are bit-exact, and must remain constant-time with respect to the secret key and nonce.", "environment": "go1.26", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-ml-dsa-dilithium-fiat-shamir-abort-hint-decompose-sign-verify", "provider": "openrouter", "solved_at": "2026-09-26T16:45:08.730Z", "version": "1.26"}
Generated from the verified corpus · MIT licensedBack to the catalog