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

This commit is contained in:
JackDoan committed 2026-10-14 11:06:40 -05:00
1 parent b26c1e9c3e
commit 4d37a3140f
10 files changed
+610 -42

No files matched your search

+13 -3
View File
@@ -71,7 +71,7 @@ func newCaFlags() *caFlags {
cf := caFlags{set: flag.NewFlagSet("ca", flag.ContinueOnError)} cf := caFlags{set: flag.NewFlagSet("ca", flag.ContinueOnError)}
cf.set.Usage = func() {} cf.set.Usage = func() {}
cf.name = cf.set.String("name", "", "Required: name of the certificate authority") 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.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.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") 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) version := cert.Version(*cf.version)
if version != cert.Version1 && version != cert.Version2 { if version != cert.Version1 && version != cert.Version2 && version != cert.Version3 {
return newHelpErrorf("-version must be either %v or %v", cert.Version1, cert.Version2) 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 var networks []netip.Prefix
@@ -295,6 +298,13 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
default: default:
return fmt.Errorf("invalid curve: %s", *cf.curve) 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{ t := &cert.TBSCertificate{
+1 -1
View File
@@ -73,7 +73,7 @@ func Test_caHelp(t *testing.T) {
" -unsafe-networks string\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"+ " \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"+ " -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(), ob.String(),
) )
} }
+44 -2
View File
@@ -17,7 +17,9 @@ type keygenFlags struct {
set *flag.FlagSet set *flag.FlagSet
outKeyPath *string outKeyPath *string
outPubPath *string outPubPath *string
inKeyPath *string
curve *string curve *string
version *uint
p11url *string 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.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.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.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) cf.p11url = p11Flag(cf.set)
return &cf return &cf
} }
@@ -51,9 +55,41 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
return err 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 pub, rawPriv []byte
var curve cert.Curve 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 { switch *cf.curve {
case "P256": case "P256":
curve = cert.Curve_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, if err := reserveOutputs(&claims,
"out-key", *cf.outKeyPath, "out-key", *cf.outKeyPath,
"out-pub", *cf.outPubPath, "out-pub", *cf.outPubPath,
+5 -1
View File
@@ -23,11 +23,15 @@ func Test_keygenHelp(t *testing.T) {
" Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+ " Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+
" -curve string\n"+ " -curve string\n"+
" \tECDH Curve (25519, P256) (default \"25519\")\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"+ " -out-key string\n"+
" \tRequired: path to write the private key to\n"+ " \tRequired: path to write the private key to\n"+
" -out-pub string\n"+ " -out-pub string\n"+
" \tRequired: path to write the public key to\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(), ob.String(),
) )
} }
+5
View File
@@ -89,6 +89,8 @@ func main() {
err = printCert(args[1:], os.Stdout, os.Stderr) err = printCert(args[1:], os.Stdout, os.Stderr)
case "verify": case "verify":
err = verify(args[1:], os.Stdout, os.Stderr) err = verify(args[1:], os.Stdout, os.Stderr)
case "pq-extend":
err = pqExtend(args[1:], os.Stdout, os.Stderr, StdinPasswordReader{})
default: default:
err = fmt.Errorf("unknown mode: %s", args[0]) err = fmt.Errorf("unknown mode: %s", args[0])
} }
@@ -122,6 +124,8 @@ func handleError(mode string, e error, out io.Writer) int {
printHelp(out) printHelp(out)
case "verify": case "verify":
verifyHelp(out) 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, " "+signSummary())
fmt.Fprintln(out, " "+printSummary()) fmt.Fprintln(out, " "+printSummary())
fmt.Fprintln(out, " "+verifySummary()) fmt.Fprintln(out, " "+verifySummary())
fmt.Fprintln(out, " "+pqExtendSummary())
fmt.Fprintln(out, "") fmt.Fprintln(out, "")
fmt.Fprintf(out, " To see usage for a given mode, use %s <mode> -h\n", os.Args[0]) fmt.Fprintf(out, " To see usage for a given mode, use %s <mode> -h\n", os.Args[0])
} }
+1
View File
@@ -23,6 +23,7 @@ func Test_help(t *testing.T) {
" " + signSummary() + "\n" + " " + signSummary() + "\n" +
" " + printSummary() + "\n" + " " + printSummary() + "\n" +
" " + verifySummary() + "\n" + " " + verifySummary() + "\n" +
" " + pqExtendSummary() + "\n" +
"\n" + "\n" +
" To see usage for a given mode, use " + os.Args[0] + " <mode> -h\n" " To see usage for a given mode, use " + os.Args[0] + " <mode> -h\n"
+272
View File
@@ -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
View File
@@ -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
View File
@@ -44,7 +44,7 @@ type signFlags struct {
func newSignFlags() *signFlags { func newSignFlags() *signFlags {
sf := signFlags{set: flag.NewFlagSet("sign", flag.ContinueOnError)} sf := signFlags{set: flag.NewFlagSet("sign", flag.ContinueOnError)}
sf.set.Usage = func() {} 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.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.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") 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) version := cert.Version(*sf.version)
if version != 0 && version != cert.Version1 && version != cert.Version2 { if version != 0 && version != cert.Version1 && version != cert.Version2 && version != cert.Version3 {
return newHelpErrorf("-version must be either %v or %v", cert.Version1, cert.Version2) return newHelpErrorf("-version must be %v, %v or %v", cert.Version1, cert.Version2, cert.Version3)
} }
if *sf.outKeyPath == "" { 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) return fmt.Errorf("error while reading ca-key: %s", err)
} }
// naively attempt to decode the private key as though it is not encrypted curve, caKey, _, err = readSigningKey(rawCAKey, errOut, pr)
caKey, _, curve, err = cert.UnmarshalSigningPrivateKeyFromPEM(rawCAKey) if err != nil {
if errors.Is(err, cert.ErrPrivateKeyEncrypted) { return err
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)
} }
} }
@@ -197,6 +169,19 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
version = caCert.Version() 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 no duration is given, expire one second before the root expires
if *sf.duration <= 0 { if *sf.duration <= 0 {
*sf.duration = time.Until(caCert.NotAfter()) - time.Second*1 *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 { if pubCurve != curve {
return fmt.Errorf("curve of in-pub does not match ca") 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 { } else if isP11 {
pub, err = p11Client.GetPubKey() pub, err = p11Client.GetPubKey()
if err != nil { if err != nil {
@@ -294,6 +285,12 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
} }
} else { } else {
pub, rawPriv = newKeypair(curve) pub, rawPriv = newKeypair(curve)
if version == cert.Version3 {
pub, rawPriv, err = appendPQKey(pub, rawPriv)
if err != nil {
return err
}
}
} }
if !isStdio(*sf.outCertPath) { if !isStdio(*sf.outCertPath) {
@@ -378,6 +375,27 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
} }
crts = append(crts, nc) 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: default:
// this should be unreachable // this should be unreachable
return fmt.Errorf("invalid version: %d", version) return fmt.Errorf("invalid version: %d", version)
@@ -470,3 +488,42 @@ func signHelp(out io.Writer) {
sf.set.SetOutput(out) sf.set.SetOutput(out)
sf.set.PrintDefaults() 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
View File
@@ -55,7 +55,7 @@ func Test_signHelp(t *testing.T) {
" -unsafe-networks string\n"+ " -unsafe-networks string\n"+
" \tOptional: comma separated list of ip address and network in CIDR notation. Unsafe networks this cert can route for\n"+ " \tOptional: comma separated list of ip address and network in CIDR notation. Unsafe networks this cert can route for\n"+
" -version uint\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(), ob.String(),
) )
} }