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

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