mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
252 lines
8.1 KiB
Go
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())
|
|
}
|