diff --git a/cert/cert_v3.go b/cert/cert_v3.go index 38df0c2c..bd656188 100644 --- a/cert/cert_v3.go +++ b/cert/cert_v3.go @@ -21,7 +21,8 @@ import ( // it with peers that only speak v2. The v2 form keeps its own classical CA signature and fingerprint. // // Hosts carry an ML-KEM-1024 encapsulation key and CAs an ML-DSA-87 public key. The issuing CA signs every field -// with ML-DSA-87, including the classical signature, so the classical signature can not be swapped out either. +// with ML-DSA-87, including the classical signature, so the classical signature can not be swapped out either. The +// ML-DSA-87 signature is deterministic, so a certificate extended twice is the same certificate. // // Wherever a v3 certificate deals in keys they are composite: the classical key followed by the post-quantum one. // PublicKey returns classical || post-quantum, and VerifyPrivateKey and signing take the private keys the same way. @@ -508,13 +509,16 @@ func ExtendToV3(c Certificate, pqPublicKey []byte, signer Certificate, pqSign Si return c3, nil } -// SignPQ returns a SignerLambda that signs certificates with the ML-DSA-87 private key seed. +// SignPQ returns a SignerLambda that signs certificates with the ML-DSA-87 private key seed. Signatures are +// deterministic (FIPS 204 section 3.4), so signing the same certificate twice gives the same bytes and the same +// fingerprint, and extending a certificate is idempotent. The hedged variant guards signers an attacker can fault +// physically, which a CA key in a file is not; the Ed25519 signature beside it is deterministic too. func SignPQ(seed []byte) (SignerLambda, error) { sk, err := mldsa.NewPrivateKey(mldsa.MLDSA87(), seed) if err != nil { return nil, err } return func(b []byte) ([]byte, error) { - return sk.Sign(nil, b, &mldsa.Options{Context: pqSignatureContext}) + return sk.SignDeterministic(b, &mldsa.Options{Context: pqSignatureContext}) }, nil } diff --git a/cert/cert_v3_test.go b/cert/cert_v3_test.go index fb24d869..942c6756 100644 --- a/cert/cert_v3_test.go +++ b/cert/cert_v3_test.go @@ -389,6 +389,47 @@ func TestExtendToV3(t *testing.T) { } } +// ML-DSA-87 signing is deterministic, so extending a certificate twice gives the same bytes and the same fingerprint: +// pq-extend is idempotent, and a CA extended again is not a second v3 CA to the pool. +func TestExtendToV3_Deterministic(t *testing.T) { + for _, curve := range testCurves { + t.Run(curve.String(), func(t *testing.T) { + ca2, _, caKey2, _ := NewTestCaCert(Version2, curve, time.Time{}, time.Time{}, nil, nil, nil) + host2, _, _, _ := NewTestCert(Version2, curve, ca2, caKey2, "host", time.Time{}, time.Time{}, []netip.Prefix{netip.MustParsePrefix("10.1.0.1/16")}, nil, nil) + sk, err := mldsa.GenerateKey(mldsa.MLDSA87()) + require.NoError(t, err) + signPQ, err := SignPQ(sk.Bytes()) + require.NoError(t, err) + dk, err := mlkem.GenerateKey1024() + require.NoError(t, err) + + extend := func(c Certificate, pq []byte, signer Certificate) ([]byte, string, Certificate) { + c3, err := ExtendToV3(c, pq, signer, signPQ) + require.NoError(t, err) + b, err := c3.Marshal() + require.NoError(t, err) + fp, err := c3.Fingerprint() + require.NoError(t, err) + return b, fp, c3 + } + + caBytesA, caFpA, caA := extend(ca2, sk.PublicKey().Bytes(), nil) + caBytesB, caFpB, caB := extend(ca2, sk.PublicKey().Bytes(), nil) + assert.Equal(t, caBytesA, caBytesB) + assert.Equal(t, caFpA, caFpB) + + hostBytesA, hostFpA, _ := extend(host2, dk.EncapsulationKey().Bytes(), caA) + hostBytesB, hostFpB, _ := extend(host2, dk.EncapsulationKey().Bytes(), caB) + assert.Equal(t, hostBytesA, hostBytesB) + assert.Equal(t, hostFpA, hostFpB) + + pool := NewCAPool() + require.NoError(t, pool.AddCA(caA)) + require.NoError(t, pool.AddCA(caB)) + }) + } +} + func TestCertificateV3_KeyPEM(t *testing.T) { for _, curve := range testCurves { t.Run(curve.String(), func(t *testing.T) {