From 1a8fb8d67e56c6cca7337a36e8a639a0017a71f9 Mon Sep 17 00:00:00 2001 From: JackDoan Date: Wed, 14 Oct 2026 10:47:41 -0500 Subject: [PATCH] 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 --- cert/ca_pool.go | 52 ++++- cert/cert.go | 13 ++ cert/cert_v3.go | 520 +++++++++++++++++++++++++++++++++++++++++++ cert/cert_v3_test.go | 429 +++++++++++++++++++++++++++++++++++ cert/crypto.go | 22 ++ cert/helper_test.go | 28 +++ cert/pem.go | 99 +++++++- cert/sign.go | 74 +++++- cert_test/cert.go | 28 +++ 9 files changed, 1255 insertions(+), 10 deletions(-) create mode 100644 cert/cert_v3.go create mode 100644 cert/cert_v3_test.go diff --git a/cert/ca_pool.go b/cert/ca_pool.go index 966f78e3..2b5a6622 100644 --- a/cert/ca_pool.go +++ b/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 } diff --git a/cert/cert.go b/cert/cert.go index 01d775e5..944251ce 100644 --- a/cert/cert.go +++ b/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 { diff --git a/cert/cert_v3.go b/cert/cert_v3.go new file mode 100644 index 00000000..38df0c2c --- /dev/null +++ b/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("", err) + } + + b, err := json.MarshalIndent(mb, "", "\t") + if err != nil { + return fmt.Sprintf("", 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 +} diff --git a/cert/cert_v3_test.go b/cert/cert_v3_test.go new file mode 100644 index 00000000..fb24d869 --- /dev/null +++ b/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) + }) + } +} diff --git a/cert/crypto.go b/cert/crypto.go index 4c236aee..a25d5c7e 100644 --- a/cert/crypto.go +++ b/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 { diff --git a/cert/helper_test.go b/cert/helper_test.go index 9becfa5c..77adb092 100644 --- a/cert/helper_test.go +++ b/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 { diff --git a/cert/pem.go b/cert/pem.go index caa19b11..40a90014 100644 --- a/cert/pem.go +++ b/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 { diff --git a/cert/sign.go b/cert/sign.go index fbfffe4e..c01f56b5 100644 --- a/cert/sign.go +++ b/cert/sign.go @@ -47,31 +47,87 @@ 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. // You should only use SignWith if you do not have direct access to your private key. func (t *TBSCertificate) SignWith(signer Certificate, curve Curve, sp SignerLambda) (Certificate, error) { @@ -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) } diff --git a/cert_test/cert.go b/cert_test/cert.go index 4c440aff..5503c470 100644 --- a/cert_test/cert.go +++ b/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 {