From 656164b2593dbdc6c077f0649100a29dc19a5785 Mon Sep 17 00:00:00 2001 From: JackDoan Date: Wed, 14 Oct 2026 13:56:04 -0500 Subject: [PATCH] cert: Name the post-quantum algorithms of a version 3 certificate with a PQSuite, like its curve, and pick the hybrid subtype by the suite's KEM --- CHANGELOG.md | 10 +- cert/ca_pool.go | 4 + cert/cert_v3.go | 180 +++++++++---------- cert/cert_v3_test.go | 65 ++++--- cert/crypto.go | 68 ++++--- cert/errors.go | 2 + cert/helper_test.go | 25 +-- cert/pem.go | 230 +++++++++++++++--------- cert/pq_suite.go | 268 ++++++++++++++++++++++++++++ cert/pq_suite_test.go | 169 ++++++++++++++++++ cert/sign.go | 31 ++-- cert_test/cert.go | 26 +-- cmd/nebula-cert/ca.go | 30 +++- cmd/nebula-cert/ca_test.go | 4 +- cmd/nebula-cert/keygen.go | 26 ++- cmd/nebula-cert/keygen_test.go | 6 +- cmd/nebula-cert/pq.go | 97 +++++++--- cmd/nebula-cert/pq_test.go | 37 +++- cmd/nebula-cert/sign.go | 25 ++- docs/pq-handshake.md | 45 +++-- handshake/credential.go | 23 ++- handshake/errors.go | 1 + handshake/fragment/fragment_test.go | 2 +- handshake/machine.go | 27 ++- handshake/patterns.go | 30 +++- handshake_manager.go | 6 +- handshake_manager_hybrid.go | 6 +- handshake_manager_pq.go | 26 ++- pki.go | 20 ++- 29 files changed, 1124 insertions(+), 365 deletions(-) create mode 100644 cert/pq_suite.go create mode 100644 cert/pq_suite_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 815b2906..a3f5b3d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Version 3 certificates and post-quantum hybrid handshakes (`ix_hybrid_mlkem1024`). A version 3 certificate is a - version 2 certificate extended with an ML-KEM-1024 key (hosts) or an ML-DSA-87 key (CAs) and a deterministic - ML-DSA-87 CA signature, and a node holding one still presents the version 2 certificate it extends to peers that - need it. `nebula-cert pq-extend` upgrades existing CAs and hosts in place, and extending a certificate twice gives - the same certificate. See `docs/pq-handshake.md`. + version 2 certificate extended with a post-quantum key and a deterministic post-quantum CA signature, and a node + holding one still presents the version 2 certificate it extends to peers that need it. Its post-quantum suite names + the algorithms, as its curve does the classical ones; the one suite today is `MLKEM1024_MLDSA87`, an ML-KEM-1024 + key for hosts and ML-DSA-87 for CAs and their signatures, chosen with `nebula-cert -pq-suite`. `nebula-cert + pq-extend` upgrades existing CAs and hosts in place, and extending a certificate twice gives the same certificate. + See `docs/pq-handshake.md`. ### Changed diff --git a/cert/ca_pool.go b/cert/ca_pool.go index 2b5a6622..8cb21173 100644 --- a/cert/ca_pool.go +++ b/cert/ca_pool.go @@ -266,6 +266,10 @@ func (ncp *CAPool) verify(c Certificate, now time.Time, certFp string, signerFp return nil, ErrCurveMismatch } + if PQSuiteOf(signer.Certificate) != PQSuiteOf(c) { + return nil, ErrPQSuiteMismatch + } + if signer.Certificate.Expired(now) { return nil, ErrRootExpired } diff --git a/cert/cert_v3.go b/cert/cert_v3.go index bd656188..b6ddc861 100644 --- a/cert/cert_v3.go +++ b/cert/cert_v3.go @@ -20,28 +20,32 @@ import ( // the fields of the v2 certificate it extends, so a v3 holder can always marshal that v2 form, byte for byte, and use // it with peers that only speak v2. The v2 form keeps its own classical CA signature and fingerprint. // -// Hosts carry an ML-KEM-1024 encapsulation key and CAs an ML-DSA-87 public key. The issuing CA signs every field -// with ML-DSA-87, including the classical signature, so the classical signature can not be swapped out either. The -// ML-DSA-87 signature is deterministic, so a certificate extended twice is the same certificate. +// The certificate's PQSuite says which post-quantum algorithms it uses, the way its curve does for the classical +// ones. Hosts carry a public key for the suite's KEM and CAs one for its signature algorithm. The issuing CA, which +// has the same suite, signs every field with that signature algorithm, including the suite and the classical +// signature, so neither can be swapped out. Signatures are deterministic, so a certificate extended twice is the same +// certificate. // // Wherever a v3 certificate deals in keys they are composite: the classical key followed by the post-quantum one. // PublicKey returns classical || post-quantum, and VerifyPrivateKey and signing take the private keys the same way. const ( - TagCertPQPublicKey = 4 | classContextSpecific - TagCertPQSignature = 5 | classContextSpecific + TagCertPQSuite = 4 | classContextSpecific + TagCertPQPublicKey = 5 | classContextSpecific + TagCertPQSignature = 6 | classContextSpecific ) +// Sizes of the keys and signatures of PQSuite_MLKEM1024_MLDSA87. const ( - // MLKEM1024PublicKeySize is the size of the ML-KEM-1024 encapsulation key in a v3 host certificate. + // MLKEM1024PublicKeySize is the size of an ML-KEM-1024 encapsulation key. MLKEM1024PublicKeySize = mlkem.EncapsulationKeySize1024 - // MLKEM1024PrivateKeySize is the size of the ML-KEM-1024 decapsulation key seed in a v3 host private key. + // MLKEM1024PrivateKeySize is the size of an ML-KEM-1024 decapsulation key seed. MLKEM1024PrivateKeySize = mlkem.SeedSize - // MLDSA87PublicKeySize is the size of the ML-DSA-87 public key in a v3 CA certificate. + // MLDSA87PublicKeySize is the size of an ML-DSA-87 public key. MLDSA87PublicKeySize = mldsa.MLDSA87PublicKeySize - // MLDSA87PrivateKeySize is the size of the ML-DSA-87 private key seed in a v3 CA private key. + // MLDSA87PrivateKeySize is the size of an ML-DSA-87 private key seed. MLDSA87PrivateKeySize = mldsa.PrivateKeySize - // MLDSA87SignatureSize is the size of the ML-DSA-87 signature in every v3 certificate. + // MLDSA87SignatureSize is the size of an ML-DSA-87 signature. MLDSA87SignatureSize = mldsa.MLDSA87SignatureSize // pqSignatureContext is the ML-DSA context string for certificate signatures. @@ -52,38 +56,16 @@ type certificateV3 struct { // v2 is the version 2 certificate this one extends. Its public key is the classical key. v2 certificateV2 - // pqPublicKey is the ML-KEM-1024 encapsulation key of a host, or the ML-DSA-87 public key of a CA. + // pqSuite names the post-quantum algorithms, and so the kind of pqPublicKey and pqSignature. + pqSuite PQSuite + + // pqPublicKey is the suite's KEM public key of a host, or its signature algorithm's public key of a CA. pqPublicKey []byte - // pqSignature is the issuing CA's ML-DSA-87 signature over every other field. + // pqSignature is the issuing CA's post-quantum signature over every other field. pqSignature []byte } -// pqPublicKeySize returns the size of the post-quantum public key a v3 certificate carries. -func pqPublicKeySize(isCA bool) int { - if isCA { - return MLDSA87PublicKeySize - } - return MLKEM1024PublicKeySize -} - -// pqPrivateKeySize returns the size of the post-quantum half of a v3 private key. -func pqPrivateKeySize(isCA bool) int { - if isCA { - return MLDSA87PrivateKeySize - } - return MLKEM1024PrivateKeySize -} - -// splitPQPublicKey splits a composite public key into its classical and post-quantum halves. -func splitPQPublicKey(isCA bool, key []byte) ([]byte, []byte, error) { - n := pqPublicKeySize(isCA) - if len(key) <= n { - return nil, nil, ErrInvalidPublicKey - } - return key[:len(key)-n], key[len(key)-n:], nil -} - // V2Form returns the version 2 certificate a version 3 certificate extends, or nil for any other version. It is // what a v3 holder presents to peers that do not speak v3. func V2Form(c Certificate) Certificate { @@ -159,24 +141,26 @@ func (c *certificateV3) Fingerprint() (string, error) { return hex.EncodeToString(sum[:]), nil } -// CheckSignature checks both signatures against the composite public key of the signing CA. +// CheckSignature checks both signatures against the composite public key of the signing CA. Like the classical +// signature with the curve, the post-quantum signature is checked with the algorithm of this certificate's suite, +// which the signing CA has to share. func (c *certificateV3) CheckSignature(key []byte) bool { - classical, pq, err := splitPQPublicKey(true, key) - if err != nil || len(pq) != MLDSA87PublicKeySize { + p := pqSuites[c.pqSuite] + if p == nil { + return false + } + classical, pq, err := c.pqSuite.SplitPublicKey(true, key) + if err != nil { return false } if !c.v2.CheckSignature(classical) { return false } - pk, err := mldsa.NewPublicKey(mldsa.MLDSA87(), pq) - if err != nil { - return false - } b, err := c.marshalForPQSigning() if err != nil { return false } - return mldsa.Verify(pk, b, c.pqSignature, &mldsa.Options{Context: pqSignatureContext}) == nil + return p.dsa.verify(pq, b, c.pqSignature) } func (c *certificateV3) Expired(t time.Time) bool { @@ -185,28 +169,17 @@ func (c *certificateV3) Expired(t time.Time) bool { // VerifyPrivateKey checks a composite private key, classical || post-quantum seed, against the composite public key. func (c *certificateV3) VerifyPrivateKey(curve Curve, key []byte) error { - n := pqPrivateKeySize(c.IsCA()) - if len(key) <= n { - return ErrInvalidPrivateKey + classical, seed, err := c.pqSuite.SplitPrivateKey(c.IsCA(), key) + if err != nil { + return err } - if err := c.v2.VerifyPrivateKey(curve, key[:len(key)-n]); err != nil { + if err := c.v2.VerifyPrivateKey(curve, classical); err != nil { return err } - var pub []byte - seed := key[len(key)-n:] - if c.IsCA() { - sk, err := mldsa.NewPrivateKey(mldsa.MLDSA87(), seed) - if err != nil { - return ErrInvalidPrivateKey - } - pub = sk.PublicKey().Bytes() - } else { - dk, err := mlkem.NewDecapsulationKey1024(seed) - if err != nil { - return ErrInvalidPrivateKey - } - pub = dk.EncapsulationKey().Bytes() + pub, err := c.pqSuite.publicKey(c.IsCA(), seed) + if err != nil { + return ErrInvalidPrivateKey } if !bytes.Equal(pub, c.pqPublicKey) { return ErrPublicPrivateKeyMismatch @@ -227,7 +200,8 @@ func (c *certificateV3) String() string { return string(b) } -// MarshalForHandshakes leaves out both public keys, the handshake carries them as the composite static key. +// MarshalForHandshakes leaves out both public keys, the handshake carries them as the composite static key. It keeps +// the suite, which says how to split that key. func (c *certificateV3) MarshalForHandshakes() ([]byte, error) { return c.marshal(false, false) } @@ -237,7 +211,7 @@ func (c *certificateV3) Marshal() ([]byte, error) { } // marshalForPQSigning returns the bytes the post-quantum signature covers: every field but that signature, with the -// curve always present. +// curve always present. The suite is always present anyway. func (c *certificateV3) marshalForPQSigning() ([]byte, error) { return c.marshal(true, true) } @@ -265,6 +239,10 @@ func (c *certificateV3) marshal(withPublicKeys, forSigning bool) ([]byte, error) b.AddBytes(c.v2.signature) }) + b.AddASN1(TagCertPQSuite, func(b *cryptobyte.Builder) { + b.AddUint8(uint8(c.pqSuite)) + }) + if withPublicKeys { b.AddASN1(TagCertPQPublicKey, func(b *cryptobyte.Builder) { b.AddBytes(c.pqPublicKey) @@ -310,6 +288,7 @@ func (c *certificateV3) marshalJSON() (m, error) { mb["v2Fingerprint"] = mb["fingerprint"] mb["fingerprint"] = fp mb["version"] = Version3 + mb["pqSuite"] = c.pqSuite.String() mb["pqPublicKey"] = fmt.Sprintf("%x", c.pqPublicKey) mb["pqSignature"] = fmt.Sprintf("%x", c.pqSignature) return mb, nil @@ -318,6 +297,7 @@ func (c *certificateV3) marshalJSON() (m, error) { func (c *certificateV3) Copy() Certificate { nc := &certificateV3{ v2: *c.v2.Copy().(*certificateV2), + pqSuite: c.pqSuite, pqPublicKey: make([]byte, len(c.pqPublicKey)), pqSignature: make([]byte, len(c.pqSignature)), } @@ -331,20 +311,14 @@ func (c *certificateV3) validate() error { return err } - if len(c.pqPublicKey) != pqPublicKeySize(c.IsCA()) { + if !c.pqSuite.Valid() { + return fmt.Errorf("%w: %v", ErrUnknownPQSuite, c.pqSuite) + } + if !c.pqSuite.checkPublicKey(c.IsCA(), c.pqPublicKey) { return ErrInvalidPublicKey } - if c.IsCA() { - if _, err := mldsa.NewPublicKey(mldsa.MLDSA87(), c.pqPublicKey); err != nil { - return ErrInvalidPublicKey - } - } else { - if _, err := mlkem.NewEncapsulationKey1024(c.pqPublicKey); err != nil { - return ErrInvalidPublicKey - } - } - if len(c.pqSignature) != MLDSA87SignatureSize { + if len(c.pqSignature) != c.pqSuite.signatureSize() { return ErrBadFormat } return nil @@ -390,6 +364,15 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica return nil, ErrBadFormat } + var rawSuite cryptobyte.String + if !body.ReadASN1(&rawSuite, TagCertPQSuite) || len(rawSuite) != 1 { + return nil, ErrBadFormat + } + suite := PQSuite(rawSuite[0]) + if !suite.Valid() { + return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, suite) + } + if fromHandshake { if body.PeekASN1Tag(TagCertPQPublicKey) { return nil, ErrCertPubkeyPresent @@ -413,7 +396,7 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica } if fromHandshake { - k, p, err := splitPQPublicKey(details.isCA, publicKey) + k, p, err := suite.SplitPublicKey(details.isCA, publicKey) if err != nil { return nil, err } @@ -429,6 +412,7 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica publicKey: classical, signature: signature, }, + pqSuite: suite, pqPublicKey: pq, pqSignature: pqSignature, } @@ -440,17 +424,21 @@ func unmarshalCertificateV3(b []byte, publicKey []byte, curve Curve) (*certifica return c, nil } -// ExtendToV3 builds the version 3 certificate that extends the version 2 certificate c with pqPublicKey, the composite -// public key's post-quantum half. pqSign produces the ML-DSA-87 signature, see SignPQ. +// ExtendToV3 builds the version 3 certificate in suite that extends the version 2 certificate c with pqPublicKey, the +// composite public key's post-quantum half. pqSign produces the suite's post-quantum signature, see SignPQ. // // A CA certificate signs its own extension: signer must be nil and pqSign must use the private key for pqPublicKey. -// A host certificate is extended by its issuing CA: signer is the v3 form of that CA. In both cases c's classical -// signature, and so its fingerprint, are left unchanged, so certificates in use can be extended in place. -func ExtendToV3(c Certificate, pqPublicKey []byte, signer Certificate, pqSign SignerLambda) (Certificate, error) { +// A host certificate is extended by its issuing CA: signer is the v3 form of that CA, which must be in the same suite. +// In both cases c's classical signature, and so its fingerprint, are left unchanged, so certificates in use can be +// extended in place. +func ExtendToV3(c Certificate, suite PQSuite, pqPublicKey []byte, signer Certificate, pqSign SignerLambda) (Certificate, error) { c2, ok := c.(*certificateV2) if !ok { return nil, fmt.Errorf("only version 2 certificates can be extended to version 3, got version %d", c.Version()) } + if !suite.Valid() { + return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, suite) + } var signingKey []byte if c2.IsCA() { @@ -465,6 +453,9 @@ func ExtendToV3(c Certificate, pqPublicKey []byte, signer Certificate, pqSign Si if signer == nil || signer.Version() != Version3 || !signer.IsCA() { return nil, fmt.Errorf("a host certificate must be extended by a version 3 CA") } + if PQSuiteOf(signer) != suite { + return nil, ErrPQSuiteMismatch + } signerV2 := V2Form(signer) issuer, err := signerV2.Fingerprint() if err != nil { @@ -481,9 +472,10 @@ func ExtendToV3(c Certificate, pqPublicKey []byte, signer Certificate, pqSign Si c3 := &certificateV3{ v2: *c2.Copy().(*certificateV2), + pqSuite: suite, pqPublicKey: bytes.Clone(pqPublicKey), } - if len(c3.pqPublicKey) != pqPublicKeySize(c3.IsCA()) { + if len(c3.pqPublicKey) != suite.PublicKeySize(c3.IsCA()) { return nil, ErrInvalidPublicKey } @@ -509,16 +501,16 @@ func ExtendToV3(c Certificate, pqPublicKey []byte, signer Certificate, pqSign Si return c3, nil } -// SignPQ returns a SignerLambda that signs certificates with the ML-DSA-87 private key seed. Signatures are -// deterministic (FIPS 204 section 3.4), so signing the same certificate twice gives the same bytes and the same -// fingerprint, and extending a certificate is idempotent. The hedged variant guards signers an attacker can fault -// physically, which a CA key in a file is not; the Ed25519 signature beside it is deterministic too. -func SignPQ(seed []byte) (SignerLambda, error) { - sk, err := mldsa.NewPrivateKey(mldsa.MLDSA87(), seed) - if err != nil { - return nil, err +// SignPQ returns a SignerLambda that signs certificates with the private key seed of suite's signature algorithm. +// Signatures are deterministic, so signing the same certificate twice gives the same bytes and the same fingerprint, +// and extending a certificate is idempotent. +func SignPQ(suite PQSuite, seed []byte) (SignerLambda, error) { + p := pqSuites[suite] + if p == nil { + return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, suite) } - return func(b []byte) ([]byte, error) { - return sk.SignDeterministic(b, &mldsa.Options{Context: pqSignatureContext}) - }, nil + if len(seed) != p.dsa.privateKeySize { + return nil, ErrInvalidPrivateKey + } + return p.dsa.signer(seed) } diff --git a/cert/cert_v3_test.go b/cert/cert_v3_test.go index 942c6756..3e113ea4 100644 --- a/cert/cert_v3_test.go +++ b/cert/cert_v3_test.go @@ -46,7 +46,7 @@ func TestCertificateV3_Sign(t *testing.T) { assert.Equal(t, caV2Fp, host.Issuer()) // A v2 CA can not sign a v3 certificate, nor a v3 CA a v2 one. - _, err = (&TBSCertificate{Version: Version3, Curve: curve, Name: "x", Networks: host.Networks(), NotBefore: host.NotBefore(), NotAfter: host.NotAfter(), PublicKey: host.PublicKey()}).Sign(V2Form(ca), curve, caKey) + _, err = (&TBSCertificate{Version: Version3, Curve: curve, PQSuite: DefaultPQSuite, Name: "x", Networks: host.Networks(), NotBefore: host.NotBefore(), NotAfter: host.NotAfter(), PublicKey: host.PublicKey()}).Sign(V2Form(ca), curve, caKey) require.Error(t, err) }) } @@ -174,13 +174,13 @@ func TestCertificateV3_StrictParse(t *testing.T) { require.ErrorIs(t, err, ErrBadFormat, "trailing bytes after the envelope") trailing := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) { - fields[6|classContextSpecific] = []byte{6 | classContextSpecific, 1, 0} - *order = append(*order, 6|classContextSpecific) + fields[7|classContextSpecific] = []byte{7 | classContextSpecific, 1, 0} + *order = append(*order, 7|classContextSpecific) }) _, err = unmarshalCertificateV3(trailing, nil, Curve_CURVE25519) require.ErrorIs(t, err, ErrBadFormat, "unknown field after the pq signature") - for _, tag := range []asn1.Tag{TagCertPQPublicKey, TagCertPQSignature} { + for _, tag := range []asn1.Tag{TagCertPQSuite, TagCertPQPublicKey, TagCertPQSignature} { missing := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) { *order = removeTag(*order, tag) }) @@ -202,6 +202,17 @@ func TestCertificateV3_StrictParse(t *testing.T) { }) _, err = unmarshalCertificateV3(short, nil, Curve_CURVE25519) require.ErrorIs(t, err, ErrInvalidPublicKey) + + // The suite is one known value, PQSuiteUnknown included in what is not. + for _, suite := range [][]byte{{byte(PQSuiteUnknown)}, {200}, {}, {1, 1}} { + bad := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) { + var b cryptobyte.Builder + b.AddASN1(TagCertPQSuite, func(b *cryptobyte.Builder) { b.AddBytes(suite) }) + fields[TagCertPQSuite] = b.BytesOrPanic() + }) + _, err = unmarshalCertificateV3(bad, nil, Curve_CURVE25519) + require.Error(t, err, "suite %x", suite) + } } func removeTag(order []asn1.Tag, tag asn1.Tag) []asn1.Tag { @@ -278,9 +289,9 @@ func TestCAPool_V3(t *testing.T) { // A second v3 extension of the same v2 CA is ambiguous. sk, err := mldsa.GenerateKey(mldsa.MLDSA87()) require.NoError(t, err) - signPQ, err := SignPQ(sk.Bytes()) + signPQ, err := SignPQ(DefaultPQSuite, sk.Bytes()) require.NoError(t, err) - again, err := ExtendToV3(V2Form(ca), sk.PublicKey().Bytes(), nil, signPQ) + again, err := ExtendToV3(V2Form(ca), DefaultPQSuite, sk.PublicKey().Bytes(), nil, signPQ) require.NoError(t, err) require.ErrorContains(t, pool.AddCA(again), "two version 3 CAs extend the same version 2 CA") }) @@ -343,17 +354,17 @@ func TestExtendToV3(t *testing.T) { sk, err := mldsa.GenerateKey(mldsa.MLDSA87()) require.NoError(t, err) - signPQ, err := SignPQ(sk.Bytes()) + signPQ, err := SignPQ(DefaultPQSuite, sk.Bytes()) require.NoError(t, err) - ca3, err := ExtendToV3(ca2, sk.PublicKey().Bytes(), nil, signPQ) + ca3, err := ExtendToV3(ca2, DefaultPQSuite, sk.PublicKey().Bytes(), nil, signPQ) require.NoError(t, err) assertCertsEqual(t, ca2, V2Form(ca3)) require.NoError(t, ca3.VerifyPrivateKey(curve, append(caKey2, sk.Bytes()...))) dk, err := mlkem.GenerateKey1024() require.NoError(t, err) - host3, err := ExtendToV3(host2, dk.EncapsulationKey().Bytes(), ca3, signPQ) + host3, err := ExtendToV3(host2, DefaultPQSuite, dk.EncapsulationKey().Bytes(), ca3, signPQ) require.NoError(t, err) assertCertsEqual(t, host2, V2Form(host3)) @@ -364,26 +375,26 @@ func TestExtendToV3(t *testing.T) { // A host must be extended by its own issuer, and with that issuer's key. other2, _, _, _ := NewTestCaCert(Version2, curve, time.Time{}, time.Time{}, nil, nil, nil) - other3, err := ExtendToV3(other2, sk.PublicKey().Bytes(), nil, signPQ) + other3, err := ExtendToV3(other2, DefaultPQSuite, sk.PublicKey().Bytes(), nil, signPQ) require.NoError(t, err) - _, err = ExtendToV3(host2, dk.EncapsulationKey().Bytes(), other3, signPQ) + _, err = ExtendToV3(host2, DefaultPQSuite, dk.EncapsulationKey().Bytes(), other3, signPQ) require.Error(t, err) wrong, err := mldsa.GenerateKey(mldsa.MLDSA87()) require.NoError(t, err) - wrongSign, err := SignPQ(wrong.Bytes()) + wrongSign, err := SignPQ(DefaultPQSuite, wrong.Bytes()) require.NoError(t, err) - _, err = ExtendToV3(host2, dk.EncapsulationKey().Bytes(), ca3, wrongSign) + _, err = ExtendToV3(host2, DefaultPQSuite, dk.EncapsulationKey().Bytes(), ca3, wrongSign) require.ErrorIs(t, err, ErrSignatureMismatch) // A CA extends itself, and needs its own post-quantum key to. - _, err = ExtendToV3(ca2, sk.PublicKey().Bytes(), nil, wrongSign) + _, err = ExtendToV3(ca2, DefaultPQSuite, sk.PublicKey().Bytes(), nil, wrongSign) require.ErrorIs(t, err, ErrSignatureMismatch) - _, err = ExtendToV3(ca2, sk.PublicKey().Bytes(), ca3, signPQ) + _, err = ExtendToV3(ca2, DefaultPQSuite, sk.PublicKey().Bytes(), ca3, signPQ) require.Error(t, err) // Only v2 certificates extend. - _, err = ExtendToV3(host3, dk.EncapsulationKey().Bytes(), ca3, signPQ) + _, err = ExtendToV3(host3, DefaultPQSuite, dk.EncapsulationKey().Bytes(), ca3, signPQ) require.Error(t, err) }) } @@ -398,13 +409,13 @@ func TestExtendToV3_Deterministic(t *testing.T) { host2, _, _, _ := NewTestCert(Version2, curve, ca2, caKey2, "host", time.Time{}, time.Time{}, []netip.Prefix{netip.MustParsePrefix("10.1.0.1/16")}, nil, nil) sk, err := mldsa.GenerateKey(mldsa.MLDSA87()) require.NoError(t, err) - signPQ, err := SignPQ(sk.Bytes()) + signPQ, err := SignPQ(DefaultPQSuite, sk.Bytes()) require.NoError(t, err) dk, err := mlkem.GenerateKey1024() require.NoError(t, err) extend := func(c Certificate, pq []byte, signer Certificate) ([]byte, string, Certificate) { - c3, err := ExtendToV3(c, pq, signer, signPQ) + c3, err := ExtendToV3(c, DefaultPQSuite, pq, signer, signPQ) require.NoError(t, err) b, err := c3.Marshal() require.NoError(t, err) @@ -446,18 +457,18 @@ func TestCertificateV3_KeyPEM(t *testing.T) { assert.Equal(t, curve, gotCurve) assert.Equal(t, ca.PublicKey(), pub) - key, _, gotCurve, err := UnmarshalPrivateKeyFromPEM(MarshalPrivateKeyToPEM(curve, hostKey)) + key, _, gotCurve, err := UnmarshalPrivateKeyFromPEM(MarshalPQPrivateKeyToPEM(curve, DefaultPQSuite, hostKey)) require.NoError(t, err) assert.Equal(t, curve, gotCurve) assert.Equal(t, hostKey, key) - key, _, gotCurve, err = UnmarshalSigningPrivateKeyFromPEM(MarshalSigningPrivateKeyToPEM(curve, caKey)) + key, _, gotCurve, err = UnmarshalSigningPrivateKeyFromPEM(MarshalPQSigningPrivateKeyToPEM(curve, DefaultPQSuite, caKey)) require.NoError(t, err) assert.Equal(t, curve, gotCurve) assert.Equal(t, caKey, key) kdf := NewArgon2Parameters(64*1024, 4, 1) - enc, err := EncryptAndMarshalSigningPrivateKey(curve, caKey, []byte("pw"), kdf) + enc, err := EncryptAndMarshalPQSigningPrivateKey(curve, DefaultPQSuite, caKey, []byte("pw"), kdf) require.NoError(t, err) _, _, _, err = UnmarshalSigningPrivateKeyFromPEM(enc) require.ErrorIs(t, err, ErrPrivateKeyEncrypted) @@ -465,6 +476,18 @@ func TestCertificateV3_KeyPEM(t *testing.T) { require.NoError(t, err) assert.Equal(t, curve, gotCurve) assert.Equal(t, caKey, key) + + // The banners name both halves. + assert.Contains(t, string(MarshalPQPrivateKeyToPEM(curve, DefaultPQSuite, hostKey)), " MLKEM1024 PRIVATE KEY-----") + assert.Contains(t, string(MarshalPQSigningPrivateKeyToPEM(curve, DefaultPQSuite, caKey)), " MLDSA87 PRIVATE KEY-----") + assert.Contains(t, string(enc), " MLDSA87 ENCRYPTED PRIVATE KEY-----") + + // A key of the wrong size, or for an unknown suite, has no banner. + assert.Nil(t, MarshalPQPrivateKeyToPEM(curve, DefaultPQSuite, hostKey[1:])) + assert.Nil(t, MarshalPQPrivateKeyToPEM(curve, PQSuiteUnknown, hostKey)) + assert.Nil(t, MarshalPQSigningPrivateKeyToPEM(curve, DefaultPQSuite, caKey[1:])) + _, err = EncryptAndMarshalPQSigningPrivateKey(curve, DefaultPQSuite, caKey[1:], []byte("pw"), kdf) + require.Error(t, err) }) } } diff --git a/cert/crypto.go b/cert/crypto.go index a25d5c7e..dc3dfa1a 100644 --- a/cert/crypto.go +++ b/cert/crypto.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "math" + "strings" "golang.org/x/crypto/argon2" "google.golang.org/protobuf/proto" @@ -158,7 +159,33 @@ func splitNonceCiphertext(blob []byte, nonceSize int) ([]byte, []byte, error) { // EncryptAndMarshalSigningPrivateKey is a simple helper to encrypt and PEM encode a private key func EncryptAndMarshalSigningPrivateKey(curve Curve, b []byte, passphrase []byte, kdfParams *Argon2Parameters) ([]byte, error) { - pq := len(b) == signingPrivateKeySize(curve)+MLDSA87PrivateKeySize + switch curve { + case Curve_CURVE25519: + return encryptAndMarshalSigningPrivateKey(EncryptedEd25519PrivateKeyBanner, b, passphrase, kdfParams) + case Curve_P256: + return encryptAndMarshalSigningPrivateKey(EncryptedECDSAP256PrivateKeyBanner, b, passphrase, kdfParams) + default: + return nil, fmt.Errorf("invalid curve: %v", curve) + } +} + +// EncryptAndMarshalPQSigningPrivateKey encrypts and PEM encodes a composite CA private key of suite, the classical +// signing key followed by the seed of the suite's signature algorithm. +func EncryptAndMarshalPQSigningPrivateKey(curve Curve, suite PQSuite, b []byte, passphrase []byte, kdfParams *Argon2Parameters) ([]byte, error) { + p := pqSuites[suite] + if p == nil { + return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, suite) + } + if curve != Curve_CURVE25519 && curve != Curve_P256 { + return nil, fmt.Errorf("invalid curve: %v", curve) + } + if len(b) != pqKeySize(pqSigningPrivateKey, curve, p) { + return nil, ErrInvalidPrivateKey + } + return encryptAndMarshalSigningPrivateKey(pqBannerName(pqEncryptedSigningPrivateKey, curve, p), b, passphrase, kdfParams) +} + +func encryptAndMarshalSigningPrivateKey(banner string, b []byte, passphrase []byte, kdfParams *Argon2Parameters) ([]byte, error) { ciphertext, err := aes256Encrypt(passphrase, kdfParams, b) if err != nil { return nil, err @@ -181,21 +208,7 @@ func EncryptAndMarshalSigningPrivateKey(curve Curve, b []byte, passphrase []byte return nil, err } - switch { - case pq && curve == Curve_CURVE25519: - return pem.EncodeToMemory(&pem.Block{Type: EncryptedEd25519MLDSA87PrivateKeyBanner, Bytes: b}), nil - case pq && curve == Curve_P256: - return pem.EncodeToMemory(&pem.Block{Type: EncryptedECDSAP256MLDSA87PrivateKeyBanner, Bytes: b}), nil - } - - switch curve { - case Curve_CURVE25519: - return pem.EncodeToMemory(&pem.Block{Type: EncryptedEd25519PrivateKeyBanner, Bytes: b}), nil - case Curve_P256: - return pem.EncodeToMemory(&pem.Block{Type: EncryptedECDSAP256PrivateKeyBanner, Bytes: b}), nil - default: - return nil, fmt.Errorf("invalid curve: %v", curve) - } + return pem.EncodeToMemory(&pem.Block{Type: banner, Bytes: b}), nil } // UnmarshalNebulaEncryptedData will unmarshal a protobuf byte representation of a nebula cert into its @@ -268,18 +281,15 @@ func DecryptAndUnmarshalSigningPrivateKey(passphrase, b []byte) (Curve, []byte, return curve, nil, r, fmt.Errorf("input did not contain a valid PEM encoded block") } - pq := false - switch k.Type { - case EncryptedEd25519PrivateKeyBanner: + pq, pqKey := pqKeyBanners[k.Type] + pqKey = pqKey && pq.kind == pqEncryptedSigningPrivateKey + switch { + case pqKey: + curve = pq.curve + case k.Type == EncryptedEd25519PrivateKeyBanner: curve = Curve_CURVE25519 - case EncryptedECDSAP256PrivateKeyBanner: + case k.Type == EncryptedECDSAP256PrivateKeyBanner: curve = Curve_P256 - case EncryptedEd25519MLDSA87PrivateKeyBanner: - curve = Curve_CURVE25519 - pq = true - case EncryptedECDSAP256MLDSA87PrivateKeyBanner: - curve = Curve_P256 - pq = true default: return curve, nil, r, fmt.Errorf("bytes did not contain a proper nebula encrypted Ed25519/ECDSA private key banner") } @@ -300,9 +310,9 @@ func DecryptAndUnmarshalSigningPrivateKey(passphrase, b []byte) (Curve, []byte, return curve, nil, r, fmt.Errorf("unsupported encryption algorithm: %s", ned.EncryptionMetadata.EncryptionAlgorithm) } - if pq { - if len(bytes) != signingPrivateKeySize(curve)+MLDSA87PrivateKeySize { - return curve, nil, r, fmt.Errorf("key was not %d bytes, is invalid %s ML-DSA-87 private key", signingPrivateKeySize(curve)+MLDSA87PrivateKeySize, curve) + if pqKey { + if len(bytes) != pq.size { + return curve, nil, r, fmt.Errorf("key was not %d bytes, is invalid %s", pq.size, strings.ToLower(strings.TrimPrefix(k.Type, "NEBULA "))) } return curve, bytes, r, nil } diff --git a/cert/errors.go b/cert/errors.go index 596cfe19..72ae2c0a 100644 --- a/cert/errors.go +++ b/cert/errors.go @@ -23,6 +23,8 @@ var ( ErrUnknownVersion = errors.New("certificate version unrecognized") ErrCertPubkeyPresent = errors.New("certificate has unexpected pubkey present") ErrCurveMismatch = errors.New("certificate curve does not match CA") + ErrPQSuiteMismatch = errors.New("certificate post-quantum suite does not match CA") + ErrUnknownPQSuite = errors.New("unknown post-quantum suite") ErrInvalidPEMBlock = errors.New("input did not contain a valid PEM encoded block") ErrInvalidPEMCertificateBanner = errors.New("bytes did not contain a proper certificate banner") diff --git a/cert/helper_test.go b/cert/helper_test.go index 77adb092..10591fcb 100644 --- a/cert/helper_test.go +++ b/cert/helper_test.go @@ -4,8 +4,6 @@ import ( "crypto/ecdh" "crypto/ecdsa" "crypto/elliptic" - "crypto/mldsa" - "crypto/mlkem" "crypto/rand" "io" "net/netip" @@ -64,6 +62,9 @@ func NewTestCaCert(version Version, curve Curve, before, after time.Time, networ Groups: groups, IsCA: true, } + if version == Version3 { + t.PQSuite = DefaultPQSuite + } c, err := t.Sign(nil, curve, priv) if err != nil { @@ -104,7 +105,7 @@ func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string } if v == Version3 { - pub, priv = appendPQKey(pub, priv) + pub, priv = appendPQKey(ca, pub, priv) } nc := &TBSCertificate{ @@ -118,6 +119,7 @@ func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string NotAfter: time.Unix(after.Unix(), 0), PublicKey: pub, IsCA: false, + PQSuite: PQSuiteOf(ca), } c, err := nc.Sign(ca, ca.Curve(), key) @@ -130,25 +132,28 @@ func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string panic(err) } + if v == Version3 { + return c, pub, MarshalPQPrivateKeyToPEM(curve, PQSuiteOf(ca), priv), pem + } return c, pub, MarshalPrivateKeyToPEM(curve, priv), pem } -// appendPQSigningKey makes a classical CA keypair into a version 3 composite one with a fresh ML-DSA-87 key. +// appendPQSigningKey makes a classical CA keypair into a version 3 composite one with a fresh key of the default suite. func appendPQSigningKey(pub, priv []byte) ([]byte, []byte) { - sk, err := mldsa.GenerateKey(mldsa.MLDSA87()) + pqPub, pqPriv, err := DefaultPQSuite.GenerateKey(true) if err != nil { panic(err) } - return append(pub, sk.PublicKey().Bytes()...), append(priv, sk.Bytes()...) + return append(pub, pqPub...), append(priv, pqPriv...) } -// appendPQKey makes a classical host keypair into a version 3 composite one with a fresh ML-KEM-1024 key. -func appendPQKey(pub, priv []byte) ([]byte, []byte) { - dk, err := mlkem.GenerateKey1024() +// appendPQKey makes a classical host keypair into a version 3 composite one with a fresh key of the CA's suite. +func appendPQKey(ca Certificate, pub, priv []byte) ([]byte, []byte) { + pqPub, pqPriv, err := PQSuiteOf(ca).GenerateKey(false) if err != nil { panic(err) } - return append(pub, dk.EncapsulationKey().Bytes()...), append(priv, dk.Bytes()...) + return append(pub, pqPub...), append(priv, pqPriv...) } func X25519Keypair() ([]byte, []byte) { diff --git a/cert/pem.go b/cert/pem.go index 40a90014..bda633f1 100644 --- a/cert/pem.go +++ b/cert/pem.go @@ -5,6 +5,8 @@ import ( "encoding/pem" "errors" "fmt" + "slices" + "strings" "golang.org/x/crypto/ed25519" ) @@ -74,23 +76,11 @@ const ( //key-agreement-key banners P256PublicKeyBanner = "NEBULA P256 PUBLIC KEY" ) -// Version 3 keys are composite: the classical key followed by the post-quantum key, an ML-KEM-1024 seed or -// encapsulation key for hosts and an ML-DSA-87 seed or public key for CAs. -const ( //post-quantum key-agreement-key banners - X25519MLKEM1024PrivateKeyBanner = "NEBULA X25519 MLKEM1024 PRIVATE KEY" - X25519MLKEM1024PublicKeyBanner = "NEBULA X25519 MLKEM1024 PUBLIC KEY" - P256MLKEM1024PrivateKeyBanner = "NEBULA P256 MLKEM1024 PRIVATE KEY" - P256MLKEM1024PublicKeyBanner = "NEBULA P256 MLKEM1024 PUBLIC KEY" -) - -const ( //post-quantum signing key banners - EncryptedECDSAP256MLDSA87PrivateKeyBanner = "NEBULA ECDSA P256 MLDSA87 ENCRYPTED PRIVATE KEY" - ECDSAP256MLDSA87PrivateKeyBanner = "NEBULA ECDSA P256 MLDSA87 PRIVATE KEY" - ECDSAP256MLDSA87PublicKeyBanner = "NEBULA ECDSA P256 MLDSA87 PUBLIC KEY" - EncryptedEd25519MLDSA87PrivateKeyBanner = "NEBULA ED25519 MLDSA87 ENCRYPTED PRIVATE KEY" - Ed25519MLDSA87PrivateKeyBanner = "NEBULA ED25519 MLDSA87 PRIVATE KEY" - Ed25519MLDSA87PublicKeyBanner = "NEBULA ED25519 MLDSA87 PUBLIC KEY" -) +// Version 3 keys are composite: the classical key followed by the post-quantum key of the certificate's PQSuite, a +// KEM seed or public key for hosts and a signature algorithm's seed or public key for CAs. Their banners name both +// halves, "NEBULA X25519 MLKEM1024 PRIVATE KEY" or "NEBULA ED25519 MLDSA87 PUBLIC KEY" for example, because the +// post-quantum algorithm can not be told from the key's size: ML-KEM and ML-DSA seeds are the same size at every +// strength. The banners name algorithms, not suites, so suites sharing an algorithm share its banners too. // Classical key sizes, the composite keys of version 3 add the post-quantum key to these. const ( @@ -102,12 +92,91 @@ const ( ecdsaP256PublicKeySize = 65 // uncompressed ) -// signingPrivateKeySize returns the size of a classical signing private key. -func signingPrivateKeySize(curve Curve) int { - if curve == Curve_P256 { - return p256PrivateKeySize +// ClassicalPrivateKey returns the classical half of a host private key: all of a classical key, or the classical half +// of a composite one, whatever its suite. +func ClassicalPrivateKey(key []byte) []byte { + // X25519 and P256 private keys are the same size. + if len(key) > x25519KeySize { + return key[:x25519KeySize] } - return ed25519PrivateKeySize + return key +} + +// pqKeyKind is what a composite key banner holds. +type pqKeyKind int + +const ( + pqPrivateKey pqKeyKind = iota + pqPublicKey + pqSigningPrivateKey + pqSigningPublicKey + pqEncryptedSigningPrivateKey +) + +// pqKeyBanner is what a composite key banner says about the key under it. +type pqKeyBanner struct { + kind pqKeyKind + curve Curve + size int // of the whole composite key; for an encrypted key, once decrypted +} + +var pqKeyKinds = []pqKeyKind{pqPrivateKey, pqPublicKey, pqSigningPrivateKey, pqSigningPublicKey, pqEncryptedSigningPrivateKey} + +// pqKeyBanners maps every composite key banner of every suite to what it holds, see registerPQSuite. +var pqKeyBanners = map[string]pqKeyBanner{} + +// pqBannerName returns the banner for a composite key. +func pqBannerName(kind pqKeyKind, curve Curve, suite *pqSuite) string { + classical := map[Curve]string{Curve_CURVE25519: "X25519", Curve_P256: "P256"}[curve] + alg := suite.kem.name + if kind != pqPrivateKey && kind != pqPublicKey { + classical = map[Curve]string{Curve_CURVE25519: "ED25519", Curve_P256: "ECDSA P256"}[curve] + alg = suite.dsa.name + } + return "NEBULA " + classical + " " + alg + " " + map[pqKeyKind]string{ + pqPrivateKey: "PRIVATE KEY", + pqPublicKey: "PUBLIC KEY", + pqSigningPrivateKey: "PRIVATE KEY", + pqSigningPublicKey: "PUBLIC KEY", + pqEncryptedSigningPrivateKey: "ENCRYPTED PRIVATE KEY", + }[kind] +} + +// pqKeySize returns the size of a composite key. +func pqKeySize(kind pqKeyKind, curve Curve, suite *pqSuite) int { + p256 := curve == Curve_P256 + switch kind { + case pqPrivateKey: + return x25519KeySize + suite.kem.privateKeySize // a P256 private key is the same size + case pqPublicKey: + return map[bool]int{false: x25519KeySize, true: p256PublicKeySize}[p256] + suite.kem.publicKeySize + case pqSigningPublicKey: + return map[bool]int{false: ed25519PublicKeySize, true: ecdsaP256PublicKeySize}[p256] + suite.dsa.publicKeySize + default: + return map[bool]int{false: ed25519PrivateKeySize, true: p256PrivateKeySize}[p256] + suite.dsa.privateKeySize + } +} + +// marshalPQKeyToPEM PEM encodes a composite key of suite, nil for an unknown curve or suite or a key of the wrong size. +func marshalPQKeyToPEM(kind pqKeyKind, curve Curve, suite PQSuite, b []byte) []byte { + p := pqSuites[suite] + if p == nil || (curve != Curve_CURVE25519 && curve != Curve_P256) || len(b) != pqKeySize(kind, curve, p) { + return nil + } + return pem.EncodeToMemory(&pem.Block{Type: pqBannerName(kind, curve, p), Bytes: b}) +} + +// unmarshalPQKeyBlock checks a PEM block that may hold a composite key of one of kinds. ok is false when the banner +// is not a composite key banner of those kinds at all. +func unmarshalPQKeyBlock(k *pem.Block, kinds ...pqKeyKind) (curve Curve, ok bool, err error) { + b, found := pqKeyBanners[k.Type] + if !found || !slices.Contains(kinds, b.kind) { + return 0, false, nil + } + if b.kind != pqEncryptedSigningPrivateKey && len(k.Bytes) != b.size { + return b.curve, true, fmt.Errorf("key was not %d bytes, is invalid %s", b.size, strings.ToLower(strings.TrimPrefix(k.Type, "NEBULA "))) + } + return b.curve, true, nil } /* including "ECDSA" in the P256 banners is a clue that these keys should be used only for signing */ @@ -154,6 +223,12 @@ func unmarshalCertificateBlock(block *pem.Block) (Certificate, error) { } func marshalCertPublicKeyToPEM(c Certificate) []byte { + if suite := PQSuiteOf(c); suite != PQSuiteUnknown { + if c.IsCA() { + return MarshalPQSigningPublicKeyToPEM(c.Curve(), suite, c.PublicKey()) + } + return MarshalPQPublicKeyToPEM(c.Curve(), suite, c.PublicKey()) + } if c.IsCA() { return MarshalSigningPublicKeyToPEM(c.Curve(), c.PublicKey()) } else { @@ -164,13 +239,6 @@ func marshalCertPublicKeyToPEM(c Certificate) []byte { // MarshalPublicKeyToPEM returns a PEM representation of a public key used for ECDH. // if your public key came from a certificate, prefer Certificate.PublicKeyPEM() if possible, to avoid mistakes! func MarshalPublicKeyToPEM(curve Curve, b []byte) []byte { - pq := len(b) > MLKEM1024PublicKeySize - switch { - case pq && curve == Curve_CURVE25519: - return pem.EncodeToMemory(&pem.Block{Type: X25519MLKEM1024PublicKeyBanner, Bytes: b}) - case pq && curve == Curve_P256: - return pem.EncodeToMemory(&pem.Block{Type: P256MLKEM1024PublicKeyBanner, Bytes: b}) - } switch curve { case Curve_CURVE25519: return pem.EncodeToMemory(&pem.Block{Type: X25519PublicKeyBanner, Bytes: b}) @@ -181,16 +249,15 @@ func MarshalPublicKeyToPEM(curve Curve, b []byte) []byte { } } +// MarshalPQPublicKeyToPEM returns a PEM representation of a composite host public key of suite, the classical ECDH +// key followed by the suite's KEM public key, or nil if b is not one. +func MarshalPQPublicKeyToPEM(curve Curve, suite PQSuite, b []byte) []byte { + return marshalPQKeyToPEM(pqPublicKey, curve, suite, b) +} + // MarshalSigningPublicKeyToPEM returns a PEM representation of a public key used for signing. // if your public key came from a certificate, prefer Certificate.PublicKeyPEM() if possible, to avoid mistakes! func MarshalSigningPublicKeyToPEM(curve Curve, b []byte) []byte { - pq := len(b) > MLDSA87PublicKeySize - switch { - case pq && curve == Curve_CURVE25519: - return pem.EncodeToMemory(&pem.Block{Type: Ed25519MLDSA87PublicKeyBanner, Bytes: b}) - case pq && curve == Curve_P256: - return pem.EncodeToMemory(&pem.Block{Type: ECDSAP256MLDSA87PublicKeyBanner, Bytes: b}) - } switch curve { case Curve_CURVE25519: return pem.EncodeToMemory(&pem.Block{Type: Ed25519PublicKeyBanner, Bytes: b}) @@ -201,6 +268,12 @@ func MarshalSigningPublicKeyToPEM(curve Curve, b []byte) []byte { } } +// MarshalPQSigningPublicKeyToPEM returns a PEM representation of a composite CA public key of suite, the classical +// signing key followed by the public key of the suite's signature algorithm, or nil if b is not one. +func MarshalPQSigningPublicKeyToPEM(curve Curve, suite PQSuite, b []byte) []byte { + return marshalPQKeyToPEM(pqSigningPublicKey, curve, suite, b) +} + // UnmarshalPublicKeyFromPEM will try to unmarshal the first pem block in a byte array, returning any non // consumed data or an error on failure. Only key-agreement (ECDH) public key banners are accepted. // Use UnmarshalSigningPublicKeyFromPEM for Ed25519/ECDSA banners. @@ -209,6 +282,12 @@ func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) { if k == nil { return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block") } + if curve, ok, err := unmarshalPQKeyBlock(k, pqPublicKey); ok { + if err != nil { + return nil, r, 0, err + } + return k.Bytes, r, curve, nil + } var expectedLen int var curve Curve switch k.Type { @@ -219,12 +298,6 @@ func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) { // Uncompressed expectedLen = 65 curve = Curve_P256 - case X25519MLKEM1024PublicKeyBanner: - expectedLen = x25519KeySize + MLKEM1024PublicKeySize - curve = Curve_CURVE25519 - case P256MLKEM1024PublicKeyBanner: - expectedLen = p256PublicKeySize + MLKEM1024PublicKeySize - curve = Curve_P256 default: return nil, r, 0, fmt.Errorf("bytes did not contain a proper public key banner") } @@ -242,6 +315,12 @@ func UnmarshalSigningPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) { if k == nil { return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block") } + if curve, ok, err := unmarshalPQKeyBlock(k, pqSigningPublicKey); ok { + if err != nil { + return nil, r, 0, err + } + return k.Bytes, r, curve, nil + } var expectedLen int var curve Curve switch k.Type { @@ -252,12 +331,6 @@ func UnmarshalSigningPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) { // Uncompressed expectedLen = 65 curve = Curve_P256 - case Ed25519MLDSA87PublicKeyBanner: - expectedLen = ed25519PublicKeySize + MLDSA87PublicKeySize - curve = Curve_CURVE25519 - case ECDSAP256MLDSA87PublicKeyBanner: - expectedLen = ecdsaP256PublicKeySize + MLDSA87PublicKeySize - curve = Curve_P256 default: return nil, r, 0, fmt.Errorf("bytes did not contain a proper Ed25519/ECDSA public key banner") } @@ -268,13 +341,6 @@ func UnmarshalSigningPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) { } func MarshalPrivateKeyToPEM(curve Curve, b []byte) []byte { - pq := len(b) > MLKEM1024PrivateKeySize - switch { - case pq && curve == Curve_CURVE25519: - return pem.EncodeToMemory(&pem.Block{Type: X25519MLKEM1024PrivateKeyBanner, Bytes: b}) - case pq && curve == Curve_P256: - return pem.EncodeToMemory(&pem.Block{Type: P256MLKEM1024PrivateKeyBanner, Bytes: b}) - } switch curve { case Curve_CURVE25519: return pem.EncodeToMemory(&pem.Block{Type: X25519PrivateKeyBanner, Bytes: b}) @@ -285,14 +351,19 @@ func MarshalPrivateKeyToPEM(curve Curve, b []byte) []byte { } } +// MarshalPQPrivateKeyToPEM returns a PEM representation of a composite host private key of suite, the classical ECDH +// key followed by the seed of the suite's KEM, or nil if b is not one. +func MarshalPQPrivateKeyToPEM(curve Curve, suite PQSuite, b []byte) []byte { + return marshalPQKeyToPEM(pqPrivateKey, curve, suite, b) +} + +// MarshalPQSigningPrivateKeyToPEM returns a PEM representation of a composite CA private key of suite, the classical +// signing key followed by the seed of the suite's signature algorithm, or nil if b is not one. +func MarshalPQSigningPrivateKeyToPEM(curve Curve, suite PQSuite, b []byte) []byte { + return marshalPQKeyToPEM(pqSigningPrivateKey, curve, suite, b) +} + func MarshalSigningPrivateKeyToPEM(curve Curve, b []byte) []byte { - pq := len(b) == signingPrivateKeySize(curve)+MLDSA87PrivateKeySize - switch { - case pq && curve == Curve_CURVE25519: - return pem.EncodeToMemory(&pem.Block{Type: Ed25519MLDSA87PrivateKeyBanner, Bytes: b}) - case pq && curve == Curve_P256: - return pem.EncodeToMemory(&pem.Block{Type: ECDSAP256MLDSA87PrivateKeyBanner, Bytes: b}) - } switch curve { case Curve_CURVE25519: return pem.EncodeToMemory(&pem.Block{Type: Ed25519PrivateKeyBanner, Bytes: b}) @@ -310,6 +381,12 @@ func UnmarshalPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) { if k == nil { return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block") } + if curve, ok, err := unmarshalPQKeyBlock(k, pqPrivateKey); ok { + if err != nil { + return nil, r, 0, err + } + return k.Bytes, r, curve, nil + } var expectedLen int var curve Curve switch k.Type { @@ -319,12 +396,6 @@ func UnmarshalPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) { case P256PrivateKeyBanner: expectedLen = 32 curve = Curve_P256 - case X25519MLKEM1024PrivateKeyBanner: - expectedLen = x25519KeySize + MLKEM1024PrivateKeySize - curve = Curve_CURVE25519 - case P256MLKEM1024PrivateKeyBanner: - expectedLen = p256PrivateKeySize + MLKEM1024PrivateKeySize - curve = Curve_P256 default: return nil, r, 0, fmt.Errorf("bytes did not contain a proper private key banner") } @@ -339,24 +410,21 @@ func UnmarshalSigningPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) if k == nil { return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block") } + if curve, ok, err := unmarshalPQKeyBlock(k, pqSigningPrivateKey, pqEncryptedSigningPrivateKey); ok { + switch { + case err != nil: + return nil, r, 0, err + case pqKeyBanners[k.Type].kind == pqEncryptedSigningPrivateKey: + return nil, nil, curve, ErrPrivateKeyEncrypted + } + return k.Bytes, r, curve, nil + } var curve Curve switch k.Type { case EncryptedEd25519PrivateKeyBanner: return nil, nil, Curve_CURVE25519, ErrPrivateKeyEncrypted - case EncryptedECDSAP256PrivateKeyBanner, EncryptedECDSAP256MLDSA87PrivateKeyBanner: + case EncryptedECDSAP256PrivateKeyBanner: return nil, nil, Curve_P256, ErrPrivateKeyEncrypted - case EncryptedEd25519MLDSA87PrivateKeyBanner: - return nil, nil, Curve_CURVE25519, ErrPrivateKeyEncrypted - case Ed25519MLDSA87PrivateKeyBanner: - curve = Curve_CURVE25519 - if len(k.Bytes) != ed25519PrivateKeySize+MLDSA87PrivateKeySize { - return nil, r, 0, fmt.Errorf("key was not %d bytes, is invalid Ed25519 ML-DSA-87 private key", ed25519PrivateKeySize+MLDSA87PrivateKeySize) - } - case ECDSAP256MLDSA87PrivateKeyBanner: - curve = Curve_P256 - if len(k.Bytes) != p256PrivateKeySize+MLDSA87PrivateKeySize { - return nil, r, 0, fmt.Errorf("key was not %d bytes, is invalid ECDSA P256 ML-DSA-87 private key", p256PrivateKeySize+MLDSA87PrivateKeySize) - } case Ed25519PrivateKeyBanner: curve = Curve_CURVE25519 if len(k.Bytes) != ed25519.PrivateKeySize { diff --git a/cert/pq_suite.go b/cert/pq_suite.go new file mode 100644 index 00000000..7d41f250 --- /dev/null +++ b/cert/pq_suite.go @@ -0,0 +1,268 @@ +package cert + +import ( + "crypto/mldsa" + "crypto/mlkem" + "fmt" + "slices" + "strings" +) + +// PQSuite names the post-quantum algorithms of a version 3 certificate, the way Curve names its classical ones: the +// key encapsulation mechanism of a host's key, and the signature algorithm of a CA's key and of the certificates it +// signs. A CA and every certificate it signs share a suite. Every version 3 certificate names its suite. +type PQSuite uint8 + +const ( + // PQSuiteUnknown is never valid in a certificate. + PQSuiteUnknown PQSuite = 0 + + // PQSuite_MLKEM1024_MLDSA87 gives hosts ML-KEM-1024 keys and CAs ML-DSA-87 keys and signatures. + PQSuite_MLKEM1024_MLDSA87 PQSuite = 1 +) + +// DefaultPQSuite is the suite tools use when none is asked for. +const DefaultPQSuite = PQSuite_MLKEM1024_MLDSA87 + +// pqKEM is the key encapsulation mechanism of a suite, what host keys use. Private keys are seeds. +type pqKEM struct { + name string // as it appears in key banners + publicKeySize int + privateKeySize int + generate func() (pub, priv []byte, err error) + publicKey func(priv []byte) ([]byte, error) + checkPublicKey func(pub []byte) bool +} + +// pqDSA is the signature algorithm of a suite, what CA keys and certificate signatures use. Private keys are seeds. +type pqDSA struct { + name string // as it appears in key banners + publicKeySize int + privateKeySize int + signatureSize int + generate func() (pub, priv []byte, err error) + publicKey func(priv []byte) ([]byte, error) + checkPublicKey func(pub []byte) bool + signer func(priv []byte) (SignerLambda, error) // must sign deterministically + verify func(pub, msg, sig []byte) bool +} + +type pqSuite struct { + kem pqKEM + dsa pqDSA +} + +// pqSuites holds every known suite, see registerPQSuite. +var pqSuites = map[PQSuite]*pqSuite{} + +func init() { + registerPQSuite(PQSuite_MLKEM1024_MLDSA87, &pqSuite{kem: mlkem1024, dsa: mldsa87}) +} + +// registerPQSuite makes a suite known, along with the banners of its composite keys. +func registerPQSuite(id PQSuite, s *pqSuite) { + pqSuites[id] = s + for _, curve := range []Curve{Curve_CURVE25519, Curve_P256} { + for _, kind := range pqKeyKinds { + pqKeyBanners[pqBannerName(kind, curve, s)] = pqKeyBanner{kind: kind, curve: curve, size: pqKeySize(kind, curve, s)} + } + } +} + +var mlkem1024 = pqKEM{ + name: "MLKEM1024", + publicKeySize: mlkem.EncapsulationKeySize1024, + privateKeySize: mlkem.SeedSize, + generate: func() ([]byte, []byte, error) { + dk, err := mlkem.GenerateKey1024() + if err != nil { + return nil, nil, err + } + return dk.EncapsulationKey().Bytes(), dk.Bytes(), nil + }, + publicKey: func(priv []byte) ([]byte, error) { + dk, err := mlkem.NewDecapsulationKey1024(priv) + if err != nil { + return nil, err + } + return dk.EncapsulationKey().Bytes(), nil + }, + checkPublicKey: func(pub []byte) bool { + _, err := mlkem.NewEncapsulationKey1024(pub) + return err == nil + }, +} + +var mldsa87 = pqDSA{ + name: "MLDSA87", + publicKeySize: mldsa.MLDSA87PublicKeySize, + privateKeySize: mldsa.PrivateKeySize, + signatureSize: mldsa.MLDSA87SignatureSize, + generate: func() ([]byte, []byte, error) { + sk, err := mldsa.GenerateKey(mldsa.MLDSA87()) + if err != nil { + return nil, nil, err + } + return sk.PublicKey().Bytes(), sk.Bytes(), nil + }, + publicKey: func(priv []byte) ([]byte, error) { + sk, err := mldsa.NewPrivateKey(mldsa.MLDSA87(), priv) + if err != nil { + return nil, err + } + return sk.PublicKey().Bytes(), nil + }, + checkPublicKey: func(pub []byte) bool { + _, err := mldsa.NewPublicKey(mldsa.MLDSA87(), pub) + return err == nil + }, + // Deterministic signing (FIPS 204 section 3.4): the hedged variant guards signers an attacker can fault + // physically, which a CA key in a file is not, and the Ed25519 signature beside it is deterministic too. + signer: func(priv []byte) (SignerLambda, error) { + sk, err := mldsa.NewPrivateKey(mldsa.MLDSA87(), priv) + if err != nil { + return nil, err + } + return func(b []byte) ([]byte, error) { + return sk.SignDeterministic(b, &mldsa.Options{Context: pqSignatureContext}) + }, nil + }, + verify: func(pub, msg, sig []byte) bool { + pk, err := mldsa.NewPublicKey(mldsa.MLDSA87(), pub) + if err != nil { + return false + } + return mldsa.Verify(pk, msg, sig, &mldsa.Options{Context: pqSignatureContext}) == nil + }, +} + +// PQSuites returns every known suite. +func PQSuites() []PQSuite { + s := make([]PQSuite, 0, len(pqSuites)) + for k := range pqSuites { + s = append(s, k) + } + slices.Sort(s) + return s +} + +// ParsePQSuite returns the suite named by s, as String names it, ignoring case. +func ParsePQSuite(s string) (PQSuite, error) { + for _, k := range PQSuites() { + if strings.EqualFold(s, k.String()) { + return k, nil + } + } + return PQSuiteUnknown, fmt.Errorf("%w: %q", ErrUnknownPQSuite, s) +} + +// PQSuiteOf returns the suite of a version 3 certificate, or PQSuiteUnknown for any other version. +func PQSuiteOf(c Certificate) PQSuite { + if c3, ok := c.(*certificateV3); ok { + return c3.pqSuite + } + return PQSuiteUnknown +} + +func (s PQSuite) String() string { + if p := pqSuites[s]; p != nil { + return p.kem.name + "_" + p.dsa.name + } + return fmt.Sprintf("PQSuite(%d)", uint8(s)) +} + +// Valid reports whether s is a known suite. +func (s PQSuite) Valid() bool { + return pqSuites[s] != nil +} + +// PublicKeySize returns the size of the post-quantum half of a composite public key, a CA's or a host's. +func (s PQSuite) PublicKeySize(isCA bool) int { + p := pqSuites[s] + switch { + case p == nil: + return 0 + case isCA: + return p.dsa.publicKeySize + default: + return p.kem.publicKeySize + } +} + +// PrivateKeySize returns the size of the post-quantum half of a composite private key, a CA's or a host's. +func (s PQSuite) PrivateKeySize(isCA bool) int { + p := pqSuites[s] + switch { + case p == nil: + return 0 + case isCA: + return p.dsa.privateKeySize + default: + return p.kem.privateKeySize + } +} + +// GenerateKey makes a post-quantum keypair, a CA's signing key or a host's KEM key, to append to a classical one. +func (s PQSuite) GenerateKey(isCA bool) (pub, priv []byte, err error) { + p := pqSuites[s] + switch { + case p == nil: + return nil, nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, s) + case isCA: + return p.dsa.generate() + default: + return p.kem.generate() + } +} + +// publicKey derives the post-quantum public key from the post-quantum half of a private key. +func (s PQSuite) publicKey(isCA bool, priv []byte) ([]byte, error) { + p := pqSuites[s] + switch { + case p == nil: + return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, s) + case isCA: + return p.dsa.publicKey(priv) + default: + return p.kem.publicKey(priv) + } +} + +// checkPublicKey reports whether pub is a well formed post-quantum public key, a CA's or a host's. +func (s PQSuite) checkPublicKey(isCA bool, pub []byte) bool { + p := pqSuites[s] + switch { + case p == nil, len(pub) != s.PublicKeySize(isCA): + return false + case isCA: + return p.dsa.checkPublicKey(pub) + default: + return p.kem.checkPublicKey(pub) + } +} + +// signatureSize returns the size of the post-quantum signature of a certificate in this suite. +func (s PQSuite) signatureSize() int { + if p := pqSuites[s]; p != nil { + return p.dsa.signatureSize + } + return 0 +} + +// SplitPublicKey splits a composite public key into its classical and post-quantum halves. +func (s PQSuite) SplitPublicKey(isCA bool, key []byte) (classical, pq []byte, err error) { + n := s.PublicKeySize(isCA) + if n == 0 || len(key) <= n { + return nil, nil, ErrInvalidPublicKey + } + return key[:len(key)-n], key[len(key)-n:], nil +} + +// SplitPrivateKey splits a composite private key into its classical and post-quantum halves. +func (s PQSuite) SplitPrivateKey(isCA bool, key []byte) (classical, pq []byte, err error) { + n := s.PrivateKeySize(isCA) + if n == 0 || len(key) <= n { + return nil, nil, ErrInvalidPrivateKey + } + return key[:len(key)-n], key[len(key)-n:], nil +} diff --git a/cert/pq_suite_test.go b/cert/pq_suite_test.go new file mode 100644 index 00000000..3880aa6b --- /dev/null +++ b/cert/pq_suite_test.go @@ -0,0 +1,169 @@ +package cert + +import ( + "net/netip" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testPQSuite stands in for a second suite. It reuses ML-KEM-1024 and ML-DSA-87 under other names, so it is +// interchangeable with the default suite in everything but its identity, which is what has to keep them apart. +const testPQSuite PQSuite = 200 + +func registerTestPQSuite(t *testing.T) { + t.Helper() + kem, dsa := mlkem1024, mldsa87 + kem.name, dsa.name = "TESTKEM", "TESTDSA" + registerPQSuite(testPQSuite, &pqSuite{kem: kem, dsa: dsa}) + t.Cleanup(func() { + delete(pqSuites, testPQSuite) + for k := range pqKeyBanners { + if strings.Contains(k, "TESTKEM") || strings.Contains(k, "TESTDSA") { + delete(pqKeyBanners, k) + } + } + }) +} + +// newTestV3InSuite issues a v3 CA and host certificate in suite. +func newTestV3InSuite(t *testing.T, curve Curve, suite PQSuite) (ca Certificate, caKey []byte, host Certificate, hostKey []byte) { + t.Helper() + ca2, _, caKey2, _ := NewTestCaCert(Version2, curve, time.Time{}, time.Time{}, nil, nil, nil) + pqPub, pqPriv, err := suite.GenerateKey(true) + require.NoError(t, err) + signPQ, err := SignPQ(suite, pqPriv) + require.NoError(t, err) + ca, err = ExtendToV3(ca2, suite, pqPub, nil, signPQ) + require.NoError(t, err) + caKey = append(caKey2, pqPriv...) + + var pub, priv []byte + if curve == Curve_P256 { + pub, priv = P256Keypair() + } else { + pub, priv = X25519Keypair() + } + hostPQPub, hostPQPriv, err := suite.GenerateKey(false) + require.NoError(t, err) + tbs := &TBSCertificate{ + Version: Version3, + Curve: curve, + PQSuite: suite, + Name: "host", + Networks: []netip.Prefix{netip.MustParsePrefix("10.1.0.1/16")}, + NotBefore: ca.NotBefore(), + NotAfter: ca.NotAfter(), + PublicKey: append(pub, hostPQPub...), + } + host, err = tbs.Sign(ca, curve, caKey) + require.NoError(t, err) + return ca, caKey, host, append(priv, hostPQPriv...) +} + +func TestPQSuite_Names(t *testing.T) { + assert.Equal(t, "MLKEM1024_MLDSA87", PQSuite_MLKEM1024_MLDSA87.String()) + assert.Equal(t, []PQSuite{PQSuite_MLKEM1024_MLDSA87}, PQSuites()) + assert.False(t, PQSuiteUnknown.Valid()) + + s, err := ParsePQSuite("mlkem1024_mldsa87") + require.NoError(t, err) + assert.Equal(t, PQSuite_MLKEM1024_MLDSA87, s) + _, err = ParsePQSuite("mlkem768_mldsa65") + require.ErrorIs(t, err, ErrUnknownPQSuite) + + _, _, host, _ := newTestV3(t, Curve_CURVE25519) + assert.Equal(t, DefaultPQSuite, PQSuiteOf(host)) + assert.Equal(t, PQSuiteUnknown, PQSuiteOf(V2Form(host))) +} + +// A second suite works end to end, and is never confused with the first, even with keys of the same sizes. +func TestPQSuite_Second(t *testing.T) { + registerTestPQSuite(t) + + for _, curve := range testCurves { + t.Run(curve.String(), func(t *testing.T) { + ca, caKey, host, hostKey := newTestV3InSuite(t, curve, testPQSuite) + assert.Equal(t, testPQSuite, PQSuiteOf(ca)) + assert.Equal(t, testPQSuite, PQSuiteOf(host)) + require.NoError(t, ca.VerifyPrivateKey(curve, caKey)) + require.NoError(t, host.VerifyPrivateKey(curve, hostKey)) + + pool := NewCAPool() + require.NoError(t, pool.AddCA(ca)) + _, err := pool.VerifyCertificate(time.Now(), host) + require.NoError(t, err) + + // The suite travels in the certificate, handshakes included. + hs, err := host.MarshalForHandshakes() + require.NoError(t, err) + c, err := Recombine(Version3, hs, host.PublicKey(), curve) + require.NoError(t, err) + assert.Equal(t, testPQSuite, PQSuiteOf(c)) + assertCertsEqual(t, host, c) + + // Keys get their algorithms' banners. + b := MarshalPQPrivateKeyToPEM(curve, testPQSuite, hostKey) + assert.Contains(t, string(b), " TESTKEM PRIVATE KEY-----") + key, _, _, err := UnmarshalPrivateKeyFromPEM(b) + require.NoError(t, err) + assert.Equal(t, hostKey, key) + assert.Contains(t, string(host.MarshalPublicKeyPEM()), " TESTKEM PUBLIC KEY-----") + assert.Contains(t, string(ca.MarshalPublicKeyPEM()), " TESTDSA PUBLIC KEY-----") + + // The suite is signed: relabelling a certificate breaks it, though the keys fit either suite. + _, _, other, _ := newTestV3(t, curve) + relabelled := other.Copy().(*certificateV3) + relabelled.pqSuite = testPQSuite + assert.False(t, relabelled.CheckSignature(ca.PublicKey())) + }) + } +} + +// A CA and the certificates it signs share a suite, like they share a curve. +func TestPQSuite_Mismatch(t *testing.T) { + registerTestPQSuite(t) + + for _, curve := range testCurves { + t.Run(curve.String(), func(t *testing.T) { + ca, caKey, host, _ := newTestV3(t, curve) + + // Signing refuses a certificate in another suite than its CA. + tbs := &TBSCertificate{ + Version: Version3, Curve: curve, PQSuite: testPQSuite, Name: "x", + Networks: host.Networks(), NotBefore: host.NotBefore(), NotAfter: host.NotAfter(), PublicKey: host.PublicKey(), + } + _, err := tbs.Sign(ca, curve, caKey) + require.ErrorIs(t, err, ErrPQSuiteMismatch) + + // Extending refuses it too. + v2CA := V2Form(ca) + v2Host := V2Form(host) + _, pqHost, err := DefaultPQSuite.SplitPublicKey(false, host.PublicKey()) + require.NoError(t, err) + signPQ, err := SignPQ(DefaultPQSuite, caKey[len(caKey)-DefaultPQSuite.PrivateKeySize(true):]) + require.NoError(t, err) + _, err = ExtendToV3(v2Host, testPQSuite, pqHost, ca, signPQ) + require.ErrorIs(t, err, ErrPQSuiteMismatch) + + // The pool rejects a certificate whose CA, found by issuer, is in another suite: here the same v2 CA + // extended once per suite, with only the default suite's extension trusted. + pqPub, pqPriv, err := testPQSuite.GenerateKey(true) + require.NoError(t, err) + signOther, err := SignPQ(testPQSuite, pqPriv) + require.NoError(t, err) + otherCA, err := ExtendToV3(v2CA, testPQSuite, pqPub, nil, signOther) + require.NoError(t, err) + otherHost, err := ExtendToV3(v2Host, testPQSuite, pqHost, otherCA, signOther) + require.NoError(t, err) + + pool := NewCAPool() + require.NoError(t, pool.AddCA(ca)) + _, err = pool.VerifyCertificate(time.Now(), otherHost) + require.ErrorIs(t, err, ErrPQSuiteMismatch) + }) + } +} diff --git a/cert/sign.go b/cert/sign.go index c01f56b5..86172682 100644 --- a/cert/sign.go +++ b/cert/sign.go @@ -26,6 +26,7 @@ type TBSCertificate struct { NotAfter time.Time PublicKey []byte Curve Curve + PQSuite PQSuite // version 3 only, the suite of the certificate and of its signing CA issuer string } @@ -48,15 +49,16 @@ type SignerLambda func(certBytes []byte) ([]byte, error) // If the TBSCertificate is a CA then signer must be nil. func (t *TBSCertificate) Sign(signer Certificate, curve Curve, key []byte) (Certificate, error) { if t.Version == Version3 { - // The signing key is a composite CA key, classical || ML-DSA-87 seed. - if len(key) <= MLDSA87PrivateKeySize { - return nil, ErrInvalidPrivateKey - } - classical, err := classicalSigner(t.Curve, key[:len(key)-MLDSA87PrivateKeySize]) + // The signing key is a composite CA key, classical || the seed of the suite's signature algorithm. + classicalKey, pqKey, err := t.PQSuite.SplitPrivateKey(true, key) if err != nil { return nil, err } - pq, err := SignPQ(key[len(key)-MLDSA87PrivateKeySize:]) + classical, err := classicalSigner(t.Curve, classicalKey) + if err != nil { + return nil, err + } + pq, err := SignPQ(t.PQSuite, pqKey) if err != nil { return nil, err } @@ -98,14 +100,18 @@ func classicalSigner(curve Curve, key []byte) (SignerLambda, error) { } } -// SignHybridWith signs a version 3 certificate, using sp for the classical signature and pqSp for the ML-DSA-87 -// signature. t.PublicKey is the composite public key. signer must be a version 3 CA, or nil for a self signed CA. -// The v2 form is signed exactly as SignWith would sign it, and so is the v2 form of signer. +// SignHybridWith signs a version 3 certificate, using sp for the classical signature and pqSp for the post-quantum +// signature of t.PQSuite. t.PublicKey is the composite public key. signer must be a version 3 CA in the same suite, +// or nil for a self signed CA. The v2 form is signed exactly as SignWith would sign it, and so is the v2 form of +// signer. func (t *TBSCertificate) SignHybridWith(signer Certificate, curve Curve, sp, pqSp SignerLambda) (Certificate, error) { if t.Version != Version3 { return nil, fmt.Errorf("SignHybridWith only signs version 3 certificates") } - classical, pq, err := splitPQPublicKey(t.IsCA, t.PublicKey) + if !t.PQSuite.Valid() { + return nil, fmt.Errorf("%w: %v", ErrUnknownPQSuite, t.PQSuite) + } + classical, pq, err := t.PQSuite.SplitPublicKey(t.IsCA, t.PublicKey) if err != nil { return nil, err } @@ -115,6 +121,9 @@ func (t *TBSCertificate) SignHybridWith(signer Certificate, curve Curve, sp, pqS if signer.Version() != Version3 { return nil, fmt.Errorf("a version 3 certificate must be signed by a version 3 CA") } + if PQSuiteOf(signer) != t.PQSuite { + return nil, ErrPQSuiteMismatch + } signerV2 = V2Form(signer) } @@ -125,7 +134,7 @@ func (t *TBSCertificate) SignHybridWith(signer Certificate, curve Curve, sp, pqS if err != nil { return nil, err } - return ExtendToV3(c2, pq, signer, pqSp) + return ExtendToV3(c2, t.PQSuite, pq, signer, pqSp) } // SignWith does the same thing as sign, but uses the function in `sp` to calculate the signature. diff --git a/cert_test/cert.go b/cert_test/cert.go index 5503c470..e0b0670a 100644 --- a/cert_test/cert.go +++ b/cert_test/cert.go @@ -4,8 +4,6 @@ import ( "crypto/ecdh" "crypto/ecdsa" "crypto/elliptic" - "crypto/mldsa" - "crypto/mlkem" "crypto/rand" "io" "net/netip" @@ -65,6 +63,9 @@ func NewTestCaCert(version cert.Version, curve cert.Curve, before, after time.Ti Groups: groups, IsCA: true, } + if version == cert.Version3 { + t.PQSuite = cert.DefaultPQSuite + } c, err := t.Sign(nil, curve, priv) if err != nil { @@ -101,7 +102,7 @@ func NewTestCert(v cert.Version, curve cert.Curve, ca cert.Certificate, key []by } if v == cert.Version3 { - pub, priv = appendPQKey(pub, priv) + pub, priv = appendPQKey(ca, pub, priv) } nc := &cert.TBSCertificate{ @@ -115,6 +116,7 @@ func NewTestCert(v cert.Version, curve cert.Curve, ca cert.Certificate, key []by NotAfter: time.Unix(after.Unix(), 0), PublicKey: pub, IsCA: false, + PQSuite: cert.PQSuiteOf(ca), } c, err := nc.Sign(ca, ca.Curve(), key) @@ -127,6 +129,9 @@ func NewTestCert(v cert.Version, curve cert.Curve, ca cert.Certificate, key []by panic(err) } + if v == cert.Version3 { + return c, pub, cert.MarshalPQPrivateKeyToPEM(curve, cert.PQSuiteOf(ca), priv), pem + } return c, pub, cert.MarshalPrivateKeyToPEM(curve, priv), pem } @@ -142,6 +147,7 @@ func NewTestCertDifferentVersion(c cert.Certificate, v cert.Version, ca cert.Cer NotAfter: time.Unix(c.NotAfter().Unix(), 0), PublicKey: c.PublicKey(), IsCA: false, + PQSuite: cert.PQSuiteOf(ca), } c, err := nc.Sign(ca, ca.Curve(), key) @@ -157,22 +163,22 @@ func NewTestCertDifferentVersion(c cert.Certificate, v cert.Version, ca cert.Cer return c, pem } -// appendPQSigningKey makes a classical CA keypair into a version 3 composite one with a fresh ML-DSA-87 key. +// appendPQSigningKey makes a classical CA keypair into a version 3 composite one with a fresh key of the default suite. func appendPQSigningKey(pub, priv []byte) ([]byte, []byte) { - sk, err := mldsa.GenerateKey(mldsa.MLDSA87()) + pqPub, pqPriv, err := cert.DefaultPQSuite.GenerateKey(true) if err != nil { panic(err) } - return append(pub, sk.PublicKey().Bytes()...), append(priv, sk.Bytes()...) + return append(pub, pqPub...), append(priv, pqPriv...) } -// appendPQKey makes a classical host keypair into a version 3 composite one with a fresh ML-KEM-1024 key. -func appendPQKey(pub, priv []byte) ([]byte, []byte) { - dk, err := mlkem.GenerateKey1024() +// appendPQKey makes a classical host keypair into a version 3 composite one with a fresh key of the CA's suite. +func appendPQKey(ca cert.Certificate, pub, priv []byte) ([]byte, []byte) { + pqPub, pqPriv, err := cert.PQSuiteOf(ca).GenerateKey(false) if err != nil { panic(err) } - return append(pub, dk.EncapsulationKey().Bytes()...), append(priv, dk.Bytes()...) + return append(pub, pqPub...), append(priv, pqPriv...) } func X25519Keypair() ([]byte, []byte) { diff --git a/cmd/nebula-cert/ca.go b/cmd/nebula-cert/ca.go index 61508dca..a0abc0a3 100644 --- a/cmd/nebula-cert/ca.go +++ b/cmd/nebula-cert/ca.go @@ -37,6 +37,7 @@ type caFlags struct { argonParallelism *uint encryption *bool version *uint + pqSuite *string curve *string p11url *string @@ -71,7 +72,8 @@ func newCaFlags() *caFlags { cf := caFlags{set: flag.NewFlagSet("ca", flag.ContinueOnError)} cf.set.Usage = func() {} cf.name = cf.set.String("name", "", "Required: name of the certificate authority") - cf.version = cf.set.Uint("version", uint(cert.Version2), "Optional: version of the certificate format to use. Version 3 adds an ML-DSA-87 key, for post-quantum certificates") + cf.version = cf.set.Uint("version", uint(cert.Version2), "Optional: version of the certificate format to use. Version 3 adds a post-quantum key, for post-quantum certificates") + cf.pqSuite = pqSuiteFlag(cf.set, "Optional: post-quantum suite of a version 3 CA, which the certificates it signs share") cf.duration = cf.set.Duration("duration", time.Duration(time.Hour*8760), "Optional: amount of time the certificate should be valid for. Valid time units are seconds: \"s\", minutes: \"m\", hours: \"h\"") cf.outKeyPath = cf.set.String("out-key", "ca.key", "Optional: path to write the private key to") cf.outCertPath = cf.set.String("out-crt", "ca.crt", "Optional: path to write the certificate to") @@ -164,7 +166,11 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error return newHelpErrorf("-version must be %v, %v or %v", cert.Version1, cert.Version2, cert.Version3) } if version == cert.Version3 && isP11 { - return newHelpErrorf("-version 3 can not be used with -pkcs11, the ML-DSA-87 key has to be in out-key") + return newHelpErrorf("-version 3 can not be used with -pkcs11, the post-quantum key has to be in out-key") + } + suite, err := parsePQSuiteFlag(cf.set, *cf.pqSuite, version == cert.Version3, "is only for -version 3") + if err != nil { + return err } var networks []netip.Prefix @@ -300,7 +306,7 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error } if version == cert.Version3 { - pub, rawPriv, err = appendPQSigningKey(pub, rawPriv) + pub, rawPriv, err = appendPQSigningKey(suite, pub, rawPriv) if err != nil { return err } @@ -319,6 +325,9 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error IsCA: true, Curve: curve, } + if version == cert.Version3 { + t.PQSuite = suite + } if !isP11 && !isStdio(*cf.outKeyPath) { if _, err := os.Stat(*cf.outKeyPath); err == nil { @@ -346,14 +355,19 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error return fmt.Errorf("error while signing: %s", err) } - if *cf.encryption { + switch { + case *cf.encryption && version == cert.Version3: + b, err = cert.EncryptAndMarshalPQSigningPrivateKey(curve, suite, rawPriv, passphrase, kdfParams) + case *cf.encryption: b, err = cert.EncryptAndMarshalSigningPrivateKey(curve, rawPriv, passphrase, kdfParams) - if err != nil { - return fmt.Errorf("error while encrypting out-key: %s", err) - } - } else { + case version == cert.Version3: + b = cert.MarshalPQSigningPrivateKeyToPEM(curve, suite, rawPriv) + default: b = cert.MarshalSigningPrivateKeyToPEM(curve, rawPriv) } + if err != nil { + return fmt.Errorf("error while encrypting out-key: %s", err) + } err = writeOutput(*cf.outKeyPath, b, 0600, out) if err != nil { diff --git a/cmd/nebula-cert/ca_test.go b/cmd/nebula-cert/ca_test.go index 016d0b59..05a60633 100644 --- a/cmd/nebula-cert/ca_test.go +++ b/cmd/nebula-cert/ca_test.go @@ -68,12 +68,14 @@ func Test_caHelp(t *testing.T) { " -out-qr string\n"+ " \tOptional: output a qr code image (png) of the certificate\n"+ optionalPkcs11String(" -pkcs11 string\n \tOptional: PKCS#11 URI to an existing private key\n")+ + " -pq-suite string\n"+ + " \tOptional: post-quantum suite of a version 3 CA, which the certificates it signs share (MLKEM1024_MLDSA87) (default \"MLKEM1024_MLDSA87\")\n"+ " -subnets string\n"+ " \tDeprecated, see -unsafe-networks\n"+ " -unsafe-networks string\n"+ " \tOptional: comma separated list of ip address and network in CIDR notation. This will limit which ip addresses and networks subordinate certs can use in unsafe networks\n"+ " -version uint\n"+ - " \tOptional: version of the certificate format to use. Version 3 adds an ML-DSA-87 key, for post-quantum certificates (default 2)\n", + " \tOptional: version of the certificate format to use. Version 3 adds a post-quantum key, for post-quantum certificates (default 2)\n", ob.String(), ) } diff --git a/cmd/nebula-cert/keygen.go b/cmd/nebula-cert/keygen.go index e3e315b5..51f4ffe9 100644 --- a/cmd/nebula-cert/keygen.go +++ b/cmd/nebula-cert/keygen.go @@ -20,6 +20,7 @@ type keygenFlags struct { inKeyPath *string curve *string version *uint + pqSuite *string p11url *string } @@ -29,8 +30,9 @@ func newKeygenFlags() *keygenFlags { cf.outPubPath = cf.set.String("out-pub", "", "Required: path to write the public key to") cf.outKeyPath = cf.set.String("out-key", "", "Required: path to write the private key to") cf.curve = cf.set.String("curve", defaultCurve(), "ECDH Curve (25519, P256)") - cf.version = cf.set.Uint("version", 0, "Optional: 3 adds an ML-KEM-1024 key, for version 3 certificates") - cf.inKeyPath = cf.set.String("in-key", "", "Optional: path to an existing private key to add an ML-KEM-1024 key to, keeping the existing key. Implies -version 3") + cf.version = cf.set.Uint("version", 0, "Optional: 3 adds a post-quantum key, for version 3 certificates") + cf.inKeyPath = cf.set.String("in-key", "", "Optional: path to an existing private key to add a post-quantum key to, keeping the existing key. Implies -version 3") + cf.pqSuite = pqSuiteFlag(cf.set, "Optional: post-quantum suite of a version 3 key, which must be the suite of the CA that signs it") cf.p11url = p11Flag(cf.set) return &cf } @@ -63,7 +65,11 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error { return newHelpErrorf("-version must be %v, or left out for keys that work with any other version", cert.Version3) } if version == cert.Version3 && isP11 { - return newHelpErrorf("-version 3 can not be used with -pkcs11, the ML-KEM-1024 key has to be in out-key") + return newHelpErrorf("-version 3 can not be used with -pkcs11, the post-quantum key has to be in out-key") + } + suite, err := parsePQSuiteFlag(cf.set, *cf.pqSuite, version == cert.Version3, "is only for -version 3") + if err != nil { + return err } var claims ioClaims @@ -113,7 +119,7 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error { } if version == cert.Version3 { - pub, rawPriv, err = appendPQKey(pub, rawPriv) + pub, rawPriv, err = appendPQKey(suite, pub, rawPriv) if err != nil { return err } @@ -139,12 +145,20 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error { return fmt.Errorf("error while getting public key: %w", err) } } else { - err = writeOutput(*cf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600, out) + b := cert.MarshalPrivateKeyToPEM(curve, rawPriv) + if version == cert.Version3 { + b = cert.MarshalPQPrivateKeyToPEM(curve, suite, rawPriv) + } + err = writeOutput(*cf.outKeyPath, b, 0600, out) if err != nil { return fmt.Errorf("error while writing out-key: %s", err) } } - err = writeOutput(*cf.outPubPath, cert.MarshalPublicKeyToPEM(curve, pub), 0600, out) + b := cert.MarshalPublicKeyToPEM(curve, pub) + if version == cert.Version3 { + b = cert.MarshalPQPublicKeyToPEM(curve, suite, pub) + } + err = writeOutput(*cf.outPubPath, b, 0600, out) if err != nil { return fmt.Errorf("error while writing out-pub: %s", err) } diff --git a/cmd/nebula-cert/keygen_test.go b/cmd/nebula-cert/keygen_test.go index 921a45a7..7fcaf004 100644 --- a/cmd/nebula-cert/keygen_test.go +++ b/cmd/nebula-cert/keygen_test.go @@ -24,14 +24,16 @@ func Test_keygenHelp(t *testing.T) { " -curve string\n"+ " \tECDH Curve (25519, P256) (default \"25519\")\n"+ " -in-key string\n"+ - " \tOptional: path to an existing private key to add an ML-KEM-1024 key to, keeping the existing key. Implies -version 3\n"+ + " \tOptional: path to an existing private key to add a post-quantum key to, keeping the existing key. Implies -version 3\n"+ " -out-key string\n"+ " \tRequired: path to write the private key to\n"+ " -out-pub string\n"+ " \tRequired: path to write the public key to\n"+ optionalPkcs11String(" -pkcs11 string\n \tOptional: PKCS#11 URI to an existing private key\n")+ + " -pq-suite string\n"+ + " \tOptional: post-quantum suite of a version 3 key, which must be the suite of the CA that signs it (MLKEM1024_MLDSA87) (default \"MLKEM1024_MLDSA87\")\n"+ " -version uint\n"+ - " \tOptional: 3 adds an ML-KEM-1024 key, for version 3 certificates\n", + " \tOptional: 3 adds a post-quantum key, for version 3 certificates\n", ob.String(), ) } diff --git a/cmd/nebula-cert/pq.go b/cmd/nebula-cert/pq.go index 8db2c271..d33f91ea 100644 --- a/cmd/nebula-cert/pq.go +++ b/cmd/nebula-cert/pq.go @@ -3,33 +3,60 @@ package main import ( "bytes" "crypto/ecdh" - "crypto/mldsa" - "crypto/mlkem" "encoding/pem" "flag" "fmt" "io" "os" + "strings" "github.com/slackhq/nebula/cert" ) -// appendPQSigningKey makes a classical CA keypair into a version 3 composite one with a fresh ML-DSA-87 key. -func appendPQSigningKey(pub, priv []byte) ([]byte, []byte, error) { - sk, err := mldsa.GenerateKey(mldsa.MLDSA87()) - if err != nil { - return nil, nil, fmt.Errorf("error while generating ML-DSA-87 key: %s", err) +// pqSuiteFlag adds -pq-suite to a flag set. +func pqSuiteFlag(set *flag.FlagSet, usage string) *string { + var names []string + for _, s := range cert.PQSuites() { + names = append(names, s.String()) } - return append(pub, sk.PublicKey().Bytes()...), append(priv, sk.Bytes()...), nil + return set.String("pq-suite", cert.DefaultPQSuite.String(), usage+" ("+strings.Join(names, ", ")+")") } -// appendPQKey makes a classical host keypair into a version 3 composite one with a fresh ML-KEM-1024 key. -func appendPQKey(pub, priv []byte) ([]byte, []byte, error) { - dk, err := mlkem.GenerateKey1024() - if err != nil { - return nil, nil, fmt.Errorf("error while generating ML-KEM-1024 key: %s", err) +// parsePQSuiteFlag returns the suite -pq-suite names. Setting it is an error unless allowed, when the suite has no use +// or comes from elsewhere. +func parsePQSuiteFlag(set *flag.FlagSet, value string, allowed bool, why string) (cert.PQSuite, error) { + given := false + set.Visit(func(f *flag.Flag) { + if f.Name == "pq-suite" { + given = true + } + }) + if given && !allowed { + return cert.PQSuiteUnknown, newHelpErrorf("-pq-suite %s", why) } - return append(pub, dk.EncapsulationKey().Bytes()...), append(priv, dk.Bytes()...), nil + s, err := cert.ParsePQSuite(value) + if err != nil { + return cert.PQSuiteUnknown, newHelpErrorf("-pq-suite: %s", err) + } + return s, nil +} + +// appendPQSigningKey makes a classical CA keypair into a version 3 composite one with a fresh key of suite. +func appendPQSigningKey(suite cert.PQSuite, pub, priv []byte) ([]byte, []byte, error) { + pqPub, pqPriv, err := suite.GenerateKey(true) + if err != nil { + return nil, nil, fmt.Errorf("error while generating %v CA key: %s", suite, err) + } + return append(pub, pqPub...), append(priv, pqPriv...), nil +} + +// appendPQKey makes a classical host keypair into a version 3 composite one with a fresh key of suite. +func appendPQKey(suite cert.PQSuite, pub, priv []byte) ([]byte, []byte, error) { + pqPub, pqPriv, err := suite.GenerateKey(false) + if err != nil { + return nil, nil, fmt.Errorf("error while generating %v key: %s", suite, err) + } + return append(pub, pqPub...), append(priv, pqPriv...), nil } // publicKeyFor returns the public key of a classical host private key. @@ -45,7 +72,7 @@ func publicKeyFor(curve cert.Curve, priv []byte) ([]byte, error) { } k, err := c.NewPrivateKey(priv) if err != nil { - return nil, fmt.Errorf("not a classical %s private key, it may already have an ML-KEM-1024 key", curve) + return nil, fmt.Errorf("not a classical %s private key, it may already have a post-quantum key", curve) } return k.PublicKey().Bytes(), nil } @@ -58,6 +85,7 @@ type pqExtendFlags struct { inPubPath *string outCertPath *string outKeyPath *string + pqSuite *string } func newPQExtendFlags() *pqExtendFlags { @@ -69,6 +97,7 @@ func newPQExtendFlags() *pqExtendFlags { pf.caCertPath = pf.set.String("ca-crt", "", "Required for a host in-crt: path to the version 3 form of the CA that issued it") pf.inPubPath = pf.set.String("in-pub", "", "Required for a host in-crt: path to the host's public key from keygen -in-key") pf.outKeyPath = pf.set.String("out-key", "", "Required for a CA in-crt: path to write the CA's version 3 key to, encrypted again if ca-key was") + pf.pqSuite = pqSuiteFlag(pf.set, "Optional, for a CA in-crt: post-quantum suite of the version 3 CA. A host takes its CA's suite") return &pf } @@ -134,9 +163,14 @@ func pqExtend(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) return err } + suite, err := parsePQSuiteFlag(pf.set, *pf.pqSuite, c.IsCA(), "is for CA certificates, a host takes its CA's suite") + if err != nil { + return err + } + var c3 cert.Certificate if c.IsCA() { - c3, err = extendCA(pf, c, curve, caKey, rawCAKey, passphrase, out) + c3, err = extendCA(pf, c, suite, curve, caKey, rawCAKey, passphrase, out) } else { c3, err = extendHost(pf, c, curve, caKey, &claims) } @@ -154,8 +188,8 @@ func pqExtend(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) return nil } -// extendCA gives a v2 CA an ML-DSA-87 key and writes out the composite key. -func extendCA(pf *pqExtendFlags, ca cert.Certificate, curve cert.Curve, key, rawKey, passphrase []byte, out io.Writer) (cert.Certificate, error) { +// extendCA gives a v2 CA a post-quantum key of suite and writes out the composite key. +func extendCA(pf *pqExtendFlags, ca cert.Certificate, suite cert.PQSuite, curve cert.Curve, key, rawKey, passphrase []byte, out io.Writer) (cert.Certificate, error) { if *pf.caCertPath != "" || *pf.inPubPath != "" { return nil, newHelpErrorf("-ca-crt and -in-pub are for host certificates, a CA extends itself") } @@ -166,15 +200,15 @@ func extendCA(pf *pqExtendFlags, ca cert.Certificate, curve cert.Curve, key, raw return nil, fmt.Errorf("refusing to extend, the CA certificate does not match ca-key") } - pub, priv, err := appendPQSigningKey(nil, append([]byte(nil), key...)) + pub, priv, err := appendPQSigningKey(suite, nil, append([]byte(nil), key...)) if err != nil { return nil, err } - signPQ, err := cert.SignPQ(priv[len(key):]) + signPQ, err := cert.SignPQ(suite, priv[len(key):]) if err != nil { return nil, err } - c3, err := cert.ExtendToV3(ca, pub, nil, signPQ) + c3, err := cert.ExtendToV3(ca, suite, pub, nil, signPQ) if err != nil { return nil, fmt.Errorf("error while extending: %s", err) } @@ -192,12 +226,12 @@ func extendCA(pf *pqExtendFlags, ca cert.Certificate, curve cert.Curve, key, raw } a := ned.EncryptionMetadata.Argon2Parameters kdf := cert.NewArgon2Parameters(a.Memory, a.Parallelism, a.Iterations) - b, err = cert.EncryptAndMarshalSigningPrivateKey(curve, priv, passphrase, kdf) + b, err = cert.EncryptAndMarshalPQSigningPrivateKey(curve, suite, priv, passphrase, kdf) if err != nil { return nil, fmt.Errorf("error while encrypting out-key: %s", err) } } else { - b = cert.MarshalSigningPrivateKeyToPEM(curve, priv) + b = cert.MarshalPQSigningPrivateKeyToPEM(curve, suite, priv) } if err := writeOutput(*pf.outKeyPath, b, 0600, out); err != nil { return nil, fmt.Errorf("error while writing out-key: %s", err) @@ -205,7 +239,7 @@ func extendCA(pf *pqExtendFlags, ca cert.Certificate, curve cert.Curve, key, raw return c3, nil } -// extendHost adds a host's ML-KEM-1024 key to its certificate, signed by the v3 form of its CA. +// extendHost adds a host's post-quantum key to its certificate, signed by the v3 form of its CA, in the CA's suite. func extendHost(pf *pqExtendFlags, c cert.Certificate, curve cert.Curve, caKey []byte, claims *ioClaims) (cert.Certificate, error) { if *pf.outKeyPath != "" { return nil, newHelpErrorf("-out-key is for CA certificates, a host's key comes from keygen -in-key") @@ -240,19 +274,24 @@ func extendHost(pf *pqExtendFlags, c cert.Certificate, curve cert.Curve, caKey [ if err != nil { return nil, fmt.Errorf("error while parsing in-pub: %s", err) } - if pubCurve != c.Curve() || len(pub) <= cert.MLKEM1024PublicKeySize { - return nil, fmt.Errorf("in-pub must be the host's key from keygen -in-key") + suite := cert.PQSuiteOf(ca) + classical, pq, err := suite.SplitPublicKey(false, pub) + if pubCurve != c.Curve() || err != nil { + return nil, fmt.Errorf("in-pub must be the host's key from keygen -in-key -pq-suite %v", suite) } - classical, pq := pub[:len(pub)-cert.MLKEM1024PublicKeySize], pub[len(pub)-cert.MLKEM1024PublicKeySize:] if !bytes.Equal(classical, c.PublicKey()) { return nil, fmt.Errorf("in-pub does not extend the key in in-crt") } - signPQ, err := cert.SignPQ(caKey[len(caKey)-cert.MLDSA87PrivateKeySize:]) + _, pqCAKey, err := suite.SplitPrivateKey(true, caKey) if err != nil { return nil, err } - c3, err := cert.ExtendToV3(c, pq, ca, signPQ) + signPQ, err := cert.SignPQ(suite, pqCAKey) + if err != nil { + return nil, err + } + c3, err := cert.ExtendToV3(c, suite, pq, ca, signPQ) if err != nil { return nil, fmt.Errorf("error while extending: %s", err) } diff --git a/cmd/nebula-cert/pq_test.go b/cmd/nebula-cert/pq_test.go index 8d34feff..8ce96232 100644 --- a/cmd/nebula-cert/pq_test.go +++ b/cmd/nebula-cert/pq_test.go @@ -100,9 +100,40 @@ func TestPQ_SignErrors(t *testing.T) { require.NoError(t, ca([]string{"-version", "3", "-name", "v3 ca", "-out-crt", p("ca3.crt"), "-out-key", p("ca3.key")}, ob, eb, nopw)) require.ErrorContains(t, signCert([]string{"-ca-crt", p("ca3.crt"), "-ca-key", p("ca3.key"), "-name", "h", "-networks", "10.1.0.1/16", "-in-pub", p("k.pub"), "-out-crt", p("h3.crt")}, ob, eb, nopw), "need an in-pub from keygen -version 3") - // keygen -in-key refuses a key that already has an ML-KEM key. + // keygen -in-key refuses a key that already has a post-quantum key. require.NoError(t, keygen([]string{"-version", "3", "-out-key", p("k3.key"), "-out-pub", p("k3.pub")}, ob, eb)) - require.ErrorContains(t, keygen([]string{"-in-key", p("k3.key"), "-out-key", p("k33.key"), "-out-pub", p("k33.pub")}, ob, eb), "already have an ML-KEM-1024 key") + require.ErrorContains(t, keygen([]string{"-in-key", p("k3.key"), "-out-key", p("k33.key"), "-out-pub", p("k33.pub")}, ob, eb), "already have a post-quantum key") +} + +func TestPQ_SuiteFlag(t *testing.T) { + dir := t.TempDir() + ob, eb := &bytes.Buffer{}, &bytes.Buffer{} + nopw := &StubPasswordReader{} + p := func(n string) string { return filepath.Join(dir, n) } + + // The suite can be named, case aside, and the certificates a CA signs share its suite. + require.NoError(t, ca([]string{"-version", "3", "-pq-suite", "mlkem1024_mldsa87", "-name", "ca", "-out-crt", p("ca.crt"), "-out-key", p("ca.key")}, ob, eb, nopw)) + assert.Equal(t, cert.PQSuite_MLKEM1024_MLDSA87, cert.PQSuiteOf(readCert(t, p("ca.crt")))) + require.NoError(t, keygen([]string{"-version", "3", "-pq-suite", "MLKEM1024_MLDSA87", "-out-key", p("k.key"), "-out-pub", p("k.pub")}, ob, eb)) + require.NoError(t, signCert([]string{"-ca-crt", p("ca.crt"), "-ca-key", p("ca.key"), "-name", "h", "-networks", "10.1.0.1/16", "-in-pub", p("k.pub"), "-out-crt", p("h.crt")}, ob, eb, nopw)) + assert.Equal(t, cert.PQSuite_MLKEM1024_MLDSA87, cert.PQSuiteOf(readCert(t, p("h.crt")))) + + // Printing shows it. + ob.Reset() + require.NoError(t, printCert([]string{"-json", "-path", p("h.crt")}, ob, eb)) + assert.Contains(t, ob.String(), `"pqSuite":"MLKEM1024_MLDSA87"`) + + // Unknown suites, and suites where there is no post-quantum key, are refused. + require.ErrorContains(t, ca([]string{"-version", "3", "-pq-suite", "mlkem768_mldsa65", "-name", "x", "-out-crt", p("x.crt"), "-out-key", p("x.key")}, ob, eb, nopw), "unknown post-quantum suite") + require.ErrorContains(t, ca([]string{"-pq-suite", "MLKEM1024_MLDSA87", "-name", "x", "-out-crt", p("x.crt"), "-out-key", p("x.key")}, ob, eb, nopw), "only for -version 3") + require.ErrorContains(t, keygen([]string{"-pq-suite", "MLKEM1024_MLDSA87", "-out-key", p("x.key"), "-out-pub", p("x.pub")}, ob, eb), "only for -version 3") + + // A host takes its CA's suite when extended. + require.NoError(t, ca([]string{"-name", "ca2", "-out-crt", p("ca2.crt"), "-out-key", p("ca2.key")}, ob, eb, nopw)) + require.NoError(t, signCert([]string{"-ca-crt", p("ca2.crt"), "-ca-key", p("ca2.key"), "-name", "h2", "-networks", "10.1.0.2/16", "-out-crt", p("h2.crt"), "-out-key", p("h2.key")}, ob, eb, nopw)) + require.NoError(t, pqExtend([]string{"-in-crt", p("ca2.crt"), "-ca-key", p("ca2.key"), "-pq-suite", "MLKEM1024_MLDSA87", "-out-crt", p("ca23.crt"), "-out-key", p("ca23.key")}, ob, eb, nopw)) + require.NoError(t, keygen([]string{"-in-key", p("h2.key"), "-out-key", p("h23.key"), "-out-pub", p("h23.pub")}, ob, eb)) + require.ErrorContains(t, pqExtend([]string{"-in-crt", p("h2.crt"), "-in-pub", p("h23.pub"), "-ca-crt", p("ca23.crt"), "-ca-key", p("ca23.key"), "-pq-suite", "MLKEM1024_MLDSA87", "-out-crt", p("h23.crt")}, ob, eb, nopw), "a host takes its CA's suite") } // TestPQ_Extend upgrades a v2 CA and a host in place, the way a running network migrates. @@ -145,7 +176,7 @@ func TestPQ_Extend(t *testing.T) { require.ErrorIs(t, err, cert.ErrPrivateKeyEncrypted, "an encrypted ca-key stays encrypted") } - // The host gets an ML-KEM key on top of the one it has, and the CA signs it. + // The host gets a post-quantum key on top of the one it has, and the CA signs it. require.NoError(t, keygen([]string{"-in-key", p("host.key"), "-out-key", p("host3.key"), "-out-pub", p("host3.pub")}, ob, eb)) require.NoError(t, pqExtend([]string{"-in-crt", p("host.crt"), "-in-pub", p("host3.pub"), "-ca-crt", p("ca3.crt"), "-ca-key", p("ca3.key"), "-out-crt", p("host3.crt")}, ob, eb, pw)) host3 := readCert(t, p("host3.crt")) diff --git a/cmd/nebula-cert/sign.go b/cmd/nebula-cert/sign.go index 6ab2bd00..4c65026b 100644 --- a/cmd/nebula-cert/sign.go +++ b/cmd/nebula-cert/sign.go @@ -169,16 +169,20 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) version = caCert.Version() } + // A version 3 certificate is in the suite of its CA. + suite := cert.PQSuiteOf(caCert) switch { case version == cert.Version3 && caCert.Version() != cert.Version3: return newHelpErrorf("version 3 certificates must be signed by a version 3 CA") case version == cert.Version3 && isP11: - return newHelpErrorf("version 3 certificates can not be signed with -pkcs11, the ML-DSA-87 key has to be in ca-key") + return newHelpErrorf("version 3 certificates can not be signed with -pkcs11, the post-quantum key has to be in ca-key") case version != cert.Version3 && caCert.Version() == cert.Version3: // A v3 CA signs anything older as its v2 form, with the classical half of its key. caCert = cert.V2Form(caCert) if caKey != nil { - caKey = caKey[:len(caKey)-cert.MLDSA87PrivateKeySize] + if caKey, _, err = suite.SplitPrivateKey(true, caKey); err != nil { + return fmt.Errorf("error while parsing ca-key: %s", err) + } } } @@ -272,12 +276,16 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) if pubCurve != curve { return fmt.Errorf("curve of in-pub does not match ca") } - if isPQ := len(pub) > cert.MLKEM1024PublicKeySize; isPQ != (version == cert.Version3) { + // Composite keys are longer than any classical one, an uncompressed P256 key being the longest. + if isPQ := len(pub) > 65; isPQ != (version == cert.Version3) { if isPQ { - return fmt.Errorf("in-pub has an ML-KEM-1024 key, which only version 3 certificates carry") + return fmt.Errorf("in-pub has a post-quantum key, which only version 3 certificates carry") } return fmt.Errorf("version 3 certificates need an in-pub from keygen -version 3") } + if _, _, err := suite.SplitPublicKey(false, pub); version == cert.Version3 && err != nil { + return fmt.Errorf("in-pub is not a key for the CA's post-quantum suite, %v", suite) + } } else if isP11 { pub, err = p11Client.GetPubKey() if err != nil { @@ -286,7 +294,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) } else { pub, rawPriv = newKeypair(curve) if version == cert.Version3 { - pub, rawPriv, err = appendPQKey(pub, rawPriv) + pub, rawPriv, err = appendPQKey(suite, pub, rawPriv) if err != nil { return err } @@ -388,6 +396,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) PublicKey: pub, IsCA: false, Curve: curve, + PQSuite: suite, } nc, err := t.Sign(caCert, curve, caKey) @@ -408,7 +417,11 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) } } - err = writeOutput(*sf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600, out) + b := cert.MarshalPrivateKeyToPEM(curve, rawPriv) + if version == cert.Version3 { + b = cert.MarshalPQPrivateKeyToPEM(curve, suite, rawPriv) + } + err = writeOutput(*sf.outKeyPath, b, 0600, out) if err != nil { return fmt.Errorf("error while writing out-key: %s", err) } diff --git a/docs/pq-handshake.md b/docs/pq-handshake.md index 174111e2..6b140d42 100644 --- a/docs/pq-handshake.md +++ b/docs/pq-handshake.md @@ -77,12 +77,23 @@ CertificateV3 ::= SEQUENCE { curve [1] Curve DEFAULT curve25519, publicKey [2] OCTET STRING, -- classical: X25519 / P256 for hosts, Ed25519 / ECDSA P256 for CAs signature [3] OCTET STRING, -- classical CA signature over details || curve || publicKey, as in v2 - pqPublicKey [4] OCTET STRING, -- ML-KEM-1024 encapsulation key for hosts, ML-DSA-87 public key for CAs - pqSignature [5] OCTET STRING -- ML-DSA-87 by the issuing CA, context "nebula certificate v3", - -- over the DER of fields 0 to 4, curve always present + pqSuite [4] OCTET STRING, -- one byte, the PQSuite; always present + pqPublicKey [5] OCTET STRING, -- the suite's KEM public key for hosts, its signature public key for CAs + pqSignature [6] OCTET STRING -- the suite's signature by the issuing CA, context "nebula certificate v3", + -- over the DER of fields 0 to 5, curve always present } ``` +- **The suite.** `pqSuite` names the post-quantum algorithms the way `curve` names the classical + ones: the KEM of host keys, and the signature algorithm of CA keys and of the certificates a CA + signs. A CA and every certificate it signs share a suite, as they share a curve; the CA pool + rejects a mismatch, and the post-quantum signature covers the suite, so a certificate can not be + relabelled. The field is always present, in handshakes too, with no default, and 0 is never + valid. There is one suite today, `MLKEM1024_MLDSA87` (1): ML-KEM-1024 hosts, ML-DSA-87 CAs. A + new suite is an entry in the cert package's suite table plus, for a new KEM, a hybrid subtype; no + new certificate version. Changing a CA's suite means a new CA, or a new v3 extension of its v2 + form that replaces the old one, since one v2 CA has one v3 extension. + - **The v2 form.** Fields 0 to 3 are a byte exact v2 certificate, signed by the v2 form of the CA. A v3 holder presents it in classical and phase 1 handshakes, so peers that only speak v2 accept it as they always have, and old parsers never see v3 bytes. A node therefore needs one @@ -109,7 +120,9 @@ CertificateV3 ::= SEQUENCE { - **Keys are composite**, classical followed by post-quantum, wherever a v3 certificate deals in them: `PublicKey()` returns `classical || pq`, and private keys are `classical || seed` (64 byte ML-KEM seed for hosts, 32 byte ML-DSA seed for CAs), in one PEM block in `pki.key` with banners - such as `NEBULA X25519 MLKEM1024 PRIVATE KEY` and `NEBULA ED25519 MLDSA87 PRIVATE KEY`. + such as `NEBULA X25519 MLKEM1024 PRIVATE KEY` and `NEBULA ED25519 MLDSA87 PRIVATE KEY`. The + banner names the post-quantum algorithm, not the suite, because a seed's size does not tell the + algorithm's strength. - **Both curves.** Curve25519 and P256; the post-quantum half does not depend on the curve. - **In-place upgrade.** `nebula-cert pq-extend` turns an existing v2 CA into a v3 CA whose v2 form is the old CA certificate, byte for byte, and extends existing host certificates the same way. @@ -118,8 +131,8 @@ CertificateV3 ::= SEQUENCE { | Certificate | Full | In a handshake | v2 form | |---|---|---|---| -| CA, Curve25519 | 7362 B | n/a | 134 B | -| Host, Curve25519 | 6375 B | 4769 B | 171 B | +| CA, Curve25519 | 7365 B | n/a | 134 B | +| Host, Curve25519 | 6378 B | 4772 B | 171 B | `crypto/mldsa` is new in Go 1.27, so `go.mod` moves to Go 1.27. @@ -163,11 +176,17 @@ design rationale and security argument), with ML-KEM-1024 supplied by nebula: initiator sealed. - v3 certificates go with this subtype and nothing else: a v3 certificate on any other subtype, or a v2 one on this one, is a protocol error. +- **One subtype per KEM.** A hybrid subtype names its KEM, and a v3 holder initiates the subtype of + its suite's KEM. Peers need the same KEM, not the same suite: suites that differ only in their + signature algorithm share a subtype, and each certificate carries its own suite. A responder + whose suite uses another KEM drops msg1, so the pair behaves as if the responder did not speak + PQ: classical fallback, or no tunnel under `require`. A suite's KEM must have a subtype for the + pki to load a certificate in it. | Message | Size (Curve25519) | Data fragments (k) | |---|---|---| -| msg1 | 8006 B | 7 | -| msg2 | 9625 B | 9 | +| msg1 | 8009 B | 7 | +| msg2 | 9628 B | 9 | | msg3 | 1616 B | 2 | **Dependency.** DefinedNet/noise is a private repository today. `go.mod` points at a local clone @@ -484,10 +503,11 @@ in every scenario above, and within three at least 99.99%. ## Implementation (phase 2) -- `cert`: `cert_v3.go` (format, `V2Form`, `ExtendToV3`, `SignPQ`), `SignHybridWith` and the - composite `Sign` path, the CA pool's v3 index and v2-form blocklisting, and the composite key - PEM banners. +- `cert`: `cert_v3.go` (format, `V2Form`, `ExtendToV3`, `SignPQ`), `pq_suite.go` (`PQSuite` and + the suite table), `SignHybridWith` and the composite `Sign` path, the CA pool's v3 index, suite + check and v2-form blocklisting, and the composite key PEM banners, generated per suite. - `handshake`: the `ix_hybrid_mlkem1024` subtype on `noise.HandshakeHybridIX`, `KEMMLKEM1024`, + `HybridSubtype` mapping a suite to the subtype of its KEM, composite credentials, cipher states ordered by role for three message patterns, and `Machine.PeerCert` for a responder that has to act before completion. - `pki.go` loads a v3 certificate with its composite key and derives the v2 form; messages after @@ -496,7 +516,8 @@ in every scenario above, and within three at least 99.99%. initiator's msg3 resends and teardown; `outside.go` keeps quiet about pending responder indexes. - `nebula-cert`: `ca`, `keygen` and `sign` take `-version 3`, `keygen -in-key` extends an existing host key, `sign -version 2` with a v3 CA signs as its v2 form, and `pq-extend` upgrades v2 - certificates in place, keeping an encrypted CA key encrypted. + certificates in place, keeping an encrypted CA key encrypted. `ca`, `keygen` and `pq-extend` of a + CA take `-pq-suite`; `sign` and `pq-extend` of a host take the CA's suite. - Tests: certificate format, strictness, tampering, pool and blocklist rules and in-place extension on both curves; Machine-level hybrid handshakes including corrupted msg3 and impersonation with only the classical key; e2e tests for a clean hybrid handshake, a lost diff --git a/handshake/credential.go b/handshake/credential.go index c5476b6f..ffd285b0 100644 --- a/handshake/credential.go +++ b/handshake/credential.go @@ -15,7 +15,7 @@ type Credential struct { Bytes []byte // pre-marshaled certificate bytes privateKey []byte // static private key (public half lives in Cert) publicKey []byte // static public key, the classical half for a v3 Cert - kemKey noise.KEMKey // static ML-KEM-1024 keypair, only for a v3 Cert + kemKey noise.KEMKey // static KEM keypair of the Cert's suite, only for a v3 Cert cipherSuite noise.CipherSuite // pre-built cipher suite (DH + cipher + hash) } @@ -24,7 +24,8 @@ type Credential struct { // appropriate DH function, cipher, and hash. // // For a version 3 certificate privateKey is the composite private key, -// classical || ML-KEM-1024 seed, matching the composite Cert.PublicKey(). +// classical || the seed of its suite's KEM, matching the composite +// Cert.PublicKey(). func NewCredential( c cert.Certificate, hsBytes []byte, @@ -38,14 +39,10 @@ func NewCredential( publicKey: c.PublicKey(), cipherSuite: cipherSuite, } - if v2 := cert.V2Form(c); v2 != nil { - classical := v2.PublicKey() - hc.publicKey = classical - hc.kemKey = noise.KEMKey{ - Private: privateKey[len(privateKey)-cert.MLKEM1024PrivateKeySize:], - Public: c.PublicKey()[len(classical):], - } - hc.privateKey = privateKey[:len(privateKey)-cert.MLKEM1024PrivateKeySize] + if suite := cert.PQSuiteOf(c); suite != cert.PQSuiteUnknown { + // The pki checked the private key against the certificate, so both split. + hc.publicKey, hc.kemKey.Public, _ = suite.SplitPublicKey(false, c.PublicKey()) + hc.privateKey, hc.kemKey.Private, _ = suite.SplitPrivateKey(false, privateKey) } return hc } @@ -53,7 +50,7 @@ func NewCredential( // buildHandshakeState creates a noise.HandshakeState from this credential. // pskPlacement selects the noise psk modifier; 0 means none. A psk for // placements 2 and up is supplied later with SetPresharedKey. A hybrid -// pattern also gets ML-KEM-1024 and the static ML-KEM key. +// pattern also gets its KEM and the static KEM key. func (hc *Credential) buildHandshakeState(initiator bool, info subtypeInfo) (*noise.HandshakeState, error) { cfg := noise.Config{ CipherSuite: hc.cipherSuite, @@ -64,8 +61,8 @@ func (hc *Credential) buildHandshakeState(initiator bool, info subtypeInfo) (*no PresharedKey: []byte{}, PresharedKeyPlacement: info.pskPlacement, } - if info.hybrid { - cfg.KEMFunc = KEMMLKEM1024 + if info.kem != nil { + cfg.KEMFunc = info.kem cfg.StaticKEMKeypair = hc.kemKey } return noise.NewHandshakeState(cfg) diff --git a/handshake/errors.go b/handshake/errors.go index 2de955da..869ae4d3 100644 --- a/handshake/errors.go +++ b/handshake/errors.go @@ -20,4 +20,5 @@ var ( ErrMultiMessageUnsupported = errors.New("multi-message handshake patterns are not yet supported by the manager") ErrSubtypeMismatch = errors.New("packet subtype does not match handshake machine subtype") ErrCertVersionMismatch = errors.New("handshake subtype does not allow this certificate version") + ErrPQSuiteMismatch = errors.New("handshake subtype does not use the KEM of this certificate's post-quantum suite") ) diff --git a/handshake/fragment/fragment_test.go b/handshake/fragment/fragment_test.go index 470e2fda..e606af5a 100644 --- a/handshake/fragment/fragment_test.go +++ b/handshake/fragment/fragment_test.go @@ -524,7 +524,7 @@ func TestTableAnswered(t *testing.T) { r := rand.New(rand.NewPCG(3, 3)) tb := NewTable[testKey, string](testConfig()) now := time.Unix(1000, 0) - const msgLen = 8006 // a hybrid msg1: K=7 data symbols, 3 repair + const msgLen = 8009 // a hybrid msg1: K=7 data symbols, 3 repair sendAll := func(key testKey, pkts [][]byte) []*Result { var out []*Result diff --git a/handshake/machine.go b/handshake/machine.go index e9e1a59c..f879bc5e 100644 --- a/handshake/machine.go +++ b/handshake/machine.go @@ -94,7 +94,8 @@ func NewMachine( // Version 3 certificates go with hybrid patterns and nothing else, the // classical and phase 1 subtypes use the v2 form instead. - if info.hybrid != (version == cert.Version3) { + hybrid := info.kem != nil + if hybrid != (version == cert.Version3) { return nil, fmt.Errorf("%w: %v", ErrCertVersionMismatch, version) } @@ -102,6 +103,11 @@ func NewMachine( if cred == nil { return nil, fmt.Errorf("%w: %v", ErrNoCredential, version) } + if hybrid { + if err := checkHybridSuite(subtype, cred.Cert); err != nil { + return nil, err + } + } hs, err := cred.buildHandshakeState(initiator, info) if err != nil { @@ -112,7 +118,7 @@ func NewMachine( hs: hs, subtype: subtype, msgs: info.msgs, - hybrid: info.hybrid, + hybrid: hybrid, getCred: getCred, allocIndex: allocIndex, verifier: verifier, @@ -390,6 +396,16 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error { return nil } +// checkHybridSuite checks that a version 3 certificate's suite uses the KEM of +// the hybrid subtype. +func checkHybridSuite(subtype header.MessageSubType, c cert.Certificate) error { + suite := cert.PQSuiteOf(c) + if st, ok := HybridSubtype(suite); !ok || st != subtype { + return fmt.Errorf("%w: %v", ErrPQSuiteMismatch, suite) + } + return nil +} + func (m *Machine) validateCert(payload Payload) error { // A v3 certificate only ever comes with a hybrid pattern, and a hybrid // pattern only ever with one. The composite static key would not recombine @@ -420,6 +436,13 @@ func (m *Machine) validateCert(payload Payload) error { return ErrPublicKeyMismatch } + if m.hybrid { + if err := checkHybridSuite(m.subtype, rc); err != nil { + m.failed = true + return err + } + } + // Version negotiation, if the peer sent a different version and we have it, switch if rc.Version() != m.myVersion { if m.getCred(rc.Version()) != nil { diff --git a/handshake/patterns.go b/handshake/patterns.go index ad8765cf..d27390f1 100644 --- a/handshake/patterns.go +++ b/handshake/patterns.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/DefinedNet/noise" + "github.com/slackhq/nebula/cert" "github.com/slackhq/nebula/header" ) @@ -22,9 +23,10 @@ type subtypeInfo struct { pskPlacement int // noise psk modifier position, 0 means no psk token msgs []msgFlags - // hybrid patterns carry composite static keys and need version 3 - // certificates on both sides; every other pattern refuses them. - hybrid bool + // kem is the KEM of a hybrid pattern, nil for any other. Hybrid patterns + // carry composite static keys and need version 3 certificates on both + // sides, in suites using this KEM; every other pattern refuses them. + kem noise.KEMFunc } // subtypeInfos defines the noise pattern and message content layout for each @@ -68,7 +70,7 @@ var subtypeInfos = map[header.MessageSubType]subtypeInfo{ // first packet under the new keys does. header.HandshakeIXHybridMLKEM1024: { pattern: noise.HandshakeHybridIX, - hybrid: true, + kem: KEMMLKEM1024, msgs: []msgFlags{ {expectsPayload: true, expectsCert: true}, {expectsPayload: true, expectsCert: true}, @@ -90,6 +92,26 @@ var subtypeInfos = map[header.MessageSubType]subtypeInfo{ //}, } +// hybridSubtypes maps the suite of a version 3 certificate to the hybrid +// subtype for its KEM. Peers need the same KEM, not the same suite, so suites +// sharing a KEM share a subtype; the suite itself travels in the certificate. +var hybridSubtypes = map[cert.PQSuite]header.MessageSubType{ + cert.PQSuite_MLKEM1024_MLDSA87: header.HandshakeIXHybridMLKEM1024, +} + +// HybridSubtype returns the hybrid handshake subtype for the KEM of a version 3 +// certificate's suite. +func HybridSubtype(suite cert.PQSuite) (header.MessageSubType, bool) { + st, ok := hybridSubtypes[suite] + return st, ok +} + +// IsHybrid reports whether subtype is a hybrid handshake, one that needs version +// 3 certificates. +func IsHybrid(subtype header.MessageSubType) bool { + return subtypeInfos[subtype].kem != nil +} + func subtypeInfoFor(subtype header.MessageSubType) (subtypeInfo, error) { if info, ok := subtypeInfos[subtype]; ok { return info, nil diff --git a/handshake_manager.go b/handshake_manager.go index ff427719..0c76ddf3 100644 --- a/handshake_manager.go +++ b/handshake_manager.go @@ -190,8 +190,8 @@ func (hm *HandshakeManager) HandleIncoming(via ViaSender, packet []byte, h *head // Gate on known handshake subtypes. Unknown subtypes (or future ones we // don't yet support) are dropped here rather than silently routed through // the IX path. Add a case when introducing a new pattern. - switch h.Subtype { - case header.HandshakeIXPSK0, header.HandshakeIXPSK2MLKEM1024, hybridSubtype: + switch { + case h.Subtype == header.HandshakeIXPSK0, h.Subtype == header.HandshakeIXPSK2MLKEM1024, handshake.IsHybrid(h.Subtype): // supported default: hm.l.Debug("dropping handshake with unsupported subtype", @@ -795,7 +795,7 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head // A hybrid responder answers with its v3 certificate and has to wait for msg3, // so its index is registered right away for msg3 to find. var pending *HandshakeHostInfo - if h.Subtype == hybridSubtype { + if handshake.IsHybrid(h.Subtype) { v = cert.Version3 pending = &HandshakeHostInfo{ hostinfo: &HostInfo{ diff --git a/handshake_manager_hybrid.go b/handshake_manager_hybrid.go index b75139d7..76ee3c44 100644 --- a/handshake_manager_hybrid.go +++ b/handshake_manager_hybrid.go @@ -27,8 +27,6 @@ import ( // within the handshake timeout the tunnel is closed. const ( - hybridSubtype = header.HandshakeIXHybridMLKEM1024 - // hybridMsg3MaxLen bounds the msg3 a pending responder reassembles. msg3 is // a ciphertext and two tags, about 1.6KB. hybridMsg3MaxLen = 4096 @@ -45,6 +43,7 @@ type hybridResponder struct { // heard from the responder. type hybridConfirm struct { hostinfo *HostInfo + subtype header.MessageSubType sender *fragment.Sender send func([]byte) error handshakeLen uint64 // messages the handshake took, which the replay window was seeded with @@ -323,6 +322,7 @@ func (hm *HandshakeManager) startHybridConfirm(via ViaSender, hostinfo *HostInfo now := time.Now() c := &hybridConfirm{ hostinfo: hostinfo, + subtype: h.Subtype, sender: s, send: hm.sendTo(via), handshakeLen: handshakeLen, @@ -341,7 +341,7 @@ func (hm *HandshakeManager) sendHybridMsg3(c *hybridConfirm) { if err != nil { return } - hm.messageMetrics.Tx(header.Handshake, hybridSubtype, int64(len(pkts)+len(later))) + hm.messageMetrics.Tx(header.Handshake, c.subtype, int64(len(pkts)+len(later))) // No repair delay: the packets cached during the handshake go out right // behind msg3, and the responder drops whatever arrives before msg3 // completes. With the repair trailing, losing either data fragment would diff --git a/handshake_manager_pq.go b/handshake_manager_pq.go index 1916b9e2..d6d9c5a1 100644 --- a/handshake_manager_pq.go +++ b/handshake_manager_pq.go @@ -276,14 +276,15 @@ func (hm *HandshakeManager) relayEstablished(target netip.Addr, relayHI *HostInf } // buildPQAttempt creates the initiator's PQ Machine and msg1 sender. A node with -// a v3 certificate speaks the hybrid handshake, anyone else the ephemeral KEM one. +// a v3 certificate speaks the hybrid handshake for its suite's KEM, anyone else +// the ephemeral KEM one. func (hm *HandshakeManager) buildPQAttempt(hh *HandshakeHostInfo) bool { cs := hm.f.pki.getCertState() v := hm.initiatingVersion(hh, cs) subtype := header.HandshakeIXPSK2MLKEM1024 - if cs.GetCredential(cert.Version3) != nil { + if st, ok := cs.hybridSubtype(); ok { v = cert.Version3 - subtype = hybridSubtype + subtype = st } if cs.GetCredential(v) == nil { hm.f.l.Error("Unable to handshake with host because no certificate is available", @@ -409,13 +410,20 @@ func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *head hm.l.Debug("dropping post-quantum handshake, not accepting them", "from", via) return } - if h.Subtype == hybridSubtype || cfg.require { - hasV3 := hm.f.pki.getCertState().GetCredential(cert.Version3) != nil - if h.Subtype == hybridSubtype && !hasV3 { + hybrid := handshake.IsHybrid(h.Subtype) + if hybrid || cfg.require { + mine, hasV3 := hm.f.pki.getCertState().hybridSubtype() + switch { + case hybrid && !hasV3: hm.l.Debug("dropping hybrid handshake, no v3 certificate to answer it with", "from", via) return - } - if h.Subtype != hybridSubtype && hasV3 { + case hybrid && h.Subtype != mine: + // The peer's suite uses another KEM. Like a peer without PQ, it + // falls back to classical unless either side requires PQ. + hm.l.Debug("dropping hybrid handshake, our v3 certificate's suite uses another KEM", "from", via, + "style", header.SubTypeName(header.Handshake, h.Subtype), "ours", header.SubTypeName(header.Handshake, mine)) + return + case !hybrid && hasV3: hm.l.Debug("dropping post-quantum handshake, hybrid handshakes are required", "from", via) return } @@ -463,7 +471,7 @@ func (hm *HandshakeManager) handleFragment(via ViaSender, packet []byte, h *head } case 3: - if h.Subtype == hybridSubtype { + if handshake.IsHybrid(h.Subtype) { hm.continueHybridResponder(via, h, fh, sym) } } diff --git a/pki.go b/pki.go index 01bb6d46..893c258c 100644 --- a/pki.go +++ b/pki.go @@ -21,6 +21,7 @@ import ( "github.com/slackhq/nebula/cert" "github.com/slackhq/nebula/config" "github.com/slackhq/nebula/handshake" + "github.com/slackhq/nebula/header" "github.com/slackhq/nebula/noiseutil" "github.com/slackhq/nebula/util" ) @@ -222,6 +223,15 @@ func (cs *CertState) GetDefaultCertificate() cert.Certificate { func (cs *CertState) DefaultVersion() cert.Version { return cs.initiatingVersion } // GetCredential returns the pre-computed handshake credential for the given version, or nil. +// hybridSubtype returns the hybrid handshake subtype for the KEM of the v3 +// certificate's suite, false without a v3 certificate. +func (cs *CertState) hybridSubtype() (header.MessageSubType, bool) { + if cs.v3Cert == nil { + return 0, false + } + return handshake.HybridSubtype(cert.PQSuiteOf(cs.v3Cert)) +} + func (cs *CertState) GetCredential(v cert.Version) *handshake.Credential { switch v { case cert.Version1: @@ -440,7 +450,7 @@ func newCertStateFromConfig(c *config.C, cipher string) (*CertState, error) { } // newCertState builds the handshake credentials. v3, when present, must be what v2 is the v2 form of. privateKey is -// then the composite key, classical || ML-KEM-1024 seed, and v1 and v2 get its classical half. +// then the composite key, classical || the seed of the KEM of v3's suite, and v1 and v2 get its classical half. func newCertState(dv cert.Version, v1, v2, v3 cert.Certificate, pkcs11backed bool, privateKeyCurve cert.Curve, privateKey []byte, cipher string) (*CertState, error) { cs := CertState{ privateKey: privateKey, @@ -468,8 +478,8 @@ func newCertState(dv cert.Version, v1, v2, v3 cert.Certificate, pkcs11backed boo } classicalKey := privateKey - if !pkcs11backed && len(privateKey) > cert.MLKEM1024PrivateKeySize { - classicalKey = privateKey[:len(privateKey)-cert.MLKEM1024PrivateKeySize] + if !pkcs11backed { + classicalKey = cert.ClassicalPrivateKey(privateKey) } if v3 != nil { @@ -479,6 +489,10 @@ func newCertState(dv cert.Version, v1, v2, v3 cert.Certificate, pkcs11backed boo if err := v3.VerifyPrivateKey(privateKeyCurve, privateKey); err != nil { return nil, fmt.Errorf("private key is not a pair with public key in v3 nebula cert: %w", err) } + suite := cert.PQSuiteOf(v3) + if _, ok := handshake.HybridSubtype(suite); !ok { + return nil, fmt.Errorf("v3 nebula cert uses post-quantum suite %v, which no hybrid handshake supports", suite) + } v3hs, err := v3.MarshalForHandshakes() if err != nil {