diff --git a/cmd/incredigo/passwords.go b/cmd/incredigo/passwords.go index cddfb6e..c60d60d 100644 --- a/cmd/incredigo/passwords.go +++ b/cmd/incredigo/passwords.go @@ -34,6 +34,7 @@ var ( flagAllowCSV bool flagExportPath string flagKdbxPath string + flagPassPrefix string flagPwLength int flagExcludeAmbig bool flagSymbolSet string @@ -54,7 +55,8 @@ func passwordsCmd() *cobra.Command { "value into the manager ONLY after you confirm the site accepted it. MFA/CAPTCHA are\n" + "always your job; incredigo never bypasses them.\n\n" + "--manager selects the backend:\n" + - " in-place (CLI, no plaintext file): bitwarden | 1password | keepassxc\n" + + " in-place (CLI, no plaintext file): bitwarden | 1password | keepassxc |\n" + + " pass | gopass | keychain(macOS)\n" + " CSV import/export (tmpfs + shred, needs --allow-csv + --export-path):\n" + " chrome | firefox | edge | brave | safari | lastpass | dashlane |\n" + " protonpass | nordpass | roboform | enpass(read-only)\n" + @@ -63,10 +65,11 @@ func passwordsCmd() *cobra.Command { "tmpfs and securely shredded after you confirm re-import).", } pf := c.PersistentFlags() - pf.StringVar(&flagPwManager, "manager", "", "backend: bitwarden|1password|keepassxc|chrome|firefox|edge|brave|safari|lastpass|dashlane|protonpass|nordpass|roboform|enpass") + pf.StringVar(&flagPwManager, "manager", "", "backend: bitwarden|1password|keepassxc|pass|gopass|keychain|chrome|firefox|edge|brave|safari|lastpass|dashlane|protonpass|nordpass|roboform|enpass") pf.BoolVar(&flagAllowCSV, "allow-csv", false, "allow the flag-gated browser/manager-CSV plaintext path (tmpfs + shred)") pf.StringVar(&flagExportPath, "export-path", "", "CSV managers: the CSV you exported from the browser/manager") pf.StringVar(&flagKdbxPath, "kdbx", "", "keepassxc: path to the .kdbx database") + pf.StringVar(&flagPassPrefix, "pass-prefix", "", "pass/gopass: only rotate entries under this subtree (e.g. logins/)") c.AddCommand(pwScanCmd(), pwPlanCmd(), pwGuideCmd(), pwCommitCmd()) return c } @@ -109,8 +112,12 @@ func buildManager(v *vault.Vault) (pwstore.Manager, error) { return nil, fmt.Errorf("firefox requires --export-path (export from about:logins)") } return &pwstore.FirefoxCSV{ExportPath: flagExportPath}, nil + case "pass", "gopass": + return &pwstore.PassStore{Flavor: strings.ToLower(strings.TrimSpace(flagPwManager)), Prefix: flagPassPrefix}, nil + case "keychain": + return &pwstore.Keychain{}, nil case "": - return nil, fmt.Errorf("--manager is required (bitwarden|1password|keepassxc|chrome|firefox|lastpass|dashlane|protonpass|nordpass|roboform|safari|edge|brave|enpass)") + return nil, fmt.Errorf("--manager is required (bitwarden|1password|keepassxc|chrome|firefox|pass|gopass|keychain|lastpass|dashlane|protonpass|nordpass|roboform|safari|edge|brave|enpass)") default: // CSV-based managers (lastpass, dashlane, protonpass, nordpass, roboform, safari, // edge, brave, enpass) all share the generic csvManager + tmpfs/shred staging. diff --git a/docs/BROWSER-ROTATION.md b/docs/BROWSER-ROTATION.md index 1517679..29efa04 100644 --- a/docs/BROWSER-ROTATION.md +++ b/docs/BROWSER-ROTATION.md @@ -235,6 +235,11 @@ via real registration crypto). **keepassxc is LIVE-VM** — proven end to end ag `lab-provision-browsercsv.sh`; only the final human re-import is unproven (see note ²). All in the `incredigo-sbx` sandbox VM. +Three more **in-place CLI adapters** are **MOCK-ONLY** (unit-validated via injected fake +binaries; no LIVE-VM POC yet): **pass / gopass** (the user's own password-store as a rotation +source — off-argv write, full body piped to ` insert -m -f`, metadata preserved across the +swap), and **keychain** (macOS `security` CLI — darwin-only at runtime, argv write caveat ³). + The **CSV-manager family** (edge/brave/safari/lastpass/dashlane/protonpass/nordpass/roboform/ enpass) shares the chrome/firefox staging machinery via one generic `csvManager`, so its security-critical path (tmpfs + correct per-manager import layout + shred, `--allow-csv` gated) @@ -249,6 +254,8 @@ headers — there is no LIVE-VM round-trip into the actual managers yet. `enpass | bitwarden | `bw edit item` (stdin) | **yes** — base64 on stdin | **real bw 2026.5.0 + Vaultwarden 1.36 (LIVE-VM)** | | keepassxc | `keepassxc-cli edit -p`| **yes** — db+new pass on stdin | **real keepassxc-cli 2.7.6 (LIVE-VM)** | | 1password | `op item edit pw=…` | **no** — argv assignment¹ | fake `op` (MOCK-ONLY) | +| pass / gopass | ` insert -m -f` (stdin) | **yes** — full body on stdin | fake `pass`/`gopass` bin (MOCK-ONLY) | +| keychain | `security add-internet-password -w` | **no** — argv assignment³ | fake `security` bin (MOCK-ONLY) | | chrome | tmpfs CSV → human re-import | n/a (no live secret to a child) | **real /dev/shm + shred (LIVE-VM²)** | | firefox | tmpfs CSV → human re-import | n/a | **real /dev/shm + shred (LIVE-VM²)** | | edge / brave | tmpfs CSV (chrome layout) → re-import | n/a | shared staging code (LIVE-VM²); layout MOCK-ONLY (unit) | @@ -286,6 +293,16 @@ than the Bitwarden/KeePassXC stdin paths; it is in-RAM, transient, and never tou choice is documented here rather than hidden in the driver. Promoting any adapter to a real-binary proof level requires a sandbox-VM POC against the genuine CLI / browser import. +³ **Keychain argv caveat:** the macOS `security` CLI has no in-place edit and no stdin path +for writing — `add-internet-password -w ` takes the secret on argv, so it is briefly +visible to **same-uid** processes via `ps`/`/proc`-equivalent, exactly like the 1Password +caveat (¹). The read side stays off argv (`find-internet-password -w` prints to stdout into the +vault). Update is delete-then-add (no atomic replace). Bulk export is also inherently +prompt-heavy on a real Mac (`find-internet-password -w` triggers a keychain-access prompt per +item unless the ACL already trusts the caller); incredigo skips items it cannot read rather than +aborting. Keychain is **darwin-only at runtime** — `Available()` is false off macOS — but the +adapter is pure-exec Go, so it is unit-validated cross-platform via an injected fake `security`. + ## 9. Roadmap - **Phase B (now):** `pwgen` + `pwstore` (interface + Bitwarden/1Password/KeePassXC in-place + diff --git a/internal/pwstore/keychain.go b/internal/pwstore/keychain.go new file mode 100644 index 0000000..46de0ca --- /dev/null +++ b/internal/pwstore/keychain.go @@ -0,0 +1,205 @@ +package pwstore + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "runtime" + "strings" + + "incredigo/internal/vault" +) + +// Keychain adapts the macOS `security` CLI as a login source for the passwords engine. +// It targets INTERNET passwords (class "inet" — website/app logins), the slice that +// maps onto a change-password rotation; generic ("genp") items (Wi-Fi, app secrets) +// are deliberately skipped. +// +// The `security` binary is pure exec with no platform syscalls, so this file compiles +// and unit-tests everywhere (via an injected fake `Bin`); Available() returns false off +// macOS so the registry singleton is inert on Linux. Read paths keep secrets off argv +// (`find-internet-password -w` prints to stdout). The WRITE path, however, has no stdin +// option: `security add-internet-password -w ` takes the password on ARGV — a CLI +// limitation, identical in shape to 1Password's argv caveat (documented, MOCK-ONLY). +type Keychain struct { + Bin string // default "security"; injectable for tests + Keychain string // optional keychain path; default == the login keychain +} + +func init() { Register(&Keychain{}) } + +func (k *Keychain) Name() string { return "keychain" } + +func (k *Keychain) bin() string { + if k.Bin != "" { + return k.Bin + } + return "security" +} + +// Available is true only on macOS (or when a fake Bin is injected for tests) — this +// guards against an unrelated `security` binary on a non-macOS PATH. +func (k *Keychain) Available() bool { + if k.Bin != "" { + return true // injected (tests) + } + if runtime.GOOS != "darwin" { + return false + } + _, err := exec.LookPath("security") + return err == nil +} + +// kcItem is one internet-password item's non-secret identity from dump-keychain. +type kcItem struct { + server string // "srvr" attribute (hostname) + account string // "acct" attribute (login) +} + +// Export dumps the keychain, parses the internet-password items, and fetches each +// secret with find-internet-password. Items whose secret cannot be read (locked, no +// ACL) are skipped rather than aborting the whole export. +func (k *Keychain) Export(ctx context.Context, v *vault.Vault) ([]Account, error) { + args := []string{"dump-keychain"} + if k.Keychain != "" { + args = append(args, k.Keychain) + } + out, err := exec.CommandContext(ctx, k.bin(), args...).Output() + if err != nil { + return nil, fmt.Errorf("keychain: dump-keychain: %w", err) + } + items := parseKeychainDump(out) + var accts []Account + for _, it := range items { + h, err := k.readPassword(ctx, v, it.server, it.account) + if err != nil { + continue // unreadable item — skip, do not fail the export + } + accts = append(accts, Account{ + ID: it.server + "/" + it.account, + Site: hostFromURL(it.server), + URL: it.server, + Username: it.account, + Secret: h, + Meta: map[string]string{"name": it.server}, + }) + } + return accts, nil +} + +// readPassword runs `find-internet-password -s -a -w`, capturing the +// printed secret straight into the vault and wiping the transient buffer. +func (k *Keychain) readPassword(ctx context.Context, v *vault.Vault, server, account string) (*vault.Handle, error) { + args := []string{"find-internet-password", "-s", server, "-a", account, "-w"} + if k.Keychain != "" { + args = append(args, k.Keychain) + } + cmd := exec.CommandContext(ctx, k.bin(), args...) + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("keychain: find-internet-password: %v: %s", err, strings.TrimSpace(stderr.String())) + } + b := bytes.TrimRight(out.Bytes(), "\r\n") + h := v.Store(b) // Store copies into locked memory and wipes b + zero(out.Bytes()) + return h, nil +} + +// UpdatePassword replaces an internet password. The keychain has no in-place edit, so +// the old item is deleted (best-effort) and a new one added. Server/account come from +// the round-trip-safe URL/Username fields (NOT Meta, which the staged list drops). +func (k *Keychain) UpdatePassword(ctx context.Context, v *vault.Vault, acct Account, newPassword *vault.Handle) error { + server := acct.URL + if server == "" { + server = acct.Site + } + account := acct.Username + if server == "" { + return fmt.Errorf("keychain: account missing server — cannot locate the item to update") + } + pwBuf, err := v.Open(newPassword) + if err != nil { + return err + } + newpw := string(pwBuf.Bytes()) + + // Best-effort delete of the existing item (a missing item is not an error here). + delArgs := []string{"delete-internet-password", "-s", server, "-a", account} + if k.Keychain != "" { + delArgs = append(delArgs, k.Keychain) + } + _ = exec.CommandContext(ctx, k.bin(), delArgs...).Run() + + // NOTE: -w puts the new password on ARGV — unavoidable with the `security` CLI, + // which offers no stdin path for add-internet-password. Documented in §8. + addArgs := []string{"add-internet-password", "-s", server, "-a", account, "-w", newpw} + if k.Keychain != "" { + addArgs = append(addArgs, k.Keychain) + } + out, err := exec.CommandContext(ctx, k.bin(), addArgs...).CombinedOutput() + wipeStr(&newpw) + if err != nil { + return fmt.Errorf("keychain: add-internet-password: %v: %s", err, bytes.TrimSpace(out)) + } + return nil +} + +// parseKeychainDump scans `security dump-keychain` output for internet-password items, +// pulling the "srvr" (server) and "acct" (account) attributes from each block. A block +// starts at a `keychain:` line; `class: "inet"` marks it as an internet password. Other +// classes (e.g. "genp") are ignored. +func parseKeychainDump(out []byte) []kcItem { + var items []kcItem + var cur kcItem + var isInet bool + flush := func() { + if isInet && cur.server != "" { + items = append(items, cur) + } + cur = kcItem{} + isInet = false + } + for _, line := range strings.Split(string(out), "\n") { + t := strings.TrimSpace(line) + switch { + case strings.HasPrefix(t, "keychain:"): + flush() // new item block begins + case strings.HasPrefix(t, "class:"): + isInet = strings.Contains(t, `"inet"`) + case strings.HasPrefix(t, `"srvr"`): + if val, ok := kcAttrValue(t); ok { + cur.server = val + } + case strings.HasPrefix(t, `"acct"`): + if val, ok := kcAttrValue(t); ok { + cur.account = val + } + } + } + flush() + return items +} + +// kcAttrValue extracts the quoted value from a dump-keychain attribute line such as +// +// "srvr"="github.com" +// +// returning ("github.com", true). Lines with no value (``) return ok=false. +func kcAttrValue(line string) (string, bool) { + i := strings.Index(line, "=") + if i < 0 { + return "", false + } + rhs := strings.TrimSpace(line[i+1:]) + if !strings.HasPrefix(rhs, `"`) { + return "", false // e.g. = or a hex 0x… form we don't decode + } + rhs = rhs[1:] + if j := strings.LastIndex(rhs, `"`); j >= 0 { + return rhs[:j], true + } + return "", false +} diff --git a/internal/pwstore/keychain_test.go b/internal/pwstore/keychain_test.go new file mode 100644 index 0000000..d27f8e7 --- /dev/null +++ b/internal/pwstore/keychain_test.go @@ -0,0 +1,178 @@ +package pwstore + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "incredigo/internal/vault" +) + +// fakeSecurity writes a `security` stand-in backed by a flat "\t\t" +// db file. It implements dump-keychain (emits the inet-block format the parser reads), +// find-internet-password -s -a -w (prints the password), and add/delete-internet-password +// (rewrites the db). $KC_DB points at the db file. +func fakeSecurity(t *testing.T) (bin, db string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake security is a POSIX shell script") + } + dir := t.TempDir() + db = filepath.Join(dir, "kc.db") + // Two inet logins + one genp (generic) item that MUST be ignored by Export. + seed := "inet\tgithub.com\talice@example.com\told-gh\n" + + "inet\tgitlab.com\tbob@example.com\told-gl\n" + + "genp\tWiFi\thome\twifi-pw\n" + if err := os.WriteFile(db, []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + bin = filepath.Join(dir, "security") + script := `#!/usr/bin/env bash +set -euo pipefail +DB="${KC_DB:?need KC_DB}" +cmd="${1:-}"; shift || true +getopt_val() { # $1=flag ; echoes value after it in remaining args + local want="$1"; shift + while [ "$#" -gt 0 ]; do + if [ "$1" = "$want" ]; then echo "$2"; return; fi + shift + done +} +case "$cmd" in + dump-keychain) + while IFS=$'\t' read -r class srvr acct pw; do + printf 'keychain: "/fake/login.keychain-db"\n' + printf ' class: "%s"\n' "$class" + printf ' attributes:\n' + printf ' "acct"="%s"\n' "$acct" + printf ' "srvr"="%s"\n' "$srvr" + done < "$DB" + ;; + find-internet-password) + s="$(getopt_val -s "$@")"; a="$(getopt_val -a "$@")" + while IFS=$'\t' read -r class srvr acct pw; do + if [ "$class" = inet ] && [ "$srvr" = "$s" ] && [ "$acct" = "$a" ]; then + printf '%s\n' "$pw"; exit 0 + fi + done < "$DB" + echo "not found" >&2; exit 44 + ;; + add-internet-password) + s="$(getopt_val -s "$@")"; a="$(getopt_val -a "$@")"; w="$(getopt_val -w "$@")" + printf 'inet\t%s\t%s\t%s\n' "$s" "$a" "$w" >> "$DB" + ;; + delete-internet-password) + s="$(getopt_val -s "$@")"; a="$(getopt_val -a "$@")" + tmp="$(mktemp)" + while IFS=$'\t' read -r class srvr acct pw; do + if [ "$class" = inet ] && [ "$srvr" = "$s" ] && [ "$acct" = "$a" ]; then continue; fi + printf '%s\t%s\t%s\t%s\n' "$class" "$srvr" "$acct" "$pw" + done < "$DB" > "$tmp" + mv "$tmp" "$DB" + ;; + *) + echo "fake security: unknown command: $cmd" >&2; exit 2 + ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + return bin, db +} + +func TestKeychainExportInetOnly(t *testing.T) { + bin, db := fakeSecurity(t) + t.Setenv("KC_DB", db) + v := vault.New() + defer v.Purge() + + k := &Keychain{Bin: bin} + if !k.Available() { + t.Fatal("Available should be true with injected bin") + } + accts, err := k.Export(context.Background(), v) + if err != nil { + t.Fatalf("Export: %v", err) + } + if len(accts) != 2 { + t.Fatalf("got %d accounts, want 2 (genp WiFi item must be skipped)", len(accts)) + } + byUser := map[string]Account{} + for _, a := range accts { + byUser[a.Username] = a + } + gh, ok := byUser["alice@example.com"] + if !ok { + t.Fatalf("github account missing: %+v", accts) + } + if gh.Site != "github.com" || gh.URL != "github.com" { + t.Errorf("github account = %+v", gh) + } + if pw := handleStr(t, v, gh.Secret); pw != "old-gh" { + t.Errorf("github password = %q, want old-gh", pw) + } +} + +func TestKeychainUpdatePassword(t *testing.T) { + bin, db := fakeSecurity(t) + t.Setenv("KC_DB", db) + v := vault.New() + defer v.Purge() + + k := &Keychain{Bin: bin} + accts, err := k.Export(context.Background(), v) + if err != nil { + t.Fatalf("Export: %v", err) + } + var gh Account + for _, a := range accts { + if a.Username == "alice@example.com" { + gh = a + } + } + newPw := v.Store([]byte("NEW-kc-pw-9")) + if err := k.UpdatePassword(context.Background(), v, gh, 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, "github.com\talice@example.com\tNEW-kc-pw-9") { + t.Errorf("db not updated with new password:\n%s", s) + } + if strings.Contains(s, "old-gh") { + t.Errorf("old github password still present (delete+add should have replaced it):\n%s", s) + } +} + +func TestKeychainUnavailableOffMacWithoutBin(t *testing.T) { + k := &Keychain{} + if runtime.GOOS != "darwin" && k.Available() { + t.Error("Available should be false off macOS with no injected bin") + } +} + +func TestParseKeychainDumpValue(t *testing.T) { + cases := []struct { + line string + want string + ok bool + }{ + {` "srvr"="github.com"`, "github.com", true}, + {` "acct"="alice@example.com"`, "alice@example.com", true}, + {` "srvr"=`, "", false}, + {` no equals here`, "", false}, + } + for _, c := range cases { + got, ok := kcAttrValue(c.line) + if got != c.want || ok != c.ok { + t.Errorf("kcAttrValue(%q) = (%q,%v), want (%q,%v)", c.line, got, ok, c.want, c.ok) + } + } +} diff --git a/internal/pwstore/passstore.go b/internal/pwstore/passstore.go new file mode 100644 index 0000000..d31ebfd --- /dev/null +++ b/internal/pwstore/passstore.go @@ -0,0 +1,232 @@ +package pwstore + +import ( + "bytes" + "context" + "fmt" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + + "incredigo/internal/vault" +) + +// PassStore adapts the Unix password-store `pass` and its drop-in `gopass` as a +// read+update login source for the passwords engine. (This is distinct from +// sink.Gopass, which is incredigo's OWN backing store for imported creds — here the +// pass/gopass store is the *user's* manager being rotated.) +// +// One entry holds one secret. The de-facto layout (passff / browserpass) is line 1 = +// the password, followed by `key: value` metadata lines (login/username/user/email, +// url). Reading is ` show `; updating rewrites the entry with +// ` insert -m -f `, piping the full body on STDIN so neither the new +// password nor the metadata ever reaches argv — the off-argv write path, on par with +// Bitwarden/KeePassXC. +// +// Entry enumeration is the one place the two backends differ: gopass exposes a flat +// list (`gopass ls --flat`); `pass` has no flat flag, so we walk the password-store +// directory for *.gpg. The Flavor field selects which. +type PassStore struct { + Bin string // default == Flavor ("pass" or "gopass"); injectable for tests + Flavor string // "pass" or "gopass" + Store string // store dir (pass walk); default $PASSWORD_STORE_DIR or ~/.password-store + Prefix string // optional subtree to scope (e.g. "logins/") +} + +func init() { + Register(&PassStore{Flavor: "pass"}) + Register(&PassStore{Flavor: "gopass"}) +} + +func (p *PassStore) Name() string { return p.Flavor } + +func (p *PassStore) bin() string { + if p.Bin != "" { + return p.Bin + } + return p.Flavor +} + +// Available reports whether the backend CLI is resolvable. +func (p *PassStore) Available() bool { + if p.Bin != "" { + return true // injected (tests) + } + _, err := exec.LookPath(p.bin()) + return err == nil +} + +// storeDir resolves the on-disk password-store root for the `pass` flavor. +func (p *PassStore) storeDir() string { + if p.Store != "" { + return p.Store + } + if d := os.Getenv("PASSWORD_STORE_DIR"); d != "" { + return d + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".password-store") +} + +// listEntries returns the entry paths (without any .gpg suffix), scoped to Prefix. +func (p *PassStore) listEntries(ctx context.Context) ([]string, error) { + var raw []string + if p.Flavor == "gopass" { + out, err := exec.CommandContext(ctx, p.bin(), "ls", "--flat").Output() + if err != nil { + return nil, fmt.Errorf("gopass: ls: %w", err) + } + for _, ln := range strings.Split(string(out), "\n") { + if s := strings.TrimSpace(ln); s != "" { + raw = append(raw, s) + } + } + } else { + root := p.storeDir() + err := filepath.WalkDir(root, func(pth string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(pth, ".gpg") { + return nil + } + rel, err := filepath.Rel(root, pth) + if err != nil { + return err + } + raw = append(raw, strings.TrimSuffix(filepath.ToSlash(rel), ".gpg")) + return nil + }) + if err != nil { + return nil, fmt.Errorf("pass: walk %q: %w", root, err) + } + } + prefix := strings.Trim(p.Prefix, "/") + var out []string + for _, e := range raw { + if prefix != "" && !strings.HasPrefix(strings.Trim(e, "/"), prefix+"/") && strings.Trim(e, "/") != prefix { + continue + } + out = append(out, e) + } + return out, nil +} + +// showRaw returns one entry's full decrypted body bytes (` show `). +func (p *PassStore) showRaw(ctx context.Context, entry string) ([]byte, error) { + cmd := exec.CommandContext(ctx, p.bin(), "show", entry) + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("%s: show %q: %v: %s", p.Flavor, entry, err, strings.TrimSpace(stderr.String())) + } + return out.Bytes(), nil +} + +// Export reads every entry into v, mapping the first line to the password handle and +// the `key: value` body lines to username/url metadata. +func (p *PassStore) Export(ctx context.Context, v *vault.Vault) ([]Account, error) { + entries, err := p.listEntries(ctx) + if err != nil { + return nil, err + } + var out []Account + for _, entry := range entries { + body, err := p.showRaw(ctx, entry) + if err != nil { + return nil, err + } + a := parsePassEntry(v, entry, body) + zero(body) // wipe the transient decrypted capture + out = append(out, a) + } + return out, nil +} + +// parsePassEntry turns "\n…" into an Account. The password lands +// in the vault as a handle; only non-secret metadata is read into the struct. +func parsePassEntry(v *vault.Vault, entry string, body []byte) Account { + text := strings.ReplaceAll(string(body), "\r\n", "\n") + lines := strings.Split(text, "\n") + pw := "" + if len(lines) > 0 { + pw = lines[0] + } + a := Account{ID: entry, Meta: map[string]string{}} + for _, ln := range lines[1:] { + k, val, ok := strings.Cut(ln, ":") + if !ok { + continue + } + key := strings.ToLower(strings.TrimSpace(k)) + val = strings.TrimSpace(val) + switch key { + case "login", "username", "user", "email": + if a.Username == "" { + a.Username = val + } + case "url", "site", "website": + if a.URL == "" { + a.URL = val + } + } + } + a.Meta["name"] = path.Base(entry) + if dir := path.Dir(entry); dir != "." && dir != "/" { + a.Meta["group"] = dir + } + if a.URL != "" { + a.Site = hostFromURL(a.URL) + } + if a.Site == "" { + // Entry path often IS the host (e.g. "github.com/alice"); use its head. + a.Site = hostFromURL(strings.SplitN(entry, "/", 2)[0]) + } + a.Secret = v.Store([]byte(pw)) + wipeStr(&pw) + return a +} + +// UpdatePassword rewrites one entry's password while preserving its metadata body. +// It re-reads the current body, swaps the first line for the new password, and pipes +// the result to ` insert -m -f ` on STDIN (nothing secret on argv). +func (p *PassStore) UpdatePassword(ctx context.Context, v *vault.Vault, acct Account, newPassword *vault.Handle) error { + if acct.ID == "" { + return fmt.Errorf("%s: account has no entry path — cannot update in place", p.Flavor) + } + body, err := p.showRaw(ctx, acct.ID) + if err != nil { + return err + } + defer zero(body) + pwBuf, err := v.Open(newPassword) + if err != nil { + return err + } + var stdin bytes.Buffer + stdin.Write(pwBuf.Bytes()) + if i := bytes.IndexByte(body, '\n'); i >= 0 { + stdin.Write(body[i:]) // keep the newline + every metadata line after line 1 + } else { + stdin.WriteByte('\n') + } + cmd := exec.CommandContext(ctx, p.bin(), "insert", "-m", "-f", acct.ID) + cmd.Stdin = &stdin + out, err := cmd.CombinedOutput() + stdin.Reset() // wipe the transient buffer holding the new password + if err != nil { + return fmt.Errorf("%s: insert %q: %v: %s", p.Flavor, acct.ID, err, bytes.TrimSpace(out)) + } + return nil +} + +// zero overwrites a byte slice in place (best-effort wipe of transient plaintext). +func zero(b []byte) { + for i := range b { + b[i] = 0 + } +} diff --git a/internal/pwstore/passstore_test.go b/internal/pwstore/passstore_test.go new file mode 100644 index 0000000..84f38f7 --- /dev/null +++ b/internal/pwstore/passstore_test.go @@ -0,0 +1,154 @@ +package pwstore + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "incredigo/internal/vault" +) + +// fakePassBin writes a `pass`/`gopass` stand-in backed by a store directory of +// `.gpg` files whose plaintext content IS the entry body (line 1 = password). +// It implements just the three subcommands the adapter uses: `show ` (cat the +// file), `insert -m -f ` (overwrite the file from stdin), and `ls --flat` (list +// entry names). The store dir is passed via $PASSWORD_STORE_DIR so both flavors find it. +func fakePassBin(t *testing.T) (bin, store string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake pass binary is a POSIX shell script") + } + store = filepath.Join(t.TempDir(), "store") + if err := os.MkdirAll(filepath.Join(store, "web"), 0o700); err != nil { + t.Fatal(err) + } + // github.com/alice: password + login + url metadata lines. + body := "old-secret\nlogin: alice@example.com\nurl: https://github.com/login\n" + if err := os.WriteFile(filepath.Join(store, "web", "github.com.gpg"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + bin = filepath.Join(t.TempDir(), "passbin") + script := `#!/usr/bin/env bash +set -euo pipefail +STORE="${PASSWORD_STORE_DIR:?need PASSWORD_STORE_DIR}" +cmd="${1:-}" +case "$cmd" in + show) + entry="$2" + cat "$STORE/$entry.gpg" + ;; + insert) + # insert -m -f ; body on stdin + entry="${@: -1}" + mkdir -p "$(dirname "$STORE/$entry.gpg")" + cat > "$STORE/$entry.gpg" + ;; + ls) + # ls --flat + ( cd "$STORE" && find . -name '*.gpg' | sed -e 's#^\./##' -e 's#\.gpg$##' ) + ;; + *) + echo "fake pass: unknown command: $*" >&2; exit 2 + ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + return bin, store +} + +func TestPassStoreExport(t *testing.T) { + for _, flavor := range []string{"pass", "gopass"} { + t.Run(flavor, func(t *testing.T) { + bin, store := fakePassBin(t) + t.Setenv("PASSWORD_STORE_DIR", store) // the fake bin (and pass walk) read this + v := vault.New() + defer v.Purge() + + p := &PassStore{Bin: bin, Flavor: flavor, Store: store} + if !p.Available() { + t.Fatal("Available should be true with injected bin") + } + accts, err := p.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 != "web/github.com" { + t.Errorf("entry path = %q, want web/github.com", a.ID) + } + if a.Username != "alice@example.com" { + t.Errorf("username = %q, want alice@example.com", a.Username) + } + if a.Site != "github.com" { + t.Errorf("site = %q, want github.com", a.Site) + } + if a.Meta["group"] != "web" { + t.Errorf("group = %q, want web", a.Meta["group"]) + } + if pw := handleStr(t, v, a.Secret); pw != "old-secret" { + t.Errorf("password = %q, want old-secret", pw) + } + }) + } +} + +func TestPassStoreUpdatePreservesMetadata(t *testing.T) { + bin, store := fakePassBin(t) + t.Setenv("PASSWORD_STORE_DIR", store) + v := vault.New() + defer v.Purge() + + p := &PassStore{Bin: bin, Flavor: "pass", Store: store} + accts, err := p.Export(context.Background(), v) + if err != nil { + t.Fatalf("Export: %v", err) + } + newPw := v.Store([]byte("NEW-pass-pw-7")) + if err := p.UpdatePassword(context.Background(), v, accts[0], newPw); err != nil { + t.Fatalf("UpdatePassword: %v", err) + } + raw, err := os.ReadFile(filepath.Join(store, "web", "github.com.gpg")) + if err != nil { + t.Fatal(err) + } + s := string(raw) + // New password on line 1, OLD gone, metadata lines preserved. + if got := s[:len("NEW-pass-pw-7")]; got != "NEW-pass-pw-7" { + t.Errorf("first line = %q, want the new password", got) + } + if want := "login: alice@example.com"; !strings.Contains(s, want) { + t.Errorf("metadata dropped: %q missing from:\n%s", want, s) + } + if strings.Contains(s, "old-secret") { + t.Errorf("old password still present:\n%s", s) + } +} + +func TestPassStorePrefixScope(t *testing.T) { + bin, store := fakePassBin(t) + t.Setenv("PASSWORD_STORE_DIR", store) + // Add an out-of-scope entry at the root. + if err := os.WriteFile(filepath.Join(store, "root-note.gpg"), []byte("x\n"), 0o600); err != nil { + t.Fatal(err) + } + v := vault.New() + defer v.Purge() + + p := &PassStore{Bin: bin, Flavor: "pass", Store: store, Prefix: "web"} + accts, err := p.Export(context.Background(), v) + if err != nil { + t.Fatalf("Export: %v", err) + } + if len(accts) != 1 || accts[0].ID != "web/github.com" { + t.Fatalf("prefix scope failed: got %+v", accts) + } +}