Sfoglia il codice sorgente

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 giorni fa
parent
commit
656164b259

+ 6 - 4
CHANGELOG.md

@@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 ### Added
 
 
 - Version 3 certificates and post-quantum hybrid handshakes (`ix_hybrid_mlkem1024`). A version 3 certificate is a
 - 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
 ### 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
 		return nil, ErrCurveMismatch
 	}
 	}
 
 
+	if PQSuiteOf(signer.Certificate) != PQSuiteOf(c) {
+		return nil, ErrPQSuiteMismatch
+	}
+
 	if signer.Certificate.Expired(now) {
 	if signer.Certificate.Expired(now) {
 		return nil, ErrRootExpired
 		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
 // 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.
 // 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.
 // 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.
 // PublicKey returns classical || post-quantum, and VerifyPrivateKey and signing take the private keys the same way.
 
 
 const (
 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 (
 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
 	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
 	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
 	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
 	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
 	MLDSA87SignatureSize = mldsa.MLDSA87SignatureSize
 
 
 	// pqSignatureContext is the ML-DSA context string for certificate signatures.
 	// 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 is the version 2 certificate this one extends. Its public key is the classical key.
 	v2 certificateV2
 	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
 	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
 	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
 // 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.
 // what a v3 holder presents to peers that do not speak v3.
 func V2Form(c Certificate) Certificate {
 func V2Form(c Certificate) Certificate {
@@ -159,24 +141,26 @@ func (c *certificateV3) Fingerprint() (string, error) {
 	return hex.EncodeToString(sum[:]), nil
 	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 {
 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
 		return false
 	}
 	}
-	if !c.v2.CheckSignature(classical) {
+	classical, pq, err := c.pqSuite.SplitPublicKey(true, key)
+	if err != nil {
 		return false
 		return false
 	}
 	}
-	pk, err := mldsa.NewPublicKey(mldsa.MLDSA87(), pq)
-	if err != nil {
+	if !c.v2.CheckSignature(classical) {
 		return false
 		return false
 	}
 	}
 	b, err := c.marshalForPQSigning()
 	b, err := c.marshalForPQSigning()
 	if err != nil {
 	if err != nil {
 		return false
 		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 {
 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.
 // VerifyPrivateKey checks a composite private key, classical || post-quantum seed, against the composite public key.
 func (c *certificateV3) VerifyPrivateKey(curve Curve, key []byte) error {
 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
 		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) {
 	if !bytes.Equal(pub, c.pqPublicKey) {
 		return ErrPublicPrivateKeyMismatch
 		return ErrPublicPrivateKeyMismatch
@@ -227,7 +200,8 @@ func (c *certificateV3) String() string {
 	return string(b)
 	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) {
 func (c *certificateV3) MarshalForHandshakes() ([]byte, error) {
 	return c.marshal(false, false)
 	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
 // 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) {
 func (c *certificateV3) marshalForPQSigning() ([]byte, error) {
 	return c.marshal(true, true)
 	return c.marshal(true, true)
 }
 }
@@ -265,6 +239,10 @@ func (c *certificateV3) marshal(withPublicKeys, forSigning bool) ([]byte, error)
 			b.AddBytes(c.v2.signature)
 			b.AddBytes(c.v2.signature)
 		})
 		})
 
 
+		b.AddASN1(TagCertPQSuite, func(b *cryptobyte.Builder) {
+			b.AddUint8(uint8(c.pqSuite))
+		})
+
 		if withPublicKeys {
 		if withPublicKeys {
 			b.AddASN1(TagCertPQPublicKey, func(b *cryptobyte.Builder) {
 			b.AddASN1(TagCertPQPublicKey, func(b *cryptobyte.Builder) {
 				b.AddBytes(c.pqPublicKey)
 				b.AddBytes(c.pqPublicKey)
@@ -310,6 +288,7 @@ func (c *certificateV3) marshalJSON() (m, error) {
 	mb["v2Fingerprint"] = mb["fingerprint"]
 	mb["v2Fingerprint"] = mb["fingerprint"]
 	mb["fingerprint"] = fp
 	mb["fingerprint"] = fp
 	mb["version"] = Version3
 	mb["version"] = Version3
+	mb["pqSuite"] = c.pqSuite.String()
 	mb["pqPublicKey"] = fmt.Sprintf("%x", c.pqPublicKey)
 	mb["pqPublicKey"] = fmt.Sprintf("%x", c.pqPublicKey)
 	mb["pqSignature"] = fmt.Sprintf("%x", c.pqSignature)
 	mb["pqSignature"] = fmt.Sprintf("%x", c.pqSignature)
 	return mb, nil
 	return mb, nil
@@ -318,6 +297,7 @@ func (c *certificateV3) marshalJSON() (m, error) {
 func (c *certificateV3) Copy() Certificate {
 func (c *certificateV3) Copy() Certificate {
 	nc := &certificateV3{
 	nc := &certificateV3{
 		v2:          *c.v2.Copy().(*certificateV2),
 		v2:          *c.v2.Copy().(*certificateV2),
+		pqSuite:     c.pqSuite,
 		pqPublicKey: make([]byte, len(c.pqPublicKey)),
 		pqPublicKey: make([]byte, len(c.pqPublicKey)),
 		pqSignature: make([]byte, len(c.pqSignature)),
 		pqSignature: make([]byte, len(c.pqSignature)),
 	}
 	}
@@ -331,20 +311,14 @@ func (c *certificateV3) validate() error {
 		return err
 		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 ErrBadFormat
 	}
 	}
 	return nil
 	return nil
@@ -390,6 +364,15 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica
 		return nil, ErrBadFormat
 		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 fromHandshake {
 		if body.PeekASN1Tag(TagCertPQPublicKey) {
 		if body.PeekASN1Tag(TagCertPQPublicKey) {
 			return nil, ErrCertPubkeyPresent
 			return nil, ErrCertPubkeyPresent
@@ -413,7 +396,7 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica
 	}
 	}
 
 
 	if fromHandshake {
 	if fromHandshake {
-		k, p, err := splitPQPublicKey(details.isCA, publicKey)
+		k, p, err := suite.SplitPublicKey(details.isCA, publicKey)
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
@@ -429,6 +412,7 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica
 			publicKey:  classical,
 			publicKey:  classical,
 			signature:  signature,
 			signature:  signature,
 		},
 		},
+		pqSuite:     suite,
 		pqPublicKey: pq,
 		pqPublicKey: pq,
 		pqSignature: pqSignature,
 		pqSignature: pqSignature,
 	}
 	}
@@ -440,17 +424,21 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica
 	return c, nil
 	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 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)
 	c2, ok := c.(*certificateV2)
 	if !ok {
 	if !ok {
 		return nil, fmt.Errorf("only version 2 certificates can be extended to version 3, got version %d", c.Version())
 		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
 	var signingKey []byte
 	if c2.IsCA() {
 	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() {
 		if signer == nil || signer.Version() != Version3 || !signer.IsCA() {
 			return nil, fmt.Errorf("a host certificate must be extended by a version 3 CA")
 			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)
 		signerV2 := V2Form(signer)
 		issuer, err := signerV2.Fingerprint()
 		issuer, err := signerV2.Fingerprint()
 		if err != nil {
 		if err != nil {
@@ -481,9 +472,10 @@ func ExtendToV3(c Certificate, pqPublicKey []byte, signer Certificate, pqSign Si
 
 
 	c3 := &certificateV3{
 	c3 := &certificateV3{
 		v2:          *c2.Copy().(*certificateV2),
 		v2:          *c2.Copy().(*certificateV2),
+		pqSuite:     suite,
 		pqPublicKey: bytes.Clone(pqPublicKey),
 		pqPublicKey: bytes.Clone(pqPublicKey),
 	}
 	}
-	if len(c3.pqPublicKey) != pqPublicKeySize(c3.IsCA()) {
+	if len(c3.pqPublicKey) != suite.PublicKeySize(c3.IsCA()) {
 		return nil, ErrInvalidPublicKey
 		return nil, ErrInvalidPublicKey
 	}
 	}
 
 
@@ -509,16 +501,16 @@ func ExtendToV3(c Certificate, pqPublicKey []byte, signer Certificate, pqSign Si
 	return c3, nil
 	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())
 			assert.Equal(t, caV2Fp, host.Issuer())
 
 
 			// A v2 CA can not sign a v3 certificate, nor a v3 CA a v2 one.
 			// 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)
 			require.Error(t, err)
 		})
 		})
 	}
 	}
@@ -174,13 +174,13 @@ func TestCertificateV3_StrictParse(t *testing.T) {
 	require.ErrorIs(t, err, ErrBadFormat, "trailing bytes after the envelope")
 	require.ErrorIs(t, err, ErrBadFormat, "trailing bytes after the envelope")
 
 
 	trailing := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) {
 	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)
 	_, err = unmarshalCertificateV3(trailing, nil, Curve_CURVE25519)
 	require.ErrorIs(t, err, ErrBadFormat, "unknown field after the pq signature")
 	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) {
 		missing := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) {
 			*order = removeTag(*order, tag)
 			*order = removeTag(*order, tag)
 		})
 		})
@@ -202,6 +202,17 @@ func TestCertificateV3_StrictParse(t *testing.T) {
 	})
 	})
 	_, err = unmarshalCertificateV3(short, nil, Curve_CURVE25519)
 	_, err = unmarshalCertificateV3(short, nil, Curve_CURVE25519)
 	require.ErrorIs(t, err, ErrInvalidPublicKey)
 	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 {
 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.
 			// A second v3 extension of the same v2 CA is ambiguous.
 			sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
 			sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
 			require.NoError(t, err)
 			require.NoError(t, err)
-			signPQ, err := SignPQ(sk.Bytes())
+			signPQ, err := SignPQ(DefaultPQSuite, sk.Bytes())
 			require.NoError(t, err)
 			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.NoError(t, err)
 			require.ErrorContains(t, pool.AddCA(again), "two version 3 CAs extend the same version 2 CA")
 			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())
 			sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
 			require.NoError(t, err)
 			require.NoError(t, err)
-			signPQ, err := SignPQ(sk.Bytes())
+			signPQ, err := SignPQ(DefaultPQSuite, sk.Bytes())
 			require.NoError(t, err)
 			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)
 			require.NoError(t, err)
 			assertCertsEqual(t, ca2, V2Form(ca3))
 			assertCertsEqual(t, ca2, V2Form(ca3))
 			require.NoError(t, ca3.VerifyPrivateKey(curve, append(caKey2, sk.Bytes()...)))
 			require.NoError(t, ca3.VerifyPrivateKey(curve, append(caKey2, sk.Bytes()...)))
 
 
 			dk, err := mlkem.GenerateKey1024()
 			dk, err := mlkem.GenerateKey1024()
 			require.NoError(t, err)
 			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)
 			require.NoError(t, err)
 			assertCertsEqual(t, host2, V2Form(host3))
 			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.
 			// 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)
 			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)
 			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)
 			require.Error(t, err)
 
 
 			wrong, err := mldsa.GenerateKey(mldsa.MLDSA87())
 			wrong, err := mldsa.GenerateKey(mldsa.MLDSA87())
 			require.NoError(t, err)
 			require.NoError(t, err)
-			wrongSign, err := SignPQ(wrong.Bytes())
+			wrongSign, err := SignPQ(DefaultPQSuite, wrong.Bytes())
 			require.NoError(t, err)
 			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)
 			require.ErrorIs(t, err, ErrSignatureMismatch)
 
 
 			// A CA extends itself, and needs its own post-quantum key to.
 			// 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)
 			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)
 			require.Error(t, err)
 
 
 			// Only v2 certificates extend.
 			// 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)
 			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)
 			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())
 			sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
 			require.NoError(t, err)
 			require.NoError(t, err)
-			signPQ, err := SignPQ(sk.Bytes())
+			signPQ, err := SignPQ(DefaultPQSuite, sk.Bytes())
 			require.NoError(t, err)
 			require.NoError(t, err)
 			dk, err := mlkem.GenerateKey1024()
 			dk, err := mlkem.GenerateKey1024()
 			require.NoError(t, err)
 			require.NoError(t, err)
 
 
 			extend := func(c Certificate, pq []byte, signer Certificate) ([]byte, string, Certificate) {
 			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)
 				require.NoError(t, err)
 				b, err := c3.Marshal()
 				b, err := c3.Marshal()
 				require.NoError(t, err)
 				require.NoError(t, err)
@@ -446,18 +457,18 @@ func TestCertificateV3_KeyPEM(t *testing.T) {
 			assert.Equal(t, curve, gotCurve)
 			assert.Equal(t, curve, gotCurve)
 			assert.Equal(t, ca.PublicKey(), pub)
 			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)
 			require.NoError(t, err)
 			assert.Equal(t, curve, gotCurve)
 			assert.Equal(t, curve, gotCurve)
 			assert.Equal(t, hostKey, key)
 			assert.Equal(t, hostKey, key)
 
 
-			key, _, gotCurve, err = UnmarshalSigningPrivateKeyFromPEM(MarshalSigningPrivateKeyToPEM(curve, caKey))
+			key, _, gotCurve, err = UnmarshalSigningPrivateKeyFromPEM(MarshalPQSigningPrivateKeyToPEM(curve, DefaultPQSuite, caKey))
 			require.NoError(t, err)
 			require.NoError(t, err)
 			assert.Equal(t, curve, gotCurve)
 			assert.Equal(t, curve, gotCurve)
 			assert.Equal(t, caKey, key)
 			assert.Equal(t, caKey, key)
 
 
 			kdf := NewArgon2Parameters(64*1024, 4, 1)
 			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)
 			require.NoError(t, err)
 			_, _, _, err = UnmarshalSigningPrivateKeyFromPEM(enc)
 			_, _, _, err = UnmarshalSigningPrivateKeyFromPEM(enc)
 			require.ErrorIs(t, err, ErrPrivateKeyEncrypted)
 			require.ErrorIs(t, err, ErrPrivateKeyEncrypted)
@@ -465,6 +476,18 @@ func TestCertificateV3_KeyPEM(t *testing.T) {
 			require.NoError(t, err)
 			require.NoError(t, err)
 			assert.Equal(t, curve, gotCurve)
 			assert.Equal(t, curve, gotCurve)
 			assert.Equal(t, caKey, key)
 			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"
 	"fmt"
 	"io"
 	"io"
 	"math"
 	"math"
+	"strings"
 
 
 	"golang.org/x/crypto/argon2"
 	"golang.org/x/crypto/argon2"
 	"google.golang.org/protobuf/proto"
 	"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
 // 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) {
 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)
 	ciphertext, err := aes256Encrypt(passphrase, kdfParams, b)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -181,21 +208,7 @@ func EncryptAndMarshalSigningPrivateKey(curve Curve, b []byte, passphrase []byte
 		return nil, err
 		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
 // 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")
 		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
 		curve = Curve_CURVE25519
-		pq = true
-	case EncryptedECDSAP256MLDSA87PrivateKeyBanner:
+	case k.Type == EncryptedECDSAP256PrivateKeyBanner:
 		curve = Curve_P256
 		curve = Curve_P256
-		pq = true
 	default:
 	default:
 		return curve, nil, r, fmt.Errorf("bytes did not contain a proper nebula encrypted Ed25519/ECDSA private key banner")
 		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)
 		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
 		return curve, bytes, r, nil
 	}
 	}

+ 2 - 0
cert/errors.go

@@ -23,6 +23,8 @@ var (
 	ErrUnknownVersion             = errors.New("certificate version unrecognized")
 	ErrUnknownVersion             = errors.New("certificate version unrecognized")
 	ErrCertPubkeyPresent          = errors.New("certificate has unexpected pubkey present")
 	ErrCertPubkeyPresent          = errors.New("certificate has unexpected pubkey present")
 	ErrCurveMismatch              = errors.New("certificate curve does not match CA")
 	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")
 	ErrInvalidPEMBlock                   = errors.New("input did not contain a valid PEM encoded block")
 	ErrInvalidPEMCertificateBanner       = errors.New("bytes did not contain a proper certificate banner")
 	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/ecdh"
 	"crypto/ecdsa"
 	"crypto/ecdsa"
 	"crypto/elliptic"
 	"crypto/elliptic"
-	"crypto/mldsa"
-	"crypto/mlkem"
 	"crypto/rand"
 	"crypto/rand"
 	"io"
 	"io"
 	"net/netip"
 	"net/netip"
@@ -64,6 +62,9 @@ func NewTestCaCert(version Version, curve Curve, before, after time.Time, networ
 		Groups:         groups,
 		Groups:         groups,
 		IsCA:           true,
 		IsCA:           true,
 	}
 	}
+	if version == Version3 {
+		t.PQSuite = DefaultPQSuite
+	}
 
 
 	c, err := t.Sign(nil, curve, priv)
 	c, err := t.Sign(nil, curve, priv)
 	if err != nil {
 	if err != nil {
@@ -104,7 +105,7 @@ func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string
 	}
 	}
 
 
 	if v == Version3 {
 	if v == Version3 {
-		pub, priv = appendPQKey(pub, priv)
+		pub, priv = appendPQKey(ca, pub, priv)
 	}
 	}
 
 
 	nc := &TBSCertificate{
 	nc := &TBSCertificate{
@@ -118,6 +119,7 @@ func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string
 		NotAfter:       time.Unix(after.Unix(), 0),
 		NotAfter:       time.Unix(after.Unix(), 0),
 		PublicKey:      pub,
 		PublicKey:      pub,
 		IsCA:           false,
 		IsCA:           false,
+		PQSuite:        PQSuiteOf(ca),
 	}
 	}
 
 
 	c, err := nc.Sign(ca, ca.Curve(), key)
 	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)
 		panic(err)
 	}
 	}
 
 
+	if v == Version3 {
+		return c, pub, MarshalPQPrivateKeyToPEM(curve, PQSuiteOf(ca), priv), pem
+	}
 	return c, pub, MarshalPrivateKeyToPEM(curve, 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) {
 func appendPQSigningKey(pub, priv []byte) ([]byte, []byte) {
-	sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
+	pqPub, pqPriv, err := DefaultPQSuite.GenerateKey(true)
 	if err != nil {
 	if err != nil {
 		panic(err)
 		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 {
 	if err != nil {
 		panic(err)
 		panic(err)
 	}
 	}
-	return append(pub, dk.EncapsulationKey().Bytes()...), append(priv, dk.Bytes()...)
+	return append(pub, pqPub...), append(priv, pqPriv...)
 }
 }
 
 
 func X25519Keypair() ([]byte, []byte) {
 func X25519Keypair() ([]byte, []byte) {

+ 149 - 81
cert/pem.go

@@ -5,6 +5,8 @@ import (
 	"encoding/pem"
 	"encoding/pem"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
+	"slices"
+	"strings"
 
 
 	"golang.org/x/crypto/ed25519"
 	"golang.org/x/crypto/ed25519"
 )
 )
@@ -74,23 +76,11 @@ const ( //key-agreement-key banners
 	P256PublicKeyBanner    = "NEBULA P256 PUBLIC KEY"
 	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.
 // Classical key sizes, the composite keys of version 3 add the post-quantum key to these.
 const (
 const (
@@ -102,12 +92,91 @@ const (
 	ecdsaP256PublicKeySize = 65 // uncompressed
 	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 */
 /* 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 {
 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() {
 	if c.IsCA() {
 		return MarshalSigningPublicKeyToPEM(c.Curve(), c.PublicKey())
 		return MarshalSigningPublicKeyToPEM(c.Curve(), c.PublicKey())
 	} else {
 	} else {
@@ -164,13 +239,6 @@ func marshalCertPublicKeyToPEM(c Certificate) []byte {
 // MarshalPublicKeyToPEM returns a PEM representation of a public key used for ECDH.
 // 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!
 // if your public key came from a certificate, prefer Certificate.PublicKeyPEM() if possible, to avoid mistakes!
 func MarshalPublicKeyToPEM(curve Curve, b []byte) []byte {
 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 {
 	switch curve {
 	case Curve_CURVE25519:
 	case Curve_CURVE25519:
 		return pem.EncodeToMemory(&pem.Block{Type: X25519PublicKeyBanner, Bytes: b})
 		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.
 // 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!
 // if your public key came from a certificate, prefer Certificate.PublicKeyPEM() if possible, to avoid mistakes!
 func MarshalSigningPublicKeyToPEM(curve Curve, b []byte) []byte {
 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 {
 	switch curve {
 	case Curve_CURVE25519:
 	case Curve_CURVE25519:
 		return pem.EncodeToMemory(&pem.Block{Type: Ed25519PublicKeyBanner, Bytes: b})
 		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
 // 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.
 // consumed data or an error on failure. Only key-agreement (ECDH) public key banners are accepted.
 // Use UnmarshalSigningPublicKeyFromPEM for Ed25519/ECDSA banners.
 // Use UnmarshalSigningPublicKeyFromPEM for Ed25519/ECDSA banners.
@@ -209,6 +282,12 @@ func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
 	if k == nil {
 	if k == nil {
 		return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block")
 		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 expectedLen int
 	var curve Curve
 	var curve Curve
 	switch k.Type {
 	switch k.Type {
@@ -219,12 +298,6 @@ func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
 		// Uncompressed
 		// Uncompressed
 		expectedLen = 65
 		expectedLen = 65
 		curve = Curve_P256
 		curve = Curve_P256
-	case X25519MLKEM1024PublicKeyBanner:
-		expectedLen = x25519KeySize + MLKEM1024PublicKeySize
-		curve = Curve_CURVE25519
-	case P256MLKEM1024PublicKeyBanner:
-		expectedLen = p256PublicKeySize + MLKEM1024PublicKeySize
-		curve = Curve_P256
 	default:
 	default:
 		return nil, r, 0, fmt.Errorf("bytes did not contain a proper public key banner")
 		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 {
 	if k == nil {
 		return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block")
 		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 expectedLen int
 	var curve Curve
 	var curve Curve
 	switch k.Type {
 	switch k.Type {
@@ -252,12 +331,6 @@ func UnmarshalSigningPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
 		// Uncompressed
 		// Uncompressed
 		expectedLen = 65
 		expectedLen = 65
 		curve = Curve_P256
 		curve = Curve_P256
-	case Ed25519MLDSA87PublicKeyBanner:
-		expectedLen = ed25519PublicKeySize + MLDSA87PublicKeySize
-		curve = Curve_CURVE25519
-	case ECDSAP256MLDSA87PublicKeyBanner:
-		expectedLen = ecdsaP256PublicKeySize + MLDSA87PublicKeySize
-		curve = Curve_P256
 	default:
 	default:
 		return nil, r, 0, fmt.Errorf("bytes did not contain a proper Ed25519/ECDSA public key banner")
 		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 {
 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 {
 	switch curve {
 	case Curve_CURVE25519:
 	case Curve_CURVE25519:
 		return pem.EncodeToMemory(&pem.Block{Type: X25519PrivateKeyBanner, Bytes: b})
 		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 {
 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 {
 	switch curve {
 	case Curve_CURVE25519:
 	case Curve_CURVE25519:
 		return pem.EncodeToMemory(&pem.Block{Type: Ed25519PrivateKeyBanner, Bytes: b})
 		return pem.EncodeToMemory(&pem.Block{Type: Ed25519PrivateKeyBanner, Bytes: b})
@@ -310,6 +381,12 @@ func UnmarshalPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
 	if k == nil {
 	if k == nil {
 		return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block")
 		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 expectedLen int
 	var curve Curve
 	var curve Curve
 	switch k.Type {
 	switch k.Type {
@@ -319,12 +396,6 @@ func UnmarshalPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
 	case P256PrivateKeyBanner:
 	case P256PrivateKeyBanner:
 		expectedLen = 32
 		expectedLen = 32
 		curve = Curve_P256
 		curve = Curve_P256
-	case X25519MLKEM1024PrivateKeyBanner:
-		expectedLen = x25519KeySize + MLKEM1024PrivateKeySize
-		curve = Curve_CURVE25519
-	case P256MLKEM1024PrivateKeyBanner:
-		expectedLen = p256PrivateKeySize + MLKEM1024PrivateKeySize
-		curve = Curve_P256
 	default:
 	default:
 		return nil, r, 0, fmt.Errorf("bytes did not contain a proper private key banner")
 		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 {
 	if k == nil {
 		return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block")
 		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
 	var curve Curve
 	switch k.Type {
 	switch k.Type {
 	case EncryptedEd25519PrivateKeyBanner:
 	case EncryptedEd25519PrivateKeyBanner:
 		return nil, nil, Curve_CURVE25519, ErrPrivateKeyEncrypted
 		return nil, nil, Curve_CURVE25519, ErrPrivateKeyEncrypted
-	case EncryptedECDSAP256PrivateKeyBanner, EncryptedECDSAP256MLDSA87PrivateKeyBanner:
+	case EncryptedECDSAP256PrivateKeyBanner:
 		return nil, nil, Curve_P256, ErrPrivateKeyEncrypted
 		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:
 	case Ed25519PrivateKeyBanner:
 		curve = Curve_CURVE25519
 		curve = Curve_CURVE25519
 		if len(k.Bytes) != ed25519.PrivateKeySize {
 		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
 	NotAfter       time.Time
 	PublicKey      []byte
 	PublicKey      []byte
 	Curve          Curve
 	Curve          Curve
+	PQSuite        PQSuite // version 3 only, the suite of the certificate and of its signing CA
 	issuer         string
 	issuer         string
 }
 }
 
 
@@ -48,15 +49,16 @@ type SignerLambda func(certBytes []byte) ([]byte, error)
 // If the TBSCertificate is a CA then signer must be nil.
 // If the TBSCertificate is a CA then signer must be nil.
 func (t *TBSCertificate) Sign(signer Certificate, curve Curve, key []byte) (Certificate, error) {
 func (t *TBSCertificate) Sign(signer Certificate, curve Curve, key []byte) (Certificate, error) {
 	if t.Version == Version3 {
 	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 {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
-		pq, err := SignPQ(key[len(key)-MLDSA87PrivateKeySize:])
+		pq, err := SignPQ(t.PQSuite, pqKey)
 		if err != nil {
 		if err != nil {
 			return nil, err
 			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) {
 func (t *TBSCertificate) SignHybridWith(signer Certificate, curve Curve, sp, pqSp SignerLambda) (Certificate, error) {
 	if t.Version != Version3 {
 	if t.Version != Version3 {
 		return nil, fmt.Errorf("SignHybridWith only signs version 3 certificates")
 		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 {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -115,6 +121,9 @@ func (t *TBSCertificate) SignHybridWith(signer Certificate, curve Curve, sp, pqS
 		if signer.Version() != Version3 {
 		if signer.Version() != Version3 {
 			return nil, fmt.Errorf("a version 3 certificate must be signed by a version 3 CA")
 			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)
 		signerV2 = V2Form(signer)
 	}
 	}
 
 
@@ -125,7 +134,7 @@ func (t *TBSCertificate) SignHybridWith(signer Certificate, curve Curve, sp, pqS
 	if err != nil {
 	if err != nil {
 		return nil, err
 		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.
 // 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/ecdh"
 	"crypto/ecdsa"
 	"crypto/ecdsa"
 	"crypto/elliptic"
 	"crypto/elliptic"
-	"crypto/mldsa"
-	"crypto/mlkem"
 	"crypto/rand"
 	"crypto/rand"
 	"io"
 	"io"
 	"net/netip"
 	"net/netip"
@@ -65,6 +63,9 @@ func NewTestCaCert(version cert.Version, curve cert.Curve, before, after time.Ti
 		Groups:         groups,
 		Groups:         groups,
 		IsCA:           true,
 		IsCA:           true,
 	}
 	}
+	if version == cert.Version3 {
+		t.PQSuite = cert.DefaultPQSuite
+	}
 
 
 	c, err := t.Sign(nil, curve, priv)
 	c, err := t.Sign(nil, curve, priv)
 	if err != nil {
 	if err != nil {
@@ -101,7 +102,7 @@ func NewTestCert(v cert.Version, curve cert.Curve, ca cert.Certificate, key []by
 	}
 	}
 
 
 	if v == cert.Version3 {
 	if v == cert.Version3 {
-		pub, priv = appendPQKey(pub, priv)
+		pub, priv = appendPQKey(ca, pub, priv)
 	}
 	}
 
 
 	nc := &cert.TBSCertificate{
 	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),
 		NotAfter:       time.Unix(after.Unix(), 0),
 		PublicKey:      pub,
 		PublicKey:      pub,
 		IsCA:           false,
 		IsCA:           false,
+		PQSuite:        cert.PQSuiteOf(ca),
 	}
 	}
 
 
 	c, err := nc.Sign(ca, ca.Curve(), key)
 	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)
 		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
 	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),
 		NotAfter:       time.Unix(c.NotAfter().Unix(), 0),
 		PublicKey:      c.PublicKey(),
 		PublicKey:      c.PublicKey(),
 		IsCA:           false,
 		IsCA:           false,
+		PQSuite:        cert.PQSuiteOf(ca),
 	}
 	}
 
 
 	c, err := nc.Sign(ca, ca.Curve(), key)
 	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
 	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) {
 func appendPQSigningKey(pub, priv []byte) ([]byte, []byte) {
-	sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
+	pqPub, pqPriv, err := cert.DefaultPQSuite.GenerateKey(true)
 	if err != nil {
 	if err != nil {
 		panic(err)
 		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 {
 	if err != nil {
 		panic(err)
 		panic(err)
 	}
 	}
-	return append(pub, dk.EncapsulationKey().Bytes()...), append(priv, dk.Bytes()...)
+	return append(pub, pqPub...), append(priv, pqPriv...)
 }
 }
 
 
 func X25519Keypair() ([]byte, []byte) {
 func X25519Keypair() ([]byte, []byte) {

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

@@ -37,6 +37,7 @@ type caFlags struct {
 	argonParallelism *uint
 	argonParallelism *uint
 	encryption       *bool
 	encryption       *bool
 	version          *uint
 	version          *uint
+	pqSuite          *string
 
 
 	curve  *string
 	curve  *string
 	p11url *string
 	p11url *string
@@ -71,7 +72,8 @@ func newCaFlags() *caFlags {
 	cf := caFlags{set: flag.NewFlagSet("ca", flag.ContinueOnError)}
 	cf := caFlags{set: flag.NewFlagSet("ca", flag.ContinueOnError)}
 	cf.set.Usage = func() {}
 	cf.set.Usage = func() {}
 	cf.name = cf.set.String("name", "", "Required: name of the certificate authority")
 	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.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.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")
 	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)
 		return newHelpErrorf("-version must be %v, %v or %v", cert.Version1, cert.Version2, cert.Version3)
 	}
 	}
 	if version == cert.Version3 && isP11 {
 	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
 	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 {
 		if version == cert.Version3 {
-			pub, rawPriv, err = appendPQSigningKey(pub, rawPriv)
+			pub, rawPriv, err = appendPQSigningKey(suite, pub, rawPriv)
 			if err != nil {
 			if err != nil {
 				return err
 				return err
 			}
 			}
@@ -319,6 +325,9 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
 		IsCA:           true,
 		IsCA:           true,
 		Curve:          curve,
 		Curve:          curve,
 	}
 	}
+	if version == cert.Version3 {
+		t.PQSuite = suite
+	}
 
 
 	if !isP11 && !isStdio(*cf.outKeyPath) {
 	if !isP11 && !isStdio(*cf.outKeyPath) {
 		if _, err := os.Stat(*cf.outKeyPath); err == nil {
 		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)
 			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)
 			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)
 			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)
 		err = writeOutput(*cf.outKeyPath, b, 0600, out)
 		if err != nil {
 		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"+
 			"  -out-qr string\n"+
 			"    \tOptional: output a qr code image (png) of the certificate\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")+
 			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"+
 			"  -subnets string\n"+
 			"    \tDeprecated, see -unsafe-networks\n"+
 			"    \tDeprecated, see -unsafe-networks\n"+
 			"  -unsafe-networks string\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"+
 			"    \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"+
 			"  -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(),
 		ob.String(),
 	)
 	)
 }
 }

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

@@ -20,6 +20,7 @@ type keygenFlags struct {
 	inKeyPath  *string
 	inKeyPath  *string
 	curve      *string
 	curve      *string
 	version    *uint
 	version    *uint
+	pqSuite    *string
 	p11url     *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.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.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.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)
 	cf.p11url = p11Flag(cf.set)
 	return &cf
 	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)
 		return newHelpErrorf("-version must be %v, or left out for keys that work with any other version", cert.Version3)
 	}
 	}
 	if version == cert.Version3 && isP11 {
 	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
 	var claims ioClaims
@@ -113,7 +119,7 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
 	}
 	}
 
 
 	if version == cert.Version3 {
 	if version == cert.Version3 {
-		pub, rawPriv, err = appendPQKey(pub, rawPriv)
+		pub, rawPriv, err = appendPQKey(suite, pub, rawPriv)
 		if err != nil {
 		if err != nil {
 			return err
 			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)
 			return fmt.Errorf("error while getting public key: %w", err)
 		}
 		}
 	} else {
 	} 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 {
 		if err != nil {
 			return fmt.Errorf("error while writing out-key: %s", err)
 			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 {
 	if err != nil {
 		return fmt.Errorf("error while writing out-pub: %s", err)
 		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"+
 			"  -curve string\n"+
 			"    \tECDH Curve (25519, P256) (default \"25519\")\n"+
 			"    \tECDH Curve (25519, P256) (default \"25519\")\n"+
 			"  -in-key string\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"+
 			"  -out-key string\n"+
 			"    \tRequired: path to write the private key to\n"+
 			"    \tRequired: path to write the private key to\n"+
 			"  -out-pub string\n"+
 			"  -out-pub string\n"+
 			"    \tRequired: path to write the public key to\n"+
 			"    \tRequired: path to write the public key to\n"+
 			optionalPkcs11String("  -pkcs11 string\n    \tOptional: PKCS#11 URI to an existing private key\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"+
 			"  -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(),
 		ob.String(),
 	)
 	)
 }
 }

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

@@ -3,33 +3,60 @@ package main
 import (
 import (
 	"bytes"
 	"bytes"
 	"crypto/ecdh"
 	"crypto/ecdh"
-	"crypto/mldsa"
-	"crypto/mlkem"
 	"encoding/pem"
 	"encoding/pem"
 	"flag"
 	"flag"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
 	"os"
 	"os"
+	"strings"
 
 
 	"github.com/slackhq/nebula/cert"
 	"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 {
 	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 {
 	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.
 // 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)
 	k, err := c.NewPrivateKey(priv)
 	if err != nil {
 	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
 	return k.PublicKey().Bytes(), nil
 }
 }
@@ -58,6 +85,7 @@ type pqExtendFlags struct {
 	inPubPath   *string
 	inPubPath   *string
 	outCertPath *string
 	outCertPath *string
 	outKeyPath  *string
 	outKeyPath  *string
+	pqSuite     *string
 }
 }
 
 
 func newPQExtendFlags() *pqExtendFlags {
 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.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.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.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
 	return &pf
 }
 }
 
 
@@ -134,9 +163,14 @@ func pqExtend(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
 		return err
 		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
 	var c3 cert.Certificate
 	if c.IsCA() {
 	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 {
 	} else {
 		c3, err = extendHost(pf, c, curve, caKey, &claims)
 		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
 	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 != "" {
 	if *pf.caCertPath != "" || *pf.inPubPath != "" {
 		return nil, newHelpErrorf("-ca-crt and -in-pub are for host certificates, a CA extends itself")
 		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")
 		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 {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-	signPQ, err := cert.SignPQ(priv[len(key):])
+	signPQ, err := cert.SignPQ(suite, priv[len(key):])
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-	c3, err := cert.ExtendToV3(ca, pub, nil, signPQ)
+	c3, err := cert.ExtendToV3(ca, suite, pub, nil, signPQ)
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("error while extending: %s", err)
 		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
 		a := ned.EncryptionMetadata.Argon2Parameters
 		kdf := cert.NewArgon2Parameters(a.Memory, a.Parallelism, a.Iterations)
 		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 {
 		if err != nil {
 			return nil, fmt.Errorf("error while encrypting out-key: %s", err)
 			return nil, fmt.Errorf("error while encrypting out-key: %s", err)
 		}
 		}
 	} else {
 	} else {
-		b = cert.MarshalSigningPrivateKeyToPEM(curve, priv)
+		b = cert.MarshalPQSigningPrivateKeyToPEM(curve, suite, priv)
 	}
 	}
 	if err := writeOutput(*pf.outKeyPath, b, 0600, out); err != nil {
 	if err := writeOutput(*pf.outKeyPath, b, 0600, out); err != nil {
 		return nil, fmt.Errorf("error while writing out-key: %s", err)
 		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
 	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) {
 func extendHost(pf *pqExtendFlags, c cert.Certificate, curve cert.Curve, caKey []byte, claims *ioClaims) (cert.Certificate, error) {
 	if *pf.outKeyPath != "" {
 	if *pf.outKeyPath != "" {
 		return nil, newHelpErrorf("-out-key is for CA certificates, a host's key comes from keygen -in-key")
 		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 {
 	if err != nil {
 		return nil, fmt.Errorf("error while parsing in-pub: %s", err)
 		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()) {
 	if !bytes.Equal(classical, c.PublicKey()) {
 		return nil, fmt.Errorf("in-pub does not extend the key in in-crt")
 		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 {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-	c3, err := cert.ExtendToV3(c, pq, ca, signPQ)
+	c3, err := cert.ExtendToV3(c, suite, pq, ca, signPQ)
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("error while extending: %s", err)
 		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.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")
 	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.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.
 // 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")
 				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, 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))
 			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"))
 			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()
 		version = caCert.Version()
 	}
 	}
 
 
+	// A version 3 certificate is in the suite of its CA.
+	suite := cert.PQSuiteOf(caCert)
 	switch {
 	switch {
 	case version == cert.Version3 && caCert.Version() != cert.Version3:
 	case version == cert.Version3 && caCert.Version() != cert.Version3:
 		return newHelpErrorf("version 3 certificates must be signed by a version 3 CA")
 		return newHelpErrorf("version 3 certificates must be signed by a version 3 CA")
 	case version == cert.Version3 && isP11:
 	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:
 	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.
 		// A v3 CA signs anything older as its v2 form, with the classical half of its key.
 		caCert = cert.V2Form(caCert)
 		caCert = cert.V2Form(caCert)
 		if caKey != nil {
 		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 {
 		if pubCurve != curve {
 			return fmt.Errorf("curve of in-pub does not match ca")
 			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 {
 			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")
 			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 {
 	} else if isP11 {
 		pub, err = p11Client.GetPubKey()
 		pub, err = p11Client.GetPubKey()
 		if err != nil {
 		if err != nil {
@@ -286,7 +294,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
 	} else {
 	} else {
 		pub, rawPriv = newKeypair(curve)
 		pub, rawPriv = newKeypair(curve)
 		if version == cert.Version3 {
 		if version == cert.Version3 {
-			pub, rawPriv, err = appendPQKey(pub, rawPriv)
+			pub, rawPriv, err = appendPQKey(suite, pub, rawPriv)
 			if err != nil {
 			if err != nil {
 				return err
 				return err
 			}
 			}
@@ -388,6 +396,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
 			PublicKey:      pub,
 			PublicKey:      pub,
 			IsCA:           false,
 			IsCA:           false,
 			Curve:          curve,
 			Curve:          curve,
+			PQSuite:        suite,
 		}
 		}
 
 
 		nc, err := t.Sign(caCert, curve, caKey)
 		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 {
 		if err != nil {
 			return fmt.Errorf("error while writing out-key: %s", err)
 			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,
     curve        [1] Curve DEFAULT curve25519,
     publicKey    [2] OCTET STRING,   -- classical: X25519 / P256 for hosts, Ed25519 / ECDSA P256 for CAs
     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
     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.
 - **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
   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
   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
 - **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
   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
   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.
 - **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
 - **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.
   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 |
 | 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.
 `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.
   initiator sealed.
 - v3 certificates go with this subtype and nothing else: a v3 certificate on any other subtype, or
 - 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.
   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) |
 | 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 |
 | msg3 | 1616 B | 2 |
 
 
 **Dependency.** DefinedNet/noise is a private repository today. `go.mod` points at a local clone
 **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)
 ## 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`,
 - `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
   composite credentials, cipher states ordered by role for three message patterns, and
   `Machine.PeerCert` for a responder that has to act before completion.
   `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
 - `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.
   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
 - `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
   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
 - Tests: certificate format, strictness, tampering, pool and blocklist rules and in-place
   extension on both curves; Machine-level hybrid handshakes including corrupted msg3 and
   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
   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
 	Bytes       []byte            // pre-marshaled certificate bytes
 	privateKey  []byte            // static private key (public half lives in Cert)
 	privateKey  []byte            // static private key (public half lives in Cert)
 	publicKey   []byte            // static public key, the classical half for a v3 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)
 	cipherSuite noise.CipherSuite // pre-built cipher suite (DH + cipher + hash)
 }
 }
 
 
@@ -24,7 +24,8 @@ type Credential struct {
 // appropriate DH function, cipher, and hash.
 // appropriate DH function, cipher, and hash.
 //
 //
 // For a version 3 certificate privateKey is the composite private key,
 // 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(
 func NewCredential(
 	c cert.Certificate,
 	c cert.Certificate,
 	hsBytes []byte,
 	hsBytes []byte,
@@ -38,14 +39,10 @@ func NewCredential(
 		publicKey:   c.PublicKey(),
 		publicKey:   c.PublicKey(),
 		cipherSuite: cipherSuite,
 		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
 	return hc
 }
 }
@@ -53,7 +50,7 @@ func NewCredential(
 // buildHandshakeState creates a noise.HandshakeState from this credential.
 // buildHandshakeState creates a noise.HandshakeState from this credential.
 // pskPlacement selects the noise psk modifier; 0 means none. A psk for
 // pskPlacement selects the noise psk modifier; 0 means none. A psk for
 // placements 2 and up is supplied later with SetPresharedKey. A hybrid
 // 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) {
 func (hc *Credential) buildHandshakeState(initiator bool, info subtypeInfo) (*noise.HandshakeState, error) {
 	cfg := noise.Config{
 	cfg := noise.Config{
 		CipherSuite:           hc.cipherSuite,
 		CipherSuite:           hc.cipherSuite,
@@ -64,8 +61,8 @@ func (hc *Credential) buildHandshakeState(initiator bool, info subtypeInfo) (*no
 		PresharedKey:          []byte{},
 		PresharedKey:          []byte{},
 		PresharedKeyPlacement: info.pskPlacement,
 		PresharedKeyPlacement: info.pskPlacement,
 	}
 	}
-	if info.hybrid {
-		cfg.KEMFunc = KEMMLKEM1024
+	if info.kem != nil {
+		cfg.KEMFunc = info.kem
 		cfg.StaticKEMKeypair = hc.kemKey
 		cfg.StaticKEMKeypair = hc.kemKey
 	}
 	}
 	return noise.NewHandshakeState(cfg)
 	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")
 	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")
 	ErrSubtypeMismatch         = errors.New("packet subtype does not match handshake machine subtype")
 	ErrCertVersionMismatch     = errors.New("handshake subtype does not allow this certificate version")
 	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))
 	r := rand.New(rand.NewPCG(3, 3))
 	tb := NewTable[testKey, string](testConfig())
 	tb := NewTable[testKey, string](testConfig())
 	now := time.Unix(1000, 0)
 	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 {
 	sendAll := func(key testKey, pkts [][]byte) []*Result {
 		var out []*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
 	// Version 3 certificates go with hybrid patterns and nothing else, the
 	// classical and phase 1 subtypes use the v2 form instead.
 	// 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)
 		return nil, fmt.Errorf("%w: %v", ErrCertVersionMismatch, version)
 	}
 	}
 
 
@@ -102,6 +103,11 @@ func NewMachine(
 	if cred == nil {
 	if cred == nil {
 		return nil, fmt.Errorf("%w: %v", ErrNoCredential, version)
 		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)
 	hs, err := cred.buildHandshakeState(initiator, info)
 	if err != nil {
 	if err != nil {
@@ -112,7 +118,7 @@ func NewMachine(
 		hs:         hs,
 		hs:         hs,
 		subtype:    subtype,
 		subtype:    subtype,
 		msgs:       info.msgs,
 		msgs:       info.msgs,
-		hybrid:     info.hybrid,
+		hybrid:     hybrid,
 		getCred:    getCred,
 		getCred:    getCred,
 		allocIndex: allocIndex,
 		allocIndex: allocIndex,
 		verifier:   verifier,
 		verifier:   verifier,
@@ -390,6 +396,16 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error {
 	return nil
 	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 {
 func (m *Machine) validateCert(payload Payload) error {
 	// A v3 certificate only ever comes with a hybrid pattern, and a hybrid
 	// 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
 	// 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
 		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
 	// Version negotiation, if the peer sent a different version and we have it, switch
 	if rc.Version() != m.myVersion {
 	if rc.Version() != m.myVersion {
 		if m.getCred(rc.Version()) != nil {
 		if m.getCred(rc.Version()) != nil {

+ 26 - 4
handshake/patterns.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"fmt"
 
 
 	"github.com/DefinedNet/noise"
 	"github.com/DefinedNet/noise"
+	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/header"
 	"github.com/slackhq/nebula/header"
 )
 )
 
 
@@ -22,9 +23,10 @@ type subtypeInfo struct {
 	pskPlacement int // noise psk modifier position, 0 means no psk token
 	pskPlacement int // noise psk modifier position, 0 means no psk token
 	msgs         []msgFlags
 	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
 // 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.
 	// first packet under the new keys does.
 	header.HandshakeIXHybridMLKEM1024: {
 	header.HandshakeIXHybridMLKEM1024: {
 		pattern: noise.HandshakeHybridIX,
 		pattern: noise.HandshakeHybridIX,
-		hybrid:  true,
+		kem:     KEMMLKEM1024,
 		msgs: []msgFlags{
 		msgs: []msgFlags{
 			{expectsPayload: true, expectsCert: true},
 			{expectsPayload: true, expectsCert: true},
 			{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) {
 func subtypeInfoFor(subtype header.MessageSubType) (subtypeInfo, error) {
 	if info, ok := subtypeInfos[subtype]; ok {
 	if info, ok := subtypeInfos[subtype]; ok {
 		return info, nil
 		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
 	// Gate on known handshake subtypes. Unknown subtypes (or future ones we
 	// don't yet support) are dropped here rather than silently routed through
 	// don't yet support) are dropped here rather than silently routed through
 	// the IX path. Add a case when introducing a new pattern.
 	// 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
 		// supported
 	default:
 	default:
 		hm.l.Debug("dropping handshake with unsupported subtype",
 		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,
 	// 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.
 	// so its index is registered right away for msg3 to find.
 	var pending *HandshakeHostInfo
 	var pending *HandshakeHostInfo
-	if h.Subtype == hybridSubtype {
+	if handshake.IsHybrid(h.Subtype) {
 		v = cert.Version3
 		v = cert.Version3
 		pending = &HandshakeHostInfo{
 		pending = &HandshakeHostInfo{
 			hostinfo: &HostInfo{
 			hostinfo: &HostInfo{

+ 3 - 3
handshake_manager_hybrid.go

@@ -27,8 +27,6 @@ import (
 //     within the handshake timeout the tunnel is closed.
 //     within the handshake timeout the tunnel is closed.
 
 
 const (
 const (
-	hybridSubtype = header.HandshakeIXHybridMLKEM1024
-
 	// hybridMsg3MaxLen bounds the msg3 a pending responder reassembles. msg3 is
 	// hybridMsg3MaxLen bounds the msg3 a pending responder reassembles. msg3 is
 	// a ciphertext and two tags, about 1.6KB.
 	// a ciphertext and two tags, about 1.6KB.
 	hybridMsg3MaxLen = 4096
 	hybridMsg3MaxLen = 4096
@@ -45,6 +43,7 @@ type hybridResponder struct {
 // heard from the responder.
 // heard from the responder.
 type hybridConfirm struct {
 type hybridConfirm struct {
 	hostinfo     *HostInfo
 	hostinfo     *HostInfo
+	subtype      header.MessageSubType
 	sender       *fragment.Sender
 	sender       *fragment.Sender
 	send         func([]byte) error
 	send         func([]byte) error
 	handshakeLen uint64 // messages the handshake took, which the replay window was seeded with
 	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()
 	now := time.Now()
 	c := &hybridConfirm{
 	c := &hybridConfirm{
 		hostinfo:     hostinfo,
 		hostinfo:     hostinfo,
+		subtype:      h.Subtype,
 		sender:       s,
 		sender:       s,
 		send:         hm.sendTo(via),
 		send:         hm.sendTo(via),
 		handshakeLen: handshakeLen,
 		handshakeLen: handshakeLen,
@@ -341,7 +341,7 @@ func (hm *HandshakeManager) sendHybridMsg3(c *hybridConfirm) {
 	if err != nil {
 	if err != nil {
 		return
 		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
 	// No repair delay: the packets cached during the handshake go out right
 	// behind msg3, and the responder drops whatever arrives before msg3
 	// behind msg3, and the responder drops whatever arrives before msg3
 	// completes. With the repair trailing, losing either data fragment would
 	// 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
 // 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 {
 func (hm *HandshakeManager) buildPQAttempt(hh *HandshakeHostInfo) bool {
 	cs := hm.f.pki.getCertState()
 	cs := hm.f.pki.getCertState()
 	v := hm.initiatingVersion(hh, cs)
 	v := hm.initiatingVersion(hh, cs)
 	subtype := header.HandshakeIXPSK2MLKEM1024
 	subtype := header.HandshakeIXPSK2MLKEM1024
-	if cs.GetCredential(cert.Version3) != nil {
+	if st, ok := cs.hybridSubtype(); ok {
 		v = cert.Version3
 		v = cert.Version3
-		subtype = hybridSubtype
+		subtype = st
 	}
 	}
 	if cs.GetCredential(v) == nil {
 	if cs.GetCredential(v) == nil {
 		hm.f.l.Error("Unable to handshake with host because no certificate is available",
 		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)
 			hm.l.Debug("dropping post-quantum handshake, not accepting them", "from", via)
 			return
 			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)
 				hm.l.Debug("dropping hybrid handshake, no v3 certificate to answer it with", "from", via)
 				return
 				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)
 				hm.l.Debug("dropping post-quantum handshake, hybrid handshakes are required", "from", via)
 				return
 				return
 			}
 			}
@@ -463,7 +471,7 @@ func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *head
 		}
 		}
 
 
 	case 3:
 	case 3:
-		if h.Subtype == hybridSubtype {
+		if handshake.IsHybrid(h.Subtype) {
 			hm.continueHybridResponder(via, h, fh, sym)
 			hm.continueHybridResponder(via, h, fh, sym)
 		}
 		}
 	}
 	}

+ 17 - 3
pki.go

@@ -21,6 +21,7 @@ import (
 	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/handshake"
 	"github.com/slackhq/nebula/handshake"
+	"github.com/slackhq/nebula/header"
 	"github.com/slackhq/nebula/noiseutil"
 	"github.com/slackhq/nebula/noiseutil"
 	"github.com/slackhq/nebula/util"
 	"github.com/slackhq/nebula/util"
 )
 )
@@ -222,6 +223,15 @@ func (cs *CertState) GetDefaultCertificate() cert.Certificate {
 func (cs *CertState) DefaultVersion() cert.Version { return cs.initiatingVersion }
 func (cs *CertState) DefaultVersion() cert.Version { return cs.initiatingVersion }
 
 
 // GetCredential returns the pre-computed handshake credential for the given version, or nil.
 // 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 {
 func (cs *CertState) GetCredential(v cert.Version) *handshake.Credential {
 	switch v {
 	switch v {
 	case cert.Version1:
 	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
 // 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) {
 func newCertState(dv cert.Version, v1, v2, v3 cert.Certificate, pkcs11backed bool, privateKeyCurve cert.Curve, privateKey []byte, cipher string) (*CertState, error) {
 	cs := CertState{
 	cs := CertState{
 		privateKey:               privateKey,
 		privateKey:               privateKey,
@@ -468,8 +478,8 @@ func newCertState(dv cert.Version, v1, v2, v3 cert.Certificate, pkcs11backed boo
 	}
 	}
 
 
 	classicalKey := privateKey
 	classicalKey := privateKey
-	if !pkcs11backed && len(privateKey) > cert.MLKEM1024PrivateKeySize {
-		classicalKey = privateKey[:len(privateKey)-cert.MLKEM1024PrivateKeySize]
+	if !pkcs11backed {
+		classicalKey = cert.ClassicalPrivateKey(privateKey)
 	}
 	}
 
 
 	if v3 != nil {
 	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 {
 		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)
 			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()
 		v3hs, err := v3.MarshalForHandshakes()
 		if err != nil {
 		if err != nil {