diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d6cc74cafbcaa..cb0736659ce4c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { @@ -15983,46 +16018,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" } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 579cb33bfaa6e..7ac09bd2bd8b9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { @@ -14614,42 +14645,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" } } } diff --git a/coderd/cryptokeys/dbkeycache.go b/coderd/cryptokeys/dbkeycache.go new file mode 100644 index 0000000000000..4986f1669c4e5 --- /dev/null +++ b/coderd/cryptokeys/dbkeycache.go @@ -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 +} diff --git a/coderd/cryptokeys/dbkeycache_internal_test.go b/coderd/cryptokeys/dbkeycache_internal_test.go new file mode 100644 index 0000000000000..a3450f5f5e0d9 --- /dev/null +++ b/coderd/cryptokeys/dbkeycache_internal_test.go @@ -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) + }) +} diff --git a/coderd/cryptokeys/dbkeycache_test.go b/coderd/cryptokeys/dbkeycache_test.go new file mode 100644 index 0000000000000..8c92cf3a90aa6 --- /dev/null +++ b/coderd/cryptokeys/dbkeycache_test.go @@ -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) + }) +} diff --git a/coderd/cryptokeys/keycache.go b/coderd/cryptokeys/keycache.go new file mode 100644 index 0000000000000..8c4ebfa13f64e --- /dev/null +++ b/coderd/cryptokeys/keycache.go @@ -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) +} diff --git a/coderd/keyrotate/rotate.go b/coderd/cryptokeys/rotate.go similarity index 97% rename from coderd/keyrotate/rotate.go rename to coderd/cryptokeys/rotate.go index b3046161aa930..224b9100d5bf8 100644 --- a/coderd/keyrotate/rotate.go +++ b/coderd/cryptokeys/rotate.go @@ -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, diff --git a/coderd/keyrotate/rotate_internal_test.go b/coderd/cryptokeys/rotate_internal_test.go similarity index 99% rename from coderd/keyrotate/rotate_internal_test.go rename to coderd/cryptokeys/rotate_internal_test.go index 94160a947bf11..36ecf4fa9d76d 100644 --- a/coderd/keyrotate/rotate_internal_test.go +++ b/coderd/cryptokeys/rotate_internal_test.go @@ -1,4 +1,4 @@ -package keyrotate +package cryptokeys import ( "database/sql" diff --git a/coderd/keyrotate/rotate_test.go b/coderd/cryptokeys/rotate_test.go similarity index 85% rename from coderd/keyrotate/rotate_test.go rename to coderd/cryptokeys/rotate_test.go index 43a62ac451b62..190ad213b1153 100644 --- a/coderd/keyrotate/rotate_test.go +++ b/coderd/cryptokeys/rotate_test.go @@ -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) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index a8e2c6cb93fad..a0e8977ff8879 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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, + } +} diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index d18da855be7b8..1a2f052a279b3 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -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") diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 82be5e710c058..846de6e36aa47 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -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 +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index e8b90a07af98f..950537286d735 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3103,3 +3103,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 +} diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 757d0aaab4ccb..7e0c3bb45a6b2 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 @@ -9768,55 +9806,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" } ] } @@ -9824,9 +9824,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 diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go index eef12b1d1b13a..47bdf53493489 100644 --- a/enterprise/coderd/workspaceproxy.go +++ b/enterprise/coderd/workspaceproxy.go @@ -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 -} diff --git a/enterprise/coderd/workspaceproxy_test.go b/enterprise/coderd/workspaceproxy_test.go index e2a687517473a..5231a0b0c4241 100644 --- a/enterprise/coderd/workspaceproxy_test.go +++ b/enterprise/coderd/workspaceproxy_test.go @@ -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) } } diff --git a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go index 891224216003a..77d36561c6de8 100644 --- a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go +++ b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go @@ -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) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 35f393e567f1e..971a0d8a4ccdc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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; @@ -2074,6 +2083,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"]