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:
@@ -39,6 +39,8 @@
|
|||||||
//
|
//
|
||||||
// pgcreds get --id UUID
|
// pgcreds get --id UUID
|
||||||
// pgcreds set --id UUID --host H --port P --db D --user U
|
// pgcreds set --id UUID --host H --port P --db D --user U
|
||||||
|
//
|
||||||
|
// rekey
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -107,6 +109,8 @@ func main() {
|
|||||||
tool.runAudit(subArgs)
|
tool.runAudit(subArgs)
|
||||||
case "pgcreds":
|
case "pgcreds":
|
||||||
tool.runPGCreds(subArgs)
|
tool.runPGCreds(subArgs)
|
||||||
|
case "rekey":
|
||||||
|
tool.runRekey(subArgs)
|
||||||
default:
|
default:
|
||||||
fatalf("unknown command %q; run with no args for usage", command)
|
fatalf("unknown command %q; run with no args for usage", command)
|
||||||
}
|
}
|
||||||
@@ -259,6 +263,9 @@ Commands:
|
|||||||
pgcreds set --id UUID --host H [--port P] --db D --user U
|
pgcreds set --id UUID --host H [--port P] --db D --user U
|
||||||
(password is prompted interactively)
|
(password is prompted interactively)
|
||||||
|
|
||||||
|
rekey Re-encrypt all secrets under a new master passphrase
|
||||||
|
(prompts interactively; requires server to be stopped)
|
||||||
|
|
||||||
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
|
NOTE: mciasdb bypasses the mciassrv API and operates directly on the SQLite
|
||||||
file. Use it only when the server is unavailable or for break-glass recovery.
|
file. Use it only when the server is unavailable or for break-glass recovery.
|
||||||
All write operations are recorded in the audit log.
|
All write operations are recorded in the audit log.
|
||||||
|
|||||||
@@ -438,3 +438,141 @@ func TestPGCredsGetNotFound(t *testing.T) {
|
|||||||
t.Fatal("expected ErrNotFound, got nil")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
154
cmd/mciasdb/rekey.go
Normal file
154
cmd/mciasdb/rekey.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
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.")
|
||||||
|
}
|
||||||
@@ -1245,3 +1245,155 @@ func (db *DB) ClearLoginFailures(accountID int64) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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))
|
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