Skip to content

feat(mldsa): add mldsa#113

Open
james-d-elliott wants to merge 1 commit into
masterfrom
feat-mldsa-x
Open

feat(mldsa): add mldsa#113
james-d-elliott wants to merge 1 commit into
masterfrom
feat-mldsa-x

Conversation

@james-d-elliott
Copy link
Copy Markdown
Member

@james-d-elliott james-d-elliott commented Apr 3, 2026

Summary by CodeRabbit

  • New Features
    • Added ML-DSA signature scheme implementation supporting parameter sets ML-DSA-44, ML-DSA-65, and ML-DSA-87.
    • Includes key generation, signing (randomized and deterministic), and verification operations.
    • Supports signing with context strings and external pre-hashed message representatives.
    • Provides standard crypto.Signer interface compatibility.

@james-d-elliott james-d-elliott requested a review from a team as a code owner April 3, 2026 14:11
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 3, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a complete implementation of the FIPS 204 ML-DSA signature scheme in Go, supporting parameter sets 44, 65, and 87. It includes internal field arithmetic and polynomial operations, key generation and signing/verification algorithms, comprehensive test infrastructure with known-answer tests, FIPS 140 support stubs, and a public cryptographic API compatible with the standard crypto.Signer interface.

Changes

Cohort / File(s) Summary
Utility and helper packages
mldsa/internal/byteorder/byteorder.go, mldsa/internal/constanttime/constant_time.go, mldsa/internal/cryptotest/stubs.go
Small utility packages: little-endian byte writing, constant-time comparison/selection helpers wrapping crypto/subtle, and a test-allocation-skipping stub.
Field arithmetic and polynomial operations
mldsa/internal/fips140/mldsa/field.go, mldsa/internal/fips140/mldsa/field_test.go
Montgomery-domain prime-field arithmetic modulo q, NTT and inverse NTT transforms with precomputed twiddle factors, polynomial sampling (bounded and in-ball), FIPS 204 decomposition and rounding helpers, bit-packing/unpacking, and comprehensive field arithmetic verification tests.
ML-DSA internal implementation
mldsa/internal/fips140/mldsa/mldsa.go, mldsa/internal/fips140/mldsa/mldsa_test.go, mldsa/internal/fips140/mldsa/semiexpanded.go, mldsa/internal/fips140/mldsa/stubs.go
Core ML-DSA key generation, signing with rejection-sampling loops and external μ support, verification with coefficient bound checks and hint validation, ACVP KAT testing, semi-expanded private key support for testing, and FIPS self-test/PCT stubs.
FIPS 140 support
mldsa/internal/fips140/stubs.go, mldsa/internal/fips140test/mldsa_test.go
FIPS 140 enablement flags and approval recording; accumulated digest validation, allocation threshold testing, and distribution-stable benchmarks for key generation, signing, and verification across all parameter sets.
Public API and integration
mldsa/mldsa.go, mldsa/mldsa_test.go, mldsa/mldsacrypto/mldsamu.go
Public-facing PrivateKey and PublicKey types implementing crypto.Signer/crypto.PublicKey interfaces, parameter object singletons, deterministic and external μ signing modes, context-based domain separation, comprehensive functional and example tests, and MLDSAMu constant for pre-hashed μ message representatives.

Sequence Diagram(s)

sequenceDiagram
    actor App as Application
    participant PK as PrivateKey
    participant Signer as Signing<br/>(rejection loop)
    participant Field as Field Arithmetic<br/>(NTT, etc.)
    participant Hash as SHAKE256
    
    App->>PK: Sign(message, context)
    PK->>Hash: μ = H(tr ∥ ctx-len ∥ context ∥ msg)
    Hash-->>PK: μ
    PK->>Signer: Sign with μ
    
    loop Rejection sampling
        Signer->>Hash: y (sample via SHAKE128)
        Signer->>Field: w = A·NTT(y)
        Field-->>Signer: w₁ coefficients
        Signer->>Hash: challenge ch (SHAKE256)
        Signer->>Field: z, r₀, ct₀ (poly ops)
        Field-->>Signer: computed values
        Signer->>Signer: Check bounds & hint count
        alt Bounds/hint valid
            Signer-->>PK: signature
        else Rejection
            Signer->>Signer: Continue loop
        end
    end
    
    PK-->>App: sig bytes
Loading
sequenceDiagram
    actor App as Application
    participant PubKey as PublicKey
    participant Verifier as Verification
    participant Field as Field Arithmetic
    participant Hash as SHAKE256
    
    App->>PubKey: Verify(message, signature, context)
    PubKey->>Hash: μ = H(tr ∥ ctx-len ∥ context ∥ msg)
    Hash-->>PubKey: μ
    PubKey->>Verifier: Decode sig (ch, z, hints)
    Verifier->>Field: w = A·NTT(z) - 2^d·ch
    Field-->>Verifier: w coefficients
    Verifier->>Field: w₁ = apply_hints(w, hints)
    Field-->>Verifier: w₁ adjusted
    Verifier->>Hash: ch' = H(w₁)
    Verifier->>Verifier: Check: ch == ch'?
    
    alt Challenge matches & bounds valid
        Verifier-->>App: ✓ Valid
    else Challenge mismatch or invalid
        Verifier-->>App: ✗ Error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Poem

🐰 Field arithmetic hops through Montgomery's land,
NTT transforms dance where twiddles are planned,
With rejection sampling loops that leap and bound,
ML-DSA signatures spring without a sound!
Polynomials in NTT form, constants so fine—
A cryptographic garden, now yours to sign! 🌿

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'feat(mldsa): add mldsa' is vague and repetitive, using generic language that doesn't convey meaningful information about the changeset beyond mentioning the package name twice. Consider a more descriptive title that highlights the primary contribution, such as 'feat(mldsa): implement FIPS 204 ML-DSA signature scheme' or 'feat: add ML-DSA cryptographic signing implementation'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-mldsa-x

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (6)
mldsa/mldsacrypto/mldsamu.go (1)

8-13: Minor documentation clarification.

The comment "MLDSAMu is a function that produces..." is slightly misleading since MLDSAMu is a sentinel constant, not a function. Consider rewording to clarify it's a sentinel value that indicates pre-hashed μ input.

📝 Suggested documentation improvement
-// MLDSAMu is a function that produces a [pre-hashed μ message representative].
-// It has no implementation, but is used a [crypto.SignerOpts.HashFunc] return
-// value for [mldsa.PrivateKey.Sign].
+// MLDSAMu is a sentinel [crypto.Hash] value indicating a [pre-hashed μ message representative].
+// It has no implementation, but is used as a [crypto.SignerOpts.HashFunc] return
+// value for [mldsa.PrivateKey.Sign] to signal external pre-hashing.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mldsa/mldsacrypto/mldsamu.go` around lines 8 - 13, Update the doc comment for
the MLDSAMu constant to clarify that MLDSAMu is a sentinel constant (not a
function) used to indicate that the input to mldsa.PrivateKey.Sign is a
pre-hashed μ message representative; change the wording around "is a function
that produces" to explicitly state it is a sentinel value returned as a
crypto.SignerOpts.HashFunc to signal pre-hashed μ input and keep the existing
link to the RFC.
mldsa/internal/fips140/mldsa/semiexpanded.go (1)

152-157: Bit length calculation could differ from encoding.

The decode uses bits.Len(uint(η)*2) to calculate coefficient bit length, while semiExpandedPrivKeySize uses bits.Len(uint(p.η)) + 1. These should be equivalent (since bits.Len(x*2) == bits.Len(x) + 1 for x > 0), but the inconsistent formulation could cause confusion during maintenance.

♻️ Suggested consistency improvement
 	s1 = make([]ringElement, l)
 	for i := range l {
-		length := n * bits.Len(uint(η)*2) / 8
+		ηBitlen := bits.Len(uint(η)) + 1
+		length := n * ηBitlen / 8
 		s1[i], err = bitUnpackSlow(sk[:length], η, η)

Apply the same pattern to the s2 decoding loop for consistency with semiExpandedPrivKeySize.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mldsa/internal/fips140/mldsa/semiexpanded.go` around lines 152 - 157, The
bit-length calculation in the s1 decoding loop uses bits.Len(uint(η)*2) while
semiExpandedPrivKeySize uses bits.Len(uint(p.η)) + 1, causing inconsistency;
update the s1 (and mirror the same change in the s2 decoding loop) to compute
length using bits.Len(uint(η)) + 1 (matching semiExpandedPrivKeySize) so length
:= n * (bits.Len(uint(η)) + 1) / 8, then proceed to call
bitUnpackSlow(sk[:length], η, η) and adjust sk = sk[length:] as before.
mldsa/internal/fips140/mldsa/mldsa_test.go (1)

299-304: Consider clarifying the purpose of the two equality checks.

Line 299 uses Equal() which performs semantic equality (parameter set + constant-time seed comparison per the context snippet), while line 302 uses struct comparison *pub != *priv.PublicKey() for byte-level field equality. A brief comment explaining why both checks are valuable would improve maintainability.

📝 Suggested clarification
+			// Equal() checks semantic equality (parameters + seed)
 			if !pub.Equal(priv.PublicKey()) {
 				t.Errorf("Parsed public key not equal to original")
 			}
+			// Struct comparison ensures all internal fields (raw, tr, t1, etc.) match
 			if *pub != *priv.PublicKey() {
 				t.Errorf("Parsed public key not identical to original")
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mldsa/internal/fips140/mldsa/mldsa_test.go` around lines 299 - 304, Add a
short comment above the two assertions explaining the difference and intent:
state that pub.Equal(priv.PublicKey()) checks semantic equality (same parameter
set and constant-time seed comparison) while the struct comparison (*pub !=
*priv.PublicKey()) verifies byte-level/field-for-field identity of the parsed
public key, and that both checks are performed to catch subtle differences in
representation versus complete structural equality; place this comment near the
Equal() and *pub != *priv.PublicKey() assertions in mldsa_test.go to clarify
their purposes.
mldsa/internal/fips140/mldsa/field.go (1)

377-382: Consider reusing the byte slice to avoid allocations in hot loop.

The make([]byte, 1) inside the loop allocates on each iteration of the outer loop. Moving the allocation outside would reduce GC pressure.

♻️ Suggested optimization
+	j := make([]byte, 1)
 	for i := 256 - p.τ; i < 256; i++ {
-		j := make([]byte, 1)
 		H.Read(j)
 		for j[0] > byte(i) {
 			H.Read(j)
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mldsa/internal/fips140/mldsa/field.go` around lines 377 - 382, The loop
repeatedly allocates a 1-byte slice with make([]byte, 1) causing GC churn; move
the allocation out of the for i := 256 - p.τ; i < 256; i++ loop and reuse a
single j []byte (e.g. declare j := make([]byte, 1) before the loop) and continue
to call H.Read(j) and check j[0] inside the loop; update the code in the same
function containing that loop (the block referencing p.τ and H.Read) so the byte
slice is reused rather than allocated each iteration.
mldsa/internal/fips140/mldsa/field_test.go (1)

359-369: Variable shadows package-level constant q.

Line 361 declares q := big.NewInt(q) which shadows the package-level constant q. While this works due to Go's scoping rules (the constant q is evaluated before the local q is declared), it reduces readability and could cause confusion.

♻️ Suggested rename
 func TestZetas(t *testing.T) {
 	ζ := big.NewInt(1753)
-	q := big.NewInt(q)
+	bigQ := big.NewInt(q)
 	for k, zeta := range zetas {
 		// ζ^BitRev₈(k) mod q
-		exp := new(big.Int).Exp(ζ, big.NewInt(int64(BitRev8(uint8(k)))), q)
+		exp := new(big.Int).Exp(ζ, big.NewInt(int64(BitRev8(uint8(k)))), bigQ)
 		got := fieldFromMontgomery(zeta)
 		if big.NewInt(int64(got)).Cmp(exp) != 0 {
 			t.Errorf("zetas[%d] = %v, expected %v", k, got, exp)
 		}
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mldsa/internal/fips140/mldsa/field_test.go` around lines 359 - 369, In
TestZetas rename the local variable that shadows the package-level constant q
(the line creating q := big.NewInt(q)) to a distinct identifier (e.g., qBig or
qInt) and update subsequent uses in that test (the Exp call and comparisons) to
use the new name; ensure you reference the package-level q only by name when
needed and avoid reusing it for the big.Int variable so TestZetas,
fieldFromMontgomery, BitRev8 and the zetas loop remain correct and clear.
mldsa/internal/fips140test/mldsa_test.go (1)

98-105: Use the re-parsed public key in the accumulated loop.

Line 102 does a full PublicKey struct comparison on every iteration, but Line 105 still verifies with dk.PublicKey(). That adds a lot of extra work to the 10k/60M variants and never exercises verification against the parsed key. Compare semantically and pass pub to Verify.

Possible cleanup
-		if *pub != *dk.PublicKey() {
+		if !pub.Equal(dk.PublicKey()) {
 			t.Fatalf("public key mismatch")
 		}
-		if err := Verify(dk.PublicKey(), msg, sig, ""); err != nil {
+		if err := Verify(pub, msg, sig, ""); err != nil {
 			t.Fatalf("Verify: %v", err)
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mldsa/internal/fips140test/mldsa_test.go` around lines 98 - 105, The test
re-parses the public key into pub via newPublicKey(pk) but still calls Verify
with dk.PublicKey(), which wastes work and doesn't exercise verification against
the parsed key; change the Verify call to use the re-parsed pub (and update the
semantic comparison accordingly) so the loop both compares pub to dk.PublicKey()
semantically and passes pub into Verify (use newPublicKey, pub, dk.PublicKey(),
Verify, msg, sig to locate the spots).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mldsa/internal/fips140/mldsa/mldsa.go`:
- Around line 341-350: The Sign and SignExternalMu functions currently ignore
errors from drbg.Read causing a zeroed "random" to be used; update both Sign and
SignExternalMu to capture the error returned by drbg.Read(random[:]) and return
nil, err when non-nil instead of proceeding, so no signature is produced on RNG
failure; locate calls to drbg.Read in signInternal invocation sites (Sign and
SignExternalMu) and add the error check/early return, and consider applying the
same pattern to GenerateKey44, GenerateKey65, GenerateKey87 and the seed
initialization in semiexpanded.go where drbg.Read is used.

In `@mldsa/mldsa.go`:
- Around line 81-91: The functions GenerateKey, NewPrivateKey, and NewPublicKey
currently match on pointer identity (e.g., comparing params to
mldsa44/mldsa65/mldsa87), which rejects callers who pass a copied Parameters
value; change the canonicalization to match by value or by an explicit internal
identifier: either add an unexported ID field to Parameters (e.g., params.id)
and switch on params.id in GenerateKey/NewPrivateKey/NewPublicKey, or
dereference and switch on the struct value (e.g., switch *params) comparing
against the canonical parameter values instead of pointer addresses; update all
referenced switches (including the ones noted around lines 97-109 and 198-210)
to use the chosen approach so copied Parameters are accepted.
- Around line 127-133: PrivateKey.Equal currently compares only seed bytes which
can make keys from different ML-DSA parameter sets equal; update
PrivateKey.Equal to nil-guard both wrappers and delegate comparison to the
underlying/internal key comparison (e.g., the internal key value or method on
the wrapped key rather than Bytes()), and ensure the comparison includes the
parameter set (so keys with same seed but different parameter sets are not
equal). Locate the PrivateKey.Equal method and replace the Bytes() based compare
with a nil-check on sk and other and a call to the internal key's equality
method (or compare both the parameter-set identifier and internal key material)
to perform the equality test.
- Around line 155-160: In Sign and SignDeterministic, avoid dereferencing a
potentially typed-nil *Options returned by the type assertion; after doing
`opts, ok := opts.(*Options)` check both ok and that opts != nil before
accessing opts.Context (and similarly for any other fields), and apply the same
nil-check pattern to the other occurrence so a typed nil options value doesn't
cause a panic when reading Context or calling HashFunc.

---

Nitpick comments:
In `@mldsa/internal/fips140/mldsa/field_test.go`:
- Around line 359-369: In TestZetas rename the local variable that shadows the
package-level constant q (the line creating q := big.NewInt(q)) to a distinct
identifier (e.g., qBig or qInt) and update subsequent uses in that test (the Exp
call and comparisons) to use the new name; ensure you reference the
package-level q only by name when needed and avoid reusing it for the big.Int
variable so TestZetas, fieldFromMontgomery, BitRev8 and the zetas loop remain
correct and clear.

In `@mldsa/internal/fips140/mldsa/field.go`:
- Around line 377-382: The loop repeatedly allocates a 1-byte slice with
make([]byte, 1) causing GC churn; move the allocation out of the for i := 256 -
p.τ; i < 256; i++ loop and reuse a single j []byte (e.g. declare j :=
make([]byte, 1) before the loop) and continue to call H.Read(j) and check j[0]
inside the loop; update the code in the same function containing that loop (the
block referencing p.τ and H.Read) so the byte slice is reused rather than
allocated each iteration.

In `@mldsa/internal/fips140/mldsa/mldsa_test.go`:
- Around line 299-304: Add a short comment above the two assertions explaining
the difference and intent: state that pub.Equal(priv.PublicKey()) checks
semantic equality (same parameter set and constant-time seed comparison) while
the struct comparison (*pub != *priv.PublicKey()) verifies
byte-level/field-for-field identity of the parsed public key, and that both
checks are performed to catch subtle differences in representation versus
complete structural equality; place this comment near the Equal() and *pub !=
*priv.PublicKey() assertions in mldsa_test.go to clarify their purposes.

In `@mldsa/internal/fips140/mldsa/semiexpanded.go`:
- Around line 152-157: The bit-length calculation in the s1 decoding loop uses
bits.Len(uint(η)*2) while semiExpandedPrivKeySize uses bits.Len(uint(p.η)) + 1,
causing inconsistency; update the s1 (and mirror the same change in the s2
decoding loop) to compute length using bits.Len(uint(η)) + 1 (matching
semiExpandedPrivKeySize) so length := n * (bits.Len(uint(η)) + 1) / 8, then
proceed to call bitUnpackSlow(sk[:length], η, η) and adjust sk = sk[length:] as
before.

In `@mldsa/internal/fips140test/mldsa_test.go`:
- Around line 98-105: The test re-parses the public key into pub via
newPublicKey(pk) but still calls Verify with dk.PublicKey(), which wastes work
and doesn't exercise verification against the parsed key; change the Verify call
to use the re-parsed pub (and update the semantic comparison accordingly) so the
loop both compares pub to dk.PublicKey() semantically and passes pub into Verify
(use newPublicKey, pub, dk.PublicKey(), Verify, msg, sig to locate the spots).

In `@mldsa/mldsacrypto/mldsamu.go`:
- Around line 8-13: Update the doc comment for the MLDSAMu constant to clarify
that MLDSAMu is a sentinel constant (not a function) used to indicate that the
input to mldsa.PrivateKey.Sign is a pre-hashed μ message representative; change
the wording around "is a function that produces" to explicitly state it is a
sentinel value returned as a crypto.SignerOpts.HashFunc to signal pre-hashed μ
input and keep the existing link to the RFC.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cb50ee9f-a8f0-43a6-b03b-93712cd9a7e8

📥 Commits

Reviewing files that changed from the base of the PR and between de1510e and a24bfe1.

📒 Files selected for processing (14)
  • mldsa/internal/byteorder/byteorder.go
  • mldsa/internal/constanttime/constant_time.go
  • mldsa/internal/cryptotest/stubs.go
  • mldsa/internal/fips140/mldsa/field.go
  • mldsa/internal/fips140/mldsa/field_test.go
  • mldsa/internal/fips140/mldsa/mldsa.go
  • mldsa/internal/fips140/mldsa/mldsa_test.go
  • mldsa/internal/fips140/mldsa/semiexpanded.go
  • mldsa/internal/fips140/mldsa/stubs.go
  • mldsa/internal/fips140/stubs.go
  • mldsa/internal/fips140test/mldsa_test.go
  • mldsa/mldsa.go
  • mldsa/mldsa_test.go
  • mldsa/mldsacrypto/mldsamu.go

Comment thread mldsa/internal/fips140/mldsa/mldsa.go
Comment thread mldsa/internal/fips140/mldsa/mldsa.go
Comment thread mldsa/mldsa.go
Comment thread mldsa/mldsa.go
Comment thread mldsa/mldsa.go
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