cert: Name the post-quantum algorithms of a version 3 certificate with a PQSuite, like its curve, and pick the hybrid subtype by the suite's KEM

This commit is contained in:
JackDoan committed 2026-10-14 14:40:52 -05:00
1 parent 84e28c62ea
commit 656164b259
29 files changed
+1124 -365

No files matched your search

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