Просмотр исходного кода

nebula-cert: Make version 3 CAs, keys and certificates, and pq-extend v2 certificates in place

JackDoan 6 дней назад
Родитель
Сommit
4d37a3140f

+ 13 - 3
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{

+ 1 - 1
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(),
 	)
 }

+ 44 - 2
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,

+ 5 - 1
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(),
 	)
 }

+ 5 - 0
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 <mode> -h\n", os.Args[0])
 }

+ 1 - 0
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] + " <mode> -h\n"
 

+ 272 - 0
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 <flags>: 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()
+}

+ 177 - 0
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)
+}

+ 91 - 34
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
+}

+ 1 - 1
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(),
 	)
 }