passwords: Phase B browser/manager propagation engine (pwgen + pwstore + CLI)
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>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package pwstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// fakeOP writes a tiny `op` stand-in. Each item is a KEY=VALUE file under $state so
|
||||
// an `item edit <id> password=NEW` (argv assignment, exactly as the adapter shells
|
||||
// it) is visible to a later `item get`. The fake also records the full argv of every
|
||||
// invocation to argv.log so a test can assert what did / didn't reach the command line.
|
||||
func fakeOP(t *testing.T) (bin, stateDir string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake op is a POSIX shell script")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
stateDir = filepath.Join(dir, "state")
|
||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := "ID=id1\nTITLE=GitHub\nUSERNAME=alice@example.com\nURL=https://github.com/login\nPW=old-secret\n"
|
||||
if err := os.WriteFile(filepath.Join(stateDir, "id1.item"), []byte(seed), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin = filepath.Join(dir, "op")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
STATE="` + stateDir + `"
|
||||
echo "$@" >> "$STATE/../argv.log"
|
||||
cmd="${1:-}"; sub="${2:-}"
|
||||
case "$cmd $sub" in
|
||||
"item list")
|
||||
first=1; printf '['
|
||||
for f in "$STATE"/*.item; do
|
||||
. "$f"
|
||||
[ "$first" = 1 ] || printf ','
|
||||
printf '{"id":"%s","category":"LOGIN"}' "$ID"
|
||||
first=0
|
||||
done
|
||||
printf ']'
|
||||
;;
|
||||
"item get")
|
||||
id="${3:?}"; . "$STATE/$id.item"
|
||||
printf '{"id":"%s","title":"%s","category":"LOGIN","urls":[{"href":"%s"}],"fields":[{"id":"username","type":"STRING","value":"%s"},{"id":"password","type":"CONCEALED","value":"%s"}]}' \
|
||||
"$ID" "$TITLE" "$URL" "$USERNAME" "$PW"
|
||||
;;
|
||||
"item edit")
|
||||
id="${3:?}"
|
||||
new=""
|
||||
for a in "$@"; do
|
||||
case "$a" in password=*) new="${a#password=}";; esac
|
||||
done
|
||||
f="$STATE/$id.item"
|
||||
. "$f"
|
||||
PW="$new"
|
||||
printf 'ID=%s\nTITLE=%s\nUSERNAME=%s\nURL=%s\nPW=%s\n' "$ID" "$TITLE" "$USERNAME" "$URL" "$PW" > "$f"
|
||||
printf '{"id":"%s"}' "$ID"
|
||||
;;
|
||||
*)
|
||||
echo "fake op: unknown command: $*" >&2; exit 2
|
||||
;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bin, stateDir
|
||||
}
|
||||
|
||||
func TestOnePasswordExport(t *testing.T) {
|
||||
bin, _ := fakeOP(t)
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
o := &OnePassword{Bin: bin}
|
||||
|
||||
if !o.Available() {
|
||||
t.Fatal("injected Bin should report Available")
|
||||
}
|
||||
accts, err := o.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 != "id1" || 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 TestOnePasswordUpdatePassword(t *testing.T) {
|
||||
bin, stateDir := fakeOP(t)
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
o := &OnePassword{Bin: bin}
|
||||
|
||||
accts, err := o.Export(context.Background(), v)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
newPw := v.Store([]byte("NEW-op-pw-7"))
|
||||
if err := o.UpdatePassword(context.Background(), v, accts[0], newPw); err != nil {
|
||||
t.Fatalf("UpdatePassword: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(stateDir, "id1.item"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "PW=NEW-op-pw-7") {
|
||||
t.Errorf("item not updated:\n%s", raw)
|
||||
}
|
||||
// Re-export reads the new password back.
|
||||
v2 := vault.New()
|
||||
defer v2.Purge()
|
||||
accts2, _ := o.Export(context.Background(), v2)
|
||||
if pw := handleStr(t, v2, accts2[0].Secret); pw != "NEW-op-pw-7" {
|
||||
t.Errorf("re-read password = %q", pw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnePasswordUpdateNoIDFails(t *testing.T) {
|
||||
bin, _ := fakeOP(t)
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
o := &OnePassword{Bin: bin}
|
||||
h := v.Store([]byte("x"))
|
||||
if err := o.UpdatePassword(context.Background(), v, Account{}, h); err == nil {
|
||||
t.Error("expected error updating an account with no item id")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user