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:
2026-03-15 13:27:29 -07:00
parent 23a27be57e
commit 0d38bbae00
5 changed files with 658 additions and 0 deletions

View File

@@ -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
}