From 4d37a3140faad72e8d1b81ef161a868c66111e53 Mon Sep 17 00:00:00 2001 From: JackDoan Date: Wed, 14 Oct 2026 11:06:40 -0500 Subject: [PATCH] nebula-cert: Make version 3 CAs, keys and certificates, and pq-extend v2 certificates in place --- cmd/nebula-cert/ca.go | 16 +- cmd/nebula-cert/ca_test.go | 2 +- cmd/nebula-cert/keygen.go | 46 +++++- cmd/nebula-cert/keygen_test.go | 6 +- cmd/nebula-cert/main.go | 5 + cmd/nebula-cert/main_test.go | 1 + cmd/nebula-cert/pq.go | 272 +++++++++++++++++++++++++++++++++ cmd/nebula-cert/pq_test.go | 177 +++++++++++++++++++++ cmd/nebula-cert/sign.go | 125 ++++++++++----- cmd/nebula-cert/sign_test.go | 2 +- 10 files changed, 610 insertions(+), 42 deletions(-) create mode 100644 cmd/nebula-cert/pq.go create mode 100644 cmd/nebula-cert/pq_test.go diff --git a/cmd/nebula-cert/ca.go b/cmd/nebula-cert/ca.go index 178d0a83..61508dca 100644 --- a/cmd/nebula-cert/ca.go +++ b/cmd/nebula-cert/ca.go @@ -71,7 +71,7 @@ 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") + 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.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") @@ -160,8 +160,11 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error } version := cert.Version(*cf.version) - if version != cert.Version1 && version != cert.Version2 { - return newHelpErrorf("-version must be either %v or %v", cert.Version1, cert.Version2) + if version != cert.Version1 && version != cert.Version2 && version != cert.Version3 { + 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") } var networks []netip.Prefix @@ -295,6 +298,13 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error default: return fmt.Errorf("invalid curve: %s", *cf.curve) } + + if version == cert.Version3 { + pub, rawPriv, err = appendPQSigningKey(pub, rawPriv) + if err != nil { + return err + } + } } t := &cert.TBSCertificate{ diff --git a/cmd/nebula-cert/ca_test.go b/cmd/nebula-cert/ca_test.go index 0201a3f8..016d0b59 100644 --- a/cmd/nebula-cert/ca_test.go +++ b/cmd/nebula-cert/ca_test.go @@ -73,7 +73,7 @@ func Test_caHelp(t *testing.T) { " -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 (default 2)\n", + " \tOptional: version of the certificate format to use. Version 3 adds an ML-DSA-87 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 598ce477..e3e315b5 100644 --- a/cmd/nebula-cert/keygen.go +++ b/cmd/nebula-cert/keygen.go @@ -17,7 +17,9 @@ type keygenFlags struct { set *flag.FlagSet outKeyPath *string outPubPath *string + inKeyPath *string curve *string + version *uint p11url *string } @@ -27,6 +29,8 @@ 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.p11url = p11Flag(cf.set) return &cf } @@ -51,9 +55,41 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error { return err } + version := cert.Version(*cf.version) + if *cf.inKeyPath != "" { + version = cert.Version3 + } + if version != 0 && version != cert.Version3 { + return newHelpErrorf("-version must be %v, or left out for keys that work with any other version", cert.Version3) + } + if version == cert.Version3 && isP11 { + return newHelpErrorf("-version 3 can not be used with -pkcs11, the ML-KEM-1024 key has to be in out-key") + } + + var claims ioClaims + if err := reserveInputs(&claims, "in-key", *cf.inKeyPath); err != nil { + return err + } + var pub, rawPriv []byte var curve cert.Curve - if isP11 { + if *cf.inKeyPath != "" { + raw, err := readInput("in-key", *cf.inKeyPath, &claims) + if err != nil { + return fmt.Errorf("error while reading in-key: %s", err) + } + rawPriv, _, curve, err = cert.UnmarshalPrivateKeyFromPEM(raw) + if err != nil { + return fmt.Errorf("error while parsing in-key: %s", err) + } + pub, err = publicKeyFor(curve, rawPriv) + if err != nil { + return fmt.Errorf("error while parsing in-key: %s", err) + } + if fips140.Enforced() && curve == cert.Curve_CURVE25519 { + return errors.New("use of Curve25519 is not allowed in FIPS 140-only mode") + } + } else if isP11 { switch *cf.curve { case "P256": curve = cert.Curve_P256 @@ -76,7 +112,13 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error { } } - var claims ioClaims + if version == cert.Version3 { + pub, rawPriv, err = appendPQKey(pub, rawPriv) + if err != nil { + return err + } + } + if err := reserveOutputs(&claims, "out-key", *cf.outKeyPath, "out-pub", *cf.outPubPath, diff --git a/cmd/nebula-cert/keygen_test.go b/cmd/nebula-cert/keygen_test.go index 98c4c456..921a45a7 100644 --- a/cmd/nebula-cert/keygen_test.go +++ b/cmd/nebula-cert/keygen_test.go @@ -23,11 +23,15 @@ func Test_keygenHelp(t *testing.T) { " Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+ " -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"+ " -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"), + optionalPkcs11String(" -pkcs11 string\n \tOptional: PKCS#11 URI to an existing private key\n")+ + " -version uint\n"+ + " \tOptional: 3 adds an ML-KEM-1024 key, for version 3 certificates\n", ob.String(), ) } diff --git a/cmd/nebula-cert/main.go b/cmd/nebula-cert/main.go index 82ad2fee..36ace28e 100644 --- a/cmd/nebula-cert/main.go +++ b/cmd/nebula-cert/main.go @@ -89,6 +89,8 @@ func main() { err = printCert(args[1:], os.Stdout, os.Stderr) case "verify": err = verify(args[1:], os.Stdout, os.Stderr) + case "pq-extend": + err = pqExtend(args[1:], os.Stdout, os.Stderr, StdinPasswordReader{}) default: err = fmt.Errorf("unknown mode: %s", args[0]) } @@ -122,6 +124,8 @@ func handleError(mode string, e error, out io.Writer) int { printHelp(out) case "verify": verifyHelp(out) + case "pq-extend": + pqExtendHelp(out) } } @@ -145,6 +149,7 @@ func help(err string, out io.Writer) { fmt.Fprintln(out, " "+signSummary()) fmt.Fprintln(out, " "+printSummary()) fmt.Fprintln(out, " "+verifySummary()) + fmt.Fprintln(out, " "+pqExtendSummary()) fmt.Fprintln(out, "") fmt.Fprintf(out, " To see usage for a given mode, use %s -h\n", os.Args[0]) } diff --git a/cmd/nebula-cert/main_test.go b/cmd/nebula-cert/main_test.go index 2e92e7e2..7cc4c6c6 100644 --- a/cmd/nebula-cert/main_test.go +++ b/cmd/nebula-cert/main_test.go @@ -23,6 +23,7 @@ func Test_help(t *testing.T) { " " + signSummary() + "\n" + " " + printSummary() + "\n" + " " + verifySummary() + "\n" + + " " + pqExtendSummary() + "\n" + "\n" + " To see usage for a given mode, use " + os.Args[0] + " -h\n" diff --git a/cmd/nebula-cert/pq.go b/cmd/nebula-cert/pq.go new file mode 100644 index 00000000..8db2c271 --- /dev/null +++ b/cmd/nebula-cert/pq.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "crypto/ecdh" + "crypto/mldsa" + "crypto/mlkem" + "encoding/pem" + "flag" + "fmt" + "io" + "os" + + "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) + } + return append(pub, sk.PublicKey().Bytes()...), append(priv, sk.Bytes()...), nil +} + +// appendPQKey makes a classical host keypair into a version 3 composite one with a fresh ML-KEM-1024 key. +func appendPQKey(pub, priv []byte) ([]byte, []byte, error) { + dk, err := mlkem.GenerateKey1024() + if err != nil { + return nil, nil, fmt.Errorf("error while generating ML-KEM-1024 key: %s", err) + } + return append(pub, dk.EncapsulationKey().Bytes()...), append(priv, dk.Bytes()...), nil +} + +// publicKeyFor returns the public key of a classical host private key. +func publicKeyFor(curve cert.Curve, priv []byte) ([]byte, error) { + var c ecdh.Curve + switch curve { + case cert.Curve_CURVE25519: + c = ecdh.X25519() + case cert.Curve_P256: + c = ecdh.P256() + default: + return nil, fmt.Errorf("invalid curve: %s", curve) + } + 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 k.PublicKey().Bytes(), nil +} + +type pqExtendFlags struct { + set *flag.FlagSet + caKeyPath *string + caCertPath *string + inCertPath *string + inPubPath *string + outCertPath *string + outKeyPath *string +} + +func newPQExtendFlags() *pqExtendFlags { + pf := pqExtendFlags{set: flag.NewFlagSet("pq-extend", flag.ContinueOnError)} + pf.set.Usage = func() {} + pf.inCertPath = pf.set.String("in-crt", "", "Required: path to the version 2 certificate to extend, a CA or a host") + pf.outCertPath = pf.set.String("out-crt", "", "Required: path to write the version 3 certificate to") + pf.caKeyPath = pf.set.String("ca-key", "ca.key", "Optional: path to the CA key. For a CA in-crt, its own version 2 key. For a host in-crt, the version 3 key of the CA that issued it") + pf.caCertPath = pf.set.String("ca-crt", "", "Required for a host in-crt: path to the version 3 form of the CA that issued it") + pf.inPubPath = pf.set.String("in-pub", "", "Required for a host in-crt: path to the host's public key from keygen -in-key") + pf.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") + return &pf +} + +// pqExtend turns a version 2 certificate into a version 3 one in place: the v2 form, and so its fingerprint and +// existing trust in it, stays exactly as it was. +func pqExtend(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error { + pf := newPQExtendFlags() + if err := pf.set.Parse(args); err != nil { + return err + } + if err := mustFlagString("in-crt", pf.inCertPath); err != nil { + return err + } + if err := mustFlagString("out-crt", pf.outCertPath); err != nil { + return err + } + if err := mustFlagString("ca-key", pf.caKeyPath); err != nil { + return err + } + + var claims ioClaims + if err := reserveInputs(&claims, + "in-crt", *pf.inCertPath, + "ca-key", *pf.caKeyPath, + "ca-crt", *pf.caCertPath, + "in-pub", *pf.inPubPath, + ); err != nil { + return err + } + if err := reserveOutputs(&claims, + "out-crt", *pf.outCertPath, + "out-key", *pf.outKeyPath, + ); err != nil { + return err + } + + rawCert, err := readInput("in-crt", *pf.inCertPath, &claims) + if err != nil { + return fmt.Errorf("error while reading in-crt: %s", err) + } + c, _, err := cert.UnmarshalCertificateFromPEM(rawCert) + if err != nil { + return fmt.Errorf("error while parsing in-crt: %s", err) + } + if c.Version() != cert.Version2 { + return fmt.Errorf("only version 2 certificates extend to version 3, in-crt is version %d", c.Version()) + } + + for _, p := range []string{*pf.outCertPath, *pf.outKeyPath} { + if p != "" && !isStdio(p) { + if _, err := os.Stat(p); err == nil { + return fmt.Errorf("refusing to overwrite existing file: %s", p) + } + } + } + + rawCAKey, err := readInput("ca-key", *pf.caKeyPath, &claims) + if err != nil { + return fmt.Errorf("error while reading ca-key: %s", err) + } + curve, caKey, passphrase, err := readSigningKey(rawCAKey, errOut, pr) + if err != nil { + return err + } + + var c3 cert.Certificate + if c.IsCA() { + c3, err = extendCA(pf, c, curve, caKey, rawCAKey, passphrase, out) + } else { + c3, err = extendHost(pf, c, curve, caKey, &claims) + } + if err != nil { + return err + } + + b, err := c3.MarshalPEM() + if err != nil { + return fmt.Errorf("error while marshalling certificate: %s", err) + } + if err := writeOutput(*pf.outCertPath, b, 0600, out); err != nil { + return fmt.Errorf("error while writing out-crt: %s", err) + } + 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) { + if *pf.caCertPath != "" || *pf.inPubPath != "" { + return nil, newHelpErrorf("-ca-crt and -in-pub are for host certificates, a CA extends itself") + } + if err := mustFlagString("out-key", pf.outKeyPath); err != nil { + return nil, err + } + if err := ca.VerifyPrivateKey(curve, key); err != nil { + return nil, fmt.Errorf("refusing to extend, the CA certificate does not match ca-key") + } + + pub, priv, err := appendPQSigningKey(nil, append([]byte(nil), key...)) + if err != nil { + return nil, err + } + signPQ, err := cert.SignPQ(priv[len(key):]) + if err != nil { + return nil, err + } + c3, err := cert.ExtendToV3(ca, pub, nil, signPQ) + if err != nil { + return nil, fmt.Errorf("error while extending: %s", err) + } + + var b []byte + if passphrase != nil { + // Encrypt it the way ca-key was, with a fresh salt. + block, _ := pem.Decode(rawKey) + if block == nil { + return nil, fmt.Errorf("error while parsing ca-key: input did not contain a valid PEM encoded block") + } + ned, err := cert.UnmarshalNebulaEncryptedData(block.Bytes) + if err != nil { + return nil, fmt.Errorf("error while parsing encrypted ca-key: %s", err) + } + a := ned.EncryptionMetadata.Argon2Parameters + kdf := cert.NewArgon2Parameters(a.Memory, a.Parallelism, a.Iterations) + b, err = cert.EncryptAndMarshalSigningPrivateKey(curve, priv, passphrase, kdf) + if err != nil { + return nil, fmt.Errorf("error while encrypting out-key: %s", err) + } + } else { + b = cert.MarshalSigningPrivateKeyToPEM(curve, priv) + } + if err := writeOutput(*pf.outKeyPath, b, 0600, out); err != nil { + return nil, fmt.Errorf("error while writing out-key: %s", err) + } + return c3, nil +} + +// extendHost adds a host's ML-KEM-1024 key to its certificate, signed by the v3 form of its CA. +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") + } + if err := mustFlagString("ca-crt", pf.caCertPath); err != nil { + return nil, err + } + if err := mustFlagString("in-pub", pf.inPubPath); err != nil { + return nil, err + } + + rawCA, err := readInput("ca-crt", *pf.caCertPath, claims) + if err != nil { + return nil, fmt.Errorf("error while reading ca-crt: %s", err) + } + ca, _, err := cert.UnmarshalCertificateFromPEM(rawCA) + if err != nil { + return nil, fmt.Errorf("error while parsing ca-crt: %s", err) + } + if ca.Version() != cert.Version3 { + return nil, fmt.Errorf("ca-crt must be a version 3 CA, extend the CA first") + } + if err := ca.VerifyPrivateKey(curve, caKey); err != nil { + return nil, fmt.Errorf("refusing to extend, ca-crt does not match ca-key") + } + + rawPub, err := readInput("in-pub", *pf.inPubPath, claims) + if err != nil { + return nil, fmt.Errorf("error while reading in-pub: %s", err) + } + pub, _, pubCurve, err := cert.UnmarshalPublicKeyFromPEM(rawPub) + 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") + } + 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:]) + if err != nil { + return nil, err + } + c3, err := cert.ExtendToV3(c, pq, ca, signPQ) + if err != nil { + return nil, fmt.Errorf("error while extending: %s", err) + } + return c3, nil +} + +func pqExtendSummary() string { + return "pq-extend : extend a version 2 CA or host certificate to version 3 in place, keeping its v2 form and fingerprint" +} + +func pqExtendHelp(out io.Writer) { + pf := newPQExtendFlags() + out.Write([]byte("Usage of " + os.Args[0] + " " + pqExtendSummary() + "\n")) + out.Write([]byte(stdioHelpText)) + pf.set.SetOutput(out) + pf.set.PrintDefaults() +} diff --git a/cmd/nebula-cert/pq_test.go b/cmd/nebula-cert/pq_test.go new file mode 100644 index 00000000..5faa0d35 --- /dev/null +++ b/cmd/nebula-cert/pq_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + "github.com/slackhq/nebula/cert" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func readCert(t *testing.T, path string) cert.Certificate { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + c, _, err := cert.UnmarshalCertificateFromPEM(b) + require.NoError(t, err) + return c +} + +func readSigningKeyFile(t *testing.T, path string) (cert.Curve, []byte) { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + k, _, curve, err := cert.UnmarshalSigningPrivateKeyFromPEM(b) + require.NoError(t, err) + return curve, k +} + +func readKeyFile(t *testing.T, path string) (cert.Curve, []byte) { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + k, _, curve, err := cert.UnmarshalPrivateKeyFromPEM(b) + require.NoError(t, err) + return curve, k +} + +func verifiedBy(t *testing.T, c cert.Certificate, cas ...cert.Certificate) error { + t.Helper() + pool := cert.NewCAPool() + for _, ca := range cas { + require.NoError(t, pool.AddCA(ca)) + } + _, err := pool.VerifyCertificate(time.Now(), c) + return err +} + +func TestPQ_CAAndSign(t *testing.T) { + for _, curve := range []string{"25519", "P256"} { + t.Run(curve, func(t *testing.T) { + dir := t.TempDir() + ob, eb := &bytes.Buffer{}, &bytes.Buffer{} + nopw := &StubPasswordReader{} + p := func(n string) string { return filepath.Join(dir, n) } + + require.NoError(t, ca([]string{"-version", "3", "-curve", curve, "-name", "pq ca", "-out-crt", p("ca.crt"), "-out-key", p("ca.key")}, ob, eb, nopw)) + caCrt := readCert(t, p("ca.crt")) + assert.Equal(t, cert.Version3, caCrt.Version()) + caCurve, caKey := readSigningKeyFile(t, p("ca.key")) + require.NoError(t, caCrt.VerifyPrivateKey(caCurve, caKey)) + + // A v3 CA signs v3 by default. + require.NoError(t, signCert([]string{"-ca-crt", p("ca.crt"), "-ca-key", p("ca.key"), "-name", "host", "-networks", "10.1.0.1/16", "-out-crt", p("host.crt"), "-out-key", p("host.key")}, ob, eb, nopw)) + host := readCert(t, p("host.crt")) + assert.Equal(t, cert.Version3, host.Version()) + hostCurve, hostKey := readKeyFile(t, p("host.key")) + require.NoError(t, host.VerifyPrivateKey(hostCurve, hostKey)) + require.NoError(t, verifiedBy(t, host, caCrt)) + + // And v2 as its v2 form. + require.NoError(t, signCert([]string{"-version", "2", "-ca-crt", p("ca.crt"), "-ca-key", p("ca.key"), "-name", "old", "-networks", "10.1.0.2/16", "-out-crt", p("old.crt"), "-out-key", p("old.key")}, ob, eb, nopw)) + old := readCert(t, p("old.crt")) + assert.Equal(t, cert.Version2, old.Version()) + require.NoError(t, verifiedBy(t, old, cert.V2Form(caCrt))) + + // keygen -version 3 makes keys for v3 certificates, and only those. + require.NoError(t, keygen([]string{"-version", "3", "-curve", curve, "-out-key", p("k3.key"), "-out-pub", p("k3.pub")}, ob, eb)) + require.NoError(t, signCert([]string{"-ca-crt", p("ca.crt"), "-ca-key", p("ca.key"), "-name", "k3", "-networks", "10.1.0.3/16", "-in-pub", p("k3.pub"), "-out-crt", p("k3.crt")}, ob, eb, nopw)) + _, k3 := readKeyFile(t, p("k3.key")) + require.NoError(t, readCert(t, p("k3.crt")).VerifyPrivateKey(hostCurve, k3)) + require.ErrorContains(t, signCert([]string{"-version", "2", "-ca-crt", p("ca.crt"), "-ca-key", p("ca.key"), "-name", "x", "-networks", "10.1.0.4/16", "-in-pub", p("k3.pub"), "-out-crt", p("x.crt")}, ob, eb, nopw), "only version 3 certificates carry") + }) + } +} + +func TestPQ_SignErrors(t *testing.T) { + dir := t.TempDir() + ob, eb := &bytes.Buffer{}, &bytes.Buffer{} + nopw := &StubPasswordReader{} + p := func(n string) string { return filepath.Join(dir, n) } + + require.NoError(t, ca([]string{"-name", "v2 ca", "-out-crt", p("ca.crt"), "-out-key", p("ca.key")}, ob, eb, nopw)) + require.ErrorContains(t, signCert([]string{"-version", "3", "-ca-crt", p("ca.crt"), "-ca-key", p("ca.key"), "-name", "h", "-networks", "10.1.0.1/16", "-out-crt", p("h.crt"), "-out-key", p("h.key")}, ob, eb, nopw), "must be signed by a version 3 CA") + + require.NoError(t, keygen([]string{"-out-key", p("k.key"), "-out-pub", p("k.pub")}, ob, eb)) + 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. + 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") +} + +// TestPQ_Extend upgrades a v2 CA and a host in place, the way a running network migrates. +func TestPQ_Extend(t *testing.T) { + for _, encrypt := range []bool{false, true} { + name := "plain" + if encrypt { + name = "encrypted" + } + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + ob, eb := &bytes.Buffer{}, &bytes.Buffer{} + pw := &StubPasswordReader{} + if encrypt { + t.Setenv("NEBULA_CA_PASSPHRASE", "hunter2") + } + p := func(n string) string { return filepath.Join(dir, n) } + + caArgs := []string{"-name", "ca", "-out-crt", p("ca.crt"), "-out-key", p("ca.key"), "-argon-memory", "1024"} + if encrypt { + caArgs = append(caArgs, "-encrypt") + } + require.NoError(t, ca(caArgs, ob, eb, pw)) + require.NoError(t, signCert([]string{"-ca-crt", p("ca.crt"), "-ca-key", p("ca.key"), "-name", "host", "-networks", "10.1.0.1/16", "-out-crt", p("host.crt"), "-out-key", p("host.key")}, ob, eb, pw)) + ca2 := readCert(t, p("ca.crt")) + host2 := readCert(t, p("host.crt")) + + // The CA extends itself. + require.NoError(t, pqExtend([]string{"-in-crt", p("ca.crt"), "-ca-key", p("ca.key"), "-out-crt", p("ca3.crt"), "-out-key", p("ca3.key")}, ob, eb, pw)) + ca3 := readCert(t, p("ca3.crt")) + assert.Equal(t, cert.Version3, ca3.Version()) + assertSameCert(t, ca2, cert.V2Form(ca3)) + raw, err := os.ReadFile(p("ca3.key")) + require.NoError(t, err) + curve, ca3Key, _, err := readSigningKey(raw, eb, pw) + require.NoError(t, err) + require.NoError(t, ca3.VerifyPrivateKey(curve, ca3Key)) + if encrypt { + _, _, _, err = cert.UnmarshalSigningPrivateKeyFromPEM(raw) + 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. + 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")) + assert.Equal(t, cert.Version3, host3.Version()) + assertSameCert(t, host2, cert.V2Form(host3)) + hostCurve, host3Key := readKeyFile(t, p("host3.key")) + require.NoError(t, host3.VerifyPrivateKey(hostCurve, host3Key)) + require.NoError(t, verifiedBy(t, host3, ca3)) + require.NoError(t, verifiedBy(t, host2, ca2), "existing trust is untouched") + + // Someone else's key does not extend this host. + require.NoError(t, keygen([]string{"-version", "3", "-out-key", p("other.key"), "-out-pub", p("other.pub")}, ob, eb)) + require.ErrorContains(t, pqExtend([]string{"-in-crt", p("host.crt"), "-in-pub", p("other.pub"), "-ca-crt", p("ca3.crt"), "-ca-key", p("ca3.key"), "-out-crt", p("bad.crt")}, ob, eb, pw), "does not extend the key") + + // Nor does the v2 CA key, or a v3 certificate. + require.ErrorContains(t, pqExtend([]string{"-in-crt", p("host.crt"), "-in-pub", p("host3.pub"), "-ca-crt", p("ca3.crt"), "-ca-key", p("ca.key"), "-out-crt", p("bad.crt")}, ob, eb, pw), "does not match ca-key") + require.ErrorContains(t, pqExtend([]string{"-in-crt", p("host3.crt"), "-in-pub", p("host3.pub"), "-ca-crt", p("ca3.crt"), "-ca-key", p("ca3.key"), "-out-crt", p("bad.crt")}, ob, eb, pw), "only version 2 certificates") + }) + } +} + +func assertSameCert(t *testing.T, want, got cert.Certificate) { + t.Helper() + wb, err := want.Marshal() + require.NoError(t, err) + gb, err := got.Marshal() + require.NoError(t, err) + assert.Equal(t, wb, gb) +} diff --git a/cmd/nebula-cert/sign.go b/cmd/nebula-cert/sign.go index 10ff3121..6ab2bd00 100644 --- a/cmd/nebula-cert/sign.go +++ b/cmd/nebula-cert/sign.go @@ -44,7 +44,7 @@ type signFlags struct { func newSignFlags() *signFlags { sf := signFlags{set: flag.NewFlagSet("sign", flag.ContinueOnError)} sf.set.Usage = func() {} - sf.version = sf.set.Uint("version", 0, "Optional: version of the certificate format to use. The default is to match the version of the signing CA") + sf.version = sf.set.Uint("version", 0, "Optional: version of the certificate format to use. The default is to match the version of the signing CA. A version 3 CA also signs version 2 certificates, as its v2 form") sf.caKeyPath = sf.set.String("ca-key", "ca.key", "Optional: path to the signing CA key") sf.caCertPath = sf.set.String("ca-crt", "ca.crt", "Optional: path to the signing CA cert") sf.name = sf.set.String("name", "", "Required: name of the cert, usually a hostname") @@ -102,8 +102,8 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) } version := cert.Version(*sf.version) - if version != 0 && version != cert.Version1 && version != cert.Version2 { - return newHelpErrorf("-version must be either %v or %v", cert.Version1, cert.Version2) + if version != 0 && version != cert.Version1 && version != cert.Version2 && version != cert.Version3 { + return newHelpErrorf("-version must be %v, %v or %v", cert.Version1, cert.Version2, cert.Version3) } if *sf.outKeyPath == "" { @@ -139,37 +139,9 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) return fmt.Errorf("error while reading ca-key: %s", err) } - // naively attempt to decode the private key as though it is not encrypted - caKey, _, curve, err = cert.UnmarshalSigningPrivateKeyFromPEM(rawCAKey) - if errors.Is(err, cert.ErrPrivateKeyEncrypted) { - var passphrase []byte - passphrase = []byte(os.Getenv("NEBULA_CA_PASSPHRASE")) - if len(passphrase) == 0 { - // ask for a passphrase until we get one - for range 5 { - errOut.Write([]byte("Enter passphrase: ")) - passphrase, err = pr.ReadPassword() - - if errors.Is(err, ErrNoTerminal) { - return fmt.Errorf("ca-key is encrypted and must be decrypted interactively") - } else if err != nil { - return fmt.Errorf("error reading password: %s", err) - } - - if len(passphrase) > 0 { - break - } - } - if len(passphrase) == 0 { - return fmt.Errorf("cannot open encrypted ca-key without passphrase") - } - } - curve, caKey, _, err = cert.DecryptAndUnmarshalSigningPrivateKey(passphrase, rawCAKey) - if err != nil { - return fmt.Errorf("error while parsing encrypted ca-key: %s", err) - } - } else if err != nil { - return fmt.Errorf("error while parsing ca-key: %s", err) + curve, caKey, _, err = readSigningKey(rawCAKey, errOut, pr) + if err != nil { + return err } } @@ -197,6 +169,19 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) version = caCert.Version() } + 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") + 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 no duration is given, expire one second before the root expires if *sf.duration <= 0 { *sf.duration = time.Until(caCert.NotAfter()) - time.Second*1 @@ -287,6 +272,12 @@ 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) { + if isPQ { + return fmt.Errorf("in-pub has an ML-KEM-1024 key, which only version 3 certificates carry") + } + return fmt.Errorf("version 3 certificates need an in-pub from keygen -version 3") + } } else if isP11 { pub, err = p11Client.GetPubKey() if err != nil { @@ -294,6 +285,12 @@ 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) + if err != nil { + return err + } + } } if !isStdio(*sf.outCertPath) { @@ -378,6 +375,27 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) } crts = append(crts, nc) + + case cert.Version3: + t := &cert.TBSCertificate{ + Version: cert.Version3, + Name: *sf.name, + Networks: append(v4Networks, v6Networks...), + Groups: groups, + UnsafeNetworks: append(v4UnsafeNetworks, v6UnsafeNetworks...), + NotBefore: notBefore, + NotAfter: notAfter, + PublicKey: pub, + IsCA: false, + Curve: curve, + } + + nc, err := t.Sign(caCert, curve, caKey) + if err != nil { + return fmt.Errorf("error while signing: %w", err) + } + crts = append(crts, nc) + default: // this should be unreachable return fmt.Errorf("invalid version: %d", version) @@ -470,3 +488,42 @@ func signHelp(out io.Writer) { sf.set.SetOutput(out) sf.set.PrintDefaults() } + +// readSigningKey decodes a CA key, asking for its passphrase if it is encrypted. +// The passphrase is returned too, nil for a key that was not encrypted. +func readSigningKey(rawCAKey []byte, errOut io.Writer, pr PasswordReader) (cert.Curve, []byte, []byte, error) { + // naively attempt to decode the private key as though it is not encrypted + caKey, _, curve, err := cert.UnmarshalSigningPrivateKeyFromPEM(rawCAKey) + if errors.Is(err, cert.ErrPrivateKeyEncrypted) { + var passphrase []byte + passphrase = []byte(os.Getenv("NEBULA_CA_PASSPHRASE")) + if len(passphrase) == 0 { + // ask for a passphrase until we get one + for range 5 { + errOut.Write([]byte("Enter passphrase: ")) + passphrase, err = pr.ReadPassword() + + if errors.Is(err, ErrNoTerminal) { + return 0, nil, nil, fmt.Errorf("ca-key is encrypted and must be decrypted interactively") + } else if err != nil { + return 0, nil, nil, fmt.Errorf("error reading password: %s", err) + } + + if len(passphrase) > 0 { + break + } + } + if len(passphrase) == 0 { + return 0, nil, nil, fmt.Errorf("cannot open encrypted ca-key without passphrase") + } + } + curve, caKey, _, err = cert.DecryptAndUnmarshalSigningPrivateKey(passphrase, rawCAKey) + if err != nil { + return 0, nil, nil, fmt.Errorf("error while parsing encrypted ca-key: %s", err) + } + return curve, caKey, passphrase, nil + } else if err != nil { + return 0, nil, nil, fmt.Errorf("error while parsing ca-key: %s", err) + } + return curve, caKey, nil, nil +} diff --git a/cmd/nebula-cert/sign_test.go b/cmd/nebula-cert/sign_test.go index 10388ca8..19862cb8 100644 --- a/cmd/nebula-cert/sign_test.go +++ b/cmd/nebula-cert/sign_test.go @@ -55,7 +55,7 @@ func Test_signHelp(t *testing.T) { " -unsafe-networks string\n"+ " \tOptional: comma separated list of ip address and network in CIDR notation. Unsafe networks this cert can route for\n"+ " -version uint\n"+ - " \tOptional: version of the certificate format to use. The default is to match the version of the signing CA\n", + " \tOptional: version of the certificate format to use. The default is to match the version of the signing CA. A version 3 CA also signs version 2 certificates, as its v2 form\n", ob.String(), ) }