Files
coder/coderd/cryptokeys/ca_internal_test.go
T

252 lines
8.1 KiB
Go

package cryptokeys
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestCASecretRoundTrip(t *testing.T) {
t.Parallel()
// The certificate's NotAfter must track the supplied keyDuration, not a
// hardcoded default, so a CA stays valid for as long as it can be the
// active signer plus the longest leaf it signs.
for _, keyDuration := range []time.Duration{DefaultKeyDuration, DefaultKeyDuration * 3, time.Hour} {
now := time.Now().UTC().Truncate(time.Second)
secret, err := generateCASecret(now, keyDuration)
require.NoError(t, err)
cert, signer, err := parseCASecret(secret)
require.NoError(t, err)
require.True(t, cert.IsCA)
require.True(t, cert.BasicConstraintsValid)
require.True(t, cert.MaxPathLenZero)
require.Equal(t, x509.KeyUsageCertSign, cert.KeyUsage)
require.Equal(t, now.Add(-clockSkewTolerance), cert.NotBefore)
require.Equal(t, now.Add(keyDuration+NATSCAOverlap), cert.NotAfter)
require.Equal(t, cert.PublicKey, signer.Public())
// The cert must outlive its active-signer window so leaves signed at
// the end of that window still chain to a valid CA.
require.True(t, cert.NotAfter.After(now.Add(keyDuration)),
"cert must remain valid past the end of its active-signer window")
// The cert must be able to verify itself as a trust root.
pool := x509.NewCertPool()
pool.AddCert(cert)
_, err = cert.Verify(x509.VerifyOptions{Roots: pool})
require.NoError(t, err)
}
}
func TestParseCASecretErrors(t *testing.T) {
t.Parallel()
now := time.Now()
secretA, err := generateCASecret(now, DefaultKeyDuration)
require.NoError(t, err)
secretB, err := generateCASecret(now, DefaultKeyDuration)
require.NoError(t, err)
certA, keyA := splitCAPEM(t, secretA)
_, keyB := splitCAPEM(t, secretB)
nonCACert, nonCAKey := generateNonCAPEM(t, now)
cases := []struct {
name string
secret string
errText string
}{
{"Empty", "", "no certificate"},
{"NotPEM", "not pem at all", "no certificate"},
{"CertOnly", string(certA), "no private key"},
{"KeyCertMismatch", string(certA) + string(keyB), "does not match certificate"},
{"MultipleCertificates", string(certA) + string(certA) + string(keyA), "multiple certificates"},
{"MultiplePrivateKeys", string(certA) + string(keyA) + string(keyA), "multiple private keys"},
{"UnexpectedBlockType", string(pemBlock("RSA PRIVATE KEY", []byte("x"))), "unexpected PEM block type"},
{"BadCertificateBytes", string(pemBlock(caCertPEMBlockType, []byte("garbage"))), "parse certificate"},
{"BadPrivateKeyBytes", string(certA) + string(pemBlock(caKeyPEMBlockType, []byte("garbage"))), "parse private key"},
{"NotASigningCA", string(nonCACert) + string(nonCAKey), "not a valid signing CA"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, _, err := parseCASecret(tc.secret)
require.ErrorContains(t, err, tc.errText)
})
}
}
// splitCAPEM splits a CA secret bundle into its certificate and private key
// PEM blocks so tests can recombine them into malformed bundles.
func splitCAPEM(t *testing.T, secret string) (certPEM, keyPEM []byte) {
t.Helper()
rest := []byte(secret)
for {
block, r := pem.Decode(rest)
if block == nil {
break
}
rest = r
switch block.Type {
case caCertPEMBlockType:
certPEM = pem.EncodeToMemory(block)
case caKeyPEMBlockType:
keyPEM = pem.EncodeToMemory(block)
}
}
require.NotNil(t, certPEM)
require.NotNil(t, keyPEM)
return certPEM, keyPEM
}
func pemBlock(blockType string, der []byte) []byte {
return pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der})
}
// generateNonCAPEM produces a structurally valid cert+key bundle whose
// certificate is not a CA (no IsCA, no KeyUsageCertSign). The key matches the
// cert, so it passes every parseCASecret check except the signing-CA check.
func generateNonCAPEM(t *testing.T, now time.Time) (certPEM, keyPEM []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "not-a-ca"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
require.NoError(t, err)
keyDER, err := x509.MarshalECPrivateKey(key)
require.NoError(t, err)
return pemBlock(caCertPEMBlockType, der), pemBlock(caKeyPEMBlockType, keyDER)
}
// TestNATSCASigningCache exercises the nats_ca feature through the generic
// signing key cache: the PEM secret decodes into a *NATSCA, SigningKey serves
// the active CA, VerifyingKey serves a specific CA by sequence, and a rotation
// is picked up on the next refresh.
func TestNATSCASigningCache(t *testing.T) {
t.Parallel()
t.Run("ActiveAndVerifyingByID", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
now := time.Now().UTC()
current := dbgen.CryptoKey(t, db, database.CryptoKey{
Feature: database.CryptoKeyFeatureNATSCA,
Sequence: 1,
StartsAt: now.Add(-time.Hour),
})
cache, err := NewSigningCache(ctx, testutil.Logger(t), &DBFetcher{DB: db}, codersdk.CryptoKeyFeatureNATSCA)
require.NoError(t, err)
defer cache.Close()
id, key, err := cache.SigningKey(ctx)
require.NoError(t, err)
ca, ok := key.(*NATSCA)
require.True(t, ok, "signing key should decode to *NATSCA, got %T", key)
require.Equal(t, current.Sequence, ca.Sequence)
require.NotNil(t, ca.Cert)
require.NotNil(t, ca.Key)
currentCert, _, err := parseCASecret(current.Secret.String)
require.NoError(t, err)
require.Equal(t, currentCert.Raw, ca.Cert.Raw)
// VerifyingKey looks the CA up by the sequence embedded in id, which is
// how a peer leaf minted under this CA is verified.
verifying, err := cache.VerifyingKey(ctx, id)
require.NoError(t, err)
vca, ok := verifying.(*NATSCA)
require.True(t, ok, "verifying key should decode to *NATSCA, got %T", verifying)
require.Equal(t, currentCert.Raw, vca.Cert.Raw)
})
t.Run("RefreshesOnRotation", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
clock := quartz.NewMock(t)
now := dbtime.Now()
clock.Set(now)
first := dbgen.CryptoKey(t, db, database.CryptoKey{
Feature: database.CryptoKeyFeatureNATSCA,
Sequence: 1,
StartsAt: now.Add(-time.Hour),
})
cache, err := NewSigningCache(ctx, testutil.Logger(t), &DBFetcher{DB: db}, codersdk.CryptoKeyFeatureNATSCA, WithCacheClock(clock))
require.NoError(t, err)
defer cache.Close()
_, key, err := cache.SigningKey(ctx)
require.NoError(t, err)
require.Equal(t, first.Sequence, key.(*NATSCA).Sequence)
// Simulate a rotation by inserting a higher-sequence active CA. The old
// CA stays valid for verification by its sequence.
second := dbgen.CryptoKey(t, db, database.CryptoKey{
Feature: database.CryptoKeyFeatureNATSCA,
Sequence: 2,
StartsAt: now.Add(-time.Minute),
})
// Fire the background refresher; the active CA advances to the new row.
clock.Advance(refreshInterval).MustWait(ctx)
_, key, err = cache.SigningKey(ctx)
require.NoError(t, err)
require.Equal(t, second.Sequence, key.(*NATSCA).Sequence)
oldVerifying, err := cache.VerifyingKey(ctx, "1")
require.NoError(t, err)
require.Equal(t, first.Sequence, oldVerifying.(*NATSCA).Sequence)
})
}
func TestNoopSigningKeycache(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
var cache SigningKeycache = NoopSigningKeycache{}
_, _, err := cache.SigningKey(ctx)
require.ErrorIs(t, err, ErrKeyNotFound)
_, err = cache.VerifyingKey(ctx, "1")
require.ErrorIs(t, err, ErrKeyNotFound)
require.NoError(t, cache.Close())
}