c2f181e56d
Add the secondary-persona path from docs/BROWSER-ROTATION.md: ingest a password manager / browser store, take a mandatory verified sealed backup, generate fresh strong passwords, hand the human a ready-to-finish task at the MFA wall, and commit the new value into the manager only after the site change is confirmed. - internal/pwgen: crypto/rand generator (rejection sampling, per-class guarantee, vault-native — never returns plaintext as a Go string). - internal/pwstore: Manager/ItemUpdater/BulkImporter contracts, secrets-free RedactIdentity, and five adapters — bitwarden (bw, stdin), keepassxc (keepassxc-cli, stdin), 1password (op, argv assignment with documented caveat), chrome/firefox (tmpfs CSV ingest-and-shred). All real code; validation status is data (MOCK-ONLY against fake binaries/CSVs, recorded in the design doc). - internal/pwstore/stage.go: age-sealed staged-list (WriteStaged/ReadStaged) + StagedImporter — the only artifacts crossing the plan->commit gap, never plaintext. - cmd/incredigo: passwords scan|plan|guide|commit. Backup gate seals + round-trip verifies before any commit; guide is interactive verify-before-commit; commit is headless over a sealed stage. Browser CSV writes are gated behind --allow-csv. Hard rules honored: backup-before-commit, verify-before-commit, no plaintext on disk (browser CSV is the flag-gated tmpfs+shred exception), MFA always a human handoff. go vet + -race clean; end-to-end verified against a fake bw. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
137 lines
3.8 KiB
Go
137 lines
3.8 KiB
Go
package pwstore
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// fakeKeePassXC writes a `keepassxc-cli` stand-in backed by a CSV "database" file.
|
|
// `export -f csv <db>` reads the DB passphrase from stdin (asserting it matches) and
|
|
// prints the CSV; `edit -p <db> <entry>` reads two stdin lines (db passphrase, then
|
|
// the new entry password) and rewrites the matching row — proving both secrets travel
|
|
// over stdin, never argv.
|
|
func fakeKeePassXC(t *testing.T) (bin, dbPath string) {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("fake keepassxc-cli is a POSIX shell script")
|
|
}
|
|
dir := t.TempDir()
|
|
dbPath = filepath.Join(dir, "lab.kdbx") // a CSV masquerading as a kdbx for the fake
|
|
csv := "Group,Title,Username,Password,URL,Notes\n" +
|
|
"Root,GitHub,alice@example.com,old-secret,https://github.com/login,\n"
|
|
if err := os.WriteFile(dbPath, []byte(csv), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bin = filepath.Join(dir, "keepassxc-cli")
|
|
const wantPass = "db-master-pass"
|
|
script := `#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
DB_WANT_PASS="` + wantPass + `"
|
|
cmd="${1:-}"
|
|
case "$cmd" in
|
|
export)
|
|
# args: export -f csv <db>
|
|
db="$4"
|
|
read -r dbpass || true
|
|
if [ "$dbpass" != "$DB_WANT_PASS" ]; then echo "bad db passphrase" >&2; exit 1; fi
|
|
cat "$db"
|
|
;;
|
|
edit)
|
|
# args: edit -p <db> <entry> ; stdin: <dbpass>\n<newpass>
|
|
db="$3"; entry="$4"
|
|
read -r dbpass || true
|
|
read -r newpass || true
|
|
if [ "$dbpass" != "$DB_WANT_PASS" ]; then echo "bad db passphrase" >&2; exit 1; fi
|
|
# entry is /Root/GitHub -> title GitHub. Rewrite the Password column of that row.
|
|
title="${entry##*/}"
|
|
tmp="$(mktemp)"
|
|
while IFS= read -r line; do
|
|
case "$line" in
|
|
*",$title,"*)
|
|
IFS=',' read -r g ti us pw url notes <<< "$line"
|
|
printf '%s,%s,%s,%s,%s,%s\n' "$g" "$ti" "$us" "$newpass" "$url" "$notes"
|
|
;;
|
|
*) printf '%s\n' "$line";;
|
|
esac
|
|
done < "$db" > "$tmp"
|
|
mv "$tmp" "$db"
|
|
;;
|
|
*)
|
|
echo "fake keepassxc-cli: unknown command: $*" >&2; exit 2
|
|
;;
|
|
esac
|
|
`
|
|
if err := os.WriteFile(bin, []byte(script), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return bin, dbPath
|
|
}
|
|
|
|
func TestKeePassXCExport(t *testing.T) {
|
|
bin, db := fakeKeePassXC(t)
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
k := &KeePassXC{Bin: bin, DBPath: db, DBKey: v.Store([]byte("db-master-pass"))}
|
|
|
|
if !k.Available() {
|
|
t.Fatal("Available should be true with db path + injected bin")
|
|
}
|
|
accts, err := k.Export(context.Background(), v)
|
|
if err != nil {
|
|
t.Fatalf("Export: %v", err)
|
|
}
|
|
if len(accts) != 1 {
|
|
t.Fatalf("got %d accounts, want 1", len(accts))
|
|
}
|
|
a := accts[0]
|
|
if a.ID != "/Root/GitHub" {
|
|
t.Errorf("entry path = %q, want /Root/GitHub", a.ID)
|
|
}
|
|
if a.Username != "alice@example.com" || a.Site != "github.com" {
|
|
t.Errorf("account = %+v", a)
|
|
}
|
|
if pw := handleStr(t, v, a.Secret); pw != "old-secret" {
|
|
t.Errorf("password = %q, want old-secret", pw)
|
|
}
|
|
}
|
|
|
|
func TestKeePassXCUpdatePassword(t *testing.T) {
|
|
bin, db := fakeKeePassXC(t)
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
k := &KeePassXC{Bin: bin, DBPath: db, DBKey: v.Store([]byte("db-master-pass"))}
|
|
|
|
accts, err := k.Export(context.Background(), v)
|
|
if err != nil {
|
|
t.Fatalf("Export: %v", err)
|
|
}
|
|
newPw := v.Store([]byte("NEW-kp-pw-3"))
|
|
if err := k.UpdatePassword(context.Background(), v, accts[0], newPw); err != nil {
|
|
t.Fatalf("UpdatePassword: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := string(raw)
|
|
if !strings.Contains(s, "NEW-kp-pw-3") {
|
|
t.Errorf("db not updated with new password:\n%s", s)
|
|
}
|
|
if strings.Contains(s, "old-secret") {
|
|
t.Errorf("db still has old password:\n%s", s)
|
|
}
|
|
}
|
|
|
|
func TestKeePassXCUnavailableWithoutDB(t *testing.T) {
|
|
k := &KeePassXC{Bin: "keepassxc-cli"}
|
|
if k.Available() {
|
|
t.Error("Available should be false with no db path")
|
|
}
|
|
}
|