mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+7
-1
@@ -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
@@ -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.';
|
||||
Generated
+2
@@ -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 {
|
||||
|
||||
Generated
+22
-13
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¶ms.PrivateKey, ¶ms.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(¶ms.PrivateKey, ¶ms.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 == "" {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user