Files
nebula/cert/cert_v3_test.go

494 lines
18 KiB
Go

package cert
import (
"crypto/mldsa"
"crypto/mlkem"
"net/netip"
"testing"
"time"
"github.com/slackhq/nebula/cert/p256"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/cryptobyte/asn1"
)
var testCurves = []Curve{Curve_CURVE25519, Curve_P256}
func newTestV3(t *testing.T, curve Curve) (ca Certificate, caKey []byte, host Certificate, hostKey []byte) {
t.Helper()
ca, _, caKey, _ = NewTestCaCert(Version3, curve, time.Time{}, time.Time{}, nil, nil, []string{"g"})
host, _, hostKeyPEM, _ := NewTestCert(Version3, curve, ca, caKey, "host", time.Time{}, time.Time{}, []netip.Prefix{netip.MustParsePrefix("10.1.0.1/16")}, nil, []string{"g"})
hostKey, _, _, err := UnmarshalPrivateKeyFromPEM(hostKeyPEM)
require.NoError(t, err)
return ca, caKey, host, hostKey
}
func TestCertificateV3_Sign(t *testing.T) {
for _, curve := range testCurves {
t.Run(curve.String(), func(t *testing.T) {
ca, caKey, host, hostKey := newTestV3(t, curve)
assert.Equal(t, Version3, ca.Version())
assert.Equal(t, Version3, host.Version())
assert.True(t, ca.CheckSignature(ca.PublicKey()))
assert.True(t, host.CheckSignature(ca.PublicKey()))
require.NoError(t, ca.VerifyPrivateKey(curve, caKey))
require.NoError(t, host.VerifyPrivateKey(curve, hostKey))
// The classical half of the key alone is not the key.
require.Error(t, host.VerifyPrivateKey(curve, hostKey[:len(hostKey)-MLKEM1024PrivateKeySize]))
// The issuer is the v2 form of the CA, which old peers know it by.
caV2Fp, err := V2Form(ca).Fingerprint()
require.NoError(t, err)
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, 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)
})
}
}
// The v2 form must be exactly the certificate the CA's v2 form would have signed, so peers that only speak v2
// accept it. Ed25519 signatures are deterministic, so the bytes can be compared outright.
func TestCertificateV3_V2FormIsExact(t *testing.T) {
ca, caKey, host, _ := newTestV3(t, Curve_CURVE25519)
classicalCAKey := caKey[:len(caKey)-MLDSA87PrivateKeySize]
classicalPub := V2Form(host).PublicKey()
tbs := &TBSCertificate{
Version: Version2,
Curve: Curve_CURVE25519,
Name: host.Name(),
Networks: host.Networks(),
Groups: host.Groups(),
NotBefore: host.NotBefore(),
NotAfter: host.NotAfter(),
PublicKey: classicalPub,
}
v2, err := tbs.Sign(V2Form(ca), Curve_CURVE25519, classicalCAKey)
require.NoError(t, err)
want, err := v2.Marshal()
require.NoError(t, err)
got, err := V2Form(host).Marshal()
require.NoError(t, err)
assert.Equal(t, want, got)
wantHS, err := v2.MarshalForHandshakes()
require.NoError(t, err)
gotHS, err := V2Form(host).MarshalForHandshakes()
require.NoError(t, err)
assert.Equal(t, wantHS, gotHS)
assert.Nil(t, V2Form(v2))
}
func TestCertificateV3_MarshalRoundTrip(t *testing.T) {
for _, curve := range testCurves {
t.Run(curve.String(), func(t *testing.T) {
ca, _, host, _ := newTestV3(t, curve)
for _, c := range []Certificate{ca, host} {
b, err := c.Marshal()
require.NoError(t, err)
c2, err := unmarshalCertificateV3(b, nil, Curve_CURVE25519)
require.NoError(t, err)
assertCertsEqual(t, c, c2)
p, err := c.MarshalPEM()
require.NoError(t, err)
c3, rest, err := UnmarshalCertificateFromPEM(p)
require.NoError(t, err)
assert.Empty(t, rest)
assertCertsEqual(t, c, c3)
// Handshakes carry the composite public key in the noise static, not in the certificate.
hs, err := c.MarshalForHandshakes()
require.NoError(t, err)
assert.Less(t, len(hs), len(b)-len(c.PublicKey()))
c4, err := Recombine(Version3, hs, c.PublicKey(), curve)
require.NoError(t, err)
assertCertsEqual(t, c, c4)
_, err = Recombine(Version3, b, c.PublicKey(), curve)
require.ErrorIs(t, err, ErrCertPubkeyPresent)
}
})
}
}
func assertCertsEqual(t *testing.T, want, got Certificate) {
t.Helper()
wb, err := want.Marshal()
require.NoError(t, err)
gb, err := got.Marshal()
require.NoError(t, err)
assert.Equal(t, wb, gb)
wf, err := want.Fingerprint()
require.NoError(t, err)
gf, err := got.Fingerprint()
require.NoError(t, err)
assert.Equal(t, wf, gf)
assert.Equal(t, want.PublicKey(), got.PublicKey())
}
// rebuildV3 re-encodes a v3 certificate's envelope after edit has had a go at its fields.
func rebuildV3(t *testing.T, c Certificate, edit func(fields map[asn1.Tag][]byte, order *[]asn1.Tag)) []byte {
t.Helper()
b, err := c.Marshal()
require.NoError(t, err)
in := cryptobyte.String(b)
var body cryptobyte.String
require.True(t, in.ReadASN1(&body, asn1.SEQUENCE))
fields := map[asn1.Tag][]byte{}
var order []asn1.Tag
for !body.Empty() {
var el cryptobyte.String
var tag asn1.Tag
require.True(t, body.ReadAnyASN1Element(&el, &tag))
fields[tag] = el
order = append(order, tag)
}
edit(fields, &order)
var out cryptobyte.Builder
out.AddASN1(asn1.SEQUENCE, func(b *cryptobyte.Builder) {
for _, tag := range order {
b.AddBytes(fields[tag])
}
})
return out.BytesOrPanic()
}
func TestCertificateV3_StrictParse(t *testing.T) {
_, _, host, _ := newTestV3(t, Curve_CURVE25519)
good, err := host.Marshal()
require.NoError(t, err)
_, err = unmarshalCertificateV3(append(good, 0), nil, Curve_CURVE25519)
require.ErrorIs(t, err, ErrBadFormat, "trailing bytes after the envelope")
trailing := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) {
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{TagCertPQSuite, TagCertPQPublicKey, TagCertPQSignature} {
missing := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) {
*order = removeTag(*order, tag)
})
_, err = unmarshalCertificateV3(missing, nil, Curve_CURVE25519)
require.Error(t, err, "missing %x", tag)
}
// A v2 certificate is not a v3 one.
v2, err := V2Form(host).Marshal()
require.NoError(t, err)
_, err = unmarshalCertificateV3(v2, nil, Curve_CURVE25519)
require.Error(t, err)
// A wrong sized post-quantum key fails validation.
short := rebuildV3(t, host, func(fields map[asn1.Tag][]byte, order *[]asn1.Tag) {
var b cryptobyte.Builder
b.AddASN1(TagCertPQPublicKey, func(b *cryptobyte.Builder) { b.AddBytes(make([]byte, MLKEM1024PublicKeySize-1)) })
fields[TagCertPQPublicKey] = b.BytesOrPanic()
})
_, 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 {
var out []asn1.Tag
for _, t := range order {
if t != tag {
out = append(out, t)
}
}
return out
}
func TestCertificateV3_Tamper(t *testing.T) {
for _, curve := range testCurves {
t.Run(curve.String(), func(t *testing.T) {
ca, _, host, _ := newTestV3(t, curve)
// Swapping the post-quantum key breaks only the post-quantum signature, which must still catch it.
dk, err := mlkem.GenerateKey1024()
require.NoError(t, err)
swapped := host.Copy().(*certificateV3)
swapped.pqPublicKey = dk.EncapsulationKey().Bytes()
assert.True(t, V2Form(swapped).CheckSignature(V2Form(ca).PublicKey()))
assert.False(t, swapped.CheckSignature(ca.PublicKey()))
// The post-quantum signature covers the classical one.
resigned := host.Copy().(*certificateV3)
if curve == Curve_P256 {
sig, err := p256.Swap(resigned.v2.signature)
require.NoError(t, err)
resigned.v2.signature = sig
assert.True(t, V2Form(resigned).CheckSignature(V2Form(ca).PublicKey()))
} else {
resigned.v2.signature[0] ^= 1
}
assert.False(t, resigned.CheckSignature(ca.PublicKey()))
// Another CA's post-quantum key does not verify.
other, _, _, _ := NewTestCaCert(Version3, curve, time.Time{}, time.Time{}, nil, nil, nil)
k := append(V2Form(ca).PublicKey(), other.PublicKey()[len(V2Form(other).PublicKey()):]...)
assert.False(t, host.CheckSignature(k))
})
}
}
func TestCAPool_V3(t *testing.T) {
for _, curve := range testCurves {
t.Run(curve.String(), func(t *testing.T) {
ca, _, host, _ := newTestV3(t, curve)
now := time.Now()
// A v3 CA verifies v3 certificates, and only those.
pool := NewCAPool()
require.NoError(t, pool.AddCA(ca))
cc, err := pool.VerifyCertificate(now, host)
require.NoError(t, err)
require.NoError(t, pool.VerifyCachedCertificate(now, cc))
_, err = pool.VerifyCertificate(now, V2Form(host))
require.ErrorIs(t, err, ErrCaNotFound)
// Listing the v2 form of the CA as well accepts the v2 form of the certificate.
require.NoError(t, pool.AddCA(V2Form(ca)))
_, err = pool.VerifyCertificate(now, V2Form(host))
require.NoError(t, err)
_, err = pool.VerifyCertificate(now, host)
require.NoError(t, err)
// The v2 form of the CA alone does not verify a v3 certificate.
v2Pool := NewCAPool()
require.NoError(t, v2Pool.AddCA(V2Form(ca)))
_, err = v2Pool.VerifyCertificate(now, host)
require.ErrorIs(t, err, ErrCaNotFound)
// A second v3 extension of the same v2 CA is ambiguous.
sk, err := mldsa.GenerateKey(mldsa.MLDSA87())
require.NoError(t, err)
signPQ, err := SignPQ(DefaultPQSuite, sk.Bytes())
require.NoError(t, err)
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")
})
}
}
func TestCAPool_V3Blocklist(t *testing.T) {
for _, curve := range testCurves {
t.Run(curve.String(), func(t *testing.T) {
ca, _, host, _ := newTestV3(t, curve)
now := time.Now()
pool := NewCAPool()
require.NoError(t, pool.AddCA(ca))
require.NoError(t, pool.AddCA(V2Form(ca)))
cc, err := pool.VerifyCertificate(now, host)
require.NoError(t, err)
fp, err := host.Fingerprint()
require.NoError(t, err)
v2Fp, err := V2Form(host).Fingerprint()
require.NoError(t, err)
assert.NotEqual(t, fp, v2Fp)
// Blocking the v3 fingerprint leaves the v2 form alone, which is why operators list both.
pool.BlocklistFingerprint(fp)
_, err = pool.VerifyCertificate(now, host)
require.ErrorIs(t, err, ErrBlockListed)
require.ErrorIs(t, pool.VerifyCachedCertificate(now, cc), ErrBlockListed)
_, err = pool.VerifyCertificate(now, V2Form(host))
require.NoError(t, err)
// Blocking the v2 fingerprint blocks both.
pool.ResetCertBlocklist()
pool.BlocklistFingerprint(v2Fp)
_, err = pool.VerifyCertificate(now, host)
require.ErrorIs(t, err, ErrBlockListed)
require.ErrorIs(t, pool.VerifyCachedCertificate(now, cc), ErrBlockListed)
_, err = pool.VerifyCertificate(now, V2Form(host))
require.ErrorIs(t, err, ErrBlockListed)
if curve == Curve_P256 {
// So does the other form of a P256 v2 signature.
alt, err := CalculateAlternateFingerprint(V2Form(host))
require.NoError(t, err)
pool.ResetCertBlocklist()
pool.BlocklistFingerprint(alt)
_, err = pool.VerifyCertificate(now, host)
require.ErrorIs(t, err, ErrBlockListed)
}
})
}
}
// Certificates already in use extend in place: their v2 forms, and fingerprints, do not change.
func TestExtendToV3(t *testing.T) {
for _, curve := range testCurves {
t.Run(curve.String(), func(t *testing.T) {
ca2, _, caKey2, _ := NewTestCaCert(Version2, curve, time.Time{}, time.Time{}, nil, nil, nil)
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(DefaultPQSuite, sk.Bytes())
require.NoError(t, err)
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, DefaultPQSuite, dk.EncapsulationKey().Bytes(), ca3, signPQ)
require.NoError(t, err)
assertCertsEqual(t, host2, V2Form(host3))
pool := NewCAPool()
require.NoError(t, pool.AddCA(ca3))
_, err = pool.VerifyCertificate(time.Now(), host3)
require.NoError(t, err)
// 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, DefaultPQSuite, sk.PublicKey().Bytes(), nil, signPQ)
require.NoError(t, err)
_, 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(DefaultPQSuite, wrong.Bytes())
require.NoError(t, err)
_, 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, DefaultPQSuite, sk.PublicKey().Bytes(), nil, wrongSign)
require.ErrorIs(t, err, ErrSignatureMismatch)
_, err = ExtendToV3(ca2, DefaultPQSuite, sk.PublicKey().Bytes(), ca3, signPQ)
require.Error(t, err)
// Only v2 certificates extend.
_, err = ExtendToV3(host3, DefaultPQSuite, dk.EncapsulationKey().Bytes(), ca3, signPQ)
require.Error(t, err)
})
}
}
// ML-DSA-87 signing is deterministic, so extending a certificate twice gives the same bytes and the same fingerprint:
// pq-extend is idempotent, and a CA extended again is not a second v3 CA to the pool.
func TestExtendToV3_Deterministic(t *testing.T) {
for _, curve := range testCurves {
t.Run(curve.String(), func(t *testing.T) {
ca2, _, caKey2, _ := NewTestCaCert(Version2, curve, time.Time{}, time.Time{}, nil, nil, nil)
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(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, DefaultPQSuite, pq, signer, signPQ)
require.NoError(t, err)
b, err := c3.Marshal()
require.NoError(t, err)
fp, err := c3.Fingerprint()
require.NoError(t, err)
return b, fp, c3
}
caBytesA, caFpA, caA := extend(ca2, sk.PublicKey().Bytes(), nil)
caBytesB, caFpB, caB := extend(ca2, sk.PublicKey().Bytes(), nil)
assert.Equal(t, caBytesA, caBytesB)
assert.Equal(t, caFpA, caFpB)
hostBytesA, hostFpA, _ := extend(host2, dk.EncapsulationKey().Bytes(), caA)
hostBytesB, hostFpB, _ := extend(host2, dk.EncapsulationKey().Bytes(), caB)
assert.Equal(t, hostBytesA, hostBytesB)
assert.Equal(t, hostFpA, hostFpB)
pool := NewCAPool()
require.NoError(t, pool.AddCA(caA))
require.NoError(t, pool.AddCA(caB))
})
}
}
func TestCertificateV3_KeyPEM(t *testing.T) {
for _, curve := range testCurves {
t.Run(curve.String(), func(t *testing.T) {
ca, caKey, host, hostKey := newTestV3(t, curve)
pub, rest, gotCurve, err := UnmarshalPublicKeyFromPEM(host.MarshalPublicKeyPEM())
require.NoError(t, err)
assert.Empty(t, rest)
assert.Equal(t, curve, gotCurve)
assert.Equal(t, host.PublicKey(), pub)
pub, _, gotCurve, err = UnmarshalSigningPublicKeyFromPEM(ca.MarshalPublicKeyPEM())
require.NoError(t, err)
assert.Equal(t, curve, gotCurve)
assert.Equal(t, ca.PublicKey(), pub)
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(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 := EncryptAndMarshalPQSigningPrivateKey(curve, DefaultPQSuite, caKey, []byte("pw"), kdf)
require.NoError(t, err)
_, _, _, err = UnmarshalSigningPrivateKeyFromPEM(enc)
require.ErrorIs(t, err, ErrPrivateKeyEncrypted)
gotCurve, key, _, err = DecryptAndUnmarshalSigningPrivateKey([]byte("pw"), enc)
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)
})
}
}