feat: encrypt gitsshkeys.private_key at rest via dbcrypt (#25872)

Adds an optional dbcrypt wrapper around gitsshkeys.private_key. The
column is encrypted on insert and update through enterprise/dbcrypt when
external token encryption is configured, and decrypted on read.

A new private_key_key_id column references
dbcrypt_keys(active_key_digest) so revocation safety is enforced by the
existing foreign key. Rows with a NULL key_id stay plaintext and remain
readable. Existing plaintext rows can be backfilled by running `coder
server dbcrypt rotate`.

Generated with assistance from Coder Agents.
This commit is contained in:
Zach
2026-06-02 08:36:01 -06:00
committed by GitHub
parent 5088b5fa5f
commit 170c33a475
18 changed files with 362 additions and 42 deletions
+7 -5
View File
@@ -3,6 +3,7 @@
package cli
import (
"database/sql"
"fmt"
"sort"
@@ -210,11 +211,12 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
return xerrors.Errorf("generate user gitsshkey: %w", err)
}
_, err = tx.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{
UserID: newUser.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
PrivateKey: privateKey,
PublicKey: publicKey,
UserID: newUser.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
PrivateKey: privateKey,
PrivateKeyKeyID: sql.NullString{}, // Plaintext; this CLI bypasses dbcrypt. Encrypted on next rotate.
PublicKey: publicKey,
})
if err != nil {
return xerrors.Errorf("insert user gitsshkey: %w", err)
+6 -5
View File
@@ -1021,11 +1021,12 @@ func User(t testing.TB, db database.Store, orig database.User) database.User {
func GitSSHKey(t testing.TB, db database.Store, orig database.GitSSHKey) database.GitSSHKey {
key, err := db.InsertGitSSHKey(genCtx, database.InsertGitSSHKeyParams{
UserID: takeFirst(orig.UserID, uuid.New()),
CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()),
PrivateKey: takeFirst(orig.PrivateKey, ""),
PublicKey: takeFirst(orig.PublicKey, ""),
UserID: takeFirst(orig.UserID, uuid.New()),
CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()),
UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()),
PrivateKey: takeFirst(orig.PrivateKey, ""),
PrivateKeyKeyID: takeFirst(orig.PrivateKeyKeyID, sql.NullString{}),
PublicKey: takeFirst(orig.PublicKey, ""),
})
require.NoError(t, err, "insert ssh key")
return key
+7 -1
View File
@@ -2013,9 +2013,12 @@ CREATE TABLE gitsshkeys (
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
private_key text NOT NULL,
public_key text NOT NULL
public_key text NOT NULL,
private_key_key_id text
);
COMMENT ON COLUMN gitsshkeys.private_key_key_id IS 'The ID of the key used to encrypt the private key. If this is NULL, the private key is not encrypted.';
CREATE TABLE group_ai_budgets (
group_id uuid NOT NULL,
spend_limit_micros bigint NOT NULL,
@@ -4701,6 +4704,9 @@ ALTER TABLE ONLY external_auth_links
ALTER TABLE ONLY external_auth_links
ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ALTER TABLE ONLY gitsshkeys
ADD CONSTRAINT gitsshkeys_private_key_key_id_fkey FOREIGN KEY (private_key_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ALTER TABLE ONLY gitsshkeys
ADD CONSTRAINT gitsshkeys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
+1
View File
@@ -46,6 +46,7 @@ const (
ForeignKeyFkOauth2ProviderAppTokensUserID ForeignKeyConstraint = "fk_oauth2_provider_app_tokens_user_id" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT fk_oauth2_provider_app_tokens_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyGitSSHKeysPrivateKeyKeyID ForeignKeyConstraint = "gitsshkeys_private_key_key_id_fkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_private_key_key_id_fkey FOREIGN KEY (private_key_key_id) REFERENCES dbcrypt_keys(active_key_digest);
ForeignKeyGitSSHKeysUserID ForeignKeyConstraint = "gitsshkeys_user_id_fkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
ForeignKeyGroupAiBudgetsGroupID ForeignKeyConstraint = "group_ai_budgets_group_id_fkey" // ALTER TABLE ONLY group_ai_budgets ADD CONSTRAINT group_ai_budgets_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
ForeignKeyGroupMembersGroupID ForeignKeyConstraint = "group_members_group_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
@@ -0,0 +1,3 @@
ALTER TABLE gitsshkeys
DROP CONSTRAINT gitsshkeys_private_key_key_id_fkey,
DROP COLUMN private_key_key_id;
@@ -0,0 +1,7 @@
ALTER TABLE gitsshkeys
ADD COLUMN private_key_key_id TEXT;
ALTER TABLE ONLY gitsshkeys
ADD CONSTRAINT gitsshkeys_private_key_key_id_fkey FOREIGN KEY (private_key_key_id) REFERENCES dbcrypt_keys(active_key_digest);
COMMENT ON COLUMN gitsshkeys.private_key_key_id IS 'The ID of the key used to encrypt the private key. If this is NULL, the private key is not encrypted.';
+2
View File
@@ -4914,6 +4914,8 @@ type GitSSHKey struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
PrivateKey string `db:"private_key" json:"private_key"`
PublicKey string `db:"public_key" json:"public_key"`
// The ID of the key used to encrypt the private key. If this is NULL, the private key is not encrypted.
PrivateKeyKeyID sql.NullString `db:"private_key_key_id" json:"private_key_key_id"`
}
type Group struct {
+22 -13
View File
@@ -12572,7 +12572,7 @@ func (q *sqlQuerier) InsertFile(ctx context.Context, arg InsertFileParams) (File
const getGitSSHKey = `-- name: GetGitSSHKey :one
SELECT
user_id, created_at, updated_at, private_key, public_key
user_id, created_at, updated_at, private_key, public_key, private_key_key_id
FROM
gitsshkeys
WHERE
@@ -12588,6 +12588,7 @@ func (q *sqlQuerier) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSH
&i.UpdatedAt,
&i.PrivateKey,
&i.PublicKey,
&i.PrivateKeyKeyID,
)
return i, err
}
@@ -12599,18 +12600,20 @@ INSERT INTO
created_at,
updated_at,
private_key,
private_key_key_id,
public_key
)
VALUES
($1, $2, $3, $4, $5) RETURNING user_id, created_at, updated_at, private_key, public_key
($1, $2, $3, $4, $5, $6) RETURNING user_id, created_at, updated_at, private_key, public_key, private_key_key_id
`
type InsertGitSSHKeyParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
PrivateKey string `db:"private_key" json:"private_key"`
PublicKey string `db:"public_key" json:"public_key"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
PrivateKey string `db:"private_key" json:"private_key"`
PrivateKeyKeyID sql.NullString `db:"private_key_key_id" json:"private_key_key_id"`
PublicKey string `db:"public_key" json:"public_key"`
}
func (q *sqlQuerier) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) {
@@ -12619,6 +12622,7 @@ func (q *sqlQuerier) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyPar
arg.CreatedAt,
arg.UpdatedAt,
arg.PrivateKey,
arg.PrivateKeyKeyID,
arg.PublicKey,
)
var i GitSSHKey
@@ -12628,6 +12632,7 @@ func (q *sqlQuerier) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyPar
&i.UpdatedAt,
&i.PrivateKey,
&i.PublicKey,
&i.PrivateKeyKeyID,
)
return i, err
}
@@ -12638,18 +12643,20 @@ UPDATE
SET
updated_at = $2,
private_key = $3,
public_key = $4
private_key_key_id = $4,
public_key = $5
WHERE
user_id = $1
RETURNING
user_id, created_at, updated_at, private_key, public_key
user_id, created_at, updated_at, private_key, public_key, private_key_key_id
`
type UpdateGitSSHKeyParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
PrivateKey string `db:"private_key" json:"private_key"`
PublicKey string `db:"public_key" json:"public_key"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
PrivateKey string `db:"private_key" json:"private_key"`
PrivateKeyKeyID sql.NullString `db:"private_key_key_id" json:"private_key_key_id"`
PublicKey string `db:"public_key" json:"public_key"`
}
func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error) {
@@ -12657,6 +12664,7 @@ func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyPar
arg.UserID,
arg.UpdatedAt,
arg.PrivateKey,
arg.PrivateKeyKeyID,
arg.PublicKey,
)
var i GitSSHKey
@@ -12666,6 +12674,7 @@ func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyPar
&i.UpdatedAt,
&i.PrivateKey,
&i.PublicKey,
&i.PrivateKeyKeyID,
)
return i, err
}
+4 -3
View File
@@ -5,10 +5,11 @@ INSERT INTO
created_at,
updated_at,
private_key,
private_key_key_id,
public_key
)
VALUES
($1, $2, $3, $4, $5) RETURNING *;
($1, $2, $3, $4, $5, $6) RETURNING *;
-- name: GetGitSSHKey :one
SELECT
@@ -24,9 +25,9 @@ UPDATE
SET
updated_at = $2,
private_key = $3,
public_key = $4
private_key_key_id = $4,
public_key = $5
WHERE
user_id = $1
RETURNING
*;
+6 -4
View File
@@ -1,6 +1,7 @@
package coderd
import (
"database/sql"
"net/http"
"github.com/coder/coder/v2/coderd/audit"
@@ -53,10 +54,11 @@ func (api *API) regenerateGitSSHKey(rw http.ResponseWriter, r *http.Request) {
}
newKey, err := api.Database.UpdateGitSSHKey(ctx, database.UpdateGitSSHKeyParams{
UserID: user.ID,
UpdatedAt: dbtime.Now(),
PrivateKey: privateKey,
PublicKey: publicKey,
UserID: user.ID,
UpdatedAt: dbtime.Now(),
PrivateKey: privateKey,
PrivateKeyKeyID: sql.NullString{}, // dbcrypt will update as required
PublicKey: publicKey,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+6 -5
View File
@@ -1967,11 +1967,12 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
return xerrors.Errorf("generate user gitsshkey: %w", err)
}
_, err = tx.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{
UserID: user.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
PrivateKey: privateKey,
PublicKey: publicKey,
UserID: user.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
PrivateKey: privateKey,
PrivateKeyKeyID: sql.NullString{}, // dbcrypt will set as required
PublicKey: publicKey,
})
if err != nil {
return xerrors.Errorf("insert user gitsshkey: %w", err)
+1 -1
View File
@@ -26,7 +26,7 @@ We track the following resources:
| AuditableOrganizationMember<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>roles</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Chat<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>agent_id</td><td>false</td></tr><tr><td>archived</td><td>true</td></tr><tr><td>build_id</td><td>false</td></tr><tr><td>client_type</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>dynamic_tools</td><td>false</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>heartbeat_at</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>labels</td><td>true</td></tr><tr><td>last_error</td><td>false</td></tr><tr><td>last_injected_context</td><td>false</td></tr><tr><td>last_model_config_id</td><td>false</td></tr><tr><td>last_read_message_id</td><td>false</td></tr><tr><td>last_turn_summary</td><td>false</td></tr><tr><td>mcp_server_ids</td><td>true</td></tr><tr><td>mode</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>owner_name</td><td>false</td></tr><tr><td>owner_username</td><td>false</td></tr><tr><td>parent_chat_id</td><td>false</td></tr><tr><td>pin_order</td><td>true</td></tr><tr><td>plan_mode</td><td>false</td></tr><tr><td>root_chat_id</td><td>false</td></tr><tr><td>started_at</td><td>false</td></tr><tr><td>status</td><td>false</td></tr><tr><td>title</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr><tr><td>worker_id</td><td>false</td></tr><tr><td>workspace_id</td><td>true</td></tr></tbody></table> |
| CustomRole<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_system</td><td>false</td></tr><tr><td>member_permissions</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>org_permissions</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>site_permissions</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_permissions</td><td>true</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>private_key_key_id</td><td>false</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| GroupSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>auto_create_missing_groups</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>legacy_group_name_mapping</td><td>false</td></tr><tr><td>mapping</td><td>true</td></tr><tr><td>regex_filter</td><td>true</td></tr></tbody></table> |
| HealthSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>dismissed_healthchecks</td><td>true</td></tr><tr><td>id</td><td>false</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
@@ -24,6 +24,7 @@ The following database fields are currently encrypted:
- `external_auth_links.oauth_refresh_token`
- `crypto_keys.secret`
- `user_secrets.value`
- `gitsshkeys.private_key`
Additional database fields may be encrypted in the future.
+6 -5
View File
@@ -84,11 +84,12 @@ var auditableResourcesTypes = map[any]map[string]Action{
"updated_at": ActionIgnore,
},
&database.GitSSHKey{}: {
"user_id": ActionTrack,
"created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
"private_key": ActionSecret, // We don't want to expose private keys in diffs.
"public_key": ActionTrack, // Public keys are ok to expose in a diff.
"user_id": ActionTrack,
"created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
"private_key": ActionSecret, // We don't want to expose private keys in diffs.
"private_key_key_id": ActionIgnore, // Internal dbcrypt metadata, not useful in audit diffs.
"public_key": ActionTrack, // Public keys are ok to expose in a diff.
},
&database.Template{}: {
"id": ActionTrack,
+20
View File
@@ -204,6 +204,12 @@ func TestServerDBCrypt(t *testing.T) {
userSecrets, err := db.ListUserSecretsWithValues(ctx, usr.ID)
require.NoError(t, err, "failed to get user secrets for user %s", usr.ID)
require.Empty(t, userSecrets)
// gitsshkey rows are preserved so the user can regenerate; only the ciphertext is wiped.
sshKey, err := db.GetGitSSHKey(ctx, usr.ID)
require.NoError(t, err, "expected gitsshkey row to remain for user %s", usr.ID)
require.Empty(t, sshKey.PrivateKey, "expected private_key to be cleared for user %s", usr.ID)
require.False(t, sshKey.PrivateKeyKeyID.Valid, "expected private_key_key_id to be cleared for user %s", usr.ID)
}
// Validate that the key has been revoked in the database.
@@ -245,6 +251,13 @@ func genData(t *testing.T, db database.Store) []database.User {
ProviderID: provider.ID,
APIKey: "provider-key-" + usr.ID.String(),
})
// gitsshkeys are not removed by the user soft-delete trigger,
// so seed one for every user including deleted ones.
_ = dbgen.GitSSHKey(t, db, database.GitSSHKey{
UserID: usr.ID,
PrivateKey: "private-" + usr.ID.String(),
PublicKey: "public-" + usr.ID.String(),
})
now := time.Now()
_, err := db.UpsertUserAIProviderKey(context.Background(), database.UpsertUserAIProviderKeyParams{
ID: uuid.New(),
@@ -325,6 +338,13 @@ func requireEncryptedWithCipher(ctx context.Context, t *testing.T, db database.S
require.Equal(t, c.HexDigest(), s.ValueKeyID.String)
}
sshKey, err := db.GetGitSSHKey(ctx, userID)
require.NoError(t, err, "failed to get gitsshkey for user %s", userID)
requireEncryptedEquals(t, c, "private-"+userID.String(), sshKey.PrivateKey)
require.Equal(t, c.HexDigest(), sshKey.PrivateKeyKeyID.String)
// Public key is never encrypted.
require.Equal(t, "public-"+userID.String(), sshKey.PublicKey)
providers, err := db.GetAIProviders(ctx, database.GetAIProvidersParams{
IncludeDeleted: true,
IncludeDisabled: true,
+51
View File
@@ -101,6 +101,31 @@ func Rotate(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciphe
log.Debug(ctx, "rotated user secret", slog.F("user_id", uid), slog.F("secret_name", secret.Name), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
}
sshKey, err := cryptTx.GetGitSSHKey(ctx, uid)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get gitsshkey for user %s: %w", uid, err)
}
if err == nil {
switch {
case sshKey.PrivateKey == "":
// Post-Delete wipes the private_key and key_id; nothing to encrypt.
log.Debug(ctx, "skipping empty gitsshkey", slog.F("user_id", uid), slog.F("current", idx+1))
case sshKey.PrivateKeyKeyID.Valid && sshKey.PrivateKeyKeyID.String == ciphers[0].HexDigest():
log.Debug(ctx, "skipping gitsshkey", slog.F("user_id", uid), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
default:
if _, err := cryptTx.UpdateGitSSHKey(ctx, database.UpdateGitSSHKeyParams{
UserID: uid,
UpdatedAt: sshKey.UpdatedAt,
PrivateKey: sshKey.PrivateKey,
PrivateKeyKeyID: sql.NullString{}, // dbcrypt will re-encrypt
PublicKey: sshKey.PublicKey,
}); err != nil {
return xerrors.Errorf("rotate gitsshkey user_id=%s: %w", uid, err)
}
log.Debug(ctx, "rotated gitsshkey", slog.F("user_id", uid), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest()))
}
}
return nil
}, &database.TxOptions{
Isolation: sql.LevelRepeatableRead,
@@ -288,6 +313,23 @@ func Decrypt(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciph
log.Debug(ctx, "decrypted user secret", slog.F("user_id", uid), slog.F("secret_name", secret.Name), slog.F("current", idx+1))
}
sshKey, err := tx.GetGitSSHKey(ctx, uid)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get gitsshkey for user %s: %w", uid, err)
}
if err == nil && sshKey.PrivateKeyKeyID.Valid {
if _, err := tx.UpdateGitSSHKey(ctx, database.UpdateGitSSHKeyParams{
UserID: uid,
UpdatedAt: sshKey.UpdatedAt,
PrivateKey: sshKey.PrivateKey,
PrivateKeyKeyID: sql.NullString{}, // clear the key ID
PublicKey: sshKey.PublicKey,
}); err != nil {
return xerrors.Errorf("decrypt gitsshkey user_id=%s: %w", uid, err)
}
log.Debug(ctx, "decrypted gitsshkey", slog.F("user_id", uid), slog.F("current", idx+1))
}
return nil
}, &database.TxOptions{
Isolation: sql.LevelRepeatableRead,
@@ -382,6 +424,15 @@ DELETE FROM user_ai_provider_keys
WHERE api_key_key_id IS NOT NULL;
DELETE FROM user_secrets
WHERE value_key_id IS NOT NULL;
-- gitsshkeys has no delete path in product code: rows are inserted on
-- user creation and only ever mutated by regenerate. dbcrypt's 'delete'
-- command is the one operation that needs to wipe encrypted content,
-- and it does so by clearing the value rather than deleting the row,
-- so users can regenerate via the UI.
UPDATE gitsshkeys
SET private_key = '',
private_key_key_id = NULL
WHERE private_key_key_id IS NOT NULL;
UPDATE ai_providers
SET settings = NULL,
settings_key_id = NULL
+39
View File
@@ -930,6 +930,45 @@ func (db *dbCrypt) UpdateUserSecretByUserIDAndName(ctx context.Context, arg data
return secret, nil
}
func (db *dbCrypt) InsertGitSSHKey(ctx context.Context, params database.InsertGitSSHKeyParams) (database.GitSSHKey, error) {
if err := db.encryptField(&params.PrivateKey, &params.PrivateKeyKeyID); err != nil {
return database.GitSSHKey{}, err
}
key, err := db.Store.InsertGitSSHKey(ctx, params)
if err != nil {
return database.GitSSHKey{}, err
}
if err := db.decryptField(&key.PrivateKey, key.PrivateKeyKeyID); err != nil {
return database.GitSSHKey{}, err
}
return key, nil
}
func (db *dbCrypt) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (database.GitSSHKey, error) {
key, err := db.Store.GetGitSSHKey(ctx, userID)
if err != nil {
return database.GitSSHKey{}, err
}
if err := db.decryptField(&key.PrivateKey, key.PrivateKeyKeyID); err != nil {
return database.GitSSHKey{}, err
}
return key, nil
}
func (db *dbCrypt) UpdateGitSSHKey(ctx context.Context, params database.UpdateGitSSHKeyParams) (database.GitSSHKey, error) {
if err := db.encryptField(&params.PrivateKey, &params.PrivateKeyKeyID); err != nil {
return database.GitSSHKey{}, err
}
key, err := db.Store.UpdateGitSSHKey(ctx, params)
if err != nil {
return database.GitSSHKey{}, err
}
if err := db.decryptField(&key.PrivateKey, key.PrivateKeyKeyID); err != nil {
return database.GitSSHKey{}, err
}
return key, nil
}
func (db *dbCrypt) encryptField(field *string, digest *sql.NullString) error {
// If no cipher is loaded, then we can't encrypt anything!
if db.ciphers == nil || db.primaryCipherDigest == "" {
+173
View File
@@ -1764,3 +1764,176 @@ func TestUserSecrets(t *testing.T) {
require.ErrorAs(t, err, &derr)
})
}
func TestGitSSHKey(t *testing.T) {
t.Parallel()
ctx := context.Background()
const (
initialPrivate = "private-key-initial"
updatedPrivate = "private-key-updated"
publicKey = "public-key"
)
insertGitSSHKey := func(t *testing.T, store database.Store, ciphers []Cipher) database.GitSSHKey {
t.Helper()
user := dbgen.User(t, store, database.User{})
key, err := store.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{
UserID: user.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
PrivateKey: initialPrivate,
PublicKey: publicKey,
})
require.NoError(t, err)
require.Equal(t, initialPrivate, key.PrivateKey)
require.Equal(t, publicKey, key.PublicKey)
if len(ciphers) > 0 {
require.True(t, key.PrivateKeyKeyID.Valid)
require.Equal(t, ciphers[0].HexDigest(), key.PrivateKeyKeyID.String)
}
return key
}
t.Run("InsertGitSSHKeyEncryptsPrivateKey", func(t *testing.T) {
t.Parallel()
db, crypt, ciphers := setup(t)
key := insertGitSSHKey(t, crypt, ciphers)
// Raw row should be ciphertext under the primary cipher.
rawKey, err := db.GetGitSSHKey(ctx, key.UserID)
require.NoError(t, err)
require.NotEqual(t, initialPrivate, rawKey.PrivateKey)
requireEncryptedEquals(t, ciphers[0], rawKey.PrivateKey, initialPrivate)
require.True(t, rawKey.PrivateKeyKeyID.Valid)
require.Equal(t, ciphers[0].HexDigest(), rawKey.PrivateKeyKeyID.String)
// Public key is not encrypted.
require.Equal(t, publicKey, rawKey.PublicKey)
})
t.Run("GetGitSSHKeyDecryptsEncryptedRow", func(t *testing.T) {
t.Parallel()
_, crypt, ciphers := setup(t)
key := insertGitSSHKey(t, crypt, ciphers)
got, err := crypt.GetGitSSHKey(ctx, key.UserID)
require.NoError(t, err)
require.Equal(t, initialPrivate, got.PrivateKey)
require.True(t, got.PrivateKeyKeyID.Valid)
require.Equal(t, ciphers[0].HexDigest(), got.PrivateKeyKeyID.String)
})
t.Run("GetGitSSHKeyReadsPlaintextRow", func(t *testing.T) {
// Pre-existing plaintext rows (private_key_key_id IS NULL) must remain readable.
t.Parallel()
db, crypt, _ := setup(t)
user := dbgen.User(t, db, database.User{})
inserted, err := db.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{
UserID: user.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
PrivateKey: initialPrivate,
PublicKey: publicKey,
})
require.NoError(t, err)
require.False(t, inserted.PrivateKeyKeyID.Valid)
got, err := crypt.GetGitSSHKey(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, initialPrivate, got.PrivateKey)
require.False(t, got.PrivateKeyKeyID.Valid)
})
t.Run("UpdateGitSSHKeyReEncrypts", func(t *testing.T) {
t.Parallel()
db, crypt, ciphers := setup(t)
key := insertGitSSHKey(t, crypt, ciphers)
updated, err := crypt.UpdateGitSSHKey(ctx, database.UpdateGitSSHKeyParams{
UserID: key.UserID,
UpdatedAt: dbtime.Now(),
PrivateKey: updatedPrivate,
PublicKey: publicKey,
})
require.NoError(t, err)
require.Equal(t, updatedPrivate, updated.PrivateKey)
require.True(t, updated.PrivateKeyKeyID.Valid)
require.Equal(t, ciphers[0].HexDigest(), updated.PrivateKeyKeyID.String)
rawKey, err := db.GetGitSSHKey(ctx, key.UserID)
require.NoError(t, err)
requireEncryptedEquals(t, ciphers[0], rawKey.PrivateKey, updatedPrivate)
require.True(t, rawKey.PrivateKeyKeyID.Valid)
require.Equal(t, ciphers[0].HexDigest(), rawKey.PrivateKeyKeyID.String)
})
t.Run("UpdateGitSSHKeyEncryptsPlaintextRow", func(t *testing.T) {
// A row that started life as plaintext must get encrypted on the next write.
t.Parallel()
db, crypt, ciphers := setup(t)
user := dbgen.User(t, db, database.User{})
_, err := db.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{
UserID: user.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
PrivateKey: initialPrivate,
PublicKey: publicKey,
})
require.NoError(t, err)
_, err = crypt.UpdateGitSSHKey(ctx, database.UpdateGitSSHKeyParams{
UserID: user.ID,
UpdatedAt: dbtime.Now(),
PrivateKey: updatedPrivate,
PublicKey: publicKey,
})
require.NoError(t, err)
rawKey, err := db.GetGitSSHKey(ctx, user.ID)
require.NoError(t, err)
requireEncryptedEquals(t, ciphers[0], rawKey.PrivateKey, updatedPrivate)
require.True(t, rawKey.PrivateKeyKeyID.Valid)
require.Equal(t, ciphers[0].HexDigest(), rawKey.PrivateKeyKeyID.String)
})
t.Run("GetGitSSHKeyDecryptErr", func(t *testing.T) {
t.Parallel()
db, crypt, ciphers := setup(t)
user := dbgen.User(t, db, database.User{})
_, err := db.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{
UserID: user.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
PrivateKey: fakeBase64RandomData(t, 32),
PrivateKeyKeyID: sql.NullString{String: ciphers[0].HexDigest(), Valid: true},
PublicKey: publicKey,
})
require.NoError(t, err)
_, err = crypt.GetGitSSHKey(ctx, user.ID)
require.Error(t, err)
var derr *DecryptFailedError
require.ErrorAs(t, err, &derr)
})
t.Run("NoCipherPassthrough", func(t *testing.T) {
t.Parallel()
db, crypt := setupNoCiphers(t)
user := dbgen.User(t, crypt, database.User{})
key, err := crypt.InsertGitSSHKey(ctx, database.InsertGitSSHKeyParams{
UserID: user.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
PrivateKey: initialPrivate,
PublicKey: publicKey,
})
require.NoError(t, err)
require.Equal(t, initialPrivate, key.PrivateKey)
require.False(t, key.PrivateKeyKeyID.Valid)
rawKey, err := db.GetGitSSHKey(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, initialPrivate, rawKey.PrivateKey)
require.False(t, rawKey.PrivateKeyKeyID.Valid)
})
}