Kaynağa Gözat

cert: Add version 3 certificates, a v2 certificate extended with ML-KEM-1024 or ML-DSA-87 keys and an ML-DSA-87 CA signature

JackDoan 6 gün önce
ebeveyn
işleme
1a8fb8d67e
9 değiştirilmiş dosya ile 1255 ekleme ve 10 silme
  1. 51 1
      cert/ca_pool.go
  2. 13 0
      cert/cert.go
  3. 520 0
      cert/cert_v3.go
  4. 429 0
      cert/cert_v3_test.go
  5. 22 0
      cert/crypto.go
  6. 28 0
      cert/helper_test.go
  7. 98 1
      cert/pem.go
  8. 66 8
      cert/sign.go
  9. 28 0
      cert_test/cert.go

+ 51 - 1
cert/ca_pool.go

@@ -15,6 +15,11 @@ import (
 type CAPool struct {
 	CAs           map[string]*CachedCertificate
 	certBlocklist map[string]struct{}
+
+	// v3CAs indexes the version 3 CAs in CAs by the fingerprint of their v2 form, which is what the certificates
+	// they issue name as issuer. A version 3 CA only verifies version 3 certificates: version 2 certificates find
+	// their issuer in CAs, where only the v2 form of the CA, if listed itself, answers to that fingerprint.
+	v3CAs map[string]*CachedCertificate
 }
 
 // NewCAPool creates an empty CAPool
@@ -22,6 +27,7 @@ func NewCAPool() *CAPool {
 	ca := CAPool{
 		CAs:           make(map[string]*CachedCertificate),
 		certBlocklist: make(map[string]struct{}),
+		v3CAs:         make(map[string]*CachedCertificate),
 	}
 
 	return &ca
@@ -122,6 +128,17 @@ func (ncp *CAPool) AddCA(c Certificate) error {
 		cc.InvertedGroups[g] = struct{}{}
 	}
 
+	if v2 := V2Form(c); v2 != nil {
+		v2Sum, err := v2.Fingerprint()
+		if err != nil {
+			return fmt.Errorf("could not calculate fingerprint for provided CA; error: %w; %s", err, c.Name())
+		}
+		if other, ok := ncp.v3CAs[v2Sum]; ok && other.Fingerprint != sum {
+			return fmt.Errorf("%s: two version 3 CAs extend the same version 2 CA %s", c.Name(), v2Sum)
+		}
+		ncp.v3CAs[v2Sum] = cc
+	}
+
 	ncp.CAs[sum] = cc
 
 	if c.Expired(time.Now()) {
@@ -180,11 +197,34 @@ func (ncp *CAPool) VerifyCertificate(now time.Time, c Certificate) (*CachedCerti
 		return nil, ErrBlockListed
 	}
 
+	// Blocking the v2 form of a version 3 certificate blocks the certificate too.
+	var v2Fps []string
+	if v2 := V2Form(c); v2 != nil {
+		v2Fp, err := v2.Fingerprint()
+		if err != nil {
+			return nil, fmt.Errorf("could not calculate v2 fingerprint to verify: %w", err)
+		}
+		v2Fp2, err := CalculateAlternateFingerprint(v2)
+		if err != nil {
+			return nil, fmt.Errorf("could not calculate alternate v2 fingerprint to verify: %w", err)
+		}
+		v2Fps = append(v2Fps, v2Fp)
+		if v2Fp2 != "" {
+			v2Fps = append(v2Fps, v2Fp2)
+		}
+		for _, f := range v2Fps {
+			if ncp.IsBlocklisted(f) {
+				return nil, ErrBlockListed
+			}
+		}
+	}
+
 	cc := CachedCertificate{
 		Certificate:       c,
 		InvertedGroups:    make(map[string]struct{}),
 		Fingerprint:       fp,
 		fingerprint2:      fp2,
+		v2Fingerprints:    v2Fps,
 		signerFingerprint: signer.Fingerprint,
 	}
 
@@ -202,6 +242,11 @@ func (ncp *CAPool) VerifyCachedCertificate(now time.Time, c *CachedCertificate)
 	if c.fingerprint2 != "" && ncp.IsBlocklisted(c.fingerprint2) {
 		return ErrBlockListed
 	}
+	for _, f := range c.v2Fingerprints {
+		if ncp.IsBlocklisted(f) {
+			return ErrBlockListed
+		}
+	}
 
 	_, err := ncp.verify(c.Certificate, now, c.Fingerprint, c.signerFingerprint)
 	return err
@@ -257,7 +302,12 @@ func (ncp *CAPool) GetCAForCert(c Certificate) (*CachedCertificate, error) {
 		return nil, fmt.Errorf("no issuer in certificate")
 	}
 
-	signer, ok := ncp.CAs[issuer]
+	cas := ncp.CAs
+	if c.Version() == Version3 {
+		cas = ncp.v3CAs
+	}
+
+	signer, ok := cas[issuer]
 	if ok {
 		return signer, nil
 	}

+ 13 - 0
cert/cert.go

@@ -14,12 +14,14 @@ const (
 	VersionPre1 Version = 0
 	Version1    Version = 1
 	Version2    Version = 2
+	Version3    Version = 3
 )
 
 type Certificate interface {
 	// Version defines the underlying certificate structure and wire protocol version
 	// Version1 certificates are ipv4 only and uses protobuf serialization
 	// Version2 certificates are ipv4 or ipv6 and uses asn.1 serialization
+	// Version3 certificates extend a Version2 certificate with post-quantum keys and signatures
 	Version() Version
 
 	// Name is the human-readable name that identifies this certificate.
@@ -115,6 +117,9 @@ type CachedCertificate struct {
 
 	// A place to store a 2nd fingerprint if the certificate could have one, such as with P256
 	fingerprint2 string
+
+	// The fingerprints of a version 3 certificate's v2 form, which the blocklist also applies to
+	v2Fingerprints []string
 }
 
 func (cc *CachedCertificate) String() string {
@@ -143,6 +148,8 @@ func Recombine(v Version, rawCertBytes, publicKey []byte, curve Curve) (Certific
 		c, err = unmarshalCertificateV1(rawCertBytes, publicKey)
 	case Version2:
 		c, err = unmarshalCertificateV2(rawCertBytes, publicKey, curve)
+	case Version3:
+		c, err = unmarshalCertificateV3(rawCertBytes, publicKey, curve)
 	default:
 		return nil, ErrUnknownVersion
 	}
@@ -165,6 +172,12 @@ func CalculateAlternateFingerprint(c Certificate) (string, error) {
 		return "", nil
 	}
 
+	// The post-quantum signature of a version 3 certificate covers its classical signature, so it has no second form.
+	// The pool checks the fingerprints of its v2 form separately.
+	if c.Version() == Version3 {
+		return "", nil
+	}
+
 	nc := c.Copy()
 	b, err := p256.Swap(nc.Signature())
 	if err != nil {

+ 520 - 0
cert/cert_v3.go

@@ -0,0 +1,520 @@
+package cert
+
+import (
+	"bytes"
+	"crypto/mldsa"
+	"crypto/mlkem"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"encoding/pem"
+	"fmt"
+	"net/netip"
+	"time"
+
+	"golang.org/x/crypto/cryptobyte"
+	"golang.org/x/crypto/cryptobyte/asn1"
+)
+
+// A version 3 certificate is a version 2 certificate extended with post-quantum keys. Its first fields are exactly
+// the fields of the v2 certificate it extends, so a v3 holder can always marshal that v2 form, byte for byte, and use
+// it with peers that only speak v2. The v2 form keeps its own classical CA signature and fingerprint.
+//
+// Hosts carry an ML-KEM-1024 encapsulation key and CAs an ML-DSA-87 public key. The issuing CA signs every field
+// with ML-DSA-87, including the classical signature, so the classical signature can not be swapped out either.
+//
+// Wherever a v3 certificate deals in keys they are composite: the classical key followed by the post-quantum one.
+// PublicKey returns classical || post-quantum, and VerifyPrivateKey and signing take the private keys the same way.
+
+const (
+	TagCertPQPublicKey = 4 | classContextSpecific
+	TagCertPQSignature = 5 | classContextSpecific
+)
+
+const (
+	// MLKEM1024PublicKeySize is the size of the ML-KEM-1024 encapsulation key in a v3 host certificate.
+	MLKEM1024PublicKeySize = mlkem.EncapsulationKeySize1024
+	// MLKEM1024PrivateKeySize is the size of the ML-KEM-1024 decapsulation key seed in a v3 host private key.
+	MLKEM1024PrivateKeySize = mlkem.SeedSize
+	// MLDSA87PublicKeySize is the size of the ML-DSA-87 public key in a v3 CA certificate.
+	MLDSA87PublicKeySize = mldsa.MLDSA87PublicKeySize
+	// MLDSA87PrivateKeySize is the size of the ML-DSA-87 private key seed in a v3 CA private key.
+	MLDSA87PrivateKeySize = mldsa.PrivateKeySize
+	// MLDSA87SignatureSize is the size of the ML-DSA-87 signature in every v3 certificate.
+	MLDSA87SignatureSize = mldsa.MLDSA87SignatureSize
+
+	// pqSignatureContext is the ML-DSA context string for certificate signatures.
+	pqSignatureContext = "nebula certificate v3"
+)
+
+type certificateV3 struct {
+	// v2 is the version 2 certificate this one extends. Its public key is the classical key.
+	v2 certificateV2
+
+	// pqPublicKey is the ML-KEM-1024 encapsulation key of a host, or the ML-DSA-87 public key of a CA.
+	pqPublicKey []byte
+
+	// pqSignature is the issuing CA's ML-DSA-87 signature over every other field.
+	pqSignature []byte
+}
+
+// pqPublicKeySize returns the size of the post-quantum public key a v3 certificate carries.
+func pqPublicKeySize(isCA bool) int {
+	if isCA {
+		return MLDSA87PublicKeySize
+	}
+	return MLKEM1024PublicKeySize
+}
+
+// pqPrivateKeySize returns the size of the post-quantum half of a v3 private key.
+func pqPrivateKeySize(isCA bool) int {
+	if isCA {
+		return MLDSA87PrivateKeySize
+	}
+	return MLKEM1024PrivateKeySize
+}
+
+// splitPQPublicKey splits a composite public key into its classical and post-quantum halves.
+func splitPQPublicKey(isCA bool, key []byte) ([]byte, []byte, error) {
+	n := pqPublicKeySize(isCA)
+	if len(key) <= n {
+		return nil, nil, ErrInvalidPublicKey
+	}
+	return key[:len(key)-n], key[len(key)-n:], nil
+}
+
+// V2Form returns the version 2 certificate a version 3 certificate extends, or nil for any other version. It is
+// what a v3 holder presents to peers that do not speak v3.
+func V2Form(c Certificate) Certificate {
+	if c3, ok := c.(*certificateV3); ok {
+		return &c3.v2
+	}
+	return nil
+}
+
+func (c *certificateV3) Version() Version {
+	return Version3
+}
+
+func (c *certificateV3) Curve() Curve {
+	return c.v2.curve
+}
+
+func (c *certificateV3) Groups() []string {
+	return c.v2.details.groups
+}
+
+func (c *certificateV3) IsCA() bool {
+	return c.v2.details.isCA
+}
+
+func (c *certificateV3) Issuer() string {
+	return c.v2.details.issuer
+}
+
+func (c *certificateV3) Name() string {
+	return c.v2.details.name
+}
+
+func (c *certificateV3) Networks() []netip.Prefix {
+	return c.v2.details.networks
+}
+
+func (c *certificateV3) NotAfter() time.Time {
+	return c.v2.details.notAfter
+}
+
+func (c *certificateV3) NotBefore() time.Time {
+	return c.v2.details.notBefore
+}
+
+// PublicKey returns the composite public key, classical || post-quantum.
+func (c *certificateV3) PublicKey() []byte {
+	k := make([]byte, 0, len(c.v2.publicKey)+len(c.pqPublicKey))
+	k = append(k, c.v2.publicKey...)
+	return append(k, c.pqPublicKey...)
+}
+
+func (c *certificateV3) MarshalPublicKeyPEM() []byte {
+	return marshalCertPublicKeyToPEM(c)
+}
+
+// Signature returns the classical signature. The post-quantum signature covers it.
+func (c *certificateV3) Signature() []byte {
+	return c.v2.signature
+}
+
+func (c *certificateV3) UnsafeNetworks() []netip.Prefix {
+	return c.v2.details.unsafeNetworks
+}
+
+// Fingerprint is the sha256 of the whole certificate. It differs from the fingerprint of the v2 form.
+func (c *certificateV3) Fingerprint() (string, error) {
+	b, err := c.Marshal()
+	if err != nil {
+		return "", err
+	}
+	sum := sha256.Sum256(b)
+	return hex.EncodeToString(sum[:]), nil
+}
+
+// CheckSignature checks both signatures against the composite public key of the signing CA.
+func (c *certificateV3) CheckSignature(key []byte) bool {
+	classical, pq, err := splitPQPublicKey(true, key)
+	if err != nil || len(pq) != MLDSA87PublicKeySize {
+		return false
+	}
+	if !c.v2.CheckSignature(classical) {
+		return false
+	}
+	pk, err := mldsa.NewPublicKey(mldsa.MLDSA87(), pq)
+	if err != nil {
+		return false
+	}
+	b, err := c.marshalForPQSigning()
+	if err != nil {
+		return false
+	}
+	return mldsa.Verify(pk, b, c.pqSignature, &mldsa.Options{Context: pqSignatureContext}) == nil
+}
+
+func (c *certificateV3) Expired(t time.Time) bool {
+	return c.v2.Expired(t)
+}
+
+// VerifyPrivateKey checks a composite private key, classical || post-quantum seed, against the composite public key.
+func (c *certificateV3) VerifyPrivateKey(curve Curve, key []byte) error {
+	n := pqPrivateKeySize(c.IsCA())
+	if len(key) <= n {
+		return ErrInvalidPrivateKey
+	}
+	if err := c.v2.VerifyPrivateKey(curve, key[:len(key)-n]); err != nil {
+		return err
+	}
+
+	var pub []byte
+	seed := key[len(key)-n:]
+	if c.IsCA() {
+		sk, err := mldsa.NewPrivateKey(mldsa.MLDSA87(), seed)
+		if err != nil {
+			return ErrInvalidPrivateKey
+		}
+		pub = sk.PublicKey().Bytes()
+	} else {
+		dk, err := mlkem.NewDecapsulationKey1024(seed)
+		if err != nil {
+			return ErrInvalidPrivateKey
+		}
+		pub = dk.EncapsulationKey().Bytes()
+	}
+	if !bytes.Equal(pub, c.pqPublicKey) {
+		return ErrPublicPrivateKeyMismatch
+	}
+	return nil
+}
+
+func (c *certificateV3) String() string {
+	mb, err := c.marshalJSON()
+	if err != nil {
+		return fmt.Sprintf("<error marshalling certificate: %v>", err)
+	}
+
+	b, err := json.MarshalIndent(mb, "", "\t")
+	if err != nil {
+		return fmt.Sprintf("<error marshalling certificate: %v>", err)
+	}
+	return string(b)
+}
+
+// MarshalForHandshakes leaves out both public keys, the handshake carries them as the composite static key.
+func (c *certificateV3) MarshalForHandshakes() ([]byte, error) {
+	return c.marshal(false, false)
+}
+
+func (c *certificateV3) Marshal() ([]byte, error) {
+	return c.marshal(true, false)
+}
+
+// marshalForPQSigning returns the bytes the post-quantum signature covers: every field but that signature, with the
+// curve always present.
+func (c *certificateV3) marshalForPQSigning() ([]byte, error) {
+	return c.marshal(true, true)
+}
+
+func (c *certificateV3) marshal(withPublicKeys, forSigning bool) ([]byte, error) {
+	if c.v2.rawDetails == nil {
+		return nil, ErrEmptyRawDetails
+	}
+	var b cryptobyte.Builder
+	b.AddASN1(asn1.SEQUENCE, func(b *cryptobyte.Builder) {
+		b.AddBytes(c.v2.rawDetails)
+
+		if withPublicKeys {
+			if forSigning || c.v2.curve != Curve_CURVE25519 {
+				b.AddASN1(TagCertCurve, func(b *cryptobyte.Builder) {
+					b.AddBytes([]byte{byte(c.v2.curve)})
+				})
+			}
+			b.AddASN1(TagCertPublicKey, func(b *cryptobyte.Builder) {
+				b.AddBytes(c.v2.publicKey)
+			})
+		}
+
+		b.AddASN1(TagCertSignature, func(b *cryptobyte.Builder) {
+			b.AddBytes(c.v2.signature)
+		})
+
+		if withPublicKeys {
+			b.AddASN1(TagCertPQPublicKey, func(b *cryptobyte.Builder) {
+				b.AddBytes(c.pqPublicKey)
+			})
+		}
+
+		if !forSigning {
+			b.AddASN1(TagCertPQSignature, func(b *cryptobyte.Builder) {
+				b.AddBytes(c.pqSignature)
+			})
+		}
+	})
+
+	return b.Bytes()
+}
+
+func (c *certificateV3) MarshalPEM() ([]byte, error) {
+	b, err := c.Marshal()
+	if err != nil {
+		return nil, err
+	}
+	return pem.EncodeToMemory(&pem.Block{Type: CertificateV3Banner, Bytes: b}), nil
+}
+
+func (c *certificateV3) MarshalJSON() ([]byte, error) {
+	b, err := c.marshalJSON()
+	if err != nil {
+		return nil, err
+	}
+	return json.Marshal(b)
+}
+
+func (c *certificateV3) marshalJSON() (m, error) {
+	mb, err := c.v2.marshalJSON()
+	if err != nil {
+		return nil, err
+	}
+	fp, err := c.Fingerprint()
+	if err != nil {
+		return nil, err
+	}
+
+	mb["v2Fingerprint"] = mb["fingerprint"]
+	mb["fingerprint"] = fp
+	mb["version"] = Version3
+	mb["pqPublicKey"] = fmt.Sprintf("%x", c.pqPublicKey)
+	mb["pqSignature"] = fmt.Sprintf("%x", c.pqSignature)
+	return mb, nil
+}
+
+func (c *certificateV3) Copy() Certificate {
+	nc := &certificateV3{
+		v2:          *c.v2.Copy().(*certificateV2),
+		pqPublicKey: make([]byte, len(c.pqPublicKey)),
+		pqSignature: make([]byte, len(c.pqSignature)),
+	}
+	copy(nc.pqPublicKey, c.pqPublicKey)
+	copy(nc.pqSignature, c.pqSignature)
+	return nc
+}
+
+func (c *certificateV3) validate() error {
+	if err := c.v2.validate(); err != nil {
+		return err
+	}
+
+	if len(c.pqPublicKey) != pqPublicKeySize(c.IsCA()) {
+		return ErrInvalidPublicKey
+	}
+	if c.IsCA() {
+		if _, err := mldsa.NewPublicKey(mldsa.MLDSA87(), c.pqPublicKey); err != nil {
+			return ErrInvalidPublicKey
+		}
+	} else {
+		if _, err := mlkem.NewEncapsulationKey1024(c.pqPublicKey); err != nil {
+			return ErrInvalidPublicKey
+		}
+	}
+
+	if len(c.pqSignature) != MLDSA87SignatureSize {
+		return ErrBadFormat
+	}
+	return nil
+}
+
+// unmarshalCertificateV3 parses a v3 certificate. publicKey is the composite public key when the certificate came
+// from a handshake, which then must not carry either public key itself.
+func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certificateV3, error) {
+	l := len(b)
+	if l == 0 || l > MaxCertificateSize {
+		return nil, ErrBadFormat
+	}
+
+	input := cryptobyte.String(b)
+	var body cryptobyte.String
+	if !input.ReadASN1(&body, asn1.SEQUENCE) || body.Empty() || !input.Empty() {
+		return nil, ErrBadFormat
+	}
+
+	var rawDetails cryptobyte.String
+	if !body.ReadASN1Element(&rawDetails, TagCertDetails) || rawDetails.Empty() {
+		return nil, ErrBadFormat
+	}
+
+	var rawCurve byte
+	if !readOptionalASN1Byte(&body, &rawCurve, TagCertCurve, byte(curve)) {
+		return nil, ErrBadFormat
+	}
+	curve = Curve(rawCurve)
+
+	fromHandshake := len(publicKey) > 0
+	var classical, pq cryptobyte.String
+	if fromHandshake {
+		if body.PeekASN1Tag(TagCertPublicKey) {
+			return nil, ErrCertPubkeyPresent
+		}
+	} else if !body.ReadASN1(&classical, TagCertPublicKey) || classical.Empty() {
+		return nil, ErrBadFormat
+	}
+
+	var signature cryptobyte.String
+	if !body.ReadASN1(&signature, TagCertSignature) || signature.Empty() {
+		return nil, ErrBadFormat
+	}
+
+	if fromHandshake {
+		if body.PeekASN1Tag(TagCertPQPublicKey) {
+			return nil, ErrCertPubkeyPresent
+		}
+	} else if !body.ReadASN1(&pq, TagCertPQPublicKey) || pq.Empty() {
+		return nil, ErrBadFormat
+	}
+
+	var pqSignature cryptobyte.String
+	if !body.ReadASN1(&pqSignature, TagCertPQSignature) || pqSignature.Empty() {
+		return nil, ErrBadFormat
+	}
+
+	if !body.Empty() {
+		return nil, ErrBadFormat
+	}
+
+	details, err := unmarshalDetails(rawDetails)
+	if err != nil {
+		return nil, err
+	}
+
+	if fromHandshake {
+		k, p, err := splitPQPublicKey(details.isCA, publicKey)
+		if err != nil {
+			return nil, err
+		}
+		classical = bytes.Clone(k)
+		pq = bytes.Clone(p)
+	}
+
+	c := &certificateV3{
+		v2: certificateV2{
+			details:    details,
+			rawDetails: rawDetails,
+			curve:      curve,
+			publicKey:  classical,
+			signature:  signature,
+		},
+		pqPublicKey: pq,
+		pqSignature: pqSignature,
+	}
+
+	if err := c.validate(); err != nil {
+		return nil, err
+	}
+
+	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.
+//
+// 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) {
+	c2, ok := c.(*certificateV2)
+	if !ok {
+		return nil, fmt.Errorf("only version 2 certificates can be extended to version 3, got version %d", c.Version())
+	}
+
+	var signingKey []byte
+	if c2.IsCA() {
+		if signer != nil {
+			return nil, fmt.Errorf("a CA certificate must extend itself, signer must be nil")
+		}
+		if !c2.CheckSignature(c2.publicKey) {
+			return nil, ErrNotSelfSigned
+		}
+		signingKey = append(bytes.Clone(c2.publicKey), pqPublicKey...)
+	} else {
+		if signer == nil || signer.Version() != Version3 || !signer.IsCA() {
+			return nil, fmt.Errorf("a host certificate must be extended by a version 3 CA")
+		}
+		signerV2 := V2Form(signer)
+		issuer, err := signerV2.Fingerprint()
+		if err != nil {
+			return nil, err
+		}
+		if c2.Issuer() != issuer {
+			return nil, fmt.Errorf("certificate was not issued by the signing CA")
+		}
+		if !c2.CheckSignature(signerV2.PublicKey()) {
+			return nil, ErrSignatureMismatch
+		}
+		signingKey = signer.PublicKey()
+	}
+
+	c3 := &certificateV3{
+		v2:          *c2.Copy().(*certificateV2),
+		pqPublicKey: bytes.Clone(pqPublicKey),
+	}
+	if len(c3.pqPublicKey) != pqPublicKeySize(c3.IsCA()) {
+		return nil, ErrInvalidPublicKey
+	}
+
+	b, err := c3.marshalForPQSigning()
+	if err != nil {
+		return nil, err
+	}
+	sig, err := pqSign(b)
+	if err != nil {
+		return nil, err
+	}
+	c3.pqSignature = sig
+
+	if err := c3.validate(); err != nil {
+		return nil, err
+	}
+
+	// Catch a pqSign that used the wrong key before the certificate goes anywhere.
+	if !c3.CheckSignature(signingKey) {
+		return nil, ErrSignatureMismatch
+	}
+
+	return c3, nil
+}
+
+// SignPQ returns a SignerLambda that signs certificates with the ML-DSA-87 private key seed.
+func SignPQ(seed []byte) (SignerLambda, error) {
+	sk, err := mldsa.NewPrivateKey(mldsa.MLDSA87(), seed)
+	if err != nil {
+		return nil, err
+	}
+	return func(b []byte) ([]byte, error) {
+		return sk.Sign(nil, b, &mldsa.Options{Context: pqSignatureContext})
+	}, nil
+}

+ 429 - 0
cert/cert_v3_test.go

@@ -0,0 +1,429 @@
+package cert
+
+import (
+	"crypto/mldsa"
+	"crypto/mlkem"
+	"net/netip"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula/cert/p256"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/crypto/cryptobyte"
+	"golang.org/x/crypto/cryptobyte/asn1"
+)
+
+var testCurves = []Curve{Curve_CURVE25519, Curve_P256}
+
+func newTestV3(t *testing.T, curve Curve) (ca Certificate, caKey []byte, host Certificate, hostKey []byte) {
+	t.Helper()
+	ca, _, caKey, _ = NewTestCaCert(Version3, curve, time.Time{}, time.Time{}, nil, nil, []string{"g"})
+	host, _, hostKeyPEM, _ := NewTestCert(Version3, curve, ca, caKey, "host", time.Time{}, time.Time{}, []netip.Prefix{netip.MustParsePrefix("10.1.0.1/16")}, nil, []string{"g"})
+	hostKey, _, _, err := UnmarshalPrivateKeyFromPEM(hostKeyPEM)
+	require.NoError(t, err)
+	return ca, caKey, host, hostKey
+}
+
+func TestCertificateV3_Sign(t *testing.T) {
+	for _, curve := range testCurves {
+		t.Run(curve.String(), func(t *testing.T) {
+			ca, caKey, host, hostKey := newTestV3(t, curve)
+			assert.Equal(t, Version3, ca.Version())
+			assert.Equal(t, Version3, host.Version())
+
+			assert.True(t, ca.CheckSignature(ca.PublicKey()))
+			assert.True(t, host.CheckSignature(ca.PublicKey()))
+			require.NoError(t, ca.VerifyPrivateKey(curve, caKey))
+			require.NoError(t, host.VerifyPrivateKey(curve, hostKey))
+
+			// The classical half of the key alone is not the key.
+			require.Error(t, host.VerifyPrivateKey(curve, hostKey[:len(hostKey)-MLKEM1024PrivateKeySize]))
+
+			// The issuer is the v2 form of the CA, which old peers know it by.
+			caV2Fp, err := V2Form(ca).Fingerprint()
+			require.NoError(t, err)
+			assert.Equal(t, caV2Fp, host.Issuer())
+
+			// A v2 CA can not sign a v3 certificate, nor a v3 CA a v2 one.
+			_, err = (&TBSCertificate{Version: Version3, Curve: curve, Name: "x", Networks: host.Networks(), NotBefore: host.NotBefore(), NotAfter: host.NotAfter(), PublicKey: host.PublicKey()}).Sign(V2Form(ca), curve, caKey)
+			require.Error(t, err)
+		})
+	}
+}
+
+// The v2 form must be exactly the certificate the CA's v2 form would have signed, so peers that only speak v2
+// accept it. Ed25519 signatures are deterministic, so the bytes can be compared outright.
+func TestCertificateV3_V2FormIsExact(t *testing.T) {
+	ca, caKey, host, _ := newTestV3(t, Curve_CURVE25519)
+	classicalCAKey := caKey[:len(caKey)-MLDSA87PrivateKeySize]
+	classicalPub := V2Form(host).PublicKey()
+
+	tbs := &TBSCertificate{
+		Version:   Version2,
+		Curve:     Curve_CURVE25519,
+		Name:      host.Name(),
+		Networks:  host.Networks(),
+		Groups:    host.Groups(),
+		NotBefore: host.NotBefore(),
+		NotAfter:  host.NotAfter(),
+		PublicKey: classicalPub,
+	}
+	v2, err := tbs.Sign(V2Form(ca), Curve_CURVE25519, classicalCAKey)
+	require.NoError(t, err)
+
+	want, err := v2.Marshal()
+	require.NoError(t, err)
+	got, err := V2Form(host).Marshal()
+	require.NoError(t, err)
+	assert.Equal(t, want, got)
+
+	wantHS, err := v2.MarshalForHandshakes()
+	require.NoError(t, err)
+	gotHS, err := V2Form(host).MarshalForHandshakes()
+	require.NoError(t, err)
+	assert.Equal(t, wantHS, gotHS)
+
+	assert.Nil(t, V2Form(v2))
+}
+
+func TestCertificateV3_MarshalRoundTrip(t *testing.T) {
+	for _, curve := range testCurves {
+		t.Run(curve.String(), func(t *testing.T) {
+			ca, _, host, _ := newTestV3(t, curve)
+
+			for _, c := range []Certificate{ca, host} {
+				b, err := c.Marshal()
+				require.NoError(t, err)
+				c2, err := unmarshalCertificateV3(b, nil, Curve_CURVE25519)
+				require.NoError(t, err)
+				assertCertsEqual(t, c, c2)
+
+				p, err := c.MarshalPEM()
+				require.NoError(t, err)
+				c3, rest, err := UnmarshalCertificateFromPEM(p)
+				require.NoError(t, err)
+				assert.Empty(t, rest)
+				assertCertsEqual(t, c, c3)
+
+				// Handshakes carry the composite public key in the noise static, not in the certificate.
+				hs, err := c.MarshalForHandshakes()
+				require.NoError(t, err)
+				assert.Less(t, len(hs), len(b)-len(c.PublicKey()))
+				c4, err := Recombine(Version3, hs, c.PublicKey(), curve)
+				require.NoError(t, err)
+				assertCertsEqual(t, c, c4)
+
+				_, err = Recombine(Version3, b, c.PublicKey(), curve)
+				require.ErrorIs(t, err, ErrCertPubkeyPresent)
+			}
+		})
+	}
+}
+
+func assertCertsEqual(t *testing.T, want, got Certificate) {
+	t.Helper()
+	wb, err := want.Marshal()
+	require.NoError(t, err)
+	gb, err := got.Marshal()
+	require.NoError(t, err)
+	assert.Equal(t, wb, gb)
+	wf, err := want.Fingerprint()
+	require.NoError(t, err)
+	gf, err := got.Fingerprint()
+	require.NoError(t, err)
+	assert.Equal(t, wf, gf)
+	assert.Equal(t, want.PublicKey(), got.PublicKey())
+}
+
+// rebuildV3 re-encodes a v3 certificate's envelope after edit has had a go at its fields.
+func rebuildV3(t *testing.T, c Certificate, edit func(fields map[asn1.Tag][]byte, order *[]asn1.Tag)) []byte {
+	t.Helper()
+	b, err := c.Marshal()
+	require.NoError(t, err)
+	in := cryptobyte.String(b)
+	var body cryptobyte.String
+	require.True(t, in.ReadASN1(&body, asn1.SEQUENCE))
+
+	fields := map[asn1.Tag][]byte{}
+	var order []asn1.Tag
+	for !body.Empty() {
+		var el cryptobyte.String
+		var tag asn1.Tag
+		require.True(t, body.ReadAnyASN1Element(&el, &tag))
+		fields[tag] = el
+		order = append(order, tag)
+	}
+	edit(fields, &order)
+
+	var out cryptobyte.Builder
+	out.AddASN1(asn1.SEQUENCE, func(b *cryptobyte.Builder) {
+		for _, tag := range order {
+			b.AddBytes(fields[tag])
+		}
+	})
+	return out.BytesOrPanic()
+}
+
+func TestCertificateV3_StrictParse(t *testing.T) {
+	_, _, host, _ := newTestV3(t, Curve_CURVE25519)
+	good, err := host.Marshal()
+	require.NoError(t, err)
+
+	_, err = unmarshalCertificateV3(append(good, 0), nil, Curve_CURVE25519)
+	require.ErrorIs(t, err, ErrBadFormat, "trailing bytes after the envelope")
+
+	trailing := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) {
+		fields[6|classContextSpecific] = []byte{6 | classContextSpecific, 1, 0}
+		*order = append(*order, 6|classContextSpecific)
+	})
+	_, err = unmarshalCertificateV3(trailing, nil, Curve_CURVE25519)
+	require.ErrorIs(t, err, ErrBadFormat, "unknown field after the pq signature")
+
+	for _, tag := range []asn1.Tag{TagCertPQPublicKey, TagCertPQSignature} {
+		missing := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) {
+			*order = removeTag(*order, tag)
+		})
+		_, err = unmarshalCertificateV3(missing, nil, Curve_CURVE25519)
+		require.Error(t, err, "missing %x", tag)
+	}
+
+	// A v2 certificate is not a v3 one.
+	v2, err := V2Form(host).Marshal()
+	require.NoError(t, err)
+	_, err = unmarshalCertificateV3(v2, nil, Curve_CURVE25519)
+	require.Error(t, err)
+
+	// A wrong sized post-quantum key fails validation.
+	short := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) {
+		var b cryptobyte.Builder
+		b.AddASN1(TagCertPQPublicKey, func(b *cryptobyte.Builder) { b.AddBytes(make([]byte, MLKEM1024PublicKeySize-1)) })
+		fields[TagCertPQPublicKey] = b.BytesOrPanic()
+	})
+	_, err = unmarshalCertificateV3(short, nil, Curve_CURVE25519)
+	require.ErrorIs(t, err, ErrInvalidPublicKey)
+}
+
+func removeTag(order []asn1.Tag, tag asn1.Tag) []asn1.Tag {
+	var out []asn1.Tag
+	for _, t := range order {
+		if t != tag {
+			out = append(out, t)
+		}
+	}
+	return out
+}
+
+func TestCertificateV3_Tamper(t *testing.T) {
+	for _, curve := range testCurves {
+		t.Run(curve.String(), func(t *testing.T) {
+			ca, _, host, _ := newTestV3(t, curve)
+
+			// Swapping the post-quantum key breaks only the post-quantum signature, which must still catch it.
+			dk, err := mlkem.GenerateKey1024()
+			require.NoError(t, err)
+			swapped := host.Copy().(*certificateV3)
+			swapped.pqPublicKey = dk.EncapsulationKey().Bytes()
+			assert.True(t, V2Form(swapped).CheckSignature(V2Form(ca).PublicKey()))
+			assert.False(t, swapped.CheckSignature(ca.PublicKey()))
+
+			// The post-quantum signature covers the classical one.
+			resigned := host.Copy().(*certificateV3)
+			if curve == Curve_P256 {
+				sig, err := p256.Swap(resigned.v2.signature)
+				require.NoError(t, err)
+				resigned.v2.signature = sig
+				assert.True(t, V2Form(resigned).CheckSignature(V2Form(ca).PublicKey()))
+			} else {
+				resigned.v2.signature[0] ^= 1
+			}
+			assert.False(t, resigned.CheckSignature(ca.PublicKey()))
+
+			// Another CA's post-quantum key does not verify.
+			other, _, _, _ := NewTestCaCert(Version3, curve, time.Time{}, time.Time{}, nil, nil, nil)
+			k := append(V2Form(ca).PublicKey(), other.PublicKey()[len(V2Form(other).PublicKey()):]...)
+			assert.False(t, host.CheckSignature(k))
+		})
+	}
+}
+
+func TestCAPool_V3(t *testing.T) {
+	for _, curve := range testCurves {
+		t.Run(curve.String(), func(t *testing.T) {
+			ca, _, host, _ := newTestV3(t, curve)
+			now := time.Now()
+
+			// A v3 CA verifies v3 certificates, and only those.
+			pool := NewCAPool()
+			require.NoError(t, pool.AddCA(ca))
+			cc, err := pool.VerifyCertificate(now, host)
+			require.NoError(t, err)
+			require.NoError(t, pool.VerifyCachedCertificate(now, cc))
+			_, err = pool.VerifyCertificate(now, V2Form(host))
+			require.ErrorIs(t, err, ErrCaNotFound)
+
+			// Listing the v2 form of the CA as well accepts the v2 form of the certificate.
+			require.NoError(t, pool.AddCA(V2Form(ca)))
+			_, err = pool.VerifyCertificate(now, V2Form(host))
+			require.NoError(t, err)
+			_, err = pool.VerifyCertificate(now, host)
+			require.NoError(t, err)
+
+			// The v2 form of the CA alone does not verify a v3 certificate.
+			v2Pool := NewCAPool()
+			require.NoError(t, v2Pool.AddCA(V2Form(ca)))
+			_, err = v2Pool.VerifyCertificate(now, host)
+			require.ErrorIs(t, err, ErrCaNotFound)
+
+			// A second v3 extension of the same v2 CA is ambiguous.
+			sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
+			require.NoError(t, err)
+			signPQ, err := SignPQ(sk.Bytes())
+			require.NoError(t, err)
+			again, err := ExtendToV3(V2Form(ca), sk.PublicKey().Bytes(), nil, signPQ)
+			require.NoError(t, err)
+			require.ErrorContains(t, pool.AddCA(again), "two version 3 CAs extend the same version 2 CA")
+		})
+	}
+}
+
+func TestCAPool_V3Blocklist(t *testing.T) {
+	for _, curve := range testCurves {
+		t.Run(curve.String(), func(t *testing.T) {
+			ca, _, host, _ := newTestV3(t, curve)
+			now := time.Now()
+			pool := NewCAPool()
+			require.NoError(t, pool.AddCA(ca))
+			require.NoError(t, pool.AddCA(V2Form(ca)))
+			cc, err := pool.VerifyCertificate(now, host)
+			require.NoError(t, err)
+
+			fp, err := host.Fingerprint()
+			require.NoError(t, err)
+			v2Fp, err := V2Form(host).Fingerprint()
+			require.NoError(t, err)
+			assert.NotEqual(t, fp, v2Fp)
+
+			// Blocking the v3 fingerprint leaves the v2 form alone, which is why operators list both.
+			pool.BlocklistFingerprint(fp)
+			_, err = pool.VerifyCertificate(now, host)
+			require.ErrorIs(t, err, ErrBlockListed)
+			require.ErrorIs(t, pool.VerifyCachedCertificate(now, cc), ErrBlockListed)
+			_, err = pool.VerifyCertificate(now, V2Form(host))
+			require.NoError(t, err)
+
+			// Blocking the v2 fingerprint blocks both.
+			pool.ResetCertBlocklist()
+			pool.BlocklistFingerprint(v2Fp)
+			_, err = pool.VerifyCertificate(now, host)
+			require.ErrorIs(t, err, ErrBlockListed)
+			require.ErrorIs(t, pool.VerifyCachedCertificate(now, cc), ErrBlockListed)
+			_, err = pool.VerifyCertificate(now, V2Form(host))
+			require.ErrorIs(t, err, ErrBlockListed)
+
+			if curve == Curve_P256 {
+				// So does the other form of a P256 v2 signature.
+				alt, err := CalculateAlternateFingerprint(V2Form(host))
+				require.NoError(t, err)
+				pool.ResetCertBlocklist()
+				pool.BlocklistFingerprint(alt)
+				_, err = pool.VerifyCertificate(now, host)
+				require.ErrorIs(t, err, ErrBlockListed)
+			}
+		})
+	}
+}
+
+// Certificates already in use extend in place: their v2 forms, and fingerprints, do not change.
+func TestExtendToV3(t *testing.T) {
+	for _, curve := range testCurves {
+		t.Run(curve.String(), func(t *testing.T) {
+			ca2, _, caKey2, _ := NewTestCaCert(Version2, curve, time.Time{}, time.Time{}, nil, nil, nil)
+			host2, _, _, _ := NewTestCert(Version2, curve, ca2, caKey2, "host", time.Time{}, time.Time{}, []netip.Prefix{netip.MustParsePrefix("10.1.0.1/16")}, nil, nil)
+
+			sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
+			require.NoError(t, err)
+			signPQ, err := SignPQ(sk.Bytes())
+			require.NoError(t, err)
+
+			ca3, err := ExtendToV3(ca2, sk.PublicKey().Bytes(), nil, signPQ)
+			require.NoError(t, err)
+			assertCertsEqual(t, ca2, V2Form(ca3))
+			require.NoError(t, ca3.VerifyPrivateKey(curve, append(caKey2, sk.Bytes()...)))
+
+			dk, err := mlkem.GenerateKey1024()
+			require.NoError(t, err)
+			host3, err := ExtendToV3(host2, dk.EncapsulationKey().Bytes(), ca3, signPQ)
+			require.NoError(t, err)
+			assertCertsEqual(t, host2, V2Form(host3))
+
+			pool := NewCAPool()
+			require.NoError(t, pool.AddCA(ca3))
+			_, err = pool.VerifyCertificate(time.Now(), host3)
+			require.NoError(t, err)
+
+			// A host must be extended by its own issuer, and with that issuer's key.
+			other2, _, _, _ := NewTestCaCert(Version2, curve, time.Time{}, time.Time{}, nil, nil, nil)
+			other3, err := ExtendToV3(other2, sk.PublicKey().Bytes(), nil, signPQ)
+			require.NoError(t, err)
+			_, err = ExtendToV3(host2, dk.EncapsulationKey().Bytes(), other3, signPQ)
+			require.Error(t, err)
+
+			wrong, err := mldsa.GenerateKey(mldsa.MLDSA87())
+			require.NoError(t, err)
+			wrongSign, err := SignPQ(wrong.Bytes())
+			require.NoError(t, err)
+			_, err = ExtendToV3(host2, dk.EncapsulationKey().Bytes(), ca3, wrongSign)
+			require.ErrorIs(t, err, ErrSignatureMismatch)
+
+			// A CA extends itself, and needs its own post-quantum key to.
+			_, err = ExtendToV3(ca2, sk.PublicKey().Bytes(), nil, wrongSign)
+			require.ErrorIs(t, err, ErrSignatureMismatch)
+			_, err = ExtendToV3(ca2, sk.PublicKey().Bytes(), ca3, signPQ)
+			require.Error(t, err)
+
+			// Only v2 certificates extend.
+			_, err = ExtendToV3(host3, dk.EncapsulationKey().Bytes(), ca3, signPQ)
+			require.Error(t, err)
+		})
+	}
+}
+
+func TestCertificateV3_KeyPEM(t *testing.T) {
+	for _, curve := range testCurves {
+		t.Run(curve.String(), func(t *testing.T) {
+			ca, caKey, host, hostKey := newTestV3(t, curve)
+
+			pub, rest, gotCurve, err := UnmarshalPublicKeyFromPEM(host.MarshalPublicKeyPEM())
+			require.NoError(t, err)
+			assert.Empty(t, rest)
+			assert.Equal(t, curve, gotCurve)
+			assert.Equal(t, host.PublicKey(), pub)
+
+			pub, _, gotCurve, err = UnmarshalSigningPublicKeyFromPEM(ca.MarshalPublicKeyPEM())
+			require.NoError(t, err)
+			assert.Equal(t, curve, gotCurve)
+			assert.Equal(t, ca.PublicKey(), pub)
+
+			key, _, gotCurve, err := UnmarshalPrivateKeyFromPEM(MarshalPrivateKeyToPEM(curve, hostKey))
+			require.NoError(t, err)
+			assert.Equal(t, curve, gotCurve)
+			assert.Equal(t, hostKey, key)
+
+			key, _, gotCurve, err = UnmarshalSigningPrivateKeyFromPEM(MarshalSigningPrivateKeyToPEM(curve, caKey))
+			require.NoError(t, err)
+			assert.Equal(t, curve, gotCurve)
+			assert.Equal(t, caKey, key)
+
+			kdf := NewArgon2Parameters(64*1024, 4, 1)
+			enc, err := EncryptAndMarshalSigningPrivateKey(curve, caKey, []byte("pw"), kdf)
+			require.NoError(t, err)
+			_, _, _, err = UnmarshalSigningPrivateKeyFromPEM(enc)
+			require.ErrorIs(t, err, ErrPrivateKeyEncrypted)
+			gotCurve, key, _, err = DecryptAndUnmarshalSigningPrivateKey([]byte("pw"), enc)
+			require.NoError(t, err)
+			assert.Equal(t, curve, gotCurve)
+			assert.Equal(t, caKey, key)
+		})
+	}
+}

+ 22 - 0
cert/crypto.go

@@ -158,6 +158,7 @@ func splitNonceCiphertext(blob []byte, nonceSize int) ([]byte, []byte, error) {
 
 // EncryptAndMarshalSigningPrivateKey is a simple helper to encrypt and PEM encode a private key
 func EncryptAndMarshalSigningPrivateKey(curve Curve, b []byte, passphrase []byte, kdfParams *Argon2Parameters) ([]byte, error) {
+	pq := len(b) == signingPrivateKeySize(curve)+MLDSA87PrivateKeySize
 	ciphertext, err := aes256Encrypt(passphrase, kdfParams, b)
 	if err != nil {
 		return nil, err
@@ -180,6 +181,13 @@ func EncryptAndMarshalSigningPrivateKey(curve Curve, b []byte, passphrase []byte
 		return nil, err
 	}
 
+	switch {
+	case pq && curve == Curve_CURVE25519:
+		return pem.EncodeToMemory(&pem.Block{Type: EncryptedEd25519MLDSA87PrivateKeyBanner, Bytes: b}), nil
+	case pq && curve == Curve_P256:
+		return pem.EncodeToMemory(&pem.Block{Type: EncryptedECDSAP256MLDSA87PrivateKeyBanner, Bytes: b}), nil
+	}
+
 	switch curve {
 	case Curve_CURVE25519:
 		return pem.EncodeToMemory(&pem.Block{Type: EncryptedEd25519PrivateKeyBanner, Bytes: b}), nil
@@ -260,11 +268,18 @@ func DecryptAndUnmarshalSigningPrivateKey(passphrase, b []byte) (Curve, []byte,
 		return curve, nil, r, fmt.Errorf("input did not contain a valid PEM encoded block")
 	}
 
+	pq := false
 	switch k.Type {
 	case EncryptedEd25519PrivateKeyBanner:
 		curve = Curve_CURVE25519
 	case EncryptedECDSAP256PrivateKeyBanner:
 		curve = Curve_P256
+	case EncryptedEd25519MLDSA87PrivateKeyBanner:
+		curve = Curve_CURVE25519
+		pq = true
+	case EncryptedECDSAP256MLDSA87PrivateKeyBanner:
+		curve = Curve_P256
+		pq = true
 	default:
 		return curve, nil, r, fmt.Errorf("bytes did not contain a proper nebula encrypted Ed25519/ECDSA private key banner")
 	}
@@ -285,6 +300,13 @@ func DecryptAndUnmarshalSigningPrivateKey(passphrase, b []byte) (Curve, []byte,
 		return curve, nil, r, fmt.Errorf("unsupported encryption algorithm: %s", ned.EncryptionMetadata.EncryptionAlgorithm)
 	}
 
+	if pq {
+		if len(bytes) != signingPrivateKeySize(curve)+MLDSA87PrivateKeySize {
+			return curve, nil, r, fmt.Errorf("key was not %d bytes, is invalid %s ML-DSA-87 private key", signingPrivateKeySize(curve)+MLDSA87PrivateKeySize, curve)
+		}
+		return curve, bytes, r, nil
+	}
+
 	switch curve {
 	case Curve_CURVE25519:
 		if len(bytes) != ed25519.PrivateKeySize {

+ 28 - 0
cert/helper_test.go

@@ -4,6 +4,8 @@ import (
 	"crypto/ecdh"
 	"crypto/ecdsa"
 	"crypto/elliptic"
+	"crypto/mldsa"
+	"crypto/mlkem"
 	"crypto/rand"
 	"io"
 	"net/netip"
@@ -39,6 +41,10 @@ func NewTestCaCert(version Version, curve Curve, before, after time.Time, networ
 		// There is no default to allow the underlying lib to respond with an error
 	}
 
+	if version == Version3 {
+		pub, priv = appendPQSigningKey(pub, priv)
+	}
+
 	if before.IsZero() {
 		before = testCertNow.Add(time.Second * -60)
 	}
@@ -97,6 +103,10 @@ func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string
 		panic("unknown curve")
 	}
 
+	if v == Version3 {
+		pub, priv = appendPQKey(pub, priv)
+	}
+
 	nc := &TBSCertificate{
 		Version:        v,
 		Curve:          curve,
@@ -123,6 +133,24 @@ func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string
 	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.
+func appendPQSigningKey(pub, priv []byte) ([]byte, []byte) {
+	sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
+	if err != nil {
+		panic(err)
+	}
+	return append(pub, sk.PublicKey().Bytes()...), append(priv, sk.Bytes()...)
+}
+
+// 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()
+	if err != nil {
+		panic(err)
+	}
+	return append(pub, dk.EncapsulationKey().Bytes()...), append(priv, dk.Bytes()...)
+}
+
 func X25519Keypair() ([]byte, []byte) {
 	privkey := make([]byte, 32)
 	if _, err := io.ReadFull(rand.Reader, privkey); err != nil {

+ 98 - 1
cert/pem.go

@@ -64,6 +64,7 @@ func SplitPEM(data []byte, atEOF bool) (advance int, token []byte, err error) {
 const ( //cert banners
 	CertificateBanner   = "NEBULA CERTIFICATE"
 	CertificateV2Banner = "NEBULA CERTIFICATE V2"
+	CertificateV3Banner = "NEBULA CERTIFICATE V3"
 )
 
 const ( //key-agreement-key banners
@@ -73,6 +74,42 @@ const ( //key-agreement-key banners
 	P256PublicKeyBanner    = "NEBULA P256 PUBLIC KEY"
 )
 
+// Version 3 keys are composite: the classical key followed by the post-quantum key, an ML-KEM-1024 seed or
+// encapsulation key for hosts and an ML-DSA-87 seed or public key for CAs.
+const ( //post-quantum key-agreement-key banners
+	X25519MLKEM1024PrivateKeyBanner = "NEBULA X25519 MLKEM1024 PRIVATE KEY"
+	X25519MLKEM1024PublicKeyBanner  = "NEBULA X25519 MLKEM1024 PUBLIC KEY"
+	P256MLKEM1024PrivateKeyBanner   = "NEBULA P256 MLKEM1024 PRIVATE KEY"
+	P256MLKEM1024PublicKeyBanner    = "NEBULA P256 MLKEM1024 PUBLIC KEY"
+)
+
+const ( //post-quantum signing key banners
+	EncryptedECDSAP256MLDSA87PrivateKeyBanner = "NEBULA ECDSA P256 MLDSA87 ENCRYPTED PRIVATE KEY"
+	ECDSAP256MLDSA87PrivateKeyBanner          = "NEBULA ECDSA P256 MLDSA87 PRIVATE KEY"
+	ECDSAP256MLDSA87PublicKeyBanner           = "NEBULA ECDSA P256 MLDSA87 PUBLIC KEY"
+	EncryptedEd25519MLDSA87PrivateKeyBanner   = "NEBULA ED25519 MLDSA87 ENCRYPTED PRIVATE KEY"
+	Ed25519MLDSA87PrivateKeyBanner            = "NEBULA ED25519 MLDSA87 PRIVATE KEY"
+	Ed25519MLDSA87PublicKeyBanner             = "NEBULA ED25519 MLDSA87 PUBLIC KEY"
+)
+
+// Classical key sizes, the composite keys of version 3 add the post-quantum key to these.
+const (
+	x25519KeySize          = 32
+	p256PublicKeySize      = 65 // uncompressed
+	p256PrivateKeySize     = 32
+	ed25519PublicKeySize   = 32
+	ed25519PrivateKeySize  = 64
+	ecdsaP256PublicKeySize = 65 // uncompressed
+)
+
+// signingPrivateKeySize returns the size of a classical signing private key.
+func signingPrivateKeySize(curve Curve) int {
+	if curve == Curve_P256 {
+		return p256PrivateKeySize
+	}
+	return ed25519PrivateKeySize
+}
+
 /* including "ECDSA" in the P256 banners is a clue that these keys should be used only for signing */
 const ( //signing key banners
 	EncryptedECDSAP256PrivateKeyBanner = "NEBULA ECDSA P256 ENCRYPTED PRIVATE KEY"
@@ -109,6 +146,8 @@ func unmarshalCertificateBlock(block *pem.Block) (Certificate, error) {
 		return unmarshalCertificateV1(block.Bytes, nil)
 	case CertificateV2Banner:
 		return unmarshalCertificateV2(block.Bytes, nil, Curve_CURVE25519)
+	case CertificateV3Banner:
+		return unmarshalCertificateV3(block.Bytes, nil, Curve_CURVE25519)
 	default:
 		return nil, ErrInvalidPEMCertificateBanner
 	}
@@ -125,6 +164,13 @@ func marshalCertPublicKeyToPEM(c Certificate) []byte {
 // MarshalPublicKeyToPEM returns a PEM representation of a public key used for ECDH.
 // if your public key came from a certificate, prefer Certificate.PublicKeyPEM() if possible, to avoid mistakes!
 func MarshalPublicKeyToPEM(curve Curve, b []byte) []byte {
+	pq := len(b) > MLKEM1024PublicKeySize
+	switch {
+	case pq && curve == Curve_CURVE25519:
+		return pem.EncodeToMemory(&pem.Block{Type: X25519MLKEM1024PublicKeyBanner, Bytes: b})
+	case pq && curve == Curve_P256:
+		return pem.EncodeToMemory(&pem.Block{Type: P256MLKEM1024PublicKeyBanner, Bytes: b})
+	}
 	switch curve {
 	case Curve_CURVE25519:
 		return pem.EncodeToMemory(&pem.Block{Type: X25519PublicKeyBanner, Bytes: b})
@@ -138,6 +184,13 @@ func MarshalPublicKeyToPEM(curve Curve, b []byte) []byte {
 // MarshalSigningPublicKeyToPEM returns a PEM representation of a public key used for signing.
 // if your public key came from a certificate, prefer Certificate.PublicKeyPEM() if possible, to avoid mistakes!
 func MarshalSigningPublicKeyToPEM(curve Curve, b []byte) []byte {
+	pq := len(b) > MLDSA87PublicKeySize
+	switch {
+	case pq && curve == Curve_CURVE25519:
+		return pem.EncodeToMemory(&pem.Block{Type: Ed25519MLDSA87PublicKeyBanner, Bytes: b})
+	case pq && curve == Curve_P256:
+		return pem.EncodeToMemory(&pem.Block{Type: ECDSAP256MLDSA87PublicKeyBanner, Bytes: b})
+	}
 	switch curve {
 	case Curve_CURVE25519:
 		return pem.EncodeToMemory(&pem.Block{Type: Ed25519PublicKeyBanner, Bytes: b})
@@ -166,6 +219,12 @@ func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
 		// Uncompressed
 		expectedLen = 65
 		curve = Curve_P256
+	case X25519MLKEM1024PublicKeyBanner:
+		expectedLen = x25519KeySize + MLKEM1024PublicKeySize
+		curve = Curve_CURVE25519
+	case P256MLKEM1024PublicKeyBanner:
+		expectedLen = p256PublicKeySize + MLKEM1024PublicKeySize
+		curve = Curve_P256
 	default:
 		return nil, r, 0, fmt.Errorf("bytes did not contain a proper public key banner")
 	}
@@ -193,6 +252,12 @@ func UnmarshalSigningPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
 		// Uncompressed
 		expectedLen = 65
 		curve = Curve_P256
+	case Ed25519MLDSA87PublicKeyBanner:
+		expectedLen = ed25519PublicKeySize + MLDSA87PublicKeySize
+		curve = Curve_CURVE25519
+	case ECDSAP256MLDSA87PublicKeyBanner:
+		expectedLen = ecdsaP256PublicKeySize + MLDSA87PublicKeySize
+		curve = Curve_P256
 	default:
 		return nil, r, 0, fmt.Errorf("bytes did not contain a proper Ed25519/ECDSA public key banner")
 	}
@@ -203,6 +268,13 @@ func UnmarshalSigningPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
 }
 
 func MarshalPrivateKeyToPEM(curve Curve, b []byte) []byte {
+	pq := len(b) > MLKEM1024PrivateKeySize
+	switch {
+	case pq && curve == Curve_CURVE25519:
+		return pem.EncodeToMemory(&pem.Block{Type: X25519MLKEM1024PrivateKeyBanner, Bytes: b})
+	case pq && curve == Curve_P256:
+		return pem.EncodeToMemory(&pem.Block{Type: P256MLKEM1024PrivateKeyBanner, Bytes: b})
+	}
 	switch curve {
 	case Curve_CURVE25519:
 		return pem.EncodeToMemory(&pem.Block{Type: X25519PrivateKeyBanner, Bytes: b})
@@ -214,6 +286,13 @@ func MarshalPrivateKeyToPEM(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 {
 	case Curve_CURVE25519:
 		return pem.EncodeToMemory(&pem.Block{Type: Ed25519PrivateKeyBanner, Bytes: b})
@@ -240,6 +319,12 @@ func UnmarshalPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
 	case P256PrivateKeyBanner:
 		expectedLen = 32
 		curve = Curve_P256
+	case X25519MLKEM1024PrivateKeyBanner:
+		expectedLen = x25519KeySize + MLKEM1024PrivateKeySize
+		curve = Curve_CURVE25519
+	case P256MLKEM1024PrivateKeyBanner:
+		expectedLen = p256PrivateKeySize + MLKEM1024PrivateKeySize
+		curve = Curve_P256
 	default:
 		return nil, r, 0, fmt.Errorf("bytes did not contain a proper private key banner")
 	}
@@ -258,8 +343,20 @@ func UnmarshalSigningPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error)
 	switch k.Type {
 	case EncryptedEd25519PrivateKeyBanner:
 		return nil, nil, Curve_CURVE25519, ErrPrivateKeyEncrypted
-	case EncryptedECDSAP256PrivateKeyBanner:
+	case EncryptedECDSAP256PrivateKeyBanner, EncryptedECDSAP256MLDSA87PrivateKeyBanner:
 		return nil, nil, Curve_P256, ErrPrivateKeyEncrypted
+	case EncryptedEd25519MLDSA87PrivateKeyBanner:
+		return nil, nil, Curve_CURVE25519, ErrPrivateKeyEncrypted
+	case Ed25519MLDSA87PrivateKeyBanner:
+		curve = Curve_CURVE25519
+		if len(k.Bytes) != ed25519PrivateKeySize+MLDSA87PrivateKeySize {
+			return nil, r, 0, fmt.Errorf("key was not %d bytes, is invalid Ed25519 ML-DSA-87 private key", ed25519PrivateKeySize+MLDSA87PrivateKeySize)
+		}
+	case ECDSAP256MLDSA87PrivateKeyBanner:
+		curve = Curve_P256
+		if len(k.Bytes) != p256PrivateKeySize+MLDSA87PrivateKeySize {
+			return nil, r, 0, fmt.Errorf("key was not %d bytes, is invalid ECDSA P256 ML-DSA-87 private key", p256PrivateKeySize+MLDSA87PrivateKeySize)
+		}
 	case Ed25519PrivateKeyBanner:
 		curve = Curve_CURVE25519
 		if len(k.Bytes) != ed25519.PrivateKeySize {

+ 66 - 8
cert/sign.go

@@ -47,29 +47,85 @@ type SignerLambda func(certBytes []byte) ([]byte, error)
 // details do not violate constraints of the signing certificate.
 // If the TBSCertificate is a CA then signer must be nil.
 func (t *TBSCertificate) Sign(signer Certificate, curve Curve, key []byte) (Certificate, error) {
-	switch t.Curve {
+	if t.Version == Version3 {
+		// The signing key is a composite CA key, classical || ML-DSA-87 seed.
+		if len(key) <= MLDSA87PrivateKeySize {
+			return nil, ErrInvalidPrivateKey
+		}
+		classical, err := classicalSigner(t.Curve, key[:len(key)-MLDSA87PrivateKeySize])
+		if err != nil {
+			return nil, err
+		}
+		pq, err := SignPQ(key[len(key)-MLDSA87PrivateKeySize:])
+		if err != nil {
+			return nil, err
+		}
+		return t.SignHybridWith(signer, curve, classical, pq)
+	}
+
+	sp, err := classicalSigner(t.Curve, key)
+	if err != nil {
+		return nil, err
+	}
+	return t.SignWith(signer, curve, sp)
+}
+
+// classicalSigner returns a SignerLambda for a classical signing private key.
+func classicalSigner(curve Curve, key []byte) (SignerLambda, error) {
+	switch curve {
 	case Curve_CURVE25519:
+		if len(key) != ed25519.PrivateKeySize {
+			return nil, ErrInvalidPrivateKey
+		}
 		pk := ed25519.PrivateKey(key)
-		sp := func(certBytes []byte) ([]byte, error) {
+		return func(certBytes []byte) ([]byte, error) {
 			sig := ed25519.Sign(pk, certBytes)
 			return sig, nil
-		}
-		return t.SignWith(signer, curve, sp)
+		}, nil
 	case Curve_P256:
 		pk, err := ecdsa.ParseRawPrivateKey(elliptic.P256(), key)
 		if err != nil {
 			return nil, err
 		}
-		sp := func(certBytes []byte) ([]byte, error) {
+		return func(certBytes []byte) ([]byte, error) {
 			// We need to hash first for ECDSA
 			// - https://pkg.go.dev/crypto/ecdsa#SignASN1
 			hashed := sha256.Sum256(certBytes)
 			return ecdsa.SignASN1(rand.Reader, pk, hashed[:])
-		}
-		return t.SignWith(signer, curve, sp)
+		}, nil
 	default:
-		return nil, fmt.Errorf("invalid curve: %s", t.Curve)
+		return nil, fmt.Errorf("invalid curve: %s", curve)
+	}
+}
+
+// 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.
+func (t *TBSCertificate) SignHybridWith(signer Certificate, curve Curve, sp, pqSp SignerLambda) (Certificate, error) {
+	if t.Version != Version3 {
+		return nil, fmt.Errorf("SignHybridWith only signs version 3 certificates")
+	}
+	classical, pq, err := splitPQPublicKey(t.IsCA, t.PublicKey)
+	if err != nil {
+		return nil, err
+	}
+
+	var signerV2 Certificate
+	if signer != nil {
+		if signer.Version() != Version3 {
+			return nil, fmt.Errorf("a version 3 certificate must be signed by a version 3 CA")
+		}
+		signerV2 = V2Form(signer)
+	}
+
+	t2 := *t
+	t2.Version = Version2
+	t2.PublicKey = classical
+	c2, err := t2.SignWith(signerV2, curve, sp)
+	if err != nil {
+		return nil, err
 	}
+	return ExtendToV3(c2, pq, signer, pqSp)
 }
 
 // SignWith does the same thing as sign, but uses the function in `sp` to calculate the signature.
@@ -114,6 +170,8 @@ func (t *TBSCertificate) SignWith(signer Certificate, curve Curve, sp SignerLamb
 		if err != nil {
 			return nil, err
 		}
+	case Version3:
+		return nil, fmt.Errorf("version 3 certificates carry two signatures, use SignHybridWith")
 	default:
 		return nil, fmt.Errorf("unknown cert version %d", t.Version)
 	}

+ 28 - 0
cert_test/cert.go

@@ -4,6 +4,8 @@ import (
 	"crypto/ecdh"
 	"crypto/ecdsa"
 	"crypto/elliptic"
+	"crypto/mldsa"
+	"crypto/mlkem"
 	"crypto/rand"
 	"io"
 	"net/netip"
@@ -40,6 +42,10 @@ func NewTestCaCert(version cert.Version, curve cert.Curve, before, after time.Ti
 		// There is no default to allow the underlying lib to respond with an error
 	}
 
+	if version == cert.Version3 {
+		pub, priv = appendPQSigningKey(pub, priv)
+	}
+
 	if before.IsZero() {
 		before = testCertNow.Add(time.Second * -60)
 	}
@@ -94,6 +100,10 @@ func NewTestCert(v cert.Version, curve cert.Curve, ca cert.Certificate, key []by
 		panic("unknown curve")
 	}
 
+	if v == cert.Version3 {
+		pub, priv = appendPQKey(pub, priv)
+	}
+
 	nc := &cert.TBSCertificate{
 		Version:        v,
 		Curve:          curve,
@@ -147,6 +157,24 @@ func NewTestCertDifferentVersion(c cert.Certificate, v cert.Version, ca cert.Cer
 	return c, pem
 }
 
+// appendPQSigningKey makes a classical CA keypair into a version 3 composite one with a fresh ML-DSA-87 key.
+func appendPQSigningKey(pub, priv []byte) ([]byte, []byte) {
+	sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
+	if err != nil {
+		panic(err)
+	}
+	return append(pub, sk.PublicKey().Bytes()...), append(priv, sk.Bytes()...)
+}
+
+// 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()
+	if err != nil {
+		panic(err)
+	}
+	return append(pub, dk.EncapsulationKey().Bytes()...), append(priv, dk.Bytes()...)
+}
+
 func X25519Keypair() ([]byte, []byte) {
 	privkey := make([]byte, 32)
 	if _, err := io.ReadFull(rand.Reader, privkey); err != nil {