feat: add cache abstraction for fetching signing keys (#14777)

- Adds the database implementation for fetching and caching keys
used for JWT signing. It's been merged into the `keyrotate` pkg and
renamed to `cryptokeys` since they're coupled concepts.
This commit is contained in:
Jon Ayers
2024-10-01 11:04:51 -05:00
committed by GitHub
parent f7ddbb744f
commit 21b92ef893
18 changed files with 1060 additions and 178 deletions
+36 -34
View File
@@ -9848,6 +9848,41 @@ const docTemplate = `{
}
}
},
"codersdk.CryptoKey": {
"type": "object",
"properties": {
"deletes_at": {
"type": "string",
"format": "date-time"
},
"feature": {
"$ref": "#/definitions/codersdk.CryptoKeyFeature"
},
"secret": {
"type": "string"
},
"sequence": {
"type": "integer"
},
"starts_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.CryptoKeyFeature": {
"type": "string",
"enum": [
"workspace_apps",
"oidc_convert",
"tailnet_resume"
],
"x-enum-varnames": [
"CryptoKeyFeatureWorkspaceApp",
"CryptoKeyFeatureOIDCConvert",
"CryptoKeyFeatureTailnetResume"
]
},
"codersdk.CustomRoleRequest": {
"type": "object",
"properties": {
@@ -15980,46 +16015,13 @@ const docTemplate = `{
}
}
},
"wsproxysdk.CryptoKey": {
"type": "object",
"properties": {
"deletes_at": {
"type": "string"
},
"feature": {
"$ref": "#/definitions/wsproxysdk.CryptoKeyFeature"
},
"secret": {
"type": "string"
},
"sequence": {
"type": "integer"
},
"starts_at": {
"type": "string"
}
}
},
"wsproxysdk.CryptoKeyFeature": {
"type": "string",
"enum": [
"workspace_apps",
"oidc_convert",
"tailnet_resume"
],
"x-enum-varnames": [
"CryptoKeyFeatureWorkspaceApp",
"CryptoKeyFeatureOIDCConvert",
"CryptoKeyFeatureTailnetResume"
]
},
"wsproxysdk.CryptoKeysResponse": {
"type": "object",
"properties": {
"crypto_keys": {
"type": "array",
"items": {
"$ref": "#/definitions/wsproxysdk.CryptoKey"
"$ref": "#/definitions/codersdk.CryptoKey"
}
}
}
+32 -30
View File
@@ -8768,6 +8768,37 @@
}
}
},
"codersdk.CryptoKey": {
"type": "object",
"properties": {
"deletes_at": {
"type": "string",
"format": "date-time"
},
"feature": {
"$ref": "#/definitions/codersdk.CryptoKeyFeature"
},
"secret": {
"type": "string"
},
"sequence": {
"type": "integer"
},
"starts_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.CryptoKeyFeature": {
"type": "string",
"enum": ["workspace_apps", "oidc_convert", "tailnet_resume"],
"x-enum-varnames": [
"CryptoKeyFeatureWorkspaceApp",
"CryptoKeyFeatureOIDCConvert",
"CryptoKeyFeatureTailnetResume"
]
},
"codersdk.CustomRoleRequest": {
"type": "object",
"properties": {
@@ -14611,42 +14642,13 @@
}
}
},
"wsproxysdk.CryptoKey": {
"type": "object",
"properties": {
"deletes_at": {
"type": "string"
},
"feature": {
"$ref": "#/definitions/wsproxysdk.CryptoKeyFeature"
},
"secret": {
"type": "string"
},
"sequence": {
"type": "integer"
},
"starts_at": {
"type": "string"
}
}
},
"wsproxysdk.CryptoKeyFeature": {
"type": "string",
"enum": ["workspace_apps", "oidc_convert", "tailnet_resume"],
"x-enum-varnames": [
"CryptoKeyFeatureWorkspaceApp",
"CryptoKeyFeatureOIDCConvert",
"CryptoKeyFeatureTailnetResume"
]
},
"wsproxysdk.CryptoKeysResponse": {
"type": "object",
"properties": {
"crypto_keys": {
"type": "array",
"items": {
"$ref": "#/definitions/wsproxysdk.CryptoKey"
"$ref": "#/definitions/codersdk.CryptoKey"
}
}
}
+210
View File
@@ -0,0 +1,210 @@
package cryptokeys
import (
"context"
"sync"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/quartz"
)
// never represents the maximum value for a time.Duration.
const never = 1<<63 - 1
// DBCache implements Keycache for callers with access to the database.
type DBCache struct {
db database.Store
feature database.CryptoKeyFeature
logger slog.Logger
clock quartz.Clock
// The following are initialized by NewDBCache.
keysMu sync.RWMutex
keys map[int32]database.CryptoKey
latestKey database.CryptoKey
timer *quartz.Timer
// invalidateAt is the time at which the keys cache should be invalidated.
invalidateAt time.Time
closed bool
}
type DBCacheOption func(*DBCache)
func WithDBCacheClock(clock quartz.Clock) DBCacheOption {
return func(d *DBCache) {
d.clock = clock
}
}
// NewDBCache creates a new DBCache. Close should be called to
// release resources associated with its internal timer.
func NewDBCache(logger slog.Logger, db database.Store, feature database.CryptoKeyFeature, opts ...func(*DBCache)) *DBCache {
d := &DBCache{
db: db,
feature: feature,
clock: quartz.NewReal(),
logger: logger,
}
for _, opt := range opts {
opt(d)
}
d.timer = d.clock.AfterFunc(never, d.clear)
return d
}
// Verifying returns the CryptoKey with the given sequence number, provided that
// it is neither deleted nor has breached its deletion date. It should only be
// used for verifying or decrypting payloads. To sign/encrypt call Signing.
func (d *DBCache) Verifying(ctx context.Context, sequence int32) (codersdk.CryptoKey, error) {
d.keysMu.RLock()
if d.closed {
d.keysMu.RUnlock()
return codersdk.CryptoKey{}, ErrClosed
}
now := d.clock.Now()
key, ok := d.keys[sequence]
d.keysMu.RUnlock()
if ok {
return checkKey(key, now)
}
d.keysMu.Lock()
defer d.keysMu.Unlock()
if d.closed {
return codersdk.CryptoKey{}, ErrClosed
}
key, ok = d.keys[sequence]
if ok {
return checkKey(key, now)
}
err := d.fetch(ctx)
if err != nil {
return codersdk.CryptoKey{}, xerrors.Errorf("fetch: %w", err)
}
key, ok = d.keys[sequence]
if !ok {
return codersdk.CryptoKey{}, ErrKeyNotFound
}
return checkKey(key, now)
}
// Signing returns the latest valid key for signing. A valid key is one that is
// both past its start time and before its deletion time.
func (d *DBCache) Signing(ctx context.Context) (codersdk.CryptoKey, error) {
d.keysMu.RLock()
if d.closed {
d.keysMu.RUnlock()
return codersdk.CryptoKey{}, ErrClosed
}
latest := d.latestKey
d.keysMu.RUnlock()
now := d.clock.Now()
if latest.CanSign(now) {
return db2sdk.CryptoKey(latest), nil
}
d.keysMu.Lock()
defer d.keysMu.Unlock()
if d.closed {
return codersdk.CryptoKey{}, ErrClosed
}
if d.latestKey.CanSign(now) {
return db2sdk.CryptoKey(d.latestKey), nil
}
// Refetch all keys for this feature so we can find the latest valid key.
err := d.fetch(ctx)
if err != nil {
return codersdk.CryptoKey{}, xerrors.Errorf("fetch: %w", err)
}
return db2sdk.CryptoKey(d.latestKey), nil
}
// clear invalidates the cache. This forces the subsequent call to fetch fresh keys.
func (d *DBCache) clear() {
now := d.clock.Now("DBCache", "clear")
d.keysMu.Lock()
defer d.keysMu.Unlock()
// Check if we raced with a fetch. It's possible that the timer fired and we
// lost the race to the mutex. We want to avoid invalidating
// a cache that was just refetched.
if now.Before(d.invalidateAt) {
return
}
d.keys = nil
d.latestKey = database.CryptoKey{}
}
// fetch fetches all keys for the given feature and determines the latest key.
// It must be called while holding the keysMu lock.
func (d *DBCache) fetch(ctx context.Context) error {
keys, err := d.db.GetCryptoKeysByFeature(ctx, d.feature)
if err != nil {
return xerrors.Errorf("get crypto keys by feature: %w", err)
}
now := d.clock.Now()
_ = d.timer.Reset(time.Minute * 10)
d.invalidateAt = now.Add(time.Minute * 10)
cache := make(map[int32]database.CryptoKey)
var latest database.CryptoKey
for _, key := range keys {
cache[key.Sequence] = key
if key.CanSign(now) && key.Sequence > latest.Sequence {
latest = key
}
}
if len(cache) == 0 {
return ErrKeyNotFound
}
if !latest.CanSign(now) {
return ErrKeyInvalid
}
d.keys, d.latestKey = cache, latest
return nil
}
func checkKey(key database.CryptoKey, now time.Time) (codersdk.CryptoKey, error) {
if !key.CanVerify(now) {
return codersdk.CryptoKey{}, ErrKeyInvalid
}
return db2sdk.CryptoKey(key), nil
}
func (d *DBCache) Close() {
d.keysMu.Lock()
defer d.keysMu.Unlock()
if d.closed {
return
}
d.timer.Stop()
d.closed = true
}
@@ -0,0 +1,467 @@
package cryptokeys
import (
"database/sql"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func Test_Verifying(t *testing.T) {
t.Parallel()
t.Run("HitsCache", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
mockDB = dbmock.NewMockStore(ctrl)
clock = quartz.NewMock(t)
logger = slogtest.Make(t, nil)
ctx = testutil.Context(t, testutil.WaitShort)
)
expectedKey := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 32,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
}
cache := map[int32]database.CryptoKey{
32: expectedKey,
}
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
defer k.Close()
k.keys = cache
got, err := k.Verifying(ctx, 32)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(expectedKey), got)
})
t.Run("MissesCache", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
mockDB = dbmock.NewMockStore(ctrl)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
expectedKey := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 33,
StartsAt: clock.Now(),
Secret: sql.NullString{
String: "secret",
Valid: true,
},
}
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{expectedKey}, nil)
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
defer k.Close()
got, err := k.Verifying(ctx, 33)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(expectedKey), got)
require.Equal(t, db2sdk.CryptoKey(expectedKey), db2sdk.CryptoKey(k.latestKey))
})
t.Run("InvalidCachedKey", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
mockDB = dbmock.NewMockStore(ctrl)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
cache := map[int32]database.CryptoKey{
32: {
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 32,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
DeletesAt: sql.NullTime{
Time: clock.Now(),
Valid: true,
},
},
}
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
defer k.Close()
k.keys = cache
_, err := k.Verifying(ctx, 32)
require.ErrorIs(t, err, ErrKeyInvalid)
})
t.Run("InvalidDBKey", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
mockDB = dbmock.NewMockStore(ctrl)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
invalidKey := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 32,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
DeletesAt: sql.NullTime{
Time: clock.Now(),
Valid: true,
},
}
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{invalidKey}, nil)
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
defer k.Close()
_, err := k.Verifying(ctx, 32)
require.ErrorIs(t, err, ErrKeyInvalid)
})
}
func Test_Signing(t *testing.T) {
t.Parallel()
t.Run("HitsCache", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
mockDB = dbmock.NewMockStore(ctrl)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
latestKey := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 32,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
StartsAt: clock.Now(),
}
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
defer k.Close()
k.latestKey = latestKey
got, err := k.Signing(ctx)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(latestKey), got)
})
t.Run("InvalidCachedKey", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
mockDB = dbmock.NewMockStore(ctrl)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
latestKey := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 33,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
StartsAt: clock.Now(),
}
invalidKey := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 32,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
StartsAt: clock.Now().Add(-time.Hour),
DeletesAt: sql.NullTime{
Time: clock.Now(),
Valid: true,
},
}
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{latestKey}, nil)
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
defer k.Close()
k.latestKey = invalidKey
got, err := k.Signing(ctx)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(latestKey), got)
})
t.Run("UsesActiveKey", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
mockDB = dbmock.NewMockStore(ctrl)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
inactiveKey := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 32,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
StartsAt: clock.Now().Add(time.Hour),
}
activeKey := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 33,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
StartsAt: clock.Now(),
}
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{inactiveKey, activeKey}, nil)
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
defer k.Close()
got, err := k.Signing(ctx)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(activeKey), got)
})
t.Run("NoValidKeys", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
mockDB = dbmock.NewMockStore(ctrl)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
inactiveKey := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 32,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
StartsAt: clock.Now().Add(time.Hour),
}
invalidKey := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 33,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
StartsAt: clock.Now().Add(-time.Hour),
DeletesAt: sql.NullTime{
Time: clock.Now(),
Valid: true,
},
}
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{inactiveKey, invalidKey}, nil)
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
defer k.Close()
_, err := k.Signing(ctx)
require.ErrorIs(t, err, ErrKeyInvalid)
})
}
func Test_clear(t *testing.T) {
t.Parallel()
t.Run("InvalidatesCache", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
mockDB = dbmock.NewMockStore(ctrl)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
defer k.Close()
activeKey := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 33,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
StartsAt: clock.Now(),
}
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{activeKey}, nil)
_, err := k.Signing(ctx)
require.NoError(t, err)
dur, wait := clock.AdvanceNext()
wait.MustWait(ctx)
require.Equal(t, time.Minute*10, dur)
require.Len(t, k.keys, 0)
require.Equal(t, database.CryptoKey{}, k.latestKey)
})
t.Run("ResetsTimer", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
mockDB = dbmock.NewMockStore(ctrl)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
defer k.Close()
key := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 32,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
StartsAt: clock.Now(),
}
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{key}, nil)
// Advance it five minutes so that we can test that the
// timer is reset and doesn't fire after another five minute.
clock.Advance(time.Minute * 5)
latest, err := k.Signing(ctx)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(key), latest)
// Advancing the clock now should require 10 minutes
// before the timer fires again.
dur, wait := clock.AdvanceNext()
wait.MustWait(ctx)
require.Equal(t, time.Minute*10, dur)
require.Len(t, k.keys, 0)
require.Equal(t, database.CryptoKey{}, k.latestKey)
})
// InvalidateAt tests that we have accounted for the race condition where a
// timer fires to invalidate the cache at the same time we are fetching new
// keys. In such cases we want to skip invalidation.
t.Run("InvalidateAt", func(t *testing.T) {
t.Parallel()
var (
ctrl = gomock.NewController(t)
mockDB = dbmock.NewMockStore(ctrl)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
trap := clock.Trap().Now("clear")
k := NewDBCache(logger, mockDB, database.CryptoKeyFeatureWorkspaceApps, WithDBCacheClock(clock))
defer k.Close()
key := database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 32,
Secret: sql.NullString{
String: "secret",
Valid: true,
},
StartsAt: clock.Now(),
}
mockDB.EXPECT().GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps).Return([]database.CryptoKey{key}, nil).Times(2)
// Move us past the initial timer.
latest, err := k.Signing(ctx)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(key), latest)
// Null these out so that we refetch.
k.keys = nil
k.latestKey = database.CryptoKey{}
// Initiate firing the timer.
dur, wait := clock.AdvanceNext()
require.Equal(t, time.Minute*10, dur)
// Trap the function just before acquiring the mutex.
call := trap.MustWait(ctx)
// Refetch keys.
latest, err = k.Signing(ctx)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(key), latest)
// Let the rest of the timer function run.
// It should see that we have refetched keys and
// not invalidate.
call.Release()
wait.MustWait(ctx)
require.Len(t, k.keys, 1)
require.Equal(t, key, k.latestKey)
trap.Close()
// Refetching the keys should've instantiated a new timer. This one should invalidate keys.
_, wait = clock.AdvanceNext()
wait.MustWait(ctx)
require.Len(t, k.keys, 0)
require.Equal(t, database.CryptoKey{}, k.latestKey)
})
}
+143
View File
@@ -0,0 +1,143 @@
package cryptokeys_test
import (
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func TestDBKeyCache(t *testing.T) {
t.Parallel()
t.Run("Verifying", func(t *testing.T) {
t.Parallel()
t.Run("HitsCache", func(t *testing.T) {
t.Parallel()
var (
db, _ = dbtestutil.NewDB(t)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
key := dbgen.CryptoKey(t, db, database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 1,
StartsAt: clock.Now().UTC(),
})
k := cryptokeys.NewDBCache(logger, db, database.CryptoKeyFeatureWorkspaceApps, cryptokeys.WithDBCacheClock(clock))
defer k.Close()
got, err := k.Verifying(ctx, key.Sequence)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(key), got)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
var (
db, _ = dbtestutil.NewDB(t)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
k := cryptokeys.NewDBCache(logger, db, database.CryptoKeyFeatureWorkspaceApps, cryptokeys.WithDBCacheClock(clock))
defer k.Close()
_, err := k.Verifying(ctx, 123)
require.ErrorIs(t, err, cryptokeys.ErrKeyNotFound)
})
})
t.Run("Signing", func(t *testing.T) {
t.Parallel()
var (
db, _ = dbtestutil.NewDB(t)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
_ = dbgen.CryptoKey(t, db, database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 10,
StartsAt: clock.Now().UTC(),
})
expectedKey := dbgen.CryptoKey(t, db, database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 12,
StartsAt: clock.Now().UTC(),
})
_ = dbgen.CryptoKey(t, db, database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 2,
StartsAt: clock.Now().UTC(),
})
k := cryptokeys.NewDBCache(logger, db, database.CryptoKeyFeatureWorkspaceApps, cryptokeys.WithDBCacheClock(clock))
defer k.Close()
got, err := k.Signing(ctx)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(expectedKey), got)
})
t.Run("Closed", func(t *testing.T) {
t.Parallel()
var (
db, _ = dbtestutil.NewDB(t)
clock = quartz.NewMock(t)
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, nil)
)
expectedKey := dbgen.CryptoKey(t, db, database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
Sequence: 10,
StartsAt: clock.Now(),
})
k := cryptokeys.NewDBCache(logger, db, database.CryptoKeyFeatureWorkspaceApps, cryptokeys.WithDBCacheClock(clock))
defer k.Close()
got, err := k.Signing(ctx)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(expectedKey), got)
got, err = k.Verifying(ctx, expectedKey.Sequence)
require.NoError(t, err)
require.Equal(t, db2sdk.CryptoKey(expectedKey), got)
k.Close()
_, err = k.Signing(ctx)
require.ErrorIs(t, err, cryptokeys.ErrClosed)
_, err = k.Verifying(ctx, expectedKey.Sequence)
require.ErrorIs(t, err, cryptokeys.ErrClosed)
})
}
+21
View File
@@ -0,0 +1,21 @@
package cryptokeys
import (
"context"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
var (
ErrKeyNotFound = xerrors.New("key not found")
ErrKeyInvalid = xerrors.New("key is invalid for use")
ErrClosed = xerrors.New("closed")
)
// Keycache provides an abstraction for fetching signing keys.
type Keycache interface {
Signing(ctx context.Context) (codersdk.CryptoKey, error)
Verifying(ctx context.Context, sequence int32) (codersdk.CryptoKey, error)
}
@@ -1,4 +1,4 @@
package keyrotate
package cryptokeys
import (
"context"
@@ -36,15 +36,15 @@ type rotator struct {
features []database.CryptoKeyFeature
}
type Option func(*rotator)
type RotatorOption func(*rotator)
func WithClock(clock quartz.Clock) Option {
func WithClock(clock quartz.Clock) RotatorOption {
return func(r *rotator) {
r.clock = clock
}
}
func WithKeyDuration(keyDuration time.Duration) Option {
func WithKeyDuration(keyDuration time.Duration) RotatorOption {
return func(r *rotator) {
r.keyDuration = keyDuration
}
@@ -53,7 +53,7 @@ func WithKeyDuration(keyDuration time.Duration) Option {
// StartRotator starts a background process that rotates keys in the database.
// It ensures there's at least one valid key per feature prior to returning.
// Canceling the provided context will stop the background process.
func StartRotator(ctx context.Context, logger slog.Logger, db database.Store, opts ...Option) error {
func StartRotator(ctx context.Context, logger slog.Logger, db database.Store, opts ...RotatorOption) error {
kr := &rotator{
db: db,
logger: logger,
@@ -1,4 +1,4 @@
package keyrotate
package cryptokeys
import (
"database/sql"
@@ -1,4 +1,4 @@
package keyrotate_test
package cryptokeys_test
import (
"testing"
@@ -9,10 +9,10 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/cryptokeys"
"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/keyrotate"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
@@ -34,7 +34,7 @@ func TestRotator(t *testing.T) {
require.NoError(t, err)
require.Len(t, dbkeys, 0)
err = keyrotate.StartRotator(ctx, logger, db, keyrotate.WithClock(clock))
err = cryptokeys.StartRotator(ctx, logger, db, cryptokeys.WithClock(clock))
require.NoError(t, err)
// Fetch the keys from the database and ensure they
@@ -59,14 +59,14 @@ func TestRotator(t *testing.T) {
rotatingKey := dbgen.CryptoKey(t, db, database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
StartsAt: now.Add(-keyrotate.DefaultKeyDuration + time.Hour + time.Minute),
StartsAt: now.Add(-cryptokeys.DefaultKeyDuration + time.Hour + time.Minute),
Sequence: 12345,
})
trap := clock.Trap().TickerFunc()
t.Cleanup(trap.Close)
err := keyrotate.StartRotator(ctx, logger, db, keyrotate.WithClock(clock))
err := cryptokeys.StartRotator(ctx, logger, db, cryptokeys.WithClock(clock))
require.NoError(t, err)
initialKeyLen := len(database.AllCryptoKeyFeatureValues())
@@ -88,14 +88,14 @@ func TestRotator(t *testing.T) {
newKey, err := db.GetLatestCryptoKeyByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps)
require.NoError(t, err)
require.Equal(t, rotatingKey.Sequence+1, newKey.Sequence)
require.Equal(t, rotatingKey.ExpiresAt(keyrotate.DefaultKeyDuration), newKey.StartsAt.UTC())
require.Equal(t, rotatingKey.ExpiresAt(cryptokeys.DefaultKeyDuration), newKey.StartsAt.UTC())
require.False(t, newKey.DeletesAt.Valid)
oldKey, err := db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
Feature: rotatingKey.Feature,
Sequence: rotatingKey.Sequence,
})
expectedDeletesAt := rotatingKey.StartsAt.Add(keyrotate.DefaultKeyDuration + time.Hour + keyrotate.WorkspaceAppsTokenDuration)
expectedDeletesAt := rotatingKey.StartsAt.Add(cryptokeys.DefaultKeyDuration + time.Hour + cryptokeys.WorkspaceAppsTokenDuration)
require.NoError(t, err)
require.Equal(t, rotatingKey.StartsAt, oldKey.StartsAt)
require.True(t, oldKey.DeletesAt.Valid)
+14
View File
@@ -659,3 +659,17 @@ func Organization(organization database.Organization) codersdk.Organization {
IsDefault: organization.IsDefault,
}
}
func CryptoKeys(keys []database.CryptoKey) []codersdk.CryptoKey {
return List(keys, CryptoKey)
}
func CryptoKey(key database.CryptoKey) codersdk.CryptoKey {
return codersdk.CryptoKey{
Feature: codersdk.CryptoKeyFeature(key.Feature),
Sequence: key.Sequence,
StartsAt: key.StartsAt,
DeletesAt: key.DeletesAt.Time,
Secret: key.Secret.String,
}
}
+1 -1
View File
@@ -920,7 +920,7 @@ func CryptoKey(t testing.TB, db database.Store, seed database.CryptoKey) databas
Secret: seed.Secret,
SecretKeyID: takeFirst(seed.SecretKeyID, sql.NullString{}),
Feature: seed.Feature,
StartsAt: takeFirst(seed.StartsAt, time.Now()),
StartsAt: takeFirst(seed.StartsAt, dbtime.Now()),
})
require.NoError(t, err, "insert crypto key")
+16
View File
@@ -1,6 +1,7 @@
package database
import (
"encoding/hex"
"sort"
"strconv"
"time"
@@ -451,3 +452,18 @@ func (r GetAuthorizationUserRolesRow) RoleNames() ([]rbac.RoleIdentifier, error)
func (k CryptoKey) ExpiresAt(keyDuration time.Duration) time.Time {
return k.StartsAt.Add(keyDuration).UTC()
}
func (k CryptoKey) DecodeString() ([]byte, error) {
return hex.DecodeString(k.Secret.String)
}
func (k CryptoKey) CanSign(now time.Time) bool {
isAfterStart := !k.StartsAt.IsZero() && !now.Before(k.StartsAt)
return isAfterStart && k.CanVerify(now)
}
func (k CryptoKey) CanVerify(now time.Time) bool {
hasSecret := k.Secret.Valid
isBeforeDeletion := !k.DeletesAt.Valid || now.Before(k.DeletesAt.Time)
return hasSecret && isBeforeDeletion
}
+29
View File
@@ -3101,3 +3101,32 @@ func (c *Client) SSHConfiguration(ctx context.Context) (SSHConfigResponse, error
var sshConfig SSHConfigResponse
return sshConfig, json.NewDecoder(res.Body).Decode(&sshConfig)
}
type CryptoKeyFeature string
const (
CryptoKeyFeatureWorkspaceApp CryptoKeyFeature = "workspace_apps"
CryptoKeyFeatureOIDCConvert CryptoKeyFeature = "oidc_convert"
CryptoKeyFeatureTailnetResume CryptoKeyFeature = "tailnet_resume"
)
type CryptoKey struct {
Feature CryptoKeyFeature `json:"feature"`
Secret string `json:"secret"`
DeletesAt time.Time `json:"deletes_at" format:"date-time"`
Sequence int32 `json:"sequence"`
StartsAt time.Time `json:"starts_at" format:"date-time"`
}
func (c CryptoKey) CanSign(now time.Time) bool {
now = now.UTC()
isAfterStartsAt := !c.StartsAt.IsZero() && !now.Before(c.StartsAt)
return isAfterStartsAt && c.CanVerify(now)
}
func (c CryptoKey) CanVerify(now time.Time) bool {
now = now.UTC()
hasSecret := c.Secret != ""
beforeDelete := c.DeletesAt.IsZero() || now.Before(c.DeletesAt)
return hasSecret && beforeDelete
}
+43 -43
View File
@@ -1406,6 +1406,44 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `template_version_id` | string | false | | Template version ID can be used to specify a specific version of a template for creating the workspace. |
| `ttl_ms` | integer | false | | |
## codersdk.CryptoKey
```json
{
"deletes_at": "2019-08-24T14:15:22Z",
"feature": "workspace_apps",
"secret": "string",
"sequence": 0,
"starts_at": "2019-08-24T14:15:22Z"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------ | ------------------------------------------------------ | -------- | ------------ | ----------- |
| `deletes_at` | string | false | | |
| `feature` | [codersdk.CryptoKeyFeature](#codersdkcryptokeyfeature) | false | | |
| `secret` | string | false | | |
| `sequence` | integer | false | | |
| `starts_at` | string | false | | |
## codersdk.CryptoKeyFeature
```json
"workspace_apps"
```
### Properties
#### Enumerated Values
| Value |
| ---------------- |
| `workspace_apps` |
| `oidc_convert` |
| `tailnet_resume` |
## codersdk.CustomRoleRequest
```json
@@ -9780,55 +9818,17 @@ _None_
| `derp_map` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | |
| `disable_direct_connections` | boolean | false | | |
## wsproxysdk.CryptoKey
```json
{
"deletes_at": "string",
"feature": "workspace_apps",
"secret": "string",
"sequence": 0,
"starts_at": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------ | ---------------------------------------------------------- | -------- | ------------ | ----------- |
| `deletes_at` | string | false | | |
| `feature` | [wsproxysdk.CryptoKeyFeature](#wsproxysdkcryptokeyfeature) | false | | |
| `secret` | string | false | | |
| `sequence` | integer | false | | |
| `starts_at` | string | false | | |
## wsproxysdk.CryptoKeyFeature
```json
"workspace_apps"
```
### Properties
#### Enumerated Values
| Value |
| ---------------- |
| `workspace_apps` |
| `oidc_convert` |
| `tailnet_resume` |
## wsproxysdk.CryptoKeysResponse
```json
{
"crypto_keys": [
{
"deletes_at": "string",
"deletes_at": "2019-08-24T14:15:22Z",
"feature": "workspace_apps",
"secret": "string",
"sequence": 0,
"starts_at": "string"
"starts_at": "2019-08-24T14:15:22Z"
}
]
}
@@ -9836,9 +9836,9 @@ _None_
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------- | ----------------------------------------------------- | -------- | ------------ | ----------- |
| `crypto_keys` | array of [wsproxysdk.CryptoKey](#wsproxysdkcryptokey) | false | | |
| Name | Type | Required | Restrictions | Description |
| ------------- | ------------------------------------------------- | -------- | ------------ | ----------- |
| `crypto_keys` | array of [codersdk.CryptoKey](#codersdkcryptokey) | false | | |
## wsproxysdk.DeregisterWorkspaceProxyRequest
+2 -15
View File
@@ -17,6 +17,7 @@ import (
agpl "github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
@@ -733,7 +734,7 @@ func (api *API) workspaceProxyCryptoKeys(rw http.ResponseWriter, r *http.Request
}
httpapi.Write(ctx, rw, http.StatusOK, wsproxysdk.CryptoKeysResponse{
CryptoKeys: fromDBCryptoKeys(keys),
CryptoKeys: db2sdk.CryptoKeys(keys),
})
}
@@ -994,17 +995,3 @@ func (w *workspaceProxiesFetchUpdater) Fetch(ctx context.Context) (codersdk.Regi
func (w *workspaceProxiesFetchUpdater) Update(ctx context.Context) error {
return w.updateFunc(ctx)
}
func fromDBCryptoKeys(keys []database.CryptoKey) []wsproxysdk.CryptoKey {
wskeys := make([]wsproxysdk.CryptoKey, 0, len(keys))
for _, key := range keys {
wskeys = append(wskeys, wsproxysdk.CryptoKey{
Feature: wsproxysdk.CryptoKeyFeature(key.Feature),
Sequence: key.Sequence,
StartsAt: key.StartsAt.UTC(),
DeletesAt: key.DeletesAt.Time.UTC(),
Secret: key.Secret.String,
})
}
return wskeys
}
+19 -12
View File
@@ -18,6 +18,7 @@ import (
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
@@ -911,21 +912,21 @@ func TestGetCryptoKeys(t *testing.T) {
},
})
now := time.Now().UTC()
now := time.Now()
expectedKey1 := dbgen.CryptoKey(t, db, database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
StartsAt: now.Add(-time.Hour),
Sequence: 2,
})
key1 := fromDBCryptoKeys(expectedKey1)
key1 := db2sdk.CryptoKey(expectedKey1)
expectedKey2 := dbgen.CryptoKey(t, db, database.CryptoKey{
Feature: database.CryptoKeyFeatureWorkspaceApps,
StartsAt: now,
Sequence: 3,
})
key2 := fromDBCryptoKeys(expectedKey2)
key2 := db2sdk.CryptoKey(expectedKey2)
// Create a deleted key.
_ = dbgen.CryptoKey(t, db, database.CryptoKey{
@@ -958,8 +959,7 @@ func TestGetCryptoKeys(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, keys)
require.Equal(t, 2, len(keys.CryptoKeys))
require.Contains(t, keys.CryptoKeys, key1)
require.Contains(t, keys.CryptoKeys, key2)
requireContainsKeys(t, keys.CryptoKeys, key1, key2)
})
t.Run("Unauthorized", func(t *testing.T) {
@@ -995,12 +995,19 @@ func TestGetCryptoKeys(t *testing.T) {
})
}
func fromDBCryptoKeys(key database.CryptoKey) wsproxysdk.CryptoKey {
return wsproxysdk.CryptoKey{
Feature: wsproxysdk.CryptoKeyFeature(key.Feature),
Sequence: key.Sequence,
StartsAt: key.StartsAt.UTC(),
DeletesAt: key.DeletesAt.Time.UTC(),
Secret: key.Secret.String,
func requireContainsKeys(t *testing.T, keys []codersdk.CryptoKey, expected ...codersdk.CryptoKey) {
t.Helper()
for _, expectedKey := range expected {
var found bool
for _, key := range keys {
if key.Feature == expectedKey.Feature && key.Sequence == expectedKey.Sequence {
require.True(t, expectedKey.StartsAt.Equal(key.StartsAt), "expected starts at %s, got %s", expectedKey.StartsAt, key.StartsAt)
require.Equal(t, expectedKey.Secret, key.Secret)
require.True(t, expectedKey.DeletesAt.Equal(key.DeletesAt), "expected deletes at %s, got %s", expectedKey.DeletesAt, key.DeletesAt)
found = true
}
}
require.True(t, found, "expected key %+v not found", expectedKey)
}
}
+1 -30
View File
@@ -204,35 +204,6 @@ type RegisterWorkspaceProxyRequest struct {
Version string `json:"version"`
}
type CryptoKeyFeature string
const (
CryptoKeyFeatureWorkspaceApp CryptoKeyFeature = "workspace_apps"
CryptoKeyFeatureOIDCConvert CryptoKeyFeature = "oidc_convert"
CryptoKeyFeatureTailnetResume CryptoKeyFeature = "tailnet_resume"
)
type CryptoKey struct {
Feature CryptoKeyFeature `json:"feature"`
Secret string `json:"secret"`
DeletesAt time.Time `json:"deletes_at"`
Sequence int32 `json:"sequence"`
StartsAt time.Time `json:"starts_at"`
}
func (c CryptoKey) CanSign(now time.Time) bool {
now = now.UTC()
isAfterStartsAt := !c.StartsAt.IsZero() && !now.Before(c.StartsAt)
return isAfterStartsAt && c.CanVerify(now)
}
func (c CryptoKey) CanVerify(now time.Time) bool {
now = now.UTC()
hasSecret := c.Secret != ""
beforeDelete := c.DeletesAt.IsZero() || now.Before(c.DeletesAt)
return hasSecret && beforeDelete
}
type RegisterWorkspaceProxyResponse struct {
AppSecurityKey string `json:"app_security_key"`
DERPMeshKey string `json:"derp_mesh_key"`
@@ -612,7 +583,7 @@ func (c *Client) DialCoordinator(ctx context.Context) (agpl.MultiAgentConn, erro
}
type CryptoKeysResponse struct {
CryptoKeys []CryptoKey `json:"crypto_keys"`
CryptoKeys []codersdk.CryptoKey `json:"crypto_keys"`
}
func (c *Client) CryptoKeys(ctx context.Context) (CryptoKeysResponse, error) {
+13
View File
@@ -343,6 +343,15 @@ export interface CreateWorkspaceRequest {
readonly automatic_updates?: AutomaticUpdates;
}
// From codersdk/deployment.go
export interface CryptoKey {
readonly feature: CryptoKeyFeature;
readonly secret: string;
readonly deletes_at: string;
readonly sequence: number;
readonly starts_at: string;
}
// From codersdk/roles.go
export interface CustomRoleRequest {
readonly name: string;
@@ -2077,6 +2086,10 @@ export const AutomaticUpdateses: AutomaticUpdates[] = ["always", "never"]
export type BuildReason = "autostart" | "autostop" | "initiator"
export const BuildReasons: BuildReason[] = ["autostart", "autostop", "initiator"]
// From codersdk/deployment.go
export type CryptoKeyFeature = "oidc_convert" | "tailnet_resume" | "workspace_apps"
export const CryptoKeyFeatures: CryptoKeyFeature[] = ["oidc_convert", "tailnet_resume", "workspace_apps"]
// From codersdk/workspaceagents.go
export type DisplayApp = "port_forwarding_helper" | "ssh_helper" | "vscode" | "vscode_insiders" | "web_terminal"
export const DisplayApps: DisplayApp[] = ["port_forwarding_helper", "ssh_helper", "vscode", "vscode_insiders", "web_terminal"]