cert: Name the post-quantum algorithms of a version 3 certificate with a PQSuite, like its curve, and pick the hybrid subtype by the suite's KEM

This commit is contained in:
JackDoan committed 2026-10-14 14:40:52 -05:00
1 parent 84e28c62ea
commit 656164b259
29 files changed
+1124 -365

No files matched your search

+4
View File
@@ -266,6 +266,10 @@ func (ncp *CAPool) verify(c Certificate, now time.Time, certFp string, signerFp
return nil, ErrCurveMismatch
}
if PQSuiteOf(signer.Certificate) != PQSuiteOf(c) {
return nil, ErrPQSuiteMismatch
}
if signer.Certificate.Expired(now) {
return nil, ErrRootExpired
}
+86 -94
View File
@@ -20,28 +20,32 @@ import (
// the fields of the v2 certificate it extends, so a v3 holder can always marshal that v2 form, byte for byte, and use
// 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. The
// ML-DSA-87 signature is deterministic, so a certificate extended twice is the same certificate.
// The certificate's PQSuite says which post-quantum algorithms it uses, the way its curve does for the classical
// ones. Hosts carry a public key for the suite's KEM and CAs one for its signature algorithm. The issuing CA, which
// has the same suite, signs every field with that signature algorithm, including the suite and the classical
// signature, so neither can be swapped out. Signatures are 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.
const (
TagCertPQPublicKey = 4 | classContextSpecific
TagCertPQSignature = 5 | classContextSpecific
TagCertPQSuite = 4 | classContextSpecific
TagCertPQPublicKey = 5 | classContextSpecific
TagCertPQSignature = 6 | classContextSpecific
)
// Sizes of the keys and signatures of PQSuite_MLKEM1024_MLDSA87.
const (
// MLKEM1024PublicKeySize is the size of the ML-KEM-1024 encapsulation key in a v3 host certificate.
// MLKEM1024PublicKeySize is the size of an ML-KEM-1024 encapsulation key.
MLKEM1024PublicKeySize = mlkem.EncapsulationKeySize1024
// MLKEM1024PrivateKeySize is the size of the ML-KEM-1024 decapsulation key seed in a v3 host private key.
// MLKEM1024PrivateKeySize is the size of an ML-KEM-1024 decapsulation key seed.
MLKEM1024PrivateKeySize = mlkem.SeedSize
// MLDSA87PublicKeySize is the size of the ML-DSA-87 public key in a v3 CA certificate.
// MLDSA87PublicKeySize is the size of an ML-DSA-87 public key.
MLDSA87PublicKeySize = mldsa.MLDSA87PublicKeySize
// MLDSA87PrivateKeySize is the size of the ML-DSA-87 private key seed in a v3 CA private key.
// MLDSA87PrivateKeySize is the size of an ML-DSA-87 private key seed.
MLDSA87PrivateKeySize = mldsa.PrivateKeySize
// MLDSA87SignatureSize is the size of the ML-DSA-87 signature in every v3 certificate.
// MLDSA87SignatureSize is the size of an ML-DSA-87 signature.
MLDSA87SignatureSize = mldsa.MLDSA87SignatureSize
// pqSignatureContext is the ML-DSA context string for certificate signatures.
@@ -52,38 +56,16 @@ type certificateV3 struct {
// v2 is the version 2 certificate this one extends. Its public key is the classical key.
v2 certificateV2
// pqPublicKey is the ML-KEM-1024 encapsulation key of a host, or the ML-DSA-87 public key of a CA.
// pqSuite names the post-quantum algorithms, and so the kind of pqPublicKey and pqSignature.
pqSuite PQSuite
// pqPublicKey is the suite's KEM public key of a host, or its signature algorithm's public key of a CA.
pqPublicKey []byte
// pqSignature is the issuing CA's ML-DSA-87 signature over every other field.
// pqSignature is the issuing CA's post-quantum signature over every other field.
pqSignature []byte
}
// pqPublicKeySize returns the size of the post-quantum public key a v3 certificate carries.
func pqPublicKeySize(isCA bool) int {
if isCA {
return MLDSA87PublicKeySize
}
return MLKEM1024PublicKeySize
}
// pqPrivateKeySize returns the size of the post-quantum half of a v3 private key.
func pqPrivateKeySize(isCA bool) int {
if isCA {
return MLDSA87PrivateKeySize
}
return MLKEM1024PrivateKeySize
}
// splitPQPublicKey splits a composite public key into its classical and post-quantum halves.
func splitPQPublicKey(isCA bool, key []byte) ([]byte, []byte, error) {
n := pqPublicKeySize(isCA)
if len(key) <= n {
return nil, nil, ErrInvalidPublicKey
}
return key[:len(key)-n], key[len(key)-n:], nil
}
// V2Form returns the version 2 certificate a version 3 certificate extends, or nil for any other version. It is
// what a v3 holder presents to peers that do not speak v3.
func V2Form(c Certificate) Certificate {
@@ -159,24 +141,26 @@ func (c *certificateV3) Fingerprint() (string, error) {
return hex.EncodeToString(sum[:]), nil
}
// CheckSignature checks both signatures against the composite public key of the signing CA.
// CheckSignature checks both signatures against the composite public key of the signing CA. Like the classical
// signature with the curve, the post-quantum signature is checked with the algorithm of this certificate's suite,
// which the signing CA has to share.
func (c *certificateV3) CheckSignature(key []byte) bool {
classical, pq, err := splitPQPublicKey(true, key)
if err != nil || len(pq) != MLDSA87PublicKeySize {
p := pqSuites[c.pqSuite]
if p == nil {
return false
}
classical, pq, err := c.pqSuite.SplitPublicKey(true, key)
if err != nil {
return false
}
if !c.v2.CheckSignature(classical) {
return false
}
pk, err := mldsa.NewPublicKey(mldsa.MLDSA87(), pq)
if err != nil {
return false
}
b, err := c.marshalForPQSigning()
if err != nil {
return false
}
return mldsa.Verify(pk, b, c.pqSignature, &mldsa.Options{Context: pqSignatureContext}) == nil
return p.dsa.verify(pq, b, c.pqSignature)
}
func (c *certificateV3) Expired(t time.Time) bool {
@@ -185,28 +169,17 @@ func (c *certificateV3) Expired(t time.Time) bool {
// VerifyPrivateKey checks a composite private key, classical || post-quantum seed, against the composite public key.
func (c *certificateV3) VerifyPrivateKey(curve Curve, key []byte) error {
n := pqPrivateKeySize(c.IsCA())
if len(key) <= n {
return ErrInvalidPrivateKey
classical, seed, err := c.pqSuite.SplitPrivateKey(c.IsCA(), key)
if err != nil {
return err
}
if err := c.v2.VerifyPrivateKey(curve, key[:len(key)-n]); err != nil {
if err := c.v2.VerifyPrivateKey(curve, classical); err != nil {
return err
}
var pub []byte
seed := key[len(key)-n:]
if c.IsCA() {
sk, err := mldsa.NewPrivateKey(mldsa.MLDSA87(), seed)
if err != nil {
return ErrInvalidPrivateKey
}
pub = sk.PublicKey().Bytes()
} else {
dk, err := mlkem.NewDecapsulationKey1024(seed)
if err != nil {
return ErrInvalidPrivateKey
}
pub = dk.EncapsulationKey().Bytes()
pub, err := c.pqSuite.publicKey(c.IsCA(), seed)
if err != nil {
return ErrInvalidPrivateKey
}
if !bytes.Equal(pub, c.pqPublicKey) {
return ErrPublicPrivateKeyMismatch
@@ -227,7 +200,8 @@ func (c *certificateV3) String() string {
return string(b)
}
// MarshalForHandshakes leaves out both public keys, the handshake carries them as the composite static key.
// MarshalForHandshakes leaves out both public keys, the handshake carries them as the composite static key. It keeps
// the suite, which says how to split that key.
func (c *certificateV3) MarshalForHandshakes() ([]byte, error) {
return c.marshal(false, false)
}
@@ -237,7 +211,7 @@ func (c *certificateV3) Marshal() ([]byte, error) {
}
// marshalForPQSigning returns the bytes the post-quantum signature covers: every field but that signature, with the
// curve always present.
// curve always present. The suite is always present anyway.
func (c *certificateV3) marshalForPQSigning() ([]byte, error) {
return c.marshal(true, true)
}
@@ -265,6 +239,10 @@ func (c *certificateV3) marshal(withPublicKeys, forSigning bool) ([]byte, error)
b.AddBytes(c.v2.signature)
})
b.AddASN1(TagCertPQSuite, func(b *cryptobyte.Builder) {
b.AddUint8(uint8(c.pqSuite))
})
if withPublicKeys {
b.AddASN1(TagCertPQPublicKey, func(b *cryptobyte.Builder) {
b.AddBytes(c.pqPublicKey)
@@ -310,6 +288,7 @@ func (c *certificateV3) marshalJSON() (m, error) {
mb["v2Fingerprint"] = mb["fingerprint"]
mb["fingerprint"] = fp
mb["version"] = Version3
mb["pqSuite"] = c.pqSuite.String()
mb["pqPublicKey"] = fmt.Sprintf("%x", c.pqPublicKey)
mb["pqSignature"] = fmt.Sprintf("%x", c.pqSignature)
return mb, nil
@@ -318,6 +297,7 @@ func (c *certificateV3) marshalJSON() (m, error) {
func (c *certificateV3) Copy() Certificate {
nc := &certificateV3{
v2: *c.v2.Copy().(*certificateV2),
pqSuite: c.pqSuite,
pqPublicKey: make([]byte, len(c.pqPublicKey)),
pqSignature: make([]byte, len(c.pqSignature)),
}
@@ -331,20 +311,14 @@ func (c *certificateV3) validate() error {
return err
}
if len(c.pqPublicKey) != pqPublicKeySize(c.IsCA()) {
if !c.pqSuite.Valid() {
return fmt.Errorf("%w: %v", ErrUnknownPQSuite, c.pqSuite)
}
if !c.pqSuite.checkPublicKey(c.IsCA(), c.pqPublicKey) {
return ErrInvalidPublicKey
}
if c.IsCA() {
if _, err := mldsa.NewPublicKey(mldsa.MLDSA87(), c.pqPublicKey); err != nil {
return ErrInvalidPublicKey
}
} else {
if _, err := mlkem.NewEncapsulationKey1024(c.pqPublicKey); err != nil {
return ErrInvalidPublicKey
}
}
if len(c.pqSignature) != MLDSA87SignatureSize {
if len(c.pqSignature) != c.pqSuite.signatureSize() {
return ErrBadFormat
}
return nil
@@ -390,6 +364,15 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica
return nil, ErrBadFormat
}
var rawSuite cryptobyte.String
if !body.ReadASN1(&rawSuite, TagCertPQSuite) || len(rawSuite) != 1 {
return nil, ErrBadFormat
}
suite := PQSuite(rawSuite[0])
if !suite.Valid() {
return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, suite)
}
if fromHandshake {
if body.PeekASN1Tag(TagCertPQPublicKey) {
return nil, ErrCertPubkeyPresent
@@ -413,7 +396,7 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica
}
if fromHandshake {
k, p, err := splitPQPublicKey(details.isCA, publicKey)
k, p, err := suite.SplitPublicKey(details.isCA, publicKey)
if err != nil {
return nil, err
}
@@ -429,6 +412,7 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica
publicKey: classical,
signature: signature,
},
pqSuite: suite,
pqPublicKey: pq,
pqSignature: pqSignature,
}
@@ -440,17 +424,21 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica
return c, nil
}
// ExtendToV3 builds the version 3 certificate that extends the version 2 certificate c with pqPublicKey, the composite
// public key's post-quantum half. pqSign produces the ML-DSA-87 signature, see SignPQ.
// ExtendToV3 builds the version 3 certificate in suite that extends the version 2 certificate c with pqPublicKey, the
// composite public key's post-quantum half. pqSign produces the suite's post-quantum signature, see SignPQ.
//
// A CA certificate signs its own extension: signer must be nil and pqSign must use the private key for pqPublicKey.
// A host certificate is extended by its issuing CA: signer is the v3 form of that CA. In both cases c's classical
// signature, and so its fingerprint, are left unchanged, so certificates in use can be extended in place.
func ExtendToV3(c Certificate, pqPublicKey []byte, signer Certificate, pqSign SignerLambda) (Certificate, error) {
// A host certificate is extended by its issuing CA: signer is the v3 form of that CA, which must be in the same suite.
// In both cases c's classical signature, and so its fingerprint, are left unchanged, so certificates in use can be
// extended in place.
func ExtendToV3(c Certificate, suite PQSuite, pqPublicKey []byte, signer Certificate, pqSign SignerLambda) (Certificate, error) {
c2, ok := c.(*certificateV2)
if !ok {
return nil, fmt.Errorf("only version 2 certificates can be extended to version 3, got version %d", c.Version())
}
if !suite.Valid() {
return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, suite)
}
var signingKey []byte
if c2.IsCA() {
@@ -465,6 +453,9 @@ func ExtendToV3(c Certificate, pqPublicKey []byte, signer Certificate, pqSign Si
if signer == nil || signer.Version() != Version3 || !signer.IsCA() {
return nil, fmt.Errorf("a host certificate must be extended by a version 3 CA")
}
if PQSuiteOf(signer) != suite {
return nil, ErrPQSuiteMismatch
}
signerV2 := V2Form(signer)
issuer, err := signerV2.Fingerprint()
if err != nil {
@@ -481,9 +472,10 @@ func ExtendToV3(c Certificate, pqPublicKey []byte, signer Certificate, pqSign Si
c3 := &certificateV3{
v2: *c2.Copy().(*certificateV2),
pqSuite: suite,
pqPublicKey: bytes.Clone(pqPublicKey),
}
if len(c3.pqPublicKey) != pqPublicKeySize(c3.IsCA()) {
if len(c3.pqPublicKey) != suite.PublicKeySize(c3.IsCA()) {
return nil, ErrInvalidPublicKey
}
@@ -509,16 +501,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. 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
// SignPQ returns a SignerLambda that signs certificates with the private key seed of suite's signature algorithm.
// Signatures are deterministic, so signing the same certificate twice gives the same bytes and the same fingerprint,
// and extending a certificate is idempotent.
func SignPQ(suite PQSuite, seed []byte) (SignerLambda, error) {
p := pqSuites[suite]
if p == nil {
return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, suite)
}
return func(b []byte) ([]byte, error) {
return sk.SignDeterministic(b, &mldsa.Options{Context: pqSignatureContext})
}, nil
if len(seed) != p.dsa.privateKeySize {
return nil, ErrInvalidPrivateKey
}
return p.dsa.signer(seed)
}
+44 -21
View File
@@ -46,7 +46,7 @@ func TestCertificateV3_Sign(t *testing.T) {
assert.Equal(t, caV2Fp, host.Issuer())
// A v2 CA can not sign a v3 certificate, nor a v3 CA a v2 one.
_, err = (&TBSCertificate{Version: Version3, Curve: curve, Name: "x", Networks: host.Networks(), NotBefore: host.NotBefore(), NotAfter: host.NotAfter(), PublicKey: host.PublicKey()}).Sign(V2Form(ca), curve, caKey)
_, err = (&TBSCertificate{Version: Version3, Curve: curve, PQSuite: DefaultPQSuite, Name: "x", Networks: host.Networks(), NotBefore: host.NotBefore(), NotAfter: host.NotAfter(), PublicKey: host.PublicKey()}).Sign(V2Form(ca), curve, caKey)
require.Error(t, err)
})
}
@@ -174,13 +174,13 @@ func TestCertificateV3_StrictParse(t *testing.T) {
require.ErrorIs(t, err, ErrBadFormat, "trailing bytes after the envelope")
trailing := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) {
fields[6|classContextSpecific] = []byte{6 | classContextSpecific, 1, 0}
*order = append(*order, 6|classContextSpecific)
fields[7|classContextSpecific] = []byte{7 | classContextSpecific, 1, 0}
*order = append(*order, 7|classContextSpecific)
})
_, err = unmarshalCertificateV3(trailing, nil, Curve_CURVE25519)
require.ErrorIs(t, err, ErrBadFormat, "unknown field after the pq signature")
for _, tag := range []asn1.Tag{TagCertPQPublicKey, TagCertPQSignature} {
for _, tag := range []asn1.Tag{TagCertPQSuite, TagCertPQPublicKey, TagCertPQSignature} {
missing := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) {
*order = removeTag(*order, tag)
})
@@ -202,6 +202,17 @@ func TestCertificateV3_StrictParse(t *testing.T) {
})
_, err = unmarshalCertificateV3(short, nil, Curve_CURVE25519)
require.ErrorIs(t, err, ErrInvalidPublicKey)
// The suite is one known value, PQSuiteUnknown included in what is not.
for _, suite := range [][]byte{{byte(PQSuiteUnknown)}, {200}, {}, {1, 1}} {
bad := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) {
var b cryptobyte.Builder
b.AddASN1(TagCertPQSuite, func(b *cryptobyte.Builder) { b.AddBytes(suite) })
fields[TagCertPQSuite] = b.BytesOrPanic()
})
_, err = unmarshalCertificateV3(bad, nil, Curve_CURVE25519)
require.Error(t, err, "suite %x", suite)
}
}
func removeTag(order []asn1.Tag, tag asn1.Tag) []asn1.Tag {
@@ -278,9 +289,9 @@ func TestCAPool_V3(t *testing.T) {
// A second v3 extension of the same v2 CA is ambiguous.
sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
require.NoError(t, err)
signPQ, err := SignPQ(sk.Bytes())
signPQ, err := SignPQ(DefaultPQSuite, sk.Bytes())
require.NoError(t, err)
again, err := ExtendToV3(V2Form(ca), sk.PublicKey().Bytes(), nil, signPQ)
again, err := ExtendToV3(V2Form(ca), DefaultPQSuite, sk.PublicKey().Bytes(), nil, signPQ)
require.NoError(t, err)
require.ErrorContains(t, pool.AddCA(again), "two version 3 CAs extend the same version 2 CA")
})
@@ -343,17 +354,17 @@ func TestExtendToV3(t *testing.T) {
sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
require.NoError(t, err)
signPQ, err := SignPQ(sk.Bytes())
signPQ, err := SignPQ(DefaultPQSuite, sk.Bytes())
require.NoError(t, err)
ca3, err := ExtendToV3(ca2, sk.PublicKey().Bytes(), nil, signPQ)
ca3, err := ExtendToV3(ca2, DefaultPQSuite, sk.PublicKey().Bytes(), nil, signPQ)
require.NoError(t, err)
assertCertsEqual(t, ca2, V2Form(ca3))
require.NoError(t, ca3.VerifyPrivateKey(curve, append(caKey2, sk.Bytes()...)))
dk, err := mlkem.GenerateKey1024()
require.NoError(t, err)
host3, err := ExtendToV3(host2, dk.EncapsulationKey().Bytes(), ca3, signPQ)
host3, err := ExtendToV3(host2, DefaultPQSuite, dk.EncapsulationKey().Bytes(), ca3, signPQ)
require.NoError(t, err)
assertCertsEqual(t, host2, V2Form(host3))
@@ -364,26 +375,26 @@ func TestExtendToV3(t *testing.T) {
// A host must be extended by its own issuer, and with that issuer's key.
other2, _, _, _ := NewTestCaCert(Version2, curve, time.Time{}, time.Time{}, nil, nil, nil)
other3, err := ExtendToV3(other2, sk.PublicKey().Bytes(), nil, signPQ)
other3, err := ExtendToV3(other2, DefaultPQSuite, sk.PublicKey().Bytes(), nil, signPQ)
require.NoError(t, err)
_, err = ExtendToV3(host2, dk.EncapsulationKey().Bytes(), other3, signPQ)
_, err = ExtendToV3(host2, DefaultPQSuite, dk.EncapsulationKey().Bytes(), other3, signPQ)
require.Error(t, err)
wrong, err := mldsa.GenerateKey(mldsa.MLDSA87())
require.NoError(t, err)
wrongSign, err := SignPQ(wrong.Bytes())
wrongSign, err := SignPQ(DefaultPQSuite, wrong.Bytes())
require.NoError(t, err)
_, err = ExtendToV3(host2, dk.EncapsulationKey().Bytes(), ca3, wrongSign)
_, err = ExtendToV3(host2, DefaultPQSuite, dk.EncapsulationKey().Bytes(), ca3, wrongSign)
require.ErrorIs(t, err, ErrSignatureMismatch)
// A CA extends itself, and needs its own post-quantum key to.
_, err = ExtendToV3(ca2, sk.PublicKey().Bytes(), nil, wrongSign)
_, err = ExtendToV3(ca2, DefaultPQSuite, sk.PublicKey().Bytes(), nil, wrongSign)
require.ErrorIs(t, err, ErrSignatureMismatch)
_, err = ExtendToV3(ca2, sk.PublicKey().Bytes(), ca3, signPQ)
_, err = ExtendToV3(ca2, DefaultPQSuite, sk.PublicKey().Bytes(), ca3, signPQ)
require.Error(t, err)
// Only v2 certificates extend.
_, err = ExtendToV3(host3, dk.EncapsulationKey().Bytes(), ca3, signPQ)
_, err = ExtendToV3(host3, DefaultPQSuite, dk.EncapsulationKey().Bytes(), ca3, signPQ)
require.Error(t, err)
})
}
@@ -398,13 +409,13 @@ func TestExtendToV3_Deterministic(t *testing.T) {
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())
signPQ, err := SignPQ(DefaultPQSuite, 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)
c3, err := ExtendToV3(c, DefaultPQSuite, pq, signer, signPQ)
require.NoError(t, err)
b, err := c3.Marshal()
require.NoError(t, err)
@@ -446,18 +457,18 @@ func TestCertificateV3_KeyPEM(t *testing.T) {
assert.Equal(t, curve, gotCurve)
assert.Equal(t, ca.PublicKey(), pub)
key, _, gotCurve, err := UnmarshalPrivateKeyFromPEM(MarshalPrivateKeyToPEM(curve, hostKey))
key, _, gotCurve, err := UnmarshalPrivateKeyFromPEM(MarshalPQPrivateKeyToPEM(curve, DefaultPQSuite, hostKey))
require.NoError(t, err)
assert.Equal(t, curve, gotCurve)
assert.Equal(t, hostKey, key)
key, _, gotCurve, err = UnmarshalSigningPrivateKeyFromPEM(MarshalSigningPrivateKeyToPEM(curve, caKey))
key, _, gotCurve, err = UnmarshalSigningPrivateKeyFromPEM(MarshalPQSigningPrivateKeyToPEM(curve, DefaultPQSuite, caKey))
require.NoError(t, err)
assert.Equal(t, curve, gotCurve)
assert.Equal(t, caKey, key)
kdf := NewArgon2Parameters(64*1024, 4, 1)
enc, err := EncryptAndMarshalSigningPrivateKey(curve, caKey, []byte("pw"), kdf)
enc, err := EncryptAndMarshalPQSigningPrivateKey(curve, DefaultPQSuite, caKey, []byte("pw"), kdf)
require.NoError(t, err)
_, _, _, err = UnmarshalSigningPrivateKeyFromPEM(enc)
require.ErrorIs(t, err, ErrPrivateKeyEncrypted)
@@ -465,6 +476,18 @@ func TestCertificateV3_KeyPEM(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, curve, gotCurve)
assert.Equal(t, caKey, key)
// The banners name both halves.
assert.Contains(t, string(MarshalPQPrivateKeyToPEM(curve, DefaultPQSuite, hostKey)), " MLKEM1024 PRIVATE KEY-----")
assert.Contains(t, string(MarshalPQSigningPrivateKeyToPEM(curve, DefaultPQSuite, caKey)), " MLDSA87 PRIVATE KEY-----")
assert.Contains(t, string(enc), " MLDSA87 ENCRYPTED PRIVATE KEY-----")
// A key of the wrong size, or for an unknown suite, has no banner.
assert.Nil(t, MarshalPQPrivateKeyToPEM(curve, DefaultPQSuite, hostKey[1:]))
assert.Nil(t, MarshalPQPrivateKeyToPEM(curve, PQSuiteUnknown, hostKey))
assert.Nil(t, MarshalPQSigningPrivateKeyToPEM(curve, DefaultPQSuite, caKey[1:]))
_, err = EncryptAndMarshalPQSigningPrivateKey(curve, DefaultPQSuite, caKey[1:], []byte("pw"), kdf)
require.Error(t, err)
})
}
}
+39 -29
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"math"
"strings"
"golang.org/x/crypto/argon2"
"google.golang.org/protobuf/proto"
@@ -158,7 +159,33 @@ func splitNonceCiphertext(blob []byte, nonceSize int) ([]byte, []byte, error) {
// EncryptAndMarshalSigningPrivateKey is a simple helper to encrypt and PEM encode a private key
func EncryptAndMarshalSigningPrivateKey(curve Curve, b []byte, passphrase []byte, kdfParams *Argon2Parameters) ([]byte, error) {
pq := len(b) == signingPrivateKeySize(curve)+MLDSA87PrivateKeySize
switch curve {
case Curve_CURVE25519:
return encryptAndMarshalSigningPrivateKey(EncryptedEd25519PrivateKeyBanner, b, passphrase, kdfParams)
case Curve_P256:
return encryptAndMarshalSigningPrivateKey(EncryptedECDSAP256PrivateKeyBanner, b, passphrase, kdfParams)
default:
return nil, fmt.Errorf("invalid curve: %v", curve)
}
}
// EncryptAndMarshalPQSigningPrivateKey encrypts and PEM encodes a composite CA private key of suite, the classical
// signing key followed by the seed of the suite's signature algorithm.
func EncryptAndMarshalPQSigningPrivateKey(curve Curve, suite PQSuite, b []byte, passphrase []byte, kdfParams *Argon2Parameters) ([]byte, error) {
p := pqSuites[suite]
if p == nil {
return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, suite)
}
if curve != Curve_CURVE25519 && curve != Curve_P256 {
return nil, fmt.Errorf("invalid curve: %v", curve)
}
if len(b) != pqKeySize(pqSigningPrivateKey, curve, p) {
return nil, ErrInvalidPrivateKey
}
return encryptAndMarshalSigningPrivateKey(pqBannerName(pqEncryptedSigningPrivateKey, curve, p), b, passphrase, kdfParams)
}
func encryptAndMarshalSigningPrivateKey(banner string, b []byte, passphrase []byte, kdfParams *Argon2Parameters) ([]byte, error) {
ciphertext, err := aes256Encrypt(passphrase, kdfParams, b)
if err != nil {
return nil, err
@@ -181,21 +208,7 @@ func EncryptAndMarshalSigningPrivateKey(curve Curve, b []byte, passphrase []byte
return nil, err
}
switch {
case pq && curve == Curve_CURVE25519:
return pem.EncodeToMemory(&pem.Block{Type: EncryptedEd25519MLDSA87PrivateKeyBanner, Bytes: b}), nil
case pq && curve == Curve_P256:
return pem.EncodeToMemory(&pem.Block{Type: EncryptedECDSAP256MLDSA87PrivateKeyBanner, Bytes: b}), nil
}
switch curve {
case Curve_CURVE25519:
return pem.EncodeToMemory(&pem.Block{Type: EncryptedEd25519PrivateKeyBanner, Bytes: b}), nil
case Curve_P256:
return pem.EncodeToMemory(&pem.Block{Type: EncryptedECDSAP256PrivateKeyBanner, Bytes: b}), nil
default:
return nil, fmt.Errorf("invalid curve: %v", curve)
}
return pem.EncodeToMemory(&pem.Block{Type: banner, Bytes: b}), nil
}
// UnmarshalNebulaEncryptedData will unmarshal a protobuf byte representation of a nebula cert into its
@@ -268,18 +281,15 @@ func DecryptAndUnmarshalSigningPrivateKey(passphrase, b []byte) (Curve, []byte,
return curve, nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
}
pq := false
switch k.Type {
case EncryptedEd25519PrivateKeyBanner:
pq, pqKey := pqKeyBanners[k.Type]
pqKey = pqKey && pq.kind == pqEncryptedSigningPrivateKey
switch {
case pqKey:
curve = pq.curve
case k.Type == EncryptedEd25519PrivateKeyBanner:
curve = Curve_CURVE25519
case EncryptedECDSAP256PrivateKeyBanner:
case k.Type == EncryptedECDSAP256PrivateKeyBanner:
curve = Curve_P256
case EncryptedEd25519MLDSA87PrivateKeyBanner:
curve = Curve_CURVE25519
pq = true
case EncryptedECDSAP256MLDSA87PrivateKeyBanner:
curve = Curve_P256
pq = true
default:
return curve, nil, r, fmt.Errorf("bytes did not contain a proper nebula encrypted Ed25519/ECDSA private key banner")
}
@@ -300,9 +310,9 @@ func DecryptAndUnmarshalSigningPrivateKey(passphrase, b []byte) (Curve, []byte,
return curve, nil, r, fmt.Errorf("unsupported encryption algorithm: %s", ned.EncryptionMetadata.EncryptionAlgorithm)
}
if pq {
if len(bytes) != signingPrivateKeySize(curve)+MLDSA87PrivateKeySize {
return curve, nil, r, fmt.Errorf("key was not %d bytes, is invalid %s ML-DSA-87 private key", signingPrivateKeySize(curve)+MLDSA87PrivateKeySize, curve)
if pqKey {
if len(bytes) != pq.size {
return curve, nil, r, fmt.Errorf("key was not %d bytes, is invalid %s", pq.size, strings.ToLower(strings.TrimPrefix(k.Type, "NEBULA ")))
}
return curve, bytes, r, nil
}
+2
View File
@@ -23,6 +23,8 @@ var (
ErrUnknownVersion = errors.New("certificate version unrecognized")
ErrCertPubkeyPresent = errors.New("certificate has unexpected pubkey present")
ErrCurveMismatch = errors.New("certificate curve does not match CA")
ErrPQSuiteMismatch = errors.New("certificate post-quantum suite does not match CA")
ErrUnknownPQSuite = errors.New("unknown post-quantum suite")
ErrInvalidPEMBlock = errors.New("input did not contain a valid PEM encoded block")
ErrInvalidPEMCertificateBanner = errors.New("bytes did not contain a proper certificate banner")
+15 -10
View File
@@ -4,8 +4,6 @@ import (
"crypto/ecdh"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/mldsa"
"crypto/mlkem"
"crypto/rand"
"io"
"net/netip"
@@ -64,6 +62,9 @@ func NewTestCaCert(version Version, curve Curve, before, after time.Time, networ
Groups: groups,
IsCA: true,
}
if version == Version3 {
t.PQSuite = DefaultPQSuite
}
c, err := t.Sign(nil, curve, priv)
if err != nil {
@@ -104,7 +105,7 @@ func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string
}
if v == Version3 {
pub, priv = appendPQKey(pub, priv)
pub, priv = appendPQKey(ca, pub, priv)
}
nc := &TBSCertificate{
@@ -118,6 +119,7 @@ func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string
NotAfter: time.Unix(after.Unix(), 0),
PublicKey: pub,
IsCA: false,
PQSuite: PQSuiteOf(ca),
}
c, err := nc.Sign(ca, ca.Curve(), key)
@@ -130,25 +132,28 @@ func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string
panic(err)
}
if v == Version3 {
return c, pub, MarshalPQPrivateKeyToPEM(curve, PQSuiteOf(ca), priv), pem
}
return c, pub, MarshalPrivateKeyToPEM(curve, priv), pem
}
// appendPQSigningKey makes a classical CA keypair into a version 3 composite one with a fresh ML-DSA-87 key.
// appendPQSigningKey makes a classical CA keypair into a version 3 composite one with a fresh key of the default suite.
func appendPQSigningKey(pub, priv []byte) ([]byte, []byte) {
sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
pqPub, pqPriv, err := DefaultPQSuite.GenerateKey(true)
if err != nil {
panic(err)
}
return append(pub, sk.PublicKey().Bytes()...), append(priv, sk.Bytes()...)
return append(pub, pqPub...), append(priv, pqPriv...)
}
// appendPQKey makes a classical host keypair into a version 3 composite one with a fresh ML-KEM-1024 key.
func appendPQKey(pub, priv []byte) ([]byte, []byte) {
dk, err := mlkem.GenerateKey1024()
// appendPQKey makes a classical host keypair into a version 3 composite one with a fresh key of the CA's suite.
func appendPQKey(ca Certificate, pub, priv []byte) ([]byte, []byte) {
pqPub, pqPriv, err := PQSuiteOf(ca).GenerateKey(false)
if err != nil {
panic(err)
}
return append(pub, dk.EncapsulationKey().Bytes()...), append(priv, dk.Bytes()...)
return append(pub, pqPub...), append(priv, pqPriv...)
}
func X25519Keypair() ([]byte, []byte) {
+149 -81
View File
@@ -5,6 +5,8 @@ import (
"encoding/pem"
"errors"
"fmt"
"slices"
"strings"
"golang.org/x/crypto/ed25519"
)
@@ -74,23 +76,11 @@ const ( //key-agreement-key banners
P256PublicKeyBanner = "NEBULA P256 PUBLIC KEY"
)
// Version 3 keys are composite: the classical key followed by the post-quantum key, an ML-KEM-1024 seed or
// encapsulation key for hosts and an ML-DSA-87 seed or public key for CAs.
const ( //post-quantum key-agreement-key banners
X25519MLKEM1024PrivateKeyBanner = "NEBULA X25519 MLKEM1024 PRIVATE KEY"
X25519MLKEM1024PublicKeyBanner = "NEBULA X25519 MLKEM1024 PUBLIC KEY"
P256MLKEM1024PrivateKeyBanner = "NEBULA P256 MLKEM1024 PRIVATE KEY"
P256MLKEM1024PublicKeyBanner = "NEBULA P256 MLKEM1024 PUBLIC KEY"
)
const ( //post-quantum signing key banners
EncryptedECDSAP256MLDSA87PrivateKeyBanner = "NEBULA ECDSA P256 MLDSA87 ENCRYPTED PRIVATE KEY"
ECDSAP256MLDSA87PrivateKeyBanner = "NEBULA ECDSA P256 MLDSA87 PRIVATE KEY"
ECDSAP256MLDSA87PublicKeyBanner = "NEBULA ECDSA P256 MLDSA87 PUBLIC KEY"
EncryptedEd25519MLDSA87PrivateKeyBanner = "NEBULA ED25519 MLDSA87 ENCRYPTED PRIVATE KEY"
Ed25519MLDSA87PrivateKeyBanner = "NEBULA ED25519 MLDSA87 PRIVATE KEY"
Ed25519MLDSA87PublicKeyBanner = "NEBULA ED25519 MLDSA87 PUBLIC KEY"
)
// Version 3 keys are composite: the classical key followed by the post-quantum key of the certificate's PQSuite, a
// KEM seed or public key for hosts and a signature algorithm's seed or public key for CAs. Their banners name both
// halves, "NEBULA X25519 MLKEM1024 PRIVATE KEY" or "NEBULA ED25519 MLDSA87 PUBLIC KEY" for example, because the
// post-quantum algorithm can not be told from the key's size: ML-KEM and ML-DSA seeds are the same size at every
// strength. The banners name algorithms, not suites, so suites sharing an algorithm share its banners too.
// Classical key sizes, the composite keys of version 3 add the post-quantum key to these.
const (
@@ -102,12 +92,91 @@ const (
ecdsaP256PublicKeySize = 65 // uncompressed
)
// signingPrivateKeySize returns the size of a classical signing private key.
func signingPrivateKeySize(curve Curve) int {
if curve == Curve_P256 {
return p256PrivateKeySize
// ClassicalPrivateKey returns the classical half of a host private key: all of a classical key, or the classical half
// of a composite one, whatever its suite.
func ClassicalPrivateKey(key []byte) []byte {
// X25519 and P256 private keys are the same size.
if len(key) > x25519KeySize {
return key[:x25519KeySize]
}
return ed25519PrivateKeySize
return key
}
// pqKeyKind is what a composite key banner holds.
type pqKeyKind int
const (
pqPrivateKey pqKeyKind = iota
pqPublicKey
pqSigningPrivateKey
pqSigningPublicKey
pqEncryptedSigningPrivateKey
)
// pqKeyBanner is what a composite key banner says about the key under it.
type pqKeyBanner struct {
kind pqKeyKind
curve Curve
size int // of the whole composite key; for an encrypted key, once decrypted
}
var pqKeyKinds = []pqKeyKind{pqPrivateKey, pqPublicKey, pqSigningPrivateKey, pqSigningPublicKey, pqEncryptedSigningPrivateKey}
// pqKeyBanners maps every composite key banner of every suite to what it holds, see registerPQSuite.
var pqKeyBanners = map[string]pqKeyBanner{}
// pqBannerName returns the banner for a composite key.
func pqBannerName(kind pqKeyKind, curve Curve, suite *pqSuite) string {
classical := map[Curve]string{Curve_CURVE25519: "X25519", Curve_P256: "P256"}[curve]
alg := suite.kem.name
if kind != pqPrivateKey && kind != pqPublicKey {
classical = map[Curve]string{Curve_CURVE25519: "ED25519", Curve_P256: "ECDSA P256"}[curve]
alg = suite.dsa.name
}
return "NEBULA " + classical + " " + alg + " " + map[pqKeyKind]string{
pqPrivateKey: "PRIVATE KEY",
pqPublicKey: "PUBLIC KEY",
pqSigningPrivateKey: "PRIVATE KEY",
pqSigningPublicKey: "PUBLIC KEY",
pqEncryptedSigningPrivateKey: "ENCRYPTED PRIVATE KEY",
}[kind]
}
// pqKeySize returns the size of a composite key.
func pqKeySize(kind pqKeyKind, curve Curve, suite *pqSuite) int {
p256 := curve == Curve_P256
switch kind {
case pqPrivateKey:
return x25519KeySize + suite.kem.privateKeySize // a P256 private key is the same size
case pqPublicKey:
return map[bool]int{false: x25519KeySize, true: p256PublicKeySize}[p256] + suite.kem.publicKeySize
case pqSigningPublicKey:
return map[bool]int{false: ed25519PublicKeySize, true: ecdsaP256PublicKeySize}[p256] + suite.dsa.publicKeySize
default:
return map[bool]int{false: ed25519PrivateKeySize, true: p256PrivateKeySize}[p256] + suite.dsa.privateKeySize
}
}
// marshalPQKeyToPEM PEM encodes a composite key of suite, nil for an unknown curve or suite or a key of the wrong size.
func marshalPQKeyToPEM(kind pqKeyKind, curve Curve, suite PQSuite, b []byte) []byte {
p := pqSuites[suite]
if p == nil || (curve != Curve_CURVE25519 && curve != Curve_P256) || len(b) != pqKeySize(kind, curve, p) {
return nil
}
return pem.EncodeToMemory(&pem.Block{Type: pqBannerName(kind, curve, p), Bytes: b})
}
// unmarshalPQKeyBlock checks a PEM block that may hold a composite key of one of kinds. ok is false when the banner
// is not a composite key banner of those kinds at all.
func unmarshalPQKeyBlock(k *pem.Block, kinds ...pqKeyKind) (curve Curve, ok bool, err error) {
b, found := pqKeyBanners[k.Type]
if !found || !slices.Contains(kinds, b.kind) {
return 0, false, nil
}
if b.kind != pqEncryptedSigningPrivateKey && len(k.Bytes) != b.size {
return b.curve, true, fmt.Errorf("key was not %d bytes, is invalid %s", b.size, strings.ToLower(strings.TrimPrefix(k.Type, "NEBULA ")))
}
return b.curve, true, nil
}
/* including "ECDSA" in the P256 banners is a clue that these keys should be used only for signing */
@@ -154,6 +223,12 @@ func unmarshalCertificateBlock(block *pem.Block) (Certificate, error) {
}
func marshalCertPublicKeyToPEM(c Certificate) []byte {
if suite := PQSuiteOf(c); suite != PQSuiteUnknown {
if c.IsCA() {
return MarshalPQSigningPublicKeyToPEM(c.Curve(), suite, c.PublicKey())
}
return MarshalPQPublicKeyToPEM(c.Curve(), suite, c.PublicKey())
}
if c.IsCA() {
return MarshalSigningPublicKeyToPEM(c.Curve(), c.PublicKey())
} else {
@@ -164,13 +239,6 @@ func marshalCertPublicKeyToPEM(c Certificate) []byte {
// MarshalPublicKeyToPEM returns a PEM representation of a public key used for ECDH.
// if your public key came from a certificate, prefer Certificate.PublicKeyPEM() if possible, to avoid mistakes!
func MarshalPublicKeyToPEM(curve Curve, b []byte) []byte {
pq := len(b) > MLKEM1024PublicKeySize
switch {
case pq && curve == Curve_CURVE25519:
return pem.EncodeToMemory(&pem.Block{Type: X25519MLKEM1024PublicKeyBanner, Bytes: b})
case pq && curve == Curve_P256:
return pem.EncodeToMemory(&pem.Block{Type: P256MLKEM1024PublicKeyBanner, Bytes: b})
}
switch curve {
case Curve_CURVE25519:
return pem.EncodeToMemory(&pem.Block{Type: X25519PublicKeyBanner, Bytes: b})
@@ -181,16 +249,15 @@ func MarshalPublicKeyToPEM(curve Curve, b []byte) []byte {
}
}
// MarshalPQPublicKeyToPEM returns a PEM representation of a composite host public key of suite, the classical ECDH
// key followed by the suite's KEM public key, or nil if b is not one.
func MarshalPQPublicKeyToPEM(curve Curve, suite PQSuite, b []byte) []byte {
return marshalPQKeyToPEM(pqPublicKey, curve, suite, b)
}
// MarshalSigningPublicKeyToPEM returns a PEM representation of a public key used for signing.
// if your public key came from a certificate, prefer Certificate.PublicKeyPEM() if possible, to avoid mistakes!
func MarshalSigningPublicKeyToPEM(curve Curve, b []byte) []byte {
pq := len(b) > MLDSA87PublicKeySize
switch {
case pq && curve == Curve_CURVE25519:
return pem.EncodeToMemory(&pem.Block{Type: Ed25519MLDSA87PublicKeyBanner, Bytes: b})
case pq && curve == Curve_P256:
return pem.EncodeToMemory(&pem.Block{Type: ECDSAP256MLDSA87PublicKeyBanner, Bytes: b})
}
switch curve {
case Curve_CURVE25519:
return pem.EncodeToMemory(&pem.Block{Type: Ed25519PublicKeyBanner, Bytes: b})
@@ -201,6 +268,12 @@ func MarshalSigningPublicKeyToPEM(curve Curve, b []byte) []byte {
}
}
// MarshalPQSigningPublicKeyToPEM returns a PEM representation of a composite CA public key of suite, the classical
// signing key followed by the public key of the suite's signature algorithm, or nil if b is not one.
func MarshalPQSigningPublicKeyToPEM(curve Curve, suite PQSuite, b []byte) []byte {
return marshalPQKeyToPEM(pqSigningPublicKey, curve, suite, b)
}
// UnmarshalPublicKeyFromPEM will try to unmarshal the first pem block in a byte array, returning any non
// consumed data or an error on failure. Only key-agreement (ECDH) public key banners are accepted.
// Use UnmarshalSigningPublicKeyFromPEM for Ed25519/ECDSA banners.
@@ -209,6 +282,12 @@ func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
if k == nil {
return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block")
}
if curve, ok, err := unmarshalPQKeyBlock(k, pqPublicKey); ok {
if err != nil {
return nil, r, 0, err
}
return k.Bytes, r, curve, nil
}
var expectedLen int
var curve Curve
switch k.Type {
@@ -219,12 +298,6 @@ func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
// Uncompressed
expectedLen = 65
curve = Curve_P256
case X25519MLKEM1024PublicKeyBanner:
expectedLen = x25519KeySize + MLKEM1024PublicKeySize
curve = Curve_CURVE25519
case P256MLKEM1024PublicKeyBanner:
expectedLen = p256PublicKeySize + MLKEM1024PublicKeySize
curve = Curve_P256
default:
return nil, r, 0, fmt.Errorf("bytes did not contain a proper public key banner")
}
@@ -242,6 +315,12 @@ func UnmarshalSigningPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
if k == nil {
return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block")
}
if curve, ok, err := unmarshalPQKeyBlock(k, pqSigningPublicKey); ok {
if err != nil {
return nil, r, 0, err
}
return k.Bytes, r, curve, nil
}
var expectedLen int
var curve Curve
switch k.Type {
@@ -252,12 +331,6 @@ func UnmarshalSigningPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
// Uncompressed
expectedLen = 65
curve = Curve_P256
case Ed25519MLDSA87PublicKeyBanner:
expectedLen = ed25519PublicKeySize + MLDSA87PublicKeySize
curve = Curve_CURVE25519
case ECDSAP256MLDSA87PublicKeyBanner:
expectedLen = ecdsaP256PublicKeySize + MLDSA87PublicKeySize
curve = Curve_P256
default:
return nil, r, 0, fmt.Errorf("bytes did not contain a proper Ed25519/ECDSA public key banner")
}
@@ -268,13 +341,6 @@ func UnmarshalSigningPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
}
func MarshalPrivateKeyToPEM(curve Curve, b []byte) []byte {
pq := len(b) > MLKEM1024PrivateKeySize
switch {
case pq && curve == Curve_CURVE25519:
return pem.EncodeToMemory(&pem.Block{Type: X25519MLKEM1024PrivateKeyBanner, Bytes: b})
case pq && curve == Curve_P256:
return pem.EncodeToMemory(&pem.Block{Type: P256MLKEM1024PrivateKeyBanner, Bytes: b})
}
switch curve {
case Curve_CURVE25519:
return pem.EncodeToMemory(&pem.Block{Type: X25519PrivateKeyBanner, Bytes: b})
@@ -285,14 +351,19 @@ func MarshalPrivateKeyToPEM(curve Curve, b []byte) []byte {
}
}
// MarshalPQPrivateKeyToPEM returns a PEM representation of a composite host private key of suite, the classical ECDH
// key followed by the seed of the suite's KEM, or nil if b is not one.
func MarshalPQPrivateKeyToPEM(curve Curve, suite PQSuite, b []byte) []byte {
return marshalPQKeyToPEM(pqPrivateKey, curve, suite, b)
}
// MarshalPQSigningPrivateKeyToPEM returns a PEM representation of a composite CA private key of suite, the classical
// signing key followed by the seed of the suite's signature algorithm, or nil if b is not one.
func MarshalPQSigningPrivateKeyToPEM(curve Curve, suite PQSuite, b []byte) []byte {
return marshalPQKeyToPEM(pqSigningPrivateKey, curve, suite, b)
}
func MarshalSigningPrivateKeyToPEM(curve Curve, b []byte) []byte {
pq := len(b) == signingPrivateKeySize(curve)+MLDSA87PrivateKeySize
switch {
case pq && curve == Curve_CURVE25519:
return pem.EncodeToMemory(&pem.Block{Type: Ed25519MLDSA87PrivateKeyBanner, Bytes: b})
case pq && curve == Curve_P256:
return pem.EncodeToMemory(&pem.Block{Type: ECDSAP256MLDSA87PrivateKeyBanner, Bytes: b})
}
switch curve {
case Curve_CURVE25519:
return pem.EncodeToMemory(&pem.Block{Type: Ed25519PrivateKeyBanner, Bytes: b})
@@ -310,6 +381,12 @@ func UnmarshalPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
if k == nil {
return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block")
}
if curve, ok, err := unmarshalPQKeyBlock(k, pqPrivateKey); ok {
if err != nil {
return nil, r, 0, err
}
return k.Bytes, r, curve, nil
}
var expectedLen int
var curve Curve
switch k.Type {
@@ -319,12 +396,6 @@ func UnmarshalPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
case P256PrivateKeyBanner:
expectedLen = 32
curve = Curve_P256
case X25519MLKEM1024PrivateKeyBanner:
expectedLen = x25519KeySize + MLKEM1024PrivateKeySize
curve = Curve_CURVE25519
case P256MLKEM1024PrivateKeyBanner:
expectedLen = p256PrivateKeySize + MLKEM1024PrivateKeySize
curve = Curve_P256
default:
return nil, r, 0, fmt.Errorf("bytes did not contain a proper private key banner")
}
@@ -339,24 +410,21 @@ func UnmarshalSigningPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error)
if k == nil {
return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block")
}
if curve, ok, err := unmarshalPQKeyBlock(k, pqSigningPrivateKey, pqEncryptedSigningPrivateKey); ok {
switch {
case err != nil:
return nil, r, 0, err
case pqKeyBanners[k.Type].kind == pqEncryptedSigningPrivateKey:
return nil, nil, curve, ErrPrivateKeyEncrypted
}
return k.Bytes, r, curve, nil
}
var curve Curve
switch k.Type {
case EncryptedEd25519PrivateKeyBanner:
return nil, nil, Curve_CURVE25519, ErrPrivateKeyEncrypted
case EncryptedECDSAP256PrivateKeyBanner, EncryptedECDSAP256MLDSA87PrivateKeyBanner:
case EncryptedECDSAP256PrivateKeyBanner:
return nil, nil, Curve_P256, ErrPrivateKeyEncrypted
case EncryptedEd25519MLDSA87PrivateKeyBanner:
return nil, nil, Curve_CURVE25519, ErrPrivateKeyEncrypted
case Ed25519MLDSA87PrivateKeyBanner:
curve = Curve_CURVE25519
if len(k.Bytes) != ed25519PrivateKeySize+MLDSA87PrivateKeySize {
return nil, r, 0, fmt.Errorf("key was not %d bytes, is invalid Ed25519 ML-DSA-87 private key", ed25519PrivateKeySize+MLDSA87PrivateKeySize)
}
case ECDSAP256MLDSA87PrivateKeyBanner:
curve = Curve_P256
if len(k.Bytes) != p256PrivateKeySize+MLDSA87PrivateKeySize {
return nil, r, 0, fmt.Errorf("key was not %d bytes, is invalid ECDSA P256 ML-DSA-87 private key", p256PrivateKeySize+MLDSA87PrivateKeySize)
}
case Ed25519PrivateKeyBanner:
curve = Curve_CURVE25519
if len(k.Bytes) != ed25519.PrivateKeySize {
+268
View File
@@ -0,0 +1,268 @@
package cert
import (
"crypto/mldsa"
"crypto/mlkem"
"fmt"
"slices"
"strings"
)
// PQSuite names the post-quantum algorithms of a version 3 certificate, the way Curve names its classical ones: the
// key encapsulation mechanism of a host's key, and the signature algorithm of a CA's key and of the certificates it
// signs. A CA and every certificate it signs share a suite. Every version 3 certificate names its suite.
type PQSuite uint8
const (
// PQSuiteUnknown is never valid in a certificate.
PQSuiteUnknown PQSuite = 0
// PQSuite_MLKEM1024_MLDSA87 gives hosts ML-KEM-1024 keys and CAs ML-DSA-87 keys and signatures.
PQSuite_MLKEM1024_MLDSA87 PQSuite = 1
)
// DefaultPQSuite is the suite tools use when none is asked for.
const DefaultPQSuite = PQSuite_MLKEM1024_MLDSA87
// pqKEM is the key encapsulation mechanism of a suite, what host keys use. Private keys are seeds.
type pqKEM struct {
name string // as it appears in key banners
publicKeySize int
privateKeySize int
generate func() (pub, priv []byte, err error)
publicKey func(priv []byte) ([]byte, error)
checkPublicKey func(pub []byte) bool
}
// pqDSA is the signature algorithm of a suite, what CA keys and certificate signatures use. Private keys are seeds.
type pqDSA struct {
name string // as it appears in key banners
publicKeySize int
privateKeySize int
signatureSize int
generate func() (pub, priv []byte, err error)
publicKey func(priv []byte) ([]byte, error)
checkPublicKey func(pub []byte) bool
signer func(priv []byte) (SignerLambda, error) // must sign deterministically
verify func(pub, msg, sig []byte) bool
}
type pqSuite struct {
kem pqKEM
dsa pqDSA
}
// pqSuites holds every known suite, see registerPQSuite.
var pqSuites = map[PQSuite]*pqSuite{}
func init() {
registerPQSuite(PQSuite_MLKEM1024_MLDSA87, &pqSuite{kem: mlkem1024, dsa: mldsa87})
}
// registerPQSuite makes a suite known, along with the banners of its composite keys.
func registerPQSuite(id PQSuite, s *pqSuite) {
pqSuites[id] = s
for _, curve := range []Curve{Curve_CURVE25519, Curve_P256} {
for _, kind := range pqKeyKinds {
pqKeyBanners[pqBannerName(kind, curve, s)] = pqKeyBanner{kind: kind, curve: curve, size: pqKeySize(kind, curve, s)}
}
}
}
var mlkem1024 = pqKEM{
name: "MLKEM1024",
publicKeySize: mlkem.EncapsulationKeySize1024,
privateKeySize: mlkem.SeedSize,
generate: func() ([]byte, []byte, error) {
dk, err := mlkem.GenerateKey1024()
if err != nil {
return nil, nil, err
}
return dk.EncapsulationKey().Bytes(), dk.Bytes(), nil
},
publicKey: func(priv []byte) ([]byte, error) {
dk, err := mlkem.NewDecapsulationKey1024(priv)
if err != nil {
return nil, err
}
return dk.EncapsulationKey().Bytes(), nil
},
checkPublicKey: func(pub []byte) bool {
_, err := mlkem.NewEncapsulationKey1024(pub)
return err == nil
},
}
var mldsa87 = pqDSA{
name: "MLDSA87",
publicKeySize: mldsa.MLDSA87PublicKeySize,
privateKeySize: mldsa.PrivateKeySize,
signatureSize: mldsa.MLDSA87SignatureSize,
generate: func() ([]byte, []byte, error) {
sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
if err != nil {
return nil, nil, err
}
return sk.PublicKey().Bytes(), sk.Bytes(), nil
},
publicKey: func(priv []byte) ([]byte, error) {
sk, err := mldsa.NewPrivateKey(mldsa.MLDSA87(), priv)
if err != nil {
return nil, err
}
return sk.PublicKey().Bytes(), nil
},
checkPublicKey: func(pub []byte) bool {
_, err := mldsa.NewPublicKey(mldsa.MLDSA87(), pub)
return err == nil
},
// Deterministic signing (FIPS 204 section 3.4): the hedged variant guards signers an attacker can fault
// physically, which a CA key in a file is not, and the Ed25519 signature beside it is deterministic too.
signer: func(priv []byte) (SignerLambda, error) {
sk, err := mldsa.NewPrivateKey(mldsa.MLDSA87(), priv)
if err != nil {
return nil, err
}
return func(b []byte) ([]byte, error) {
return sk.SignDeterministic(b, &mldsa.Options{Context: pqSignatureContext})
}, nil
},
verify: func(pub, msg, sig []byte) bool {
pk, err := mldsa.NewPublicKey(mldsa.MLDSA87(), pub)
if err != nil {
return false
}
return mldsa.Verify(pk, msg, sig, &mldsa.Options{Context: pqSignatureContext}) == nil
},
}
// PQSuites returns every known suite.
func PQSuites() []PQSuite {
s := make([]PQSuite, 0, len(pqSuites))
for k := range pqSuites {
s = append(s, k)
}
slices.Sort(s)
return s
}
// ParsePQSuite returns the suite named by s, as String names it, ignoring case.
func ParsePQSuite(s string) (PQSuite, error) {
for _, k := range PQSuites() {
if strings.EqualFold(s, k.String()) {
return k, nil
}
}
return PQSuiteUnknown, fmt.Errorf("%w: %q", ErrUnknownPQSuite, s)
}
// PQSuiteOf returns the suite of a version 3 certificate, or PQSuiteUnknown for any other version.
func PQSuiteOf(c Certificate) PQSuite {
if c3, ok := c.(*certificateV3); ok {
return c3.pqSuite
}
return PQSuiteUnknown
}
func (s PQSuite) String() string {
if p := pqSuites[s]; p != nil {
return p.kem.name + "_" + p.dsa.name
}
return fmt.Sprintf("PQSuite(%d)", uint8(s))
}
// Valid reports whether s is a known suite.
func (s PQSuite) Valid() bool {
return pqSuites[s] != nil
}
// PublicKeySize returns the size of the post-quantum half of a composite public key, a CA's or a host's.
func (s PQSuite) PublicKeySize(isCA bool) int {
p := pqSuites[s]
switch {
case p == nil:
return 0
case isCA:
return p.dsa.publicKeySize
default:
return p.kem.publicKeySize
}
}
// PrivateKeySize returns the size of the post-quantum half of a composite private key, a CA's or a host's.
func (s PQSuite) PrivateKeySize(isCA bool) int {
p := pqSuites[s]
switch {
case p == nil:
return 0
case isCA:
return p.dsa.privateKeySize
default:
return p.kem.privateKeySize
}
}
// GenerateKey makes a post-quantum keypair, a CA's signing key or a host's KEM key, to append to a classical one.
func (s PQSuite) GenerateKey(isCA bool) (pub, priv []byte, err error) {
p := pqSuites[s]
switch {
case p == nil:
return nil, nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, s)
case isCA:
return p.dsa.generate()
default:
return p.kem.generate()
}
}
// publicKey derives the post-quantum public key from the post-quantum half of a private key.
func (s PQSuite) publicKey(isCA bool, priv []byte) ([]byte, error) {
p := pqSuites[s]
switch {
case p == nil:
return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, s)
case isCA:
return p.dsa.publicKey(priv)
default:
return p.kem.publicKey(priv)
}
}
// checkPublicKey reports whether pub is a well formed post-quantum public key, a CA's or a host's.
func (s PQSuite) checkPublicKey(isCA bool, pub []byte) bool {
p := pqSuites[s]
switch {
case p == nil, len(pub) != s.PublicKeySize(isCA):
return false
case isCA:
return p.dsa.checkPublicKey(pub)
default:
return p.kem.checkPublicKey(pub)
}
}
// signatureSize returns the size of the post-quantum signature of a certificate in this suite.
func (s PQSuite) signatureSize() int {
if p := pqSuites[s]; p != nil {
return p.dsa.signatureSize
}
return 0
}
// SplitPublicKey splits a composite public key into its classical and post-quantum halves.
func (s PQSuite) SplitPublicKey(isCA bool, key []byte) (classical, pq []byte, err error) {
n := s.PublicKeySize(isCA)
if n == 0 || len(key) <= n {
return nil, nil, ErrInvalidPublicKey
}
return key[:len(key)-n], key[len(key)-n:], nil
}
// SplitPrivateKey splits a composite private key into its classical and post-quantum halves.
func (s PQSuite) SplitPrivateKey(isCA bool, key []byte) (classical, pq []byte, err error) {
n := s.PrivateKeySize(isCA)
if n == 0 || len(key) <= n {
return nil, nil, ErrInvalidPrivateKey
}
return key[:len(key)-n], key[len(key)-n:], nil
}
+169
View File
@@ -0,0 +1,169 @@
package cert
import (
"net/netip"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testPQSuite stands in for a second suite. It reuses ML-KEM-1024 and ML-DSA-87 under other names, so it is
// interchangeable with the default suite in everything but its identity, which is what has to keep them apart.
const testPQSuite PQSuite = 200
func registerTestPQSuite(t *testing.T) {
t.Helper()
kem, dsa := mlkem1024, mldsa87
kem.name, dsa.name = "TESTKEM", "TESTDSA"
registerPQSuite(testPQSuite, &pqSuite{kem: kem, dsa: dsa})
t.Cleanup(func() {
delete(pqSuites, testPQSuite)
for k := range pqKeyBanners {
if strings.Contains(k, "TESTKEM") || strings.Contains(k, "TESTDSA") {
delete(pqKeyBanners, k)
}
}
})
}
// newTestV3InSuite issues a v3 CA and host certificate in suite.
func newTestV3InSuite(t *testing.T, curve Curve, suite PQSuite) (ca Certificate, caKey []byte, host Certificate, hostKey []byte) {
t.Helper()
ca2, _, caKey2, _ := NewTestCaCert(Version2, curve, time.Time{}, time.Time{}, nil, nil, nil)
pqPub, pqPriv, err := suite.GenerateKey(true)
require.NoError(t, err)
signPQ, err := SignPQ(suite, pqPriv)
require.NoError(t, err)
ca, err = ExtendToV3(ca2, suite, pqPub, nil, signPQ)
require.NoError(t, err)
caKey = append(caKey2, pqPriv...)
var pub, priv []byte
if curve == Curve_P256 {
pub, priv = P256Keypair()
} else {
pub, priv = X25519Keypair()
}
hostPQPub, hostPQPriv, err := suite.GenerateKey(false)
require.NoError(t, err)
tbs := &TBSCertificate{
Version: Version3,
Curve: curve,
PQSuite: suite,
Name: "host",
Networks: []netip.Prefix{netip.MustParsePrefix("10.1.0.1/16")},
NotBefore: ca.NotBefore(),
NotAfter: ca.NotAfter(),
PublicKey: append(pub, hostPQPub...),
}
host, err = tbs.Sign(ca, curve, caKey)
require.NoError(t, err)
return ca, caKey, host, append(priv, hostPQPriv...)
}
func TestPQSuite_Names(t *testing.T) {
assert.Equal(t, "MLKEM1024_MLDSA87", PQSuite_MLKEM1024_MLDSA87.String())
assert.Equal(t, []PQSuite{PQSuite_MLKEM1024_MLDSA87}, PQSuites())
assert.False(t, PQSuiteUnknown.Valid())
s, err := ParsePQSuite("mlkem1024_mldsa87")
require.NoError(t, err)
assert.Equal(t, PQSuite_MLKEM1024_MLDSA87, s)
_, err = ParsePQSuite("mlkem768_mldsa65")
require.ErrorIs(t, err, ErrUnknownPQSuite)
_, _, host, _ := newTestV3(t, Curve_CURVE25519)
assert.Equal(t, DefaultPQSuite, PQSuiteOf(host))
assert.Equal(t, PQSuiteUnknown, PQSuiteOf(V2Form(host)))
}
// A second suite works end to end, and is never confused with the first, even with keys of the same sizes.
func TestPQSuite_Second(t *testing.T) {
registerTestPQSuite(t)
for _, curve := range testCurves {
t.Run(curve.String(), func(t *testing.T) {
ca, caKey, host, hostKey := newTestV3InSuite(t, curve, testPQSuite)
assert.Equal(t, testPQSuite, PQSuiteOf(ca))
assert.Equal(t, testPQSuite, PQSuiteOf(host))
require.NoError(t, ca.VerifyPrivateKey(curve, caKey))
require.NoError(t, host.VerifyPrivateKey(curve, hostKey))
pool := NewCAPool()
require.NoError(t, pool.AddCA(ca))
_, err := pool.VerifyCertificate(time.Now(), host)
require.NoError(t, err)
// The suite travels in the certificate, handshakes included.
hs, err := host.MarshalForHandshakes()
require.NoError(t, err)
c, err := Recombine(Version3, hs, host.PublicKey(), curve)
require.NoError(t, err)
assert.Equal(t, testPQSuite, PQSuiteOf(c))
assertCertsEqual(t, host, c)
// Keys get their algorithms' banners.
b := MarshalPQPrivateKeyToPEM(curve, testPQSuite, hostKey)
assert.Contains(t, string(b), " TESTKEM PRIVATE KEY-----")
key, _, _, err := UnmarshalPrivateKeyFromPEM(b)
require.NoError(t, err)
assert.Equal(t, hostKey, key)
assert.Contains(t, string(host.MarshalPublicKeyPEM()), " TESTKEM PUBLIC KEY-----")
assert.Contains(t, string(ca.MarshalPublicKeyPEM()), " TESTDSA PUBLIC KEY-----")
// The suite is signed: relabelling a certificate breaks it, though the keys fit either suite.
_, _, other, _ := newTestV3(t, curve)
relabelled := other.Copy().(*certificateV3)
relabelled.pqSuite = testPQSuite
assert.False(t, relabelled.CheckSignature(ca.PublicKey()))
})
}
}
// A CA and the certificates it signs share a suite, like they share a curve.
func TestPQSuite_Mismatch(t *testing.T) {
registerTestPQSuite(t)
for _, curve := range testCurves {
t.Run(curve.String(), func(t *testing.T) {
ca, caKey, host, _ := newTestV3(t, curve)
// Signing refuses a certificate in another suite than its CA.
tbs := &TBSCertificate{
Version: Version3, Curve: curve, PQSuite: testPQSuite, Name: "x",
Networks: host.Networks(), NotBefore: host.NotBefore(), NotAfter: host.NotAfter(), PublicKey: host.PublicKey(),
}
_, err := tbs.Sign(ca, curve, caKey)
require.ErrorIs(t, err, ErrPQSuiteMismatch)
// Extending refuses it too.
v2CA := V2Form(ca)
v2Host := V2Form(host)
_, pqHost, err := DefaultPQSuite.SplitPublicKey(false, host.PublicKey())
require.NoError(t, err)
signPQ, err := SignPQ(DefaultPQSuite, caKey[len(caKey)-DefaultPQSuite.PrivateKeySize(true):])
require.NoError(t, err)
_, err = ExtendToV3(v2Host, testPQSuite, pqHost, ca, signPQ)
require.ErrorIs(t, err, ErrPQSuiteMismatch)
// The pool rejects a certificate whose CA, found by issuer, is in another suite: here the same v2 CA
// extended once per suite, with only the default suite's extension trusted.
pqPub, pqPriv, err := testPQSuite.GenerateKey(true)
require.NoError(t, err)
signOther, err := SignPQ(testPQSuite, pqPriv)
require.NoError(t, err)
otherCA, err := ExtendToV3(v2CA, testPQSuite, pqPub, nil, signOther)
require.NoError(t, err)
otherHost, err := ExtendToV3(v2Host, testPQSuite, pqHost, otherCA, signOther)
require.NoError(t, err)
pool := NewCAPool()
require.NoError(t, pool.AddCA(ca))
_, err = pool.VerifyCertificate(time.Now(), otherHost)
require.ErrorIs(t, err, ErrPQSuiteMismatch)
})
}
}
+20 -11
View File
@@ -26,6 +26,7 @@ type TBSCertificate struct {
NotAfter time.Time
PublicKey []byte
Curve Curve
PQSuite PQSuite // version 3 only, the suite of the certificate and of its signing CA
issuer string
}
@@ -48,15 +49,16 @@ type SignerLambda func(certBytes []byte) ([]byte, error)
// If the TBSCertificate is a CA then signer must be nil.
func (t *TBSCertificate) Sign(signer Certificate, curve Curve, key []byte) (Certificate, error) {
if t.Version == Version3 {
// The signing key is a composite CA key, classical || ML-DSA-87 seed.
if len(key) <= MLDSA87PrivateKeySize {
return nil, ErrInvalidPrivateKey
}
classical, err := classicalSigner(t.Curve, key[:len(key)-MLDSA87PrivateKeySize])
// The signing key is a composite CA key, classical || the seed of the suite's signature algorithm.
classicalKey, pqKey, err := t.PQSuite.SplitPrivateKey(true, key)
if err != nil {
return nil, err
}
pq, err := SignPQ(key[len(key)-MLDSA87PrivateKeySize:])
classical, err := classicalSigner(t.Curve, classicalKey)
if err != nil {
return nil, err
}
pq, err := SignPQ(t.PQSuite, pqKey)
if err != nil {
return nil, err
}
@@ -98,14 +100,18 @@ func classicalSigner(curve Curve, key []byte) (SignerLambda, error) {
}
}
// SignHybridWith signs a version 3 certificate, using sp for the classical signature and pqSp for the ML-DSA-87
// signature. t.PublicKey is the composite public key. signer must be a version 3 CA, or nil for a self signed CA.
// The v2 form is signed exactly as SignWith would sign it, and so is the v2 form of signer.
// SignHybridWith signs a version 3 certificate, using sp for the classical signature and pqSp for the post-quantum
// signature of t.PQSuite. t.PublicKey is the composite public key. signer must be a version 3 CA in the same suite,
// or nil for a self signed CA. The v2 form is signed exactly as SignWith would sign it, and so is the v2 form of
// signer.
func (t *TBSCertificate) SignHybridWith(signer Certificate, curve Curve, sp, pqSp SignerLambda) (Certificate, error) {
if t.Version != Version3 {
return nil, fmt.Errorf("SignHybridWith only signs version 3 certificates")
}
classical, pq, err := splitPQPublicKey(t.IsCA, t.PublicKey)
if !t.PQSuite.Valid() {
return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, t.PQSuite)
}
classical, pq, err := t.PQSuite.SplitPublicKey(t.IsCA, t.PublicKey)
if err != nil {
return nil, err
}
@@ -115,6 +121,9 @@ func (t *TBSCertificate) SignHybridWith(signer Certificate, curve Curve, sp, pqS
if signer.Version() != Version3 {
return nil, fmt.Errorf("a version 3 certificate must be signed by a version 3 CA")
}
if PQSuiteOf(signer) != t.PQSuite {
return nil, ErrPQSuiteMismatch
}
signerV2 = V2Form(signer)
}
@@ -125,7 +134,7 @@ func (t *TBSCertificate) SignHybridWith(signer Certificate, curve Curve, sp, pqS
if err != nil {
return nil, err
}
return ExtendToV3(c2, pq, signer, pqSp)
return ExtendToV3(c2, t.PQSuite, pq, signer, pqSp)
}
// SignWith does the same thing as sign, but uses the function in `sp` to calculate the signature.