Add mciasdb rekey command
- internal/db/accounts.go: add ListAccountsWithTOTP, ListAllPGCredentials, TOTPRekeyRow, PGRekeyRow, and Rekey — atomic transaction that replaces master_key_salt, signing_key_enc/nonce, all TOTP enc/nonce, and all pg_password enc/nonce in one SQLite BEGIN/COMMIT - cmd/mciasdb/rekey.go: runRekey — decrypts all secrets under old master key, prompts for new passphrase (with confirmation), derives new key from fresh Argon2id salt, re-encrypts everything, and commits atomically - cmd/mciasdb/main.go: wire "rekey" command + update usage - Tests: DB-layer tests for ListAccountsWithTOTP, ListAllPGCredentials, Rekey (happy path, empty DB, salt replacement); command-level TestRekeyCommandRoundTrip verifies full round-trip and adversarially confirms old key no longer decrypts after rekey Security: fresh random salt is always generated so a reused passphrase still produces an independent key; old and new master keys are zeroed via defer; no passphrase or key material appears in logs or audit events; the entire re-encryption is done in-memory before the single atomic DB write so the database is never in a mixed state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1245,3 +1245,155 @@ func (db *DB) ClearLoginFailures(accountID int64) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAccountsWithTOTP returns all accounts (including deleted) that have a
|
||||
// non-null TOTP secret stored, so that rekey can re-encrypt every secret even
|
||||
// for inactive or deleted accounts.
|
||||
func (db *DB) ListAccountsWithTOTP() ([]*model.Account, error) {
|
||||
rows, err := db.sql.Query(`
|
||||
SELECT id, uuid, username, account_type, COALESCE(password_hash,''),
|
||||
status, totp_required,
|
||||
totp_secret_enc, totp_secret_nonce,
|
||||
created_at, updated_at, deleted_at
|
||||
FROM accounts
|
||||
WHERE totp_secret_enc IS NOT NULL
|
||||
ORDER BY id ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list accounts with TOTP: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var accounts []*model.Account
|
||||
for rows.Next() {
|
||||
a, err := db.scanAccountRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts = append(accounts, a)
|
||||
}
|
||||
return accounts, rows.Err()
|
||||
}
|
||||
|
||||
// ListAllPGCredentials returns every row in pg_credentials. Used by rekey
|
||||
// to re-encrypt all stored passwords under a new master key.
|
||||
func (db *DB) ListAllPGCredentials() ([]*model.PGCredential, error) {
|
||||
rows, err := db.sql.Query(`
|
||||
SELECT id, account_id, pg_host, pg_port, pg_database, pg_username,
|
||||
pg_password_enc, pg_password_nonce, created_at, updated_at, owner_id
|
||||
FROM pg_credentials
|
||||
ORDER BY id ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list all pg credentials: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var creds []*model.PGCredential
|
||||
for rows.Next() {
|
||||
var cred model.PGCredential
|
||||
var createdAtStr, updatedAtStr string
|
||||
var ownerID sql.NullInt64
|
||||
|
||||
if err := rows.Scan(
|
||||
&cred.ID, &cred.AccountID, &cred.PGHost, &cred.PGPort,
|
||||
&cred.PGDatabase, &cred.PGUsername,
|
||||
&cred.PGPasswordEnc, &cred.PGPasswordNonce,
|
||||
&createdAtStr, &updatedAtStr, &ownerID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("db: scan pg credential: %w", err)
|
||||
}
|
||||
var parseErr error
|
||||
cred.CreatedAt, parseErr = parseTime(createdAtStr)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
cred.UpdatedAt, parseErr = parseTime(updatedAtStr)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
if ownerID.Valid {
|
||||
v := ownerID.Int64
|
||||
cred.OwnerID = &v
|
||||
}
|
||||
creds = append(creds, &cred)
|
||||
}
|
||||
return creds, rows.Err()
|
||||
}
|
||||
|
||||
// TOTPRekeyRow carries a re-encrypted TOTP secret for a single account.
|
||||
type TOTPRekeyRow struct {
|
||||
Enc []byte
|
||||
Nonce []byte
|
||||
AccountID int64
|
||||
}
|
||||
|
||||
// PGRekeyRow carries a re-encrypted Postgres password for a single credential row.
|
||||
type PGRekeyRow struct {
|
||||
Enc []byte
|
||||
Nonce []byte
|
||||
CredentialID int64
|
||||
}
|
||||
|
||||
// Rekey atomically replaces the master-key salt and all secrets encrypted
|
||||
// under the old master key with values encrypted under the new master key.
|
||||
//
|
||||
// Security: The entire replacement is performed inside a single SQLite
|
||||
// transaction so that a crash mid-way leaves the database either fully on the
|
||||
// old key or fully on the new key — never in a mixed state. The caller is
|
||||
// responsible for zeroing the old and new master keys after this call returns.
|
||||
func (db *DB) Rekey(newSalt, newSigningKeyEnc, newSigningKeyNonce []byte, totpRows []TOTPRekeyRow, pgRows []PGRekeyRow) error {
|
||||
tx, err := db.sql.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: rekey begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
n := now()
|
||||
|
||||
// Replace master key salt and signing key atomically.
|
||||
_, err = tx.Exec(`
|
||||
UPDATE server_config
|
||||
SET master_key_salt = ?,
|
||||
signing_key_enc = ?,
|
||||
signing_key_nonce = ?,
|
||||
updated_at = ?
|
||||
WHERE id = 1
|
||||
`, newSalt, newSigningKeyEnc, newSigningKeyNonce, n)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: rekey update server_config: %w", err)
|
||||
}
|
||||
|
||||
// Re-encrypt each TOTP secret.
|
||||
for _, row := range totpRows {
|
||||
_, err = tx.Exec(`
|
||||
UPDATE accounts
|
||||
SET totp_secret_enc = ?,
|
||||
totp_secret_nonce = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`, row.Enc, row.Nonce, n, row.AccountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: rekey update TOTP for account %d: %w", row.AccountID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-encrypt each pg_credentials password.
|
||||
for _, row := range pgRows {
|
||||
_, err = tx.Exec(`
|
||||
UPDATE pg_credentials
|
||||
SET pg_password_enc = ?,
|
||||
pg_password_nonce = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`, row.Enc, row.Nonce, n, row.CredentialID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: rekey update pg credential %d: %w", row.CredentialID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("db: rekey commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -194,3 +194,210 @@ func TestListAuditEventsCombinedFilters(t *testing.T) {
|
||||
t.Fatalf("expected 0 events, got %d", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- rekey helper tests ----
|
||||
|
||||
func TestListAccountsWithTOTP(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
// No accounts with TOTP yet.
|
||||
accounts, err := database.ListAccountsWithTOTP()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccountsWithTOTP (empty): %v", err)
|
||||
}
|
||||
if len(accounts) != 0 {
|
||||
t.Fatalf("expected 0 accounts, got %d", len(accounts))
|
||||
}
|
||||
|
||||
// Create an account and store a TOTP secret.
|
||||
a, err := database.CreateAccount("totpuser", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
if err := database.SetTOTP(a.ID, []byte("enc"), []byte("nonce")); err != nil {
|
||||
t.Fatalf("set TOTP: %v", err)
|
||||
}
|
||||
|
||||
// Create another account without TOTP.
|
||||
if _, err := database.CreateAccount("nototp", model.AccountTypeHuman, "hash"); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
accounts, err = database.ListAccountsWithTOTP()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccountsWithTOTP: %v", err)
|
||||
}
|
||||
if len(accounts) != 1 {
|
||||
t.Fatalf("expected 1 account with TOTP, got %d", len(accounts))
|
||||
}
|
||||
if accounts[0].ID != a.ID {
|
||||
t.Errorf("expected account ID %d, got %d", a.ID, accounts[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAllPGCredentials(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
creds, err := database.ListAllPGCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllPGCredentials (empty): %v", err)
|
||||
}
|
||||
if len(creds) != 0 {
|
||||
t.Fatalf("expected 0 creds, got %d", len(creds))
|
||||
}
|
||||
|
||||
a, err := database.CreateAccount("pguser", model.AccountTypeSystem, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
if err := database.WritePGCredentials(a.ID, "host", 5432, "db", "user", []byte("enc"), []byte("nonce")); err != nil {
|
||||
t.Fatalf("write pg credentials: %v", err)
|
||||
}
|
||||
|
||||
creds, err = database.ListAllPGCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllPGCredentials: %v", err)
|
||||
}
|
||||
if len(creds) != 1 {
|
||||
t.Fatalf("expected 1 credential, got %d", len(creds))
|
||||
}
|
||||
if creds[0].AccountID != a.ID {
|
||||
t.Errorf("expected account ID %d, got %d", a.ID, creds[0].AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRekey(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
// Set up: salt + signing key.
|
||||
oldSalt := []byte("oldsaltoldsaltoldsaltoldsaltoldt") // 32 bytes
|
||||
if err := database.WriteMasterKeySalt(oldSalt); err != nil {
|
||||
t.Fatalf("write salt: %v", err)
|
||||
}
|
||||
if err := database.WriteServerConfig([]byte("oldenc"), []byte("oldnonce")); err != nil {
|
||||
t.Fatalf("write server config: %v", err)
|
||||
}
|
||||
|
||||
// Set up: account with TOTP.
|
||||
a, err := database.CreateAccount("rekeyuser", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
if err := database.SetTOTP(a.ID, []byte("totpenc"), []byte("totpnonce")); err != nil {
|
||||
t.Fatalf("set TOTP: %v", err)
|
||||
}
|
||||
|
||||
// Set up: pg credential.
|
||||
if err := database.WritePGCredentials(a.ID, "host", 5432, "db", "user", []byte("pgenc"), []byte("pgnonce")); err != nil {
|
||||
t.Fatalf("write pg creds: %v", err)
|
||||
}
|
||||
|
||||
// Execute Rekey.
|
||||
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
|
||||
totpRows := []TOTPRekeyRow{{AccountID: a.ID, Enc: []byte("newtotpenc"), Nonce: []byte("newtotpnonce")}}
|
||||
pgCred, err := database.ReadPGCredentials(a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("read pg creds: %v", err)
|
||||
}
|
||||
pgRows := []PGRekeyRow{{CredentialID: pgCred.ID, Enc: []byte("newpgenc"), Nonce: []byte("newpgnonce")}}
|
||||
|
||||
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), totpRows, pgRows); err != nil {
|
||||
t.Fatalf("Rekey: %v", err)
|
||||
}
|
||||
|
||||
// Verify: salt replaced.
|
||||
gotSalt, err := database.ReadMasterKeySalt()
|
||||
if err != nil {
|
||||
t.Fatalf("read salt after rekey: %v", err)
|
||||
}
|
||||
if string(gotSalt) != string(newSalt) {
|
||||
t.Errorf("salt mismatch: got %q, want %q", gotSalt, newSalt)
|
||||
}
|
||||
|
||||
// Verify: signing key replaced.
|
||||
gotEnc, gotNonce, err := database.ReadServerConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("read server config after rekey: %v", err)
|
||||
}
|
||||
if string(gotEnc) != "newenc" || string(gotNonce) != "newnonce" {
|
||||
t.Errorf("signing key enc/nonce mismatch after rekey")
|
||||
}
|
||||
|
||||
// Verify: TOTP replaced.
|
||||
updatedAcct, err := database.GetAccountByID(a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get account after rekey: %v", err)
|
||||
}
|
||||
if string(updatedAcct.TOTPSecretEnc) != "newtotpenc" || string(updatedAcct.TOTPSecretNonce) != "newtotpnonce" {
|
||||
t.Errorf("TOTP enc/nonce mismatch after rekey: enc=%q nonce=%q",
|
||||
updatedAcct.TOTPSecretEnc, updatedAcct.TOTPSecretNonce)
|
||||
}
|
||||
|
||||
// Verify: pg credential replaced.
|
||||
updatedCred, err := database.ReadPGCredentials(a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("read pg creds after rekey: %v", err)
|
||||
}
|
||||
if string(updatedCred.PGPasswordEnc) != "newpgenc" || string(updatedCred.PGPasswordNonce) != "newpgnonce" {
|
||||
t.Errorf("pg enc/nonce mismatch after rekey: enc=%q nonce=%q",
|
||||
updatedCred.PGPasswordEnc, updatedCred.PGPasswordNonce)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRekeyEmptyDatabase(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
// Minimal setup: salt and signing key only; no TOTP, no pg creds.
|
||||
salt := []byte("saltsaltsaltsaltsaltsaltsaltsalt") // 32 bytes
|
||||
if err := database.WriteMasterKeySalt(salt); err != nil {
|
||||
t.Fatalf("write salt: %v", err)
|
||||
}
|
||||
if err := database.WriteServerConfig([]byte("enc"), []byte("nonce")); err != nil {
|
||||
t.Fatalf("write server config: %v", err)
|
||||
}
|
||||
|
||||
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
|
||||
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), nil, nil); err != nil {
|
||||
t.Fatalf("Rekey (empty): %v", err)
|
||||
}
|
||||
|
||||
gotSalt, err := database.ReadMasterKeySalt()
|
||||
if err != nil {
|
||||
t.Fatalf("read salt: %v", err)
|
||||
}
|
||||
if string(gotSalt) != string(newSalt) {
|
||||
t.Errorf("salt mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRekeyOldSaltUnchangedOnQueryError verifies the salt and encrypted data
|
||||
// is only present under the new values after a successful Rekey — the old
|
||||
// values must be gone. Uses the same approach as TestRekey but reads the
|
||||
// stored salt before and confirms it changes.
|
||||
func TestRekeyReplacesSalt(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
|
||||
oldSalt := []byte("oldsaltoldsaltoldsaltoldsaltoldt") // 32 bytes
|
||||
if err := database.WriteMasterKeySalt(oldSalt); err != nil {
|
||||
t.Fatalf("write salt: %v", err)
|
||||
}
|
||||
if err := database.WriteServerConfig([]byte("enc"), []byte("nonce")); err != nil {
|
||||
t.Fatalf("write server config: %v", err)
|
||||
}
|
||||
|
||||
newSalt := []byte("newsaltnewsaltnewsaltnewsaltnews") // 32 bytes
|
||||
if err := database.Rekey(newSalt, []byte("newenc"), []byte("newnonce"), nil, nil); err != nil {
|
||||
t.Fatalf("Rekey: %v", err)
|
||||
}
|
||||
|
||||
gotSalt, err := database.ReadMasterKeySalt()
|
||||
if err != nil {
|
||||
t.Fatalf("read salt: %v", err)
|
||||
}
|
||||
if string(gotSalt) == string(oldSalt) {
|
||||
t.Error("old salt still present after rekey")
|
||||
}
|
||||
if string(gotSalt) != string(newSalt) {
|
||||
t.Errorf("expected new salt %q, got %q", newSalt, gotSalt)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user