mirror of
https://github.com/coder/coder.git
synced 2026-11-01 21:13:14 +08:00
Extends the Agent-chat MCP tools so an unattended UAT evidence loop can
fetch artifacts, monitor long runs, and find prior runs without burning
model context.
## Backend
- New `chat_files_token` crypto key feature (migration 000571) with
rotator support and a dedicated signing keycache on coderd.
- `POST /api/experimental/chats/files/{file}/download-url`
(authenticated) mints a short-lived (5 min) signed URL and returns it
with `sha256`, `size_bytes`, `name`, `mime_type`, and `expires_at`.
- `GET /api/experimental/chats/files/{file}/download?token=` (no session
token) redeems the signed URL: verifies the JWS, requires the token's
`file_id` to match the path, and re-checks the minting user's RBAC
access live at redemption. Clients can `curl -o` artifacts with zero
credentials in the URL consumer.
- `ChatFileMetadata` gains `size_bytes` (via `octet_length`, no bytes
fetched).
## MCP tools (`codersdk/toolsdk`)
- `coder_download_chat_file`: by `file_id` or `chat_id`+`file_name`;
returns the signed URL plus checksum and size instead of base64.
- `coder_await_chat`: blocks (bounded `wait_secs`, 1-120) until a chat
leaves `running`/`interrupting`, using the existing watch stream with
subscribe-before-read.
- `coder_list_chats`: label, query, and limit filtering; chat
projections now include labels.
- `coder_get_chat_messages`: `after_id` forward cursor with
`next_after_id` (exact incremental reads), plus per-message `files`
metadata so artifact-bearing messages are identifiable.
- `coder_get_chat`: file listings now include `size_bytes` and
`created_at`.
- `coder_list_templates`: exposes `agents_allowed` for pre-flight
checks.
## Testing
- coderd: mint/redeem happy path with an unauthenticated client,
expired/tampered/file-mismatched tokens, auth still required on the
plain file endpoint, non-owner mint rejection.
- toolsdk: harness + integration coverage for all new/changed tools,
including signed-URL redemption with checksum verification,
forward-cursor exactness, await transition/timeout paths, and label
filtering.
- Remote dogfood UAT (dev.coder.com Coder Agent) passed all six
acceptance scenarios end to end over both MCP transports.
Note: `go test ./codersdk/toolsdk/` has a pre-existing goleak flake on
main (leaked `agentssh` non-PTY session goroutines from SSH exec tests;
reproduced 3/3 on clean `b4971bc49f1`). It is unrelated to this diff.
> Mux acted on Mike's behalf to create this PR.
<!-- mux-attribution: model=claude-sonnet-4-6 thinking=high -->
725 lines
21 KiB
Go
725 lines
21 KiB
Go
package cryptokeys
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"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/testutil"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
func Test_rotateKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("RotatesKeysNearExpiration", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 7
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
features: []database.CryptoKeyFeature{
|
|
database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
},
|
|
}
|
|
|
|
now := dbnow(clock)
|
|
|
|
// Seed the database with an existing key.
|
|
oldKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
StartsAt: now,
|
|
Sequence: 15,
|
|
})
|
|
|
|
// Advance the window to just inside rotation time.
|
|
_ = clock.Advance(keyDuration - time.Minute*59)
|
|
err := kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
now = dbnow(clock)
|
|
expectedDeletesAt := oldKey.ExpiresAt(keyDuration).Add(WorkspaceAppsTokenDuration + time.Hour)
|
|
|
|
// Fetch the old key, it should have an deletes_at now.
|
|
oldKey, err = db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
|
|
Feature: oldKey.Feature,
|
|
Sequence: oldKey.Sequence,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, oldKey.DeletesAt.Time.UTC(), expectedDeletesAt)
|
|
|
|
// The new key should be created and have a starts_at of the old key's expires_at.
|
|
newKey, err := db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
|
|
Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
Sequence: oldKey.Sequence + 1,
|
|
})
|
|
require.NoError(t, err)
|
|
requireKey(t, newKey, database.CryptoKeyFeatureWorkspaceAppsAPIKey, oldKey.ExpiresAt(keyDuration), nullTime, oldKey.Sequence+1)
|
|
|
|
// Advance the clock just before the keys delete time.
|
|
clock.Advance(oldKey.DeletesAt.Time.UTC().Sub(now) - time.Second)
|
|
|
|
// No action should be taken.
|
|
err = kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
keys, err := db.GetCryptoKeys(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 2)
|
|
|
|
// Advance the clock just past the keys delete time.
|
|
clock.Advance(oldKey.DeletesAt.Time.UTC().Sub(now) + time.Second)
|
|
|
|
// We should have deleted the old key.
|
|
err = kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// The old key should be "deleted".
|
|
_, err = db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
|
|
Feature: oldKey.Feature,
|
|
Sequence: oldKey.Sequence,
|
|
})
|
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
|
|
|
keys, err = db.GetCryptoKeys(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 1)
|
|
require.Equal(t, newKey, keys[0])
|
|
})
|
|
|
|
t.Run("RotatesNATSCA", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 7
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
features: []database.CryptoKeyFeature{
|
|
database.CryptoKeyFeatureNATSCA,
|
|
},
|
|
}
|
|
|
|
now := dbnow(clock)
|
|
|
|
oldKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureNATSCA,
|
|
StartsAt: now,
|
|
Sequence: 4,
|
|
})
|
|
|
|
// Advance the window to just inside rotation time.
|
|
_ = clock.Advance(keyDuration - time.Minute*59)
|
|
err := kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// The old CA row is retained roughly as long as its certificate is
|
|
// valid: NATSCAOverlap past the active-signing window, plus the
|
|
// rotator's standard 1h propagation buffer.
|
|
expectedDeletesAt := oldKey.ExpiresAt(keyDuration).Add(NATSCAOverlap + time.Hour)
|
|
oldKey, err = db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
|
|
Feature: oldKey.Feature,
|
|
Sequence: oldKey.Sequence,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, expectedDeletesAt, oldKey.DeletesAt.Time.UTC())
|
|
|
|
newKey, err := db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
|
|
Feature: database.CryptoKeyFeatureNATSCA,
|
|
Sequence: oldKey.Sequence + 1,
|
|
})
|
|
require.NoError(t, err)
|
|
requireKey(t, newKey, database.CryptoKeyFeatureNATSCA, oldKey.ExpiresAt(keyDuration), nullTime, oldKey.Sequence+1)
|
|
})
|
|
|
|
t.Run("IgnoresUnmanagedFeatureKeys", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Regression: a rotator managing a subset of features (e.g. after the
|
|
// nats_ca experiment is toggled off) must still rotate its managed
|
|
// features even when the DB holds keys for features it does not manage,
|
|
// such as nats_ca rows left over from a prior experiment-on run.
|
|
// Previously such rows aborted every rotation.
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 7
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
// Manages only tailnet resume; nats_ca is intentionally not managed,
|
|
// mirroring the experiment being off.
|
|
features: []database.CryptoKeyFeature{
|
|
database.CryptoKeyFeatureTailnetResume,
|
|
},
|
|
}
|
|
|
|
now := dbnow(clock)
|
|
|
|
// A leftover nats_ca row the rotator does not manage.
|
|
_ = dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureNATSCA,
|
|
StartsAt: now,
|
|
Sequence: 1,
|
|
})
|
|
|
|
// No managed key exists yet, so rotation must insert one for the managed
|
|
// feature and must not error on the unmanaged nats_ca row.
|
|
err := kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
newKey, err := db.GetLatestCryptoKeyByFeature(ctx, database.CryptoKeyFeatureTailnetResume)
|
|
require.NoError(t, err)
|
|
require.Equal(t, database.CryptoKeyFeatureTailnetResume, newKey.Feature)
|
|
|
|
// The unmanaged nats_ca row is untouched (no rotation, no delete).
|
|
natsKeys, err := db.GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureNATSCA)
|
|
require.NoError(t, err)
|
|
require.Len(t, natsKeys, 1)
|
|
require.False(t, natsKeys[0].DeletesAt.Valid)
|
|
})
|
|
|
|
t.Run("DoesNotRotateValidKeys", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 7
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
features: []database.CryptoKeyFeature{
|
|
database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
},
|
|
}
|
|
|
|
now := dbnow(clock)
|
|
|
|
// Seed the database with an existing key
|
|
existingKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
StartsAt: now,
|
|
Sequence: 123,
|
|
})
|
|
|
|
// Advance the clock by 6 days, 22 hours. Once we
|
|
// breach the last hour we will insert a new key.
|
|
clock.Advance(keyDuration - 2*time.Hour)
|
|
|
|
err := kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
keys, err := db.GetCryptoKeys(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 1)
|
|
require.Equal(t, existingKey, keys[0])
|
|
|
|
// Advance it again to just before the key is scheduled to be rotated for sanity purposes.
|
|
clock.Advance(time.Hour - time.Second)
|
|
|
|
err = kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Verify that the existing key is still the only key in the database
|
|
keys, err = db.GetCryptoKeys(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 1)
|
|
requireKey(t, keys[0], existingKey.Feature, existingKey.StartsAt.UTC(), nullTime, existingKey.Sequence)
|
|
})
|
|
|
|
// Simulate a situation where the database was manually altered such that we only have a key that is scheduled to be deleted and assert we insert a new key.
|
|
t.Run("DeletesExpiredKeys", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 7
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
features: []database.CryptoKeyFeature{
|
|
database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
},
|
|
}
|
|
|
|
now := dbnow(clock)
|
|
|
|
// Seed the database with an existing key
|
|
deletingKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
StartsAt: now.Add(-keyDuration),
|
|
Sequence: 789,
|
|
DeletesAt: sql.NullTime{
|
|
Time: now,
|
|
Valid: true,
|
|
},
|
|
})
|
|
|
|
err := kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// We should only get one key since the old key
|
|
// should be deleted.
|
|
keys, err := db.GetCryptoKeys(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 1)
|
|
requireKey(t, keys[0], deletingKey.Feature, deletingKey.DeletesAt.Time.UTC(), nullTime, deletingKey.Sequence+1)
|
|
// The old key should be "deleted".
|
|
_, err = db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
|
|
Feature: deletingKey.Feature,
|
|
Sequence: deletingKey.Sequence,
|
|
})
|
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
|
})
|
|
|
|
// This tests a situation where we have a key scheduled for deletion but it's still valid for use.
|
|
// If no other key is detected we should insert a new key.
|
|
t.Run("AddsKeyForDeletingKey", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 7
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
features: []database.CryptoKeyFeature{
|
|
database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
},
|
|
}
|
|
|
|
now := dbnow(clock)
|
|
|
|
// Seed the database with an existing key
|
|
deletingKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
StartsAt: now,
|
|
Sequence: 456,
|
|
DeletesAt: sql.NullTime{
|
|
Time: now.Add(time.Hour),
|
|
Valid: true,
|
|
},
|
|
})
|
|
|
|
// We should only have inserted a key.
|
|
err := kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
keys, err := db.GetCryptoKeys(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 2)
|
|
oldKey, newKey := keys[0], keys[1]
|
|
if oldKey.Sequence != deletingKey.Sequence {
|
|
oldKey, newKey = newKey, oldKey
|
|
}
|
|
requireKey(t, oldKey, deletingKey.Feature, deletingKey.StartsAt.UTC(), deletingKey.DeletesAt, deletingKey.Sequence)
|
|
requireKey(t, newKey, deletingKey.Feature, now, nullTime, deletingKey.Sequence+1)
|
|
})
|
|
|
|
t.Run("NoKeys", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 7
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
features: []database.CryptoKeyFeature{
|
|
database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
},
|
|
}
|
|
|
|
err := kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
keys, err := db.GetCryptoKeys(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 1)
|
|
requireKey(t, keys[0], database.CryptoKeyFeatureWorkspaceAppsAPIKey, clock.Now().UTC(), nullTime, 1)
|
|
})
|
|
|
|
// Assert we insert a new key when the only key was manually deleted.
|
|
t.Run("OnlyDeletedKeys", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 7
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
features: []database.CryptoKeyFeature{
|
|
database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
},
|
|
}
|
|
|
|
now := dbnow(clock)
|
|
|
|
deletedkey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
StartsAt: now,
|
|
Sequence: 19,
|
|
DeletesAt: sql.NullTime{
|
|
Time: now.Add(time.Hour),
|
|
Valid: true,
|
|
},
|
|
Secret: sql.NullString{
|
|
String: "deleted",
|
|
Valid: false,
|
|
},
|
|
})
|
|
|
|
err := kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
keys, err := db.GetCryptoKeys(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 1)
|
|
requireKey(t, keys[0], database.CryptoKeyFeatureWorkspaceAppsAPIKey, now, nullTime, deletedkey.Sequence+1)
|
|
})
|
|
|
|
// This tests ensures that rotation works with multiple
|
|
// features. It's mainly a sanity test since some bugs
|
|
// are not unveiled in the simple n=1 case.
|
|
t.Run("AllFeatures", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 30
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
features: defaultRotatedFeatures,
|
|
}
|
|
|
|
now := dbnow(clock)
|
|
|
|
// We'll test a scenario where:
|
|
// - One feature has no valid keys.
|
|
// - One has a key that should be rotated.
|
|
// - One has a valid key that shouldn't trigger an action.
|
|
// - One has no keys at all.
|
|
_ = dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureTailnetResume,
|
|
StartsAt: now.Add(-keyDuration),
|
|
Sequence: 5,
|
|
Secret: sql.NullString{
|
|
String: "older key",
|
|
Valid: false,
|
|
},
|
|
})
|
|
// Generate another deleted key to ensure we insert after the latest sequence.
|
|
deletedKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureTailnetResume,
|
|
StartsAt: now.Add(-keyDuration),
|
|
Sequence: 19,
|
|
Secret: sql.NullString{
|
|
String: "old key",
|
|
Valid: false,
|
|
},
|
|
})
|
|
|
|
// Insert a key that should be rotated.
|
|
rotatedKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
StartsAt: now.Add(-keyDuration + time.Hour),
|
|
Sequence: 42,
|
|
})
|
|
|
|
// Insert a key that should not trigger an action.
|
|
validKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureOIDCConvert,
|
|
StartsAt: now,
|
|
Sequence: 17,
|
|
})
|
|
|
|
err := kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
keys, err := db.GetCryptoKeys(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 6)
|
|
|
|
kbf := keysByFeature(keys, defaultRotatedFeatures)
|
|
|
|
// No actions on OIDC convert.
|
|
require.Len(t, kbf[database.CryptoKeyFeatureOIDCConvert], 1)
|
|
// Workspace apps should have been rotated.
|
|
require.Len(t, kbf[database.CryptoKeyFeatureWorkspaceAppsAPIKey], 2)
|
|
// No existing key for tailnet resume should've
|
|
// caused a key to be inserted.
|
|
require.Len(t, kbf[database.CryptoKeyFeatureTailnetResume], 1)
|
|
require.Len(t, kbf[database.CryptoKeyFeatureWorkspaceAppsToken], 1)
|
|
require.Len(t, kbf[database.CryptoKeyFeatureChatFilesToken], 1)
|
|
|
|
oidcKey := kbf[database.CryptoKeyFeatureOIDCConvert][0]
|
|
tailnetKey := kbf[database.CryptoKeyFeatureTailnetResume][0]
|
|
appTokenKey := kbf[database.CryptoKeyFeatureWorkspaceAppsToken][0]
|
|
chatFileTokenKey := kbf[database.CryptoKeyFeatureChatFilesToken][0]
|
|
requireKey(t, oidcKey, database.CryptoKeyFeatureOIDCConvert, now, nullTime, validKey.Sequence)
|
|
requireKey(t, tailnetKey, database.CryptoKeyFeatureTailnetResume, now, nullTime, deletedKey.Sequence+1)
|
|
requireKey(t, appTokenKey, database.CryptoKeyFeatureWorkspaceAppsToken, now, nullTime, 1)
|
|
requireKey(t, chatFileTokenKey, database.CryptoKeyFeatureChatFilesToken, now, nullTime, 1)
|
|
newKey := kbf[database.CryptoKeyFeatureWorkspaceAppsAPIKey][0]
|
|
oldKey := kbf[database.CryptoKeyFeatureWorkspaceAppsAPIKey][1]
|
|
if newKey.Sequence == rotatedKey.Sequence {
|
|
oldKey, newKey = newKey, oldKey
|
|
}
|
|
deletesAt := sql.NullTime{
|
|
Time: rotatedKey.ExpiresAt(keyDuration).Add(WorkspaceAppsTokenDuration + time.Hour),
|
|
Valid: true,
|
|
}
|
|
requireKey(t, oldKey, database.CryptoKeyFeatureWorkspaceAppsAPIKey, rotatedKey.StartsAt.UTC(), deletesAt, rotatedKey.Sequence)
|
|
requireKey(t, newKey, database.CryptoKeyFeatureWorkspaceAppsAPIKey, rotatedKey.ExpiresAt(keyDuration), nullTime, rotatedKey.Sequence+1)
|
|
})
|
|
|
|
t.Run("UnknownFeature", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 7
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
features: []database.CryptoKeyFeature{database.CryptoKeyFeature("unknown")},
|
|
}
|
|
|
|
err := kr.rotateKeys(ctx)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("MinStartsAt", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 5
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
now := dbnow(clock)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
features: []database.CryptoKeyFeature{database.CryptoKeyFeatureWorkspaceAppsAPIKey},
|
|
}
|
|
|
|
expiringKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
StartsAt: now.Add(-keyDuration),
|
|
Sequence: 345,
|
|
})
|
|
|
|
err := kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
keys, err := db.GetCryptoKeys(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 2)
|
|
|
|
rotatedKey, err := db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
|
|
Feature: expiringKey.Feature,
|
|
Sequence: expiringKey.Sequence + 1,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, now.Add(defaultRotationInterval*3), rotatedKey.StartsAt.UTC())
|
|
})
|
|
|
|
// Test that the the deletes_at of a key that is well past its expiration
|
|
// Has its deletes_at field set to value that is relative
|
|
// to the current time to afford propagation time for the
|
|
// new key.
|
|
t.Run("ExtensivelyExpiredKey", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
db, _ = dbtestutil.NewDB(t)
|
|
clock = quartz.NewMock(t)
|
|
keyDuration = time.Hour * 24 * 3
|
|
logger = testutil.Logger(t)
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
)
|
|
|
|
kr := &rotator{
|
|
db: db,
|
|
keyDuration: keyDuration,
|
|
clock: clock,
|
|
logger: logger,
|
|
features: []database.CryptoKeyFeature{database.CryptoKeyFeatureWorkspaceAppsAPIKey},
|
|
}
|
|
|
|
now := dbnow(clock)
|
|
|
|
expiredKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
StartsAt: now.Add(-keyDuration - 2*time.Hour),
|
|
Sequence: 19,
|
|
})
|
|
|
|
deletedKey := dbgen.CryptoKey(t, db, database.CryptoKey{
|
|
Feature: database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
|
StartsAt: now,
|
|
Sequence: 20,
|
|
Secret: sql.NullString{
|
|
String: "deleted",
|
|
Valid: false,
|
|
},
|
|
})
|
|
|
|
err := kr.rotateKeys(ctx)
|
|
require.NoError(t, err)
|
|
|
|
keys, err := db.GetCryptoKeys(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 2)
|
|
|
|
deletesAtKey, err := db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
|
|
Feature: expiredKey.Feature,
|
|
Sequence: expiredKey.Sequence,
|
|
})
|
|
|
|
deletesAt := sql.NullTime{
|
|
Time: now.Add(defaultRotationInterval * 3).Add(WorkspaceAppsTokenDuration + time.Hour),
|
|
Valid: true,
|
|
}
|
|
require.NoError(t, err)
|
|
requireKey(t, deletesAtKey, expiredKey.Feature, expiredKey.StartsAt.UTC(), deletesAt, expiredKey.Sequence)
|
|
|
|
newKey, err := db.GetCryptoKeyByFeatureAndSequence(ctx, database.GetCryptoKeyByFeatureAndSequenceParams{
|
|
Feature: expiredKey.Feature,
|
|
Sequence: deletedKey.Sequence + 1,
|
|
})
|
|
require.NoError(t, err)
|
|
requireKey(t, newKey, expiredKey.Feature, now.Add(defaultRotationInterval*3), nullTime, deletedKey.Sequence+1)
|
|
})
|
|
}
|
|
|
|
func dbnow(c quartz.Clock) time.Time {
|
|
return dbtime.Time(c.Now().UTC())
|
|
}
|
|
|
|
func requireKey(t *testing.T, key database.CryptoKey, feature database.CryptoKeyFeature, startsAt time.Time, deletesAt sql.NullTime, sequence int32) {
|
|
t.Helper()
|
|
require.Equal(t, feature, key.Feature)
|
|
require.Equal(t, startsAt, key.StartsAt.UTC())
|
|
require.Equal(t, deletesAt.Valid, key.DeletesAt.Valid)
|
|
require.Equal(t, deletesAt.Time.UTC(), key.DeletesAt.Time.UTC())
|
|
require.Equal(t, sequence, key.Sequence)
|
|
|
|
// The NATS CA secret is a PEM bundle rather than hex-encoded bytes.
|
|
if key.Feature == database.CryptoKeyFeatureNATSCA {
|
|
cert, _, err := parseCASecret(key.Secret.String)
|
|
require.NoError(t, err)
|
|
require.True(t, cert.IsCA)
|
|
return
|
|
}
|
|
|
|
secret, err := hex.DecodeString(key.Secret.String)
|
|
require.NoError(t, err)
|
|
|
|
switch key.Feature {
|
|
case database.CryptoKeyFeatureOIDCConvert:
|
|
require.Len(t, secret, 64)
|
|
case database.CryptoKeyFeatureChatFilesToken:
|
|
require.Len(t, secret, 64)
|
|
case database.CryptoKeyFeatureWorkspaceAppsToken:
|
|
require.Len(t, secret, 64)
|
|
case database.CryptoKeyFeatureWorkspaceAppsAPIKey:
|
|
require.Len(t, secret, 32)
|
|
case database.CryptoKeyFeatureTailnetResume:
|
|
require.Len(t, secret, 64)
|
|
default:
|
|
t.Fatalf("unknown key feature: %s", key.Feature)
|
|
}
|
|
}
|
|
|
|
var nullTime = sql.NullTime{Time: time.Time{}, Valid: false}
|