Просмотр исходного кода

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

JackDoan 6 дней назад
Родитель
Сommit
656164b259

+ 6 - 4
CHANGELOG.md

@@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 
 - Version 3 certificates and post-quantum hybrid handshakes (`ix_hybrid_mlkem1024`). A version 3 certificate is a
-  version 2 certificate extended with an ML-KEM-1024 key (hosts) or an ML-DSA-87 key (CAs) and a deterministic
-  ML-DSA-87 CA signature, and a node holding one still presents the version 2 certificate it extends to peers that
-  need it. `nebula-cert pq-extend` upgrades existing CAs and hosts in place, and extending a certificate twice gives
-  the same certificate. See `docs/pq-handshake.md`.
+  version 2 certificate extended with a post-quantum key and a deterministic post-quantum CA signature, and a node
+  holding one still presents the version 2 certificate it extends to peers that need it. Its post-quantum suite names
+  the algorithms, as its curve does the classical ones; the one suite today is `MLKEM1024_MLDSA87`, an ML-KEM-1024
+  key for hosts and ML-DSA-87 for CAs and their signatures, chosen with `nebula-cert -pq-suite`. `nebula-cert
+  pq-extend` upgrades existing CAs and hosts in place, and extending a certificate twice gives the same certificate.
+  See `docs/pq-handshake.md`.
 
 ### Changed
 

+ 4 - 0
cert/ca_pool.go

@@ -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
 	}

+ 85 - 93
cert/cert_v3.go

@@ -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
 	}
-	if !c.v2.CheckSignature(classical) {
+	classical, pq, err := c.pqSuite.SplitPublicKey(true, key)
+	if err != nil {
 		return false
 	}
-	pk, err := mldsa.NewPublicKey(mldsa.MLDSA87(), pq)
-	if err != nil {
+	if !c.v2.CheckSignature(classical) {
 		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()) {
-		return ErrInvalidPublicKey
+	if !c.pqSuite.Valid() {
+		return fmt.Errorf("%w: %v", ErrUnknownPQSuite, c.pqSuite)
 	}
-	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 !c.pqSuite.checkPublicKey(c.IsCA(), c.pqPublicKey) {
+		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)
+	}
+	if len(seed) != p.dsa.privateKeySize {
+		return nil, ErrInvalidPrivateKey
 	}
-	return func(b []byte) ([]byte, error) {
-		return sk.SignDeterministic(b, &mldsa.Options{Context: pqSignatureContext})
-	}, nil
+	return p.dsa.signer(seed)
 }

+ 44 - 21
cert/cert_v3_test.go

@@ -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
cert/crypto.go

@@ -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:
-		curve = Curve_CURVE25519
-	case EncryptedECDSAP256PrivateKeyBanner:
-		curve = Curve_P256
-	case EncryptedEd25519MLDSA87PrivateKeyBanner:
+	pq, pqKey := pqKeyBanners[k.Type]
+	pqKey = pqKey && pq.kind == pqEncryptedSigningPrivateKey
+	switch {
+	case pqKey:
+		curve = pq.curve
+	case k.Type == EncryptedEd25519PrivateKeyBanner:
 		curve = Curve_CURVE25519
-		pq = true
-	case EncryptedECDSAP256MLDSA87PrivateKeyBanner:
+	case k.Type == EncryptedECDSAP256PrivateKeyBanner:
 		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 - 0
cert/errors.go

@@ -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
cert/helper_test.go

@@ -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
cert/pem.go

@@ -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 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 ed25519PrivateKeySize
+	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 - 0
cert/pq_suite.go

@@ -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 - 0
cert/pq_suite_test.go

@@ -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)
+		})
+	}
+}

+ 19 - 10
cert/sign.go

@@ -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
+		// 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
 		}
-		classical, err := classicalSigner(t.Curve, key[:len(key)-MLDSA87PrivateKeySize])
+		classical, err := classicalSigner(t.Curve, classicalKey)
 		if err != nil {
 			return nil, err
 		}
-		pq, err := SignPQ(key[len(key)-MLDSA87PrivateKeySize:])
+		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.

+ 16 - 10
cert_test/cert.go

@@ -4,8 +4,6 @@ import (
 	"crypto/ecdh"
 	"crypto/ecdsa"
 	"crypto/elliptic"
-	"crypto/mldsa"
-	"crypto/mlkem"
 	"crypto/rand"
 	"io"
 	"net/netip"
@@ -65,6 +63,9 @@ func NewTestCaCert(version cert.Version, curve cert.Curve, before, after time.Ti
 		Groups:         groups,
 		IsCA:           true,
 	}
+	if version == cert.Version3 {
+		t.PQSuite = cert.DefaultPQSuite
+	}
 
 	c, err := t.Sign(nil, curve, priv)
 	if err != nil {
@@ -101,7 +102,7 @@ func NewTestCert(v cert.Version, curve cert.Curve, ca cert.Certificate, key []by
 	}
 
 	if v == cert.Version3 {
-		pub, priv = appendPQKey(pub, priv)
+		pub, priv = appendPQKey(ca, pub, priv)
 	}
 
 	nc := &cert.TBSCertificate{
@@ -115,6 +116,7 @@ func NewTestCert(v cert.Version, curve cert.Curve, ca cert.Certificate, key []by
 		NotAfter:       time.Unix(after.Unix(), 0),
 		PublicKey:      pub,
 		IsCA:           false,
+		PQSuite:        cert.PQSuiteOf(ca),
 	}
 
 	c, err := nc.Sign(ca, ca.Curve(), key)
@@ -127,6 +129,9 @@ func NewTestCert(v cert.Version, curve cert.Curve, ca cert.Certificate, key []by
 		panic(err)
 	}
 
+	if v == cert.Version3 {
+		return c, pub, cert.MarshalPQPrivateKeyToPEM(curve, cert.PQSuiteOf(ca), priv), pem
+	}
 	return c, pub, cert.MarshalPrivateKeyToPEM(curve, priv), pem
 }
 
@@ -142,6 +147,7 @@ func NewTestCertDifferentVersion(c cert.Certificate, v cert.Version, ca cert.Cer
 		NotAfter:       time.Unix(c.NotAfter().Unix(), 0),
 		PublicKey:      c.PublicKey(),
 		IsCA:           false,
+		PQSuite:        cert.PQSuiteOf(ca),
 	}
 
 	c, err := nc.Sign(ca, ca.Curve(), key)
@@ -157,22 +163,22 @@ func NewTestCertDifferentVersion(c cert.Certificate, v cert.Version, ca cert.Cer
 	return c, 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 := cert.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 cert.Certificate, pub, priv []byte) ([]byte, []byte) {
+	pqPub, pqPriv, err := cert.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) {

+ 22 - 8
cmd/nebula-cert/ca.go

@@ -37,6 +37,7 @@ type caFlags struct {
 	argonParallelism *uint
 	encryption       *bool
 	version          *uint
+	pqSuite          *string
 
 	curve  *string
 	p11url *string
@@ -71,7 +72,8 @@ func newCaFlags() *caFlags {
 	cf := caFlags{set: flag.NewFlagSet("ca", flag.ContinueOnError)}
 	cf.set.Usage = func() {}
 	cf.name = cf.set.String("name", "", "Required: name of the certificate authority")
-	cf.version = cf.set.Uint("version", uint(cert.Version2), "Optional: version of the certificate format to use. Version 3 adds an ML-DSA-87 key, for post-quantum certificates")
+	cf.version = cf.set.Uint("version", uint(cert.Version2), "Optional: version of the certificate format to use. Version 3 adds a post-quantum key, for post-quantum certificates")
+	cf.pqSuite = pqSuiteFlag(cf.set, "Optional: post-quantum suite of a version 3 CA, which the certificates it signs share")
 	cf.duration = cf.set.Duration("duration", time.Duration(time.Hour*8760), "Optional: amount of time the certificate should be valid for. Valid time units are seconds: \"s\", minutes: \"m\", hours: \"h\"")
 	cf.outKeyPath = cf.set.String("out-key", "ca.key", "Optional: path to write the private key to")
 	cf.outCertPath = cf.set.String("out-crt", "ca.crt", "Optional: path to write the certificate to")
@@ -164,7 +166,11 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
 		return newHelpErrorf("-version must be %v, %v or %v", cert.Version1, cert.Version2, cert.Version3)
 	}
 	if version == cert.Version3 && isP11 {
-		return newHelpErrorf("-version 3 can not be used with -pkcs11, the ML-DSA-87 key has to be in out-key")
+		return newHelpErrorf("-version 3 can not be used with -pkcs11, the post-quantum key has to be in out-key")
+	}
+	suite, err := parsePQSuiteFlag(cf.set, *cf.pqSuite, version == cert.Version3, "is only for -version 3")
+	if err != nil {
+		return err
 	}
 
 	var networks []netip.Prefix
@@ -300,7 +306,7 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
 		}
 
 		if version == cert.Version3 {
-			pub, rawPriv, err = appendPQSigningKey(pub, rawPriv)
+			pub, rawPriv, err = appendPQSigningKey(suite, pub, rawPriv)
 			if err != nil {
 				return err
 			}
@@ -319,6 +325,9 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
 		IsCA:           true,
 		Curve:          curve,
 	}
+	if version == cert.Version3 {
+		t.PQSuite = suite
+	}
 
 	if !isP11 && !isStdio(*cf.outKeyPath) {
 		if _, err := os.Stat(*cf.outKeyPath); err == nil {
@@ -346,14 +355,19 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
 			return fmt.Errorf("error while signing: %s", err)
 		}
 
-		if *cf.encryption {
+		switch {
+		case *cf.encryption && version == cert.Version3:
+			b, err = cert.EncryptAndMarshalPQSigningPrivateKey(curve, suite, rawPriv, passphrase, kdfParams)
+		case *cf.encryption:
 			b, err = cert.EncryptAndMarshalSigningPrivateKey(curve, rawPriv, passphrase, kdfParams)
-			if err != nil {
-				return fmt.Errorf("error while encrypting out-key: %s", err)
-			}
-		} else {
+		case version == cert.Version3:
+			b = cert.MarshalPQSigningPrivateKeyToPEM(curve, suite, rawPriv)
+		default:
 			b = cert.MarshalSigningPrivateKeyToPEM(curve, rawPriv)
 		}
+		if err != nil {
+			return fmt.Errorf("error while encrypting out-key: %s", err)
+		}
 
 		err = writeOutput(*cf.outKeyPath, b, 0600, out)
 		if err != nil {

+ 3 - 1
cmd/nebula-cert/ca_test.go

@@ -68,12 +68,14 @@ func Test_caHelp(t *testing.T) {
 			"  -out-qr string\n"+
 			"    \tOptional: output a qr code image (png) of the certificate\n"+
 			optionalPkcs11String("  -pkcs11 string\n    \tOptional: PKCS#11 URI to an existing private key\n")+
+			"  -pq-suite string\n"+
+			"    \tOptional: post-quantum suite of a version 3 CA, which the certificates it signs share (MLKEM1024_MLDSA87) (default \"MLKEM1024_MLDSA87\")\n"+
 			"  -subnets string\n"+
 			"    \tDeprecated, see -unsafe-networks\n"+
 			"  -unsafe-networks string\n"+
 			"    \tOptional: comma separated list of ip address and network in CIDR notation. This will limit which ip addresses and networks subordinate certs can use in unsafe networks\n"+
 			"  -version uint\n"+
-			"    \tOptional: version of the certificate format to use. Version 3 adds an ML-DSA-87 key, for post-quantum certificates (default 2)\n",
+			"    \tOptional: version of the certificate format to use. Version 3 adds a post-quantum key, for post-quantum certificates (default 2)\n",
 		ob.String(),
 	)
 }

+ 20 - 6
cmd/nebula-cert/keygen.go

@@ -20,6 +20,7 @@ type keygenFlags struct {
 	inKeyPath  *string
 	curve      *string
 	version    *uint
+	pqSuite    *string
 	p11url     *string
 }
 
@@ -29,8 +30,9 @@ func newKeygenFlags() *keygenFlags {
 	cf.outPubPath = cf.set.String("out-pub", "", "Required: path to write the public key to")
 	cf.outKeyPath = cf.set.String("out-key", "", "Required: path to write the private key to")
 	cf.curve = cf.set.String("curve", defaultCurve(), "ECDH Curve (25519, P256)")
-	cf.version = cf.set.Uint("version", 0, "Optional: 3 adds an ML-KEM-1024 key, for version 3 certificates")
-	cf.inKeyPath = cf.set.String("in-key", "", "Optional: path to an existing private key to add an ML-KEM-1024 key to, keeping the existing key. Implies -version 3")
+	cf.version = cf.set.Uint("version", 0, "Optional: 3 adds a post-quantum key, for version 3 certificates")
+	cf.inKeyPath = cf.set.String("in-key", "", "Optional: path to an existing private key to add a post-quantum key to, keeping the existing key. Implies -version 3")
+	cf.pqSuite = pqSuiteFlag(cf.set, "Optional: post-quantum suite of a version 3 key, which must be the suite of the CA that signs it")
 	cf.p11url = p11Flag(cf.set)
 	return &cf
 }
@@ -63,7 +65,11 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
 		return newHelpErrorf("-version must be %v, or left out for keys that work with any other version", cert.Version3)
 	}
 	if version == cert.Version3 && isP11 {
-		return newHelpErrorf("-version 3 can not be used with -pkcs11, the ML-KEM-1024 key has to be in out-key")
+		return newHelpErrorf("-version 3 can not be used with -pkcs11, the post-quantum key has to be in out-key")
+	}
+	suite, err := parsePQSuiteFlag(cf.set, *cf.pqSuite, version == cert.Version3, "is only for -version 3")
+	if err != nil {
+		return err
 	}
 
 	var claims ioClaims
@@ -113,7 +119,7 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
 	}
 
 	if version == cert.Version3 {
-		pub, rawPriv, err = appendPQKey(pub, rawPriv)
+		pub, rawPriv, err = appendPQKey(suite, pub, rawPriv)
 		if err != nil {
 			return err
 		}
@@ -139,12 +145,20 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
 			return fmt.Errorf("error while getting public key: %w", err)
 		}
 	} else {
-		err = writeOutput(*cf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600, out)
+		b := cert.MarshalPrivateKeyToPEM(curve, rawPriv)
+		if version == cert.Version3 {
+			b = cert.MarshalPQPrivateKeyToPEM(curve, suite, rawPriv)
+		}
+		err = writeOutput(*cf.outKeyPath, b, 0600, out)
 		if err != nil {
 			return fmt.Errorf("error while writing out-key: %s", err)
 		}
 	}
-	err = writeOutput(*cf.outPubPath, cert.MarshalPublicKeyToPEM(curve, pub), 0600, out)
+	b := cert.MarshalPublicKeyToPEM(curve, pub)
+	if version == cert.Version3 {
+		b = cert.MarshalPQPublicKeyToPEM(curve, suite, pub)
+	}
+	err = writeOutput(*cf.outPubPath, b, 0600, out)
 	if err != nil {
 		return fmt.Errorf("error while writing out-pub: %s", err)
 	}

+ 4 - 2
cmd/nebula-cert/keygen_test.go

@@ -24,14 +24,16 @@ func Test_keygenHelp(t *testing.T) {
 			"  -curve string\n"+
 			"    \tECDH Curve (25519, P256) (default \"25519\")\n"+
 			"  -in-key string\n"+
-			"    \tOptional: path to an existing private key to add an ML-KEM-1024 key to, keeping the existing key. Implies -version 3\n"+
+			"    \tOptional: path to an existing private key to add a post-quantum key to, keeping the existing key. Implies -version 3\n"+
 			"  -out-key string\n"+
 			"    \tRequired: path to write the private key to\n"+
 			"  -out-pub string\n"+
 			"    \tRequired: path to write the public key to\n"+
 			optionalPkcs11String("  -pkcs11 string\n    \tOptional: PKCS#11 URI to an existing private key\n")+
+			"  -pq-suite string\n"+
+			"    \tOptional: post-quantum suite of a version 3 key, which must be the suite of the CA that signs it (MLKEM1024_MLDSA87) (default \"MLKEM1024_MLDSA87\")\n"+
 			"  -version uint\n"+
-			"    \tOptional: 3 adds an ML-KEM-1024 key, for version 3 certificates\n",
+			"    \tOptional: 3 adds a post-quantum key, for version 3 certificates\n",
 		ob.String(),
 	)
 }

+ 66 - 27
cmd/nebula-cert/pq.go

@@ -3,33 +3,60 @@ package main
 import (
 	"bytes"
 	"crypto/ecdh"
-	"crypto/mldsa"
-	"crypto/mlkem"
 	"encoding/pem"
 	"flag"
 	"fmt"
 	"io"
 	"os"
+	"strings"
 
 	"github.com/slackhq/nebula/cert"
 )
 
-// appendPQSigningKey makes a classical CA keypair into a version 3 composite one with a fresh ML-DSA-87 key.
-func appendPQSigningKey(pub, priv []byte) ([]byte, []byte, error) {
-	sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
+// pqSuiteFlag adds -pq-suite to a flag set.
+func pqSuiteFlag(set *flag.FlagSet, usage string) *string {
+	var names []string
+	for _, s := range cert.PQSuites() {
+		names = append(names, s.String())
+	}
+	return set.String("pq-suite", cert.DefaultPQSuite.String(), usage+" ("+strings.Join(names, ", ")+")")
+}
+
+// parsePQSuiteFlag returns the suite -pq-suite names. Setting it is an error unless allowed, when the suite has no use
+// or comes from elsewhere.
+func parsePQSuiteFlag(set *flag.FlagSet, value string, allowed bool, why string) (cert.PQSuite, error) {
+	given := false
+	set.Visit(func(f *flag.Flag) {
+		if f.Name == "pq-suite" {
+			given = true
+		}
+	})
+	if given && !allowed {
+		return cert.PQSuiteUnknown, newHelpErrorf("-pq-suite %s", why)
+	}
+	s, err := cert.ParsePQSuite(value)
+	if err != nil {
+		return cert.PQSuiteUnknown, newHelpErrorf("-pq-suite: %s", err)
+	}
+	return s, nil
+}
+
+// appendPQSigningKey makes a classical CA keypair into a version 3 composite one with a fresh key of suite.
+func appendPQSigningKey(suite cert.PQSuite, pub, priv []byte) ([]byte, []byte, error) {
+	pqPub, pqPriv, err := suite.GenerateKey(true)
 	if err != nil {
-		return nil, nil, fmt.Errorf("error while generating ML-DSA-87 key: %s", err)
+		return nil, nil, fmt.Errorf("error while generating %v CA key: %s", suite, err)
 	}
-	return append(pub, sk.PublicKey().Bytes()...), append(priv, sk.Bytes()...), nil
+	return append(pub, pqPub...), append(priv, pqPriv...), nil
 }
 
-// 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, error) {
-	dk, err := mlkem.GenerateKey1024()
+// appendPQKey makes a classical host keypair into a version 3 composite one with a fresh key of suite.
+func appendPQKey(suite cert.PQSuite, pub, priv []byte) ([]byte, []byte, error) {
+	pqPub, pqPriv, err := suite.GenerateKey(false)
 	if err != nil {
-		return nil, nil, fmt.Errorf("error while generating ML-KEM-1024 key: %s", err)
+		return nil, nil, fmt.Errorf("error while generating %v key: %s", suite, err)
 	}
-	return append(pub, dk.EncapsulationKey().Bytes()...), append(priv, dk.Bytes()...), nil
+	return append(pub, pqPub...), append(priv, pqPriv...), nil
 }
 
 // publicKeyFor returns the public key of a classical host private key.
@@ -45,7 +72,7 @@ func publicKeyFor(curve cert.Curve, priv []byte) ([]byte, error) {
 	}
 	k, err := c.NewPrivateKey(priv)
 	if err != nil {
-		return nil, fmt.Errorf("not a classical %s private key, it may already have an ML-KEM-1024 key", curve)
+		return nil, fmt.Errorf("not a classical %s private key, it may already have a post-quantum key", curve)
 	}
 	return k.PublicKey().Bytes(), nil
 }
@@ -58,6 +85,7 @@ type pqExtendFlags struct {
 	inPubPath   *string
 	outCertPath *string
 	outKeyPath  *string
+	pqSuite     *string
 }
 
 func newPQExtendFlags() *pqExtendFlags {
@@ -69,6 +97,7 @@ func newPQExtendFlags() *pqExtendFlags {
 	pf.caCertPath = pf.set.String("ca-crt", "", "Required for a host in-crt: path to the version 3 form of the CA that issued it")
 	pf.inPubPath = pf.set.String("in-pub", "", "Required for a host in-crt: path to the host's public key from keygen -in-key")
 	pf.outKeyPath = pf.set.String("out-key", "", "Required for a CA in-crt: path to write the CA's version 3 key to, encrypted again if ca-key was")
+	pf.pqSuite = pqSuiteFlag(pf.set, "Optional, for a CA in-crt: post-quantum suite of the version 3 CA. A host takes its CA's suite")
 	return &pf
 }
 
@@ -134,9 +163,14 @@ func pqExtend(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
 		return err
 	}
 
+	suite, err := parsePQSuiteFlag(pf.set, *pf.pqSuite, c.IsCA(), "is for CA certificates, a host takes its CA's suite")
+	if err != nil {
+		return err
+	}
+
 	var c3 cert.Certificate
 	if c.IsCA() {
-		c3, err = extendCA(pf, c, curve, caKey, rawCAKey, passphrase, out)
+		c3, err = extendCA(pf, c, suite, curve, caKey, rawCAKey, passphrase, out)
 	} else {
 		c3, err = extendHost(pf, c, curve, caKey, &claims)
 	}
@@ -154,8 +188,8 @@ func pqExtend(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
 	return nil
 }
 
-// extendCA gives a v2 CA an ML-DSA-87 key and writes out the composite key.
-func extendCA(pf *pqExtendFlags, ca cert.Certificate, curve cert.Curve, key, rawKey, passphrase []byte, out io.Writer) (cert.Certificate, error) {
+// extendCA gives a v2 CA a post-quantum key of suite and writes out the composite key.
+func extendCA(pf *pqExtendFlags, ca cert.Certificate, suite cert.PQSuite, curve cert.Curve, key, rawKey, passphrase []byte, out io.Writer) (cert.Certificate, error) {
 	if *pf.caCertPath != "" || *pf.inPubPath != "" {
 		return nil, newHelpErrorf("-ca-crt and -in-pub are for host certificates, a CA extends itself")
 	}
@@ -166,15 +200,15 @@ func extendCA(pf *pqExtendFlags, ca cert.Certificate, curve cert.Curve, key, raw
 		return nil, fmt.Errorf("refusing to extend, the CA certificate does not match ca-key")
 	}
 
-	pub, priv, err := appendPQSigningKey(nil, append([]byte(nil), key...))
+	pub, priv, err := appendPQSigningKey(suite, nil, append([]byte(nil), key...))
 	if err != nil {
 		return nil, err
 	}
-	signPQ, err := cert.SignPQ(priv[len(key):])
+	signPQ, err := cert.SignPQ(suite, priv[len(key):])
 	if err != nil {
 		return nil, err
 	}
-	c3, err := cert.ExtendToV3(ca, pub, nil, signPQ)
+	c3, err := cert.ExtendToV3(ca, suite, pub, nil, signPQ)
 	if err != nil {
 		return nil, fmt.Errorf("error while extending: %s", err)
 	}
@@ -192,12 +226,12 @@ func extendCA(pf *pqExtendFlags, ca cert.Certificate, curve cert.Curve, key, raw
 		}
 		a := ned.EncryptionMetadata.Argon2Parameters
 		kdf := cert.NewArgon2Parameters(a.Memory, a.Parallelism, a.Iterations)
-		b, err = cert.EncryptAndMarshalSigningPrivateKey(curve, priv, passphrase, kdf)
+		b, err = cert.EncryptAndMarshalPQSigningPrivateKey(curve, suite, priv, passphrase, kdf)
 		if err != nil {
 			return nil, fmt.Errorf("error while encrypting out-key: %s", err)
 		}
 	} else {
-		b = cert.MarshalSigningPrivateKeyToPEM(curve, priv)
+		b = cert.MarshalPQSigningPrivateKeyToPEM(curve, suite, priv)
 	}
 	if err := writeOutput(*pf.outKeyPath, b, 0600, out); err != nil {
 		return nil, fmt.Errorf("error while writing out-key: %s", err)
@@ -205,7 +239,7 @@ func extendCA(pf *pqExtendFlags, ca cert.Certificate, curve cert.Curve, key, raw
 	return c3, nil
 }
 
-// extendHost adds a host's ML-KEM-1024 key to its certificate, signed by the v3 form of its CA.
+// extendHost adds a host's post-quantum key to its certificate, signed by the v3 form of its CA, in the CA's suite.
 func extendHost(pf *pqExtendFlags, c cert.Certificate, curve cert.Curve, caKey []byte, claims *ioClaims) (cert.Certificate, error) {
 	if *pf.outKeyPath != "" {
 		return nil, newHelpErrorf("-out-key is for CA certificates, a host's key comes from keygen -in-key")
@@ -240,19 +274,24 @@ func extendHost(pf *pqExtendFlags, c cert.Certificate, curve cert.Curve, caKey [
 	if err != nil {
 		return nil, fmt.Errorf("error while parsing in-pub: %s", err)
 	}
-	if pubCurve != c.Curve() || len(pub) <= cert.MLKEM1024PublicKeySize {
-		return nil, fmt.Errorf("in-pub must be the host's key from keygen -in-key")
+	suite := cert.PQSuiteOf(ca)
+	classical, pq, err := suite.SplitPublicKey(false, pub)
+	if pubCurve != c.Curve() || err != nil {
+		return nil, fmt.Errorf("in-pub must be the host's key from keygen -in-key -pq-suite %v", suite)
 	}
-	classical, pq := pub[:len(pub)-cert.MLKEM1024PublicKeySize], pub[len(pub)-cert.MLKEM1024PublicKeySize:]
 	if !bytes.Equal(classical, c.PublicKey()) {
 		return nil, fmt.Errorf("in-pub does not extend the key in in-crt")
 	}
 
-	signPQ, err := cert.SignPQ(caKey[len(caKey)-cert.MLDSA87PrivateKeySize:])
+	_, pqCAKey, err := suite.SplitPrivateKey(true, caKey)
+	if err != nil {
+		return nil, err
+	}
+	signPQ, err := cert.SignPQ(suite, pqCAKey)
 	if err != nil {
 		return nil, err
 	}
-	c3, err := cert.ExtendToV3(c, pq, ca, signPQ)
+	c3, err := cert.ExtendToV3(c, suite, pq, ca, signPQ)
 	if err != nil {
 		return nil, fmt.Errorf("error while extending: %s", err)
 	}

+ 34 - 3
cmd/nebula-cert/pq_test.go

@@ -100,9 +100,40 @@ func TestPQ_SignErrors(t *testing.T) {
 	require.NoError(t, ca([]string{"-version", "3", "-name", "v3 ca", "-out-crt", p("ca3.crt"), "-out-key", p("ca3.key")}, ob, eb, nopw))
 	require.ErrorContains(t, signCert([]string{"-ca-crt", p("ca3.crt"), "-ca-key", p("ca3.key"), "-name", "h", "-networks", "10.1.0.1/16", "-in-pub", p("k.pub"), "-out-crt", p("h3.crt")}, ob, eb, nopw), "need an in-pub from keygen -version 3")
 
-	// keygen -in-key refuses a key that already has an ML-KEM key.
+	// keygen -in-key refuses a key that already has a post-quantum key.
 	require.NoError(t, keygen([]string{"-version", "3", "-out-key", p("k3.key"), "-out-pub", p("k3.pub")}, ob, eb))
-	require.ErrorContains(t, keygen([]string{"-in-key", p("k3.key"), "-out-key", p("k33.key"), "-out-pub", p("k33.pub")}, ob, eb), "already have an ML-KEM-1024 key")
+	require.ErrorContains(t, keygen([]string{"-in-key", p("k3.key"), "-out-key", p("k33.key"), "-out-pub", p("k33.pub")}, ob, eb), "already have a post-quantum key")
+}
+
+func TestPQ_SuiteFlag(t *testing.T) {
+	dir := t.TempDir()
+	ob, eb := &bytes.Buffer{}, &bytes.Buffer{}
+	nopw := &StubPasswordReader{}
+	p := func(n string) string { return filepath.Join(dir, n) }
+
+	// The suite can be named, case aside, and the certificates a CA signs share its suite.
+	require.NoError(t, ca([]string{"-version", "3", "-pq-suite", "mlkem1024_mldsa87", "-name", "ca", "-out-crt", p("ca.crt"), "-out-key", p("ca.key")}, ob, eb, nopw))
+	assert.Equal(t, cert.PQSuite_MLKEM1024_MLDSA87, cert.PQSuiteOf(readCert(t, p("ca.crt"))))
+	require.NoError(t, keygen([]string{"-version", "3", "-pq-suite", "MLKEM1024_MLDSA87", "-out-key", p("k.key"), "-out-pub", p("k.pub")}, ob, eb))
+	require.NoError(t, signCert([]string{"-ca-crt", p("ca.crt"), "-ca-key", p("ca.key"), "-name", "h", "-networks", "10.1.0.1/16", "-in-pub", p("k.pub"), "-out-crt", p("h.crt")}, ob, eb, nopw))
+	assert.Equal(t, cert.PQSuite_MLKEM1024_MLDSA87, cert.PQSuiteOf(readCert(t, p("h.crt"))))
+
+	// Printing shows it.
+	ob.Reset()
+	require.NoError(t, printCert([]string{"-json", "-path", p("h.crt")}, ob, eb))
+	assert.Contains(t, ob.String(), `"pqSuite":"MLKEM1024_MLDSA87"`)
+
+	// Unknown suites, and suites where there is no post-quantum key, are refused.
+	require.ErrorContains(t, ca([]string{"-version", "3", "-pq-suite", "mlkem768_mldsa65", "-name", "x", "-out-crt", p("x.crt"), "-out-key", p("x.key")}, ob, eb, nopw), "unknown post-quantum suite")
+	require.ErrorContains(t, ca([]string{"-pq-suite", "MLKEM1024_MLDSA87", "-name", "x", "-out-crt", p("x.crt"), "-out-key", p("x.key")}, ob, eb, nopw), "only for -version 3")
+	require.ErrorContains(t, keygen([]string{"-pq-suite", "MLKEM1024_MLDSA87", "-out-key", p("x.key"), "-out-pub", p("x.pub")}, ob, eb), "only for -version 3")
+
+	// A host takes its CA's suite when extended.
+	require.NoError(t, ca([]string{"-name", "ca2", "-out-crt", p("ca2.crt"), "-out-key", p("ca2.key")}, ob, eb, nopw))
+	require.NoError(t, signCert([]string{"-ca-crt", p("ca2.crt"), "-ca-key", p("ca2.key"), "-name", "h2", "-networks", "10.1.0.2/16", "-out-crt", p("h2.crt"), "-out-key", p("h2.key")}, ob, eb, nopw))
+	require.NoError(t, pqExtend([]string{"-in-crt", p("ca2.crt"), "-ca-key", p("ca2.key"), "-pq-suite", "MLKEM1024_MLDSA87", "-out-crt", p("ca23.crt"), "-out-key", p("ca23.key")}, ob, eb, nopw))
+	require.NoError(t, keygen([]string{"-in-key", p("h2.key"), "-out-key", p("h23.key"), "-out-pub", p("h23.pub")}, ob, eb))
+	require.ErrorContains(t, pqExtend([]string{"-in-crt", p("h2.crt"), "-in-pub", p("h23.pub"), "-ca-crt", p("ca23.crt"), "-ca-key", p("ca23.key"), "-pq-suite", "MLKEM1024_MLDSA87", "-out-crt", p("h23.crt")}, ob, eb, nopw), "a host takes its CA's suite")
 }
 
 // TestPQ_Extend upgrades a v2 CA and a host in place, the way a running network migrates.
@@ -145,7 +176,7 @@ func TestPQ_Extend(t *testing.T) {
 				require.ErrorIs(t, err, cert.ErrPrivateKeyEncrypted, "an encrypted ca-key stays encrypted")
 			}
 
-			// The host gets an ML-KEM key on top of the one it has, and the CA signs it.
+			// The host gets a post-quantum key on top of the one it has, and the CA signs it.
 			require.NoError(t, keygen([]string{"-in-key", p("host.key"), "-out-key", p("host3.key"), "-out-pub", p("host3.pub")}, ob, eb))
 			require.NoError(t, pqExtend([]string{"-in-crt", p("host.crt"), "-in-pub", p("host3.pub"), "-ca-crt", p("ca3.crt"), "-ca-key", p("ca3.key"), "-out-crt", p("host3.crt")}, ob, eb, pw))
 			host3 := readCert(t, p("host3.crt"))

+ 19 - 6
cmd/nebula-cert/sign.go

@@ -169,16 +169,20 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
 		version = caCert.Version()
 	}
 
+	// A version 3 certificate is in the suite of its CA.
+	suite := cert.PQSuiteOf(caCert)
 	switch {
 	case version == cert.Version3 && caCert.Version() != cert.Version3:
 		return newHelpErrorf("version 3 certificates must be signed by a version 3 CA")
 	case version == cert.Version3 && isP11:
-		return newHelpErrorf("version 3 certificates can not be signed with -pkcs11, the ML-DSA-87 key has to be in ca-key")
+		return newHelpErrorf("version 3 certificates can not be signed with -pkcs11, the post-quantum key has to be in ca-key")
 	case version != cert.Version3 && caCert.Version() == cert.Version3:
 		// A v3 CA signs anything older as its v2 form, with the classical half of its key.
 		caCert = cert.V2Form(caCert)
 		if caKey != nil {
-			caKey = caKey[:len(caKey)-cert.MLDSA87PrivateKeySize]
+			if caKey, _, err = suite.SplitPrivateKey(true, caKey); err != nil {
+				return fmt.Errorf("error while parsing ca-key: %s", err)
+			}
 		}
 	}
 
@@ -272,12 +276,16 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
 		if pubCurve != curve {
 			return fmt.Errorf("curve of in-pub does not match ca")
 		}
-		if isPQ := len(pub) > cert.MLKEM1024PublicKeySize; isPQ != (version == cert.Version3) {
+		// Composite keys are longer than any classical one, an uncompressed P256 key being the longest.
+		if isPQ := len(pub) > 65; isPQ != (version == cert.Version3) {
 			if isPQ {
-				return fmt.Errorf("in-pub has an ML-KEM-1024 key, which only version 3 certificates carry")
+				return fmt.Errorf("in-pub has a post-quantum key, which only version 3 certificates carry")
 			}
 			return fmt.Errorf("version 3 certificates need an in-pub from keygen -version 3")
 		}
+		if _, _, err := suite.SplitPublicKey(false, pub); version == cert.Version3 && err != nil {
+			return fmt.Errorf("in-pub is not a key for the CA's post-quantum suite, %v", suite)
+		}
 	} else if isP11 {
 		pub, err = p11Client.GetPubKey()
 		if err != nil {
@@ -286,7 +294,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
 	} else {
 		pub, rawPriv = newKeypair(curve)
 		if version == cert.Version3 {
-			pub, rawPriv, err = appendPQKey(pub, rawPriv)
+			pub, rawPriv, err = appendPQKey(suite, pub, rawPriv)
 			if err != nil {
 				return err
 			}
@@ -388,6 +396,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
 			PublicKey:      pub,
 			IsCA:           false,
 			Curve:          curve,
+			PQSuite:        suite,
 		}
 
 		nc, err := t.Sign(caCert, curve, caKey)
@@ -408,7 +417,11 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
 			}
 		}
 
-		err = writeOutput(*sf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600, out)
+		b := cert.MarshalPrivateKeyToPEM(curve, rawPriv)
+		if version == cert.Version3 {
+			b = cert.MarshalPQPrivateKeyToPEM(curve, suite, rawPriv)
+		}
+		err = writeOutput(*sf.outKeyPath, b, 0600, out)
 		if err != nil {
 			return fmt.Errorf("error while writing out-key: %s", err)
 		}

+ 33 - 12
docs/pq-handshake.md

@@ -77,12 +77,23 @@ CertificateV3 ::= SEQUENCE {
     curve        [1] Curve DEFAULT curve25519,
     publicKey    [2] OCTET STRING,   -- classical: X25519 / P256 for hosts, Ed25519 / ECDSA P256 for CAs
     signature    [3] OCTET STRING,   -- classical CA signature over details || curve || publicKey, as in v2
-    pqPublicKey  [4] OCTET STRING,   -- ML-KEM-1024 encapsulation key for hosts, ML-DSA-87 public key for CAs
-    pqSignature  [5] OCTET STRING    -- ML-DSA-87 by the issuing CA, context "nebula certificate v3",
-                                     -- over the DER of fields 0 to 4, curve always present
+    pqSuite      [4] OCTET STRING,   -- one byte, the PQSuite; always present
+    pqPublicKey  [5] OCTET STRING,   -- the suite's KEM public key for hosts, its signature public key for CAs
+    pqSignature  [6] OCTET STRING    -- the suite's signature by the issuing CA, context "nebula certificate v3",
+                                     -- over the DER of fields 0 to 5, curve always present
 }
 ```
 
+- **The suite.** `pqSuite` names the post-quantum algorithms the way `curve` names the classical
+  ones: the KEM of host keys, and the signature algorithm of CA keys and of the certificates a CA
+  signs. A CA and every certificate it signs share a suite, as they share a curve; the CA pool
+  rejects a mismatch, and the post-quantum signature covers the suite, so a certificate can not be
+  relabelled. The field is always present, in handshakes too, with no default, and 0 is never
+  valid. There is one suite today, `MLKEM1024_MLDSA87` (1): ML-KEM-1024 hosts, ML-DSA-87 CAs. A
+  new suite is an entry in the cert package's suite table plus, for a new KEM, a hybrid subtype; no
+  new certificate version. Changing a CA's suite means a new CA, or a new v3 extension of its v2
+  form that replaces the old one, since one v2 CA has one v3 extension.
+
 - **The v2 form.** Fields 0 to 3 are a byte exact v2 certificate, signed by the v2 form of the CA.
   A v3 holder presents it in classical and phase 1 handshakes, so peers that only speak v2 accept
   it as they always have, and old parsers never see v3 bytes. A node therefore needs one
@@ -109,7 +120,9 @@ CertificateV3 ::= SEQUENCE {
 - **Keys are composite**, classical followed by post-quantum, wherever a v3 certificate deals in
   them: `PublicKey()` returns `classical || pq`, and private keys are `classical || seed` (64 byte
   ML-KEM seed for hosts, 32 byte ML-DSA seed for CAs), in one PEM block in `pki.key` with banners
-  such as `NEBULA X25519 MLKEM1024 PRIVATE KEY` and `NEBULA ED25519 MLDSA87 PRIVATE KEY`.
+  such as `NEBULA X25519 MLKEM1024 PRIVATE KEY` and `NEBULA ED25519 MLDSA87 PRIVATE KEY`. The
+  banner names the post-quantum algorithm, not the suite, because a seed's size does not tell the
+  algorithm's strength.
 - **Both curves.** Curve25519 and P256; the post-quantum half does not depend on the curve.
 - **In-place upgrade.** `nebula-cert pq-extend` turns an existing v2 CA into a v3 CA whose v2 form
   is the old CA certificate, byte for byte, and extends existing host certificates the same way.
@@ -118,8 +131,8 @@ CertificateV3 ::= SEQUENCE {
 
 | Certificate | Full | In a handshake | v2 form |
 |---|---|---|---|
-| CA, Curve25519 | 7362 B | n/a | 134 B |
-| Host, Curve25519 | 6375 B | 4769 B | 171 B |
+| CA, Curve25519 | 7365 B | n/a | 134 B |
+| Host, Curve25519 | 6378 B | 4772 B | 171 B |
 
 `crypto/mldsa` is new in Go 1.27, so `go.mod` moves to Go 1.27.
 
@@ -163,11 +176,17 @@ design rationale and security argument), with ML-KEM-1024 supplied by nebula:
   initiator sealed.
 - v3 certificates go with this subtype and nothing else: a v3 certificate on any other subtype, or
   a v2 one on this one, is a protocol error.
+- **One subtype per KEM.** A hybrid subtype names its KEM, and a v3 holder initiates the subtype of
+  its suite's KEM. Peers need the same KEM, not the same suite: suites that differ only in their
+  signature algorithm share a subtype, and each certificate carries its own suite. A responder
+  whose suite uses another KEM drops msg1, so the pair behaves as if the responder did not speak
+  PQ: classical fallback, or no tunnel under `require`. A suite's KEM must have a subtype for the
+  pki to load a certificate in it.
 
 | Message | Size (Curve25519) | Data fragments (k) |
 |---|---|---|
-| msg1 | 8006 B | 7 |
-| msg2 | 9625 B | 9 |
+| msg1 | 8009 B | 7 |
+| msg2 | 9628 B | 9 |
 | msg3 | 1616 B | 2 |
 
 **Dependency.** DefinedNet/noise is a private repository today. `go.mod` points at a local clone
@@ -484,10 +503,11 @@ in every scenario above, and within three at least 99.99%.
 
 ## Implementation (phase 2)
 
-- `cert`: `cert_v3.go` (format, `V2Form`, `ExtendToV3`, `SignPQ`), `SignHybridWith` and the
-  composite `Sign` path, the CA pool's v3 index and v2-form blocklisting, and the composite key
-  PEM banners.
+- `cert`: `cert_v3.go` (format, `V2Form`, `ExtendToV3`, `SignPQ`), `pq_suite.go` (`PQSuite` and
+  the suite table), `SignHybridWith` and the composite `Sign` path, the CA pool's v3 index, suite
+  check and v2-form blocklisting, and the composite key PEM banners, generated per suite.
 - `handshake`: the `ix_hybrid_mlkem1024` subtype on `noise.HandshakeHybridIX`, `KEMMLKEM1024`,
+  `HybridSubtype` mapping a suite to the subtype of its KEM,
   composite credentials, cipher states ordered by role for three message patterns, and
   `Machine.PeerCert` for a responder that has to act before completion.
 - `pki.go` loads a v3 certificate with its composite key and derives the v2 form; messages after
@@ -496,7 +516,8 @@ in every scenario above, and within three at least 99.99%.
   initiator's msg3 resends and teardown; `outside.go` keeps quiet about pending responder indexes.
 - `nebula-cert`: `ca`, `keygen` and `sign` take `-version 3`, `keygen -in-key` extends an existing
   host key, `sign -version 2` with a v3 CA signs as its v2 form, and `pq-extend` upgrades v2
-  certificates in place, keeping an encrypted CA key encrypted.
+  certificates in place, keeping an encrypted CA key encrypted. `ca`, `keygen` and `pq-extend` of a
+  CA take `-pq-suite`; `sign` and `pq-extend` of a host take the CA's suite.
 - Tests: certificate format, strictness, tampering, pool and blocklist rules and in-place
   extension on both curves; Machine-level hybrid handshakes including corrupted msg3 and
   impersonation with only the classical key; e2e tests for a clean hybrid handshake, a lost

+ 10 - 13
handshake/credential.go

@@ -15,7 +15,7 @@ type Credential struct {
 	Bytes       []byte            // pre-marshaled certificate bytes
 	privateKey  []byte            // static private key (public half lives in Cert)
 	publicKey   []byte            // static public key, the classical half for a v3 Cert
-	kemKey      noise.KEMKey      // static ML-KEM-1024 keypair, only for a v3 Cert
+	kemKey      noise.KEMKey      // static KEM keypair of the Cert's suite, only for a v3 Cert
 	cipherSuite noise.CipherSuite // pre-built cipher suite (DH + cipher + hash)
 }
 
@@ -24,7 +24,8 @@ type Credential struct {
 // appropriate DH function, cipher, and hash.
 //
 // For a version 3 certificate privateKey is the composite private key,
-// classical || ML-KEM-1024 seed, matching the composite Cert.PublicKey().
+// classical || the seed of its suite's KEM, matching the composite
+// Cert.PublicKey().
 func NewCredential(
 	c cert.Certificate,
 	hsBytes []byte,
@@ -38,14 +39,10 @@ func NewCredential(
 		publicKey:   c.PublicKey(),
 		cipherSuite: cipherSuite,
 	}
-	if v2 := cert.V2Form(c); v2 != nil {
-		classical := v2.PublicKey()
-		hc.publicKey = classical
-		hc.kemKey = noise.KEMKey{
-			Private: privateKey[len(privateKey)-cert.MLKEM1024PrivateKeySize:],
-			Public:  c.PublicKey()[len(classical):],
-		}
-		hc.privateKey = privateKey[:len(privateKey)-cert.MLKEM1024PrivateKeySize]
+	if suite := cert.PQSuiteOf(c); suite != cert.PQSuiteUnknown {
+		// The pki checked the private key against the certificate, so both split.
+		hc.publicKey, hc.kemKey.Public, _ = suite.SplitPublicKey(false, c.PublicKey())
+		hc.privateKey, hc.kemKey.Private, _ = suite.SplitPrivateKey(false, privateKey)
 	}
 	return hc
 }
@@ -53,7 +50,7 @@ func NewCredential(
 // buildHandshakeState creates a noise.HandshakeState from this credential.
 // pskPlacement selects the noise psk modifier; 0 means none. A psk for
 // placements 2 and up is supplied later with SetPresharedKey. A hybrid
-// pattern also gets ML-KEM-1024 and the static ML-KEM key.
+// pattern also gets its KEM and the static KEM key.
 func (hc *Credential) buildHandshakeState(initiator bool, info subtypeInfo) (*noise.HandshakeState, error) {
 	cfg := noise.Config{
 		CipherSuite:           hc.cipherSuite,
@@ -64,8 +61,8 @@ func (hc *Credential) buildHandshakeState(initiator bool, info subtypeInfo) (*no
 		PresharedKey:          []byte{},
 		PresharedKeyPlacement: info.pskPlacement,
 	}
-	if info.hybrid {
-		cfg.KEMFunc = KEMMLKEM1024
+	if info.kem != nil {
+		cfg.KEMFunc = info.kem
 		cfg.StaticKEMKeypair = hc.kemKey
 	}
 	return noise.NewHandshakeState(cfg)

+ 1 - 0
handshake/errors.go

@@ -20,4 +20,5 @@ var (
 	ErrMultiMessageUnsupported = errors.New("multi-message handshake patterns are not yet supported by the manager")
 	ErrSubtypeMismatch         = errors.New("packet subtype does not match handshake machine subtype")
 	ErrCertVersionMismatch     = errors.New("handshake subtype does not allow this certificate version")
+	ErrPQSuiteMismatch         = errors.New("handshake subtype does not use the KEM of this certificate's post-quantum suite")
 )

+ 1 - 1
handshake/fragment/fragment_test.go

@@ -524,7 +524,7 @@ func TestTableAnswered(t *testing.T) {
 	r := rand.New(rand.NewPCG(3, 3))
 	tb := NewTable[testKey, string](testConfig())
 	now := time.Unix(1000, 0)
-	const msgLen = 8006 // a hybrid msg1: K=7 data symbols, 3 repair
+	const msgLen = 8009 // a hybrid msg1: K=7 data symbols, 3 repair
 
 	sendAll := func(key testKey, pkts [][]byte) []*Result {
 		var out []*Result

+ 25 - 2
handshake/machine.go

@@ -94,7 +94,8 @@ func NewMachine(
 
 	// Version 3 certificates go with hybrid patterns and nothing else, the
 	// classical and phase 1 subtypes use the v2 form instead.
-	if info.hybrid != (version == cert.Version3) {
+	hybrid := info.kem != nil
+	if hybrid != (version == cert.Version3) {
 		return nil, fmt.Errorf("%w: %v", ErrCertVersionMismatch, version)
 	}
 
@@ -102,6 +103,11 @@ func NewMachine(
 	if cred == nil {
 		return nil, fmt.Errorf("%w: %v", ErrNoCredential, version)
 	}
+	if hybrid {
+		if err := checkHybridSuite(subtype, cred.Cert); err != nil {
+			return nil, err
+		}
+	}
 
 	hs, err := cred.buildHandshakeState(initiator, info)
 	if err != nil {
@@ -112,7 +118,7 @@ func NewMachine(
 		hs:         hs,
 		subtype:    subtype,
 		msgs:       info.msgs,
-		hybrid:     info.hybrid,
+		hybrid:     hybrid,
 		getCred:    getCred,
 		allocIndex: allocIndex,
 		verifier:   verifier,
@@ -390,6 +396,16 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error {
 	return nil
 }
 
+// checkHybridSuite checks that a version 3 certificate's suite uses the KEM of
+// the hybrid subtype.
+func checkHybridSuite(subtype header.MessageSubType, c cert.Certificate) error {
+	suite := cert.PQSuiteOf(c)
+	if st, ok := HybridSubtype(suite); !ok || st != subtype {
+		return fmt.Errorf("%w: %v", ErrPQSuiteMismatch, suite)
+	}
+	return nil
+}
+
 func (m *Machine) validateCert(payload Payload) error {
 	// A v3 certificate only ever comes with a hybrid pattern, and a hybrid
 	// pattern only ever with one. The composite static key would not recombine
@@ -420,6 +436,13 @@ func (m *Machine) validateCert(payload Payload) error {
 		return ErrPublicKeyMismatch
 	}
 
+	if m.hybrid {
+		if err := checkHybridSuite(m.subtype, rc); err != nil {
+			m.failed = true
+			return err
+		}
+	}
+
 	// Version negotiation, if the peer sent a different version and we have it, switch
 	if rc.Version() != m.myVersion {
 		if m.getCred(rc.Version()) != nil {

+ 26 - 4
handshake/patterns.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/DefinedNet/noise"
+	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/header"
 )
 
@@ -22,9 +23,10 @@ type subtypeInfo struct {
 	pskPlacement int // noise psk modifier position, 0 means no psk token
 	msgs         []msgFlags
 
-	// hybrid patterns carry composite static keys and need version 3
-	// certificates on both sides; every other pattern refuses them.
-	hybrid bool
+	// kem is the KEM of a hybrid pattern, nil for any other. Hybrid patterns
+	// carry composite static keys and need version 3 certificates on both
+	// sides, in suites using this KEM; every other pattern refuses them.
+	kem noise.KEMFunc
 }
 
 // subtypeInfos defines the noise pattern and message content layout for each
@@ -68,7 +70,7 @@ var subtypeInfos = map[header.MessageSubType]subtypeInfo{
 	// first packet under the new keys does.
 	header.HandshakeIXHybridMLKEM1024: {
 		pattern: noise.HandshakeHybridIX,
-		hybrid:  true,
+		kem:     KEMMLKEM1024,
 		msgs: []msgFlags{
 			{expectsPayload: true, expectsCert: true},
 			{expectsPayload: true, expectsCert: true},
@@ -90,6 +92,26 @@ var subtypeInfos = map[header.MessageSubType]subtypeInfo{
 	//},
 }
 
+// hybridSubtypes maps the suite of a version 3 certificate to the hybrid
+// subtype for its KEM. Peers need the same KEM, not the same suite, so suites
+// sharing a KEM share a subtype; the suite itself travels in the certificate.
+var hybridSubtypes = map[cert.PQSuite]header.MessageSubType{
+	cert.PQSuite_MLKEM1024_MLDSA87: header.HandshakeIXHybridMLKEM1024,
+}
+
+// HybridSubtype returns the hybrid handshake subtype for the KEM of a version 3
+// certificate's suite.
+func HybridSubtype(suite cert.PQSuite) (header.MessageSubType, bool) {
+	st, ok := hybridSubtypes[suite]
+	return st, ok
+}
+
+// IsHybrid reports whether subtype is a hybrid handshake, one that needs version
+// 3 certificates.
+func IsHybrid(subtype header.MessageSubType) bool {
+	return subtypeInfos[subtype].kem != nil
+}
+
 func subtypeInfoFor(subtype header.MessageSubType) (subtypeInfo, error) {
 	if info, ok := subtypeInfos[subtype]; ok {
 		return info, nil

+ 3 - 3
handshake_manager.go

@@ -190,8 +190,8 @@ func (hm *HandshakeManager) HandleIncoming(via ViaSender, packet []byte, h *head
 	// Gate on known handshake subtypes. Unknown subtypes (or future ones we
 	// don't yet support) are dropped here rather than silently routed through
 	// the IX path. Add a case when introducing a new pattern.
-	switch h.Subtype {
-	case header.HandshakeIXPSK0, header.HandshakeIXPSK2MLKEM1024, hybridSubtype:
+	switch {
+	case h.Subtype == header.HandshakeIXPSK0, h.Subtype == header.HandshakeIXPSK2MLKEM1024, handshake.IsHybrid(h.Subtype):
 		// supported
 	default:
 		hm.l.Debug("dropping handshake with unsupported subtype",
@@ -795,7 +795,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
 	// A hybrid responder answers with its v3 certificate and has to wait for msg3,
 	// so its index is registered right away for msg3 to find.
 	var pending *HandshakeHostInfo
-	if h.Subtype == hybridSubtype {
+	if handshake.IsHybrid(h.Subtype) {
 		v = cert.Version3
 		pending = &HandshakeHostInfo{
 			hostinfo: &HostInfo{

+ 3 - 3
handshake_manager_hybrid.go

@@ -27,8 +27,6 @@ import (
 //     within the handshake timeout the tunnel is closed.
 
 const (
-	hybridSubtype = header.HandshakeIXHybridMLKEM1024
-
 	// hybridMsg3MaxLen bounds the msg3 a pending responder reassembles. msg3 is
 	// a ciphertext and two tags, about 1.6KB.
 	hybridMsg3MaxLen = 4096
@@ -45,6 +43,7 @@ type hybridResponder struct {
 // heard from the responder.
 type hybridConfirm struct {
 	hostinfo     *HostInfo
+	subtype      header.MessageSubType
 	sender       *fragment.Sender
 	send         func([]byte) error
 	handshakeLen uint64 // messages the handshake took, which the replay window was seeded with
@@ -323,6 +322,7 @@ func (hm *HandshakeManager) startHybridConfirm(via ViaSender, hostinfo *HostInfo
 	now := time.Now()
 	c := &hybridConfirm{
 		hostinfo:     hostinfo,
+		subtype:      h.Subtype,
 		sender:       s,
 		send:         hm.sendTo(via),
 		handshakeLen: handshakeLen,
@@ -341,7 +341,7 @@ func (hm *HandshakeManager) sendHybridMsg3(c *hybridConfirm) {
 	if err != nil {
 		return
 	}
-	hm.messageMetrics.Tx(header.Handshake, hybridSubtype, int64(len(pkts)+len(later)))
+	hm.messageMetrics.Tx(header.Handshake, c.subtype, int64(len(pkts)+len(later)))
 	// No repair delay: the packets cached during the handshake go out right
 	// behind msg3, and the responder drops whatever arrives before msg3
 	// completes. With the repair trailing, losing either data fragment would

+ 17 - 9
handshake_manager_pq.go

@@ -276,14 +276,15 @@ func (hm *HandshakeManager) relayEstablished(target netip.Addr, relayHI *HostInf
 }
 
 // buildPQAttempt creates the initiator's PQ Machine and msg1 sender. A node with
-// a v3 certificate speaks the hybrid handshake, anyone else the ephemeral KEM one.
+// a v3 certificate speaks the hybrid handshake for its suite's KEM, anyone else
+// the ephemeral KEM one.
 func (hm *HandshakeManager) buildPQAttempt(hh *HandshakeHostInfo) bool {
 	cs := hm.f.pki.getCertState()
 	v := hm.initiatingVersion(hh, cs)
 	subtype := header.HandshakeIXPSK2MLKEM1024
-	if cs.GetCredential(cert.Version3) != nil {
+	if st, ok := cs.hybridSubtype(); ok {
 		v = cert.Version3
-		subtype = hybridSubtype
+		subtype = st
 	}
 	if cs.GetCredential(v) == nil {
 		hm.f.l.Error("Unable to handshake with host because no certificate is available",
@@ -409,13 +410,20 @@ func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *head
 			hm.l.Debug("dropping post-quantum handshake, not accepting them", "from", via)
 			return
 		}
-		if h.Subtype == hybridSubtype || cfg.require {
-			hasV3 := hm.f.pki.getCertState().GetCredential(cert.Version3) != nil
-			if h.Subtype == hybridSubtype && !hasV3 {
+		hybrid := handshake.IsHybrid(h.Subtype)
+		if hybrid || cfg.require {
+			mine, hasV3 := hm.f.pki.getCertState().hybridSubtype()
+			switch {
+			case hybrid && !hasV3:
 				hm.l.Debug("dropping hybrid handshake, no v3 certificate to answer it with", "from", via)
 				return
-			}
-			if h.Subtype != hybridSubtype && hasV3 {
+			case hybrid && h.Subtype != mine:
+				// The peer's suite uses another KEM. Like a peer without PQ, it
+				// falls back to classical unless either side requires PQ.
+				hm.l.Debug("dropping hybrid handshake, our v3 certificate's suite uses another KEM", "from", via,
+					"style", header.SubTypeName(header.Handshake, h.Subtype), "ours", header.SubTypeName(header.Handshake, mine))
+				return
+			case !hybrid && hasV3:
 				hm.l.Debug("dropping post-quantum handshake, hybrid handshakes are required", "from", via)
 				return
 			}
@@ -463,7 +471,7 @@ func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *head
 		}
 
 	case 3:
-		if h.Subtype == hybridSubtype {
+		if handshake.IsHybrid(h.Subtype) {
 			hm.continueHybridResponder(via, h, fh, sym)
 		}
 	}

+ 17 - 3
pki.go

@@ -21,6 +21,7 @@ import (
 	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/handshake"
+	"github.com/slackhq/nebula/header"
 	"github.com/slackhq/nebula/noiseutil"
 	"github.com/slackhq/nebula/util"
 )
@@ -222,6 +223,15 @@ func (cs *CertState) GetDefaultCertificate() cert.Certificate {
 func (cs *CertState) DefaultVersion() cert.Version { return cs.initiatingVersion }
 
 // GetCredential returns the pre-computed handshake credential for the given version, or nil.
+// hybridSubtype returns the hybrid handshake subtype for the KEM of the v3
+// certificate's suite, false without a v3 certificate.
+func (cs *CertState) hybridSubtype() (header.MessageSubType, bool) {
+	if cs.v3Cert == nil {
+		return 0, false
+	}
+	return handshake.HybridSubtype(cert.PQSuiteOf(cs.v3Cert))
+}
+
 func (cs *CertState) GetCredential(v cert.Version) *handshake.Credential {
 	switch v {
 	case cert.Version1:
@@ -440,7 +450,7 @@ func newCertStateFromConfig(c *config.C, cipher string) (*CertState, error) {
 }
 
 // newCertState builds the handshake credentials. v3, when present, must be what v2 is the v2 form of. privateKey is
-// then the composite key, classical || ML-KEM-1024 seed, and v1 and v2 get its classical half.
+// then the composite key, classical || the seed of the KEM of v3's suite, and v1 and v2 get its classical half.
 func newCertState(dv cert.Version, v1, v2, v3 cert.Certificate, pkcs11backed bool, privateKeyCurve cert.Curve, privateKey []byte, cipher string) (*CertState, error) {
 	cs := CertState{
 		privateKey:               privateKey,
@@ -468,8 +478,8 @@ func newCertState(dv cert.Version, v1, v2, v3 cert.Certificate, pkcs11backed boo
 	}
 
 	classicalKey := privateKey
-	if !pkcs11backed && len(privateKey) > cert.MLKEM1024PrivateKeySize {
-		classicalKey = privateKey[:len(privateKey)-cert.MLKEM1024PrivateKeySize]
+	if !pkcs11backed {
+		classicalKey = cert.ClassicalPrivateKey(privateKey)
 	}
 
 	if v3 != nil {
@@ -479,6 +489,10 @@ func newCertState(dv cert.Version, v1, v2, v3 cert.Certificate, pkcs11backed boo
 		if err := v3.VerifyPrivateKey(privateKeyCurve, privateKey); err != nil {
 			return nil, fmt.Errorf("private key is not a pair with public key in v3 nebula cert: %w", err)
 		}
+		suite := cert.PQSuiteOf(v3)
+		if _, ok := handshake.HybridSubtype(suite); !ok {
+			return nil, fmt.Errorf("v3 nebula cert uses post-quantum suite %v, which no hybrid handshake supports", suite)
+		}
 
 		v3hs, err := v3.MarshalForHandshakes()
 		if err != nil {