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

@@ -438,3 +438,141 @@ func TestPGCredsGetNotFound(t *testing.T) {
t.Fatal("expected ErrNotFound, got nil")
}
}
// ---- rekey command tests ----
// TestRekeyCommandRoundTrip exercises runRekey end-to-end with real AES-256-GCM
// encryption and actual Argon2id key derivation. It verifies that all secrets
// (signing key, TOTP, pg password) remain accessible after rekey and that the
// old master key no longer decrypts the re-encrypted values.
//
// Note: Argon2id derivation (time=3, memory=128 MiB) makes this test slow (~2 s).
func TestRekeyCommandRoundTrip(t *testing.T) {
tool := newTestTool(t)
// ── Setup: signing key encrypted under old master key ──
_, privKey, err := crypto.GenerateEd25519KeyPair()
if err != nil {
t.Fatalf("generate key pair: %v", err)
}
sigKeyPEM, err := crypto.MarshalPrivateKeyPEM(privKey)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
sigEnc, sigNonce, err := crypto.SealAESGCM(tool.masterKey, sigKeyPEM)
if err != nil {
t.Fatalf("seal signing key: %v", err)
}
if err := tool.db.WriteServerConfig(sigEnc, sigNonce); err != nil {
t.Fatalf("write server config: %v", err)
}
// WriteMasterKeySalt so ReadServerConfig has a valid salt row.
oldSalt, err := crypto.NewSalt()
if err != nil {
t.Fatalf("gen salt: %v", err)
}
if err := tool.db.WriteMasterKeySalt(oldSalt); err != nil {
t.Fatalf("write salt: %v", err)
}
// ── Setup: account with TOTP ──
a, err := tool.db.CreateAccount("rekeyuser", "human", "")
if err != nil {
t.Fatalf("create account: %v", err)
}
totpSecret := []byte("JBSWY3DPEHPK3PXP")
totpEnc, totpNonce, err := crypto.SealAESGCM(tool.masterKey, totpSecret)
if err != nil {
t.Fatalf("seal totp: %v", err)
}
if err := tool.db.SetTOTP(a.ID, totpEnc, totpNonce); err != nil {
t.Fatalf("set totp: %v", err)
}
// ── Setup: pg credentials ──
pgPass := []byte("pgpassword123")
pgEnc, pgNonce, err := crypto.SealAESGCM(tool.masterKey, pgPass)
if err != nil {
t.Fatalf("seal pg pass: %v", err)
}
if err := tool.db.WritePGCredentials(a.ID, "localhost", 5432, "mydb", "myuser", pgEnc, pgNonce); err != nil {
t.Fatalf("write pg creds: %v", err)
}
// ── Pipe new passphrase twice into stdin ──
const newPassphrase = "new-master-passphrase-for-test"
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("create stdin pipe: %v", err)
}
origStdin := os.Stdin
os.Stdin = r
t.Cleanup(func() { os.Stdin = origStdin })
if _, err := fmt.Fprintf(w, "%s\n%s\n", newPassphrase, newPassphrase); err != nil {
t.Fatalf("write stdin: %v", err)
}
_ = w.Close()
// ── Execute rekey ──
tool.runRekey(nil)
// ── Derive new key from stored salt + new passphrase ──
newSalt, err := tool.db.ReadMasterKeySalt()
if err != nil {
t.Fatalf("read new salt: %v", err)
}
newKey, err := crypto.DeriveKey(newPassphrase, newSalt)
if err != nil {
t.Fatalf("derive new key: %v", err)
}
defer func() {
for i := range newKey {
newKey[i] = 0
}
}()
// Signing key must decrypt with new key.
newSigEnc, newSigNonce, err := tool.db.ReadServerConfig()
if err != nil {
t.Fatalf("read server config after rekey: %v", err)
}
decPEM, err := crypto.OpenAESGCM(newKey, newSigNonce, newSigEnc)
if err != nil {
t.Fatalf("decrypt signing key with new key: %v", err)
}
if string(decPEM) != string(sigKeyPEM) {
t.Error("signing key PEM mismatch after rekey")
}
// Old key must NOT decrypt the re-encrypted signing key.
// Security: adversarial check that old key is invalidated.
if _, err := crypto.OpenAESGCM(tool.masterKey, newSigNonce, newSigEnc); err == nil {
t.Error("old key still decrypts signing key after rekey — ciphertext was not replaced")
}
// TOTP must decrypt with new key.
updatedAcct, err := tool.db.GetAccountByUUID(a.UUID)
if err != nil {
t.Fatalf("get account after rekey: %v", err)
}
decTOTP, err := crypto.OpenAESGCM(newKey, updatedAcct.TOTPSecretNonce, updatedAcct.TOTPSecretEnc)
if err != nil {
t.Fatalf("decrypt TOTP with new key: %v", err)
}
if string(decTOTP) != string(totpSecret) {
t.Errorf("TOTP mismatch: got %q, want %q", decTOTP, totpSecret)
}
// pg password must decrypt with new key.
updatedCred, err := tool.db.ReadPGCredentials(a.ID)
if err != nil {
t.Fatalf("read pg creds after rekey: %v", err)
}
decPG, err := crypto.OpenAESGCM(newKey, updatedCred.PGPasswordNonce, updatedCred.PGPasswordEnc)
if err != nil {
t.Fatalf("decrypt pg password with new key: %v", err)
}
if string(decPG) != string(pgPass) {
t.Errorf("pg password mismatch: got %q, want %q", decPG, pgPass)
}
}