- 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>
155 lines
5.9 KiB
Go
155 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
|
)
|
|
|
|
// runRekey re-encrypts all secrets under a new passphrase-derived master key.
|
|
//
|
|
// The current master key (already loaded in tool.masterKey by openDB) is used
|
|
// to decrypt every encrypted secret: the Ed25519 signing key, all TOTP secrets,
|
|
// and all Postgres credential passwords. The operator is then prompted for a
|
|
// new passphrase (confirmed), a fresh Argon2id salt is generated, a new 256-bit
|
|
// master key is derived, and all secrets are re-encrypted and written back in a
|
|
// single atomic SQLite transaction.
|
|
//
|
|
// Security: The entire re-encryption happens in memory first; the database is
|
|
// only updated once all ciphertext has been produced successfully. The new
|
|
// salt replaces the old salt atomically within the same transaction so the
|
|
// database is never left in a mixed state. Both the old and new master keys
|
|
// are zeroed in deferred cleanup. No secret material is logged or printed.
|
|
func (t *tool) runRekey(_ []string) {
|
|
// ── 1. Decrypt signing key under old master key ──────────────────────
|
|
sigKeyEnc, sigKeyNonce, err := t.db.ReadServerConfig()
|
|
if err != nil {
|
|
fatalf("read server config: %v", err)
|
|
}
|
|
sigKeyPEM, err := crypto.OpenAESGCM(t.masterKey, sigKeyNonce, sigKeyEnc)
|
|
if err != nil {
|
|
fatalf("decrypt signing key: %v", err)
|
|
}
|
|
|
|
// ── 2. Decrypt all TOTP secrets under old master key ─────────────────
|
|
totpAccounts, err := t.db.ListAccountsWithTOTP()
|
|
if err != nil {
|
|
fatalf("list accounts with TOTP: %v", err)
|
|
}
|
|
type totpPlain struct {
|
|
secret []byte
|
|
accountID int64
|
|
}
|
|
totpPlaintexts := make([]totpPlain, 0, len(totpAccounts))
|
|
for _, a := range totpAccounts {
|
|
pt, err := crypto.OpenAESGCM(t.masterKey, a.TOTPSecretNonce, a.TOTPSecretEnc)
|
|
if err != nil {
|
|
fatalf("decrypt TOTP secret for account %s: %v", a.Username, err)
|
|
}
|
|
totpPlaintexts = append(totpPlaintexts, totpPlain{accountID: a.ID, secret: pt})
|
|
}
|
|
|
|
// ── 3. Decrypt all pg_credentials passwords under old master key ──────
|
|
pgCreds, err := t.db.ListAllPGCredentials()
|
|
if err != nil {
|
|
fatalf("list pg credentials: %v", err)
|
|
}
|
|
type pgPlain struct {
|
|
password []byte
|
|
credID int64
|
|
}
|
|
pgPlaintexts := make([]pgPlain, 0, len(pgCreds))
|
|
for _, c := range pgCreds {
|
|
pt, err := crypto.OpenAESGCM(t.masterKey, c.PGPasswordNonce, c.PGPasswordEnc)
|
|
if err != nil {
|
|
fatalf("decrypt pg password for credential %d: %v", c.ID, err)
|
|
}
|
|
pgPlaintexts = append(pgPlaintexts, pgPlain{credID: c.ID, password: pt})
|
|
}
|
|
|
|
// ── 4. Prompt for new passphrase (confirmed) ──────────────────────────
|
|
fmt.Fprintln(os.Stderr, "Enter new master passphrase (will not echo):")
|
|
newPassphrase, err := readPassword("New passphrase: ")
|
|
if err != nil {
|
|
fatalf("read passphrase: %v", err)
|
|
}
|
|
if newPassphrase == "" {
|
|
fatalf("passphrase must not be empty")
|
|
}
|
|
confirm, err := readPassword("Confirm passphrase: ")
|
|
if err != nil {
|
|
fatalf("read passphrase confirmation: %v", err)
|
|
}
|
|
if newPassphrase != confirm {
|
|
fatalf("passphrases do not match")
|
|
}
|
|
|
|
// ── 5. Derive new master key ──────────────────────────────────────────
|
|
// Security: a fresh random salt is generated for every rekey so that the
|
|
// new key is independent of the old key even if the same passphrase is
|
|
// reused. The new salt is stored atomically with the re-encrypted secrets.
|
|
newSalt, err := crypto.NewSalt()
|
|
if err != nil {
|
|
fatalf("generate new salt: %v", err)
|
|
}
|
|
newKey, err := crypto.DeriveKey(newPassphrase, newSalt)
|
|
if err != nil {
|
|
fatalf("derive new master key: %v", err)
|
|
}
|
|
// Zero both keys when done, regardless of outcome.
|
|
defer func() {
|
|
for i := range newKey {
|
|
newKey[i] = 0
|
|
}
|
|
}()
|
|
|
|
// ── 6. Re-encrypt signing key ─────────────────────────────────────────
|
|
newSigKeyEnc, newSigKeyNonce, err := crypto.SealAESGCM(newKey, sigKeyPEM)
|
|
if err != nil {
|
|
fatalf("re-encrypt signing key: %v", err)
|
|
}
|
|
|
|
// ── 7. Re-encrypt TOTP secrets ────────────────────────────────────────
|
|
totpRows := make([]db.TOTPRekeyRow, 0, len(totpPlaintexts))
|
|
for _, tp := range totpPlaintexts {
|
|
enc, nonce, err := crypto.SealAESGCM(newKey, tp.secret)
|
|
if err != nil {
|
|
fatalf("re-encrypt TOTP secret for account %d: %v", tp.accountID, err)
|
|
}
|
|
totpRows = append(totpRows, db.TOTPRekeyRow{
|
|
AccountID: tp.accountID,
|
|
Enc: enc,
|
|
Nonce: nonce,
|
|
})
|
|
}
|
|
|
|
// ── 8. Re-encrypt pg_credentials passwords ────────────────────────────
|
|
pgRows := make([]db.PGRekeyRow, 0, len(pgPlaintexts))
|
|
for _, pp := range pgPlaintexts {
|
|
enc, nonce, err := crypto.SealAESGCM(newKey, pp.password)
|
|
if err != nil {
|
|
fatalf("re-encrypt pg password for credential %d: %v", pp.credID, err)
|
|
}
|
|
pgRows = append(pgRows, db.PGRekeyRow{
|
|
CredentialID: pp.credID,
|
|
Enc: enc,
|
|
Nonce: nonce,
|
|
})
|
|
}
|
|
|
|
// ── 9. Atomic commit ──────────────────────────────────────────────────
|
|
if err := t.db.Rekey(newSalt, newSigKeyEnc, newSigKeyNonce, totpRows, pgRows); err != nil {
|
|
fatalf("rekey database: %v", err)
|
|
}
|
|
|
|
if err := t.db.WriteAuditEvent("master_key_rekeyed", nil, nil, "", `{"actor":"mciasdb"}`); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: write audit event: %v\n", err)
|
|
}
|
|
|
|
fmt.Printf("Rekey complete: %d TOTP secrets and %d pg credentials re-encrypted.\n",
|
|
len(totpRows), len(pgRows))
|
|
fmt.Fprintln(os.Stderr, "Update your mcias.toml or passphrase environment variable to use the new passphrase.")
|
|
}
|