pwstore: pass/gopass + macOS Keychain in-place adapters
Group 2 of the manager wire-up — two CLI ItemUpdaters: pass/gopass: the user's own password-store as a rotation source. Reads line-1=password + key:value metadata via `<bin> show`; updates in place by piping the full body to `<bin> insert -m -f` (off-argv), preserving metadata across the password swap. gopass enumerates via `ls --flat`, pass by walking the store dir; --pass-prefix scopes a subtree. keychain: macOS internet passwords via the `security` CLI. Pure-exec (no build tag) so it unit-tests cross-platform via a fake bin; Available() is false off darwin. Read off-argv (find-internet-password -w); write is delete+add with -w on argv (CLI limitation, documented like 1password). Both MOCK-ONLY (fake-binary unit tests + leak checks); recorded as DATA in BROWSER-ROTATION.md §8. 153 tests green, vet clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ var (
|
|||||||
flagAllowCSV bool
|
flagAllowCSV bool
|
||||||
flagExportPath string
|
flagExportPath string
|
||||||
flagKdbxPath string
|
flagKdbxPath string
|
||||||
|
flagPassPrefix string
|
||||||
flagPwLength int
|
flagPwLength int
|
||||||
flagExcludeAmbig bool
|
flagExcludeAmbig bool
|
||||||
flagSymbolSet string
|
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" +
|
"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" +
|
"always your job; incredigo never bypasses them.\n\n" +
|
||||||
"--manager selects the backend:\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" +
|
" CSV import/export (tmpfs + shred, needs --allow-csv + --export-path):\n" +
|
||||||
" chrome | firefox | edge | brave | safari | lastpass | dashlane |\n" +
|
" chrome | firefox | edge | brave | safari | lastpass | dashlane |\n" +
|
||||||
" protonpass | nordpass | roboform | enpass(read-only)\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).",
|
"tmpfs and securely shredded after you confirm re-import).",
|
||||||
}
|
}
|
||||||
pf := c.PersistentFlags()
|
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.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(&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(&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())
|
c.AddCommand(pwScanCmd(), pwPlanCmd(), pwGuideCmd(), pwCommitCmd())
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
@@ -109,8 +112,12 @@ func buildManager(v *vault.Vault) (pwstore.Manager, error) {
|
|||||||
return nil, fmt.Errorf("firefox requires --export-path <csv> (export from about:logins)")
|
return nil, fmt.Errorf("firefox requires --export-path <csv> (export from about:logins)")
|
||||||
}
|
}
|
||||||
return &pwstore.FirefoxCSV{ExportPath: flagExportPath}, nil
|
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 "":
|
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:
|
default:
|
||||||
// CSV-based managers (lastpass, dashlane, protonpass, nordpass, roboform, safari,
|
// CSV-based managers (lastpass, dashlane, protonpass, nordpass, roboform, safari,
|
||||||
// edge, brave, enpass) all share the generic csvManager + tmpfs/shred staging.
|
// edge, brave, enpass) all share the generic csvManager + tmpfs/shred staging.
|
||||||
|
|||||||
@@ -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
|
`lab-provision-browsercsv.sh`; only the final human re-import is unproven (see note ²). All in
|
||||||
the `incredigo-sbx` sandbox VM.
|
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 `<bin> 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/
|
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
|
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)
|
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)** |
|
| 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)** |
|
| 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) |
|
| 1password | `op item edit pw=…` | **no** — argv assignment¹ | fake `op` (MOCK-ONLY) |
|
||||||
|
| pass / gopass | `<bin> 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²)** |
|
| 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²)** |
|
| 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) |
|
| 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
|
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.
|
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 <new>` 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
|
## 9. Roadmap
|
||||||
|
|
||||||
- **Phase B (now):** `pwgen` + `pwstore` (interface + Bitwarden/1Password/KeePassXC in-place +
|
- **Phase B (now):** `pwgen` + `pwstore` (interface + Bitwarden/1Password/KeePassXC in-place +
|
||||||
|
|||||||
@@ -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 <pw>` 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 <server> -a <account> -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"<blob>="github.com"
|
||||||
|
//
|
||||||
|
// returning ("github.com", true). Lines with no value (`<NULL>`) 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. =<NULL> 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
|
||||||
|
}
|
||||||
@@ -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 "<server>\t<account>\t<pw>"
|
||||||
|
// 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"<blob>="%s"\n' "$acct"
|
||||||
|
printf ' "srvr"<blob>="%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"<blob>="github.com"`, "github.com", true},
|
||||||
|
{` "acct"<blob>="alice@example.com"`, "alice@example.com", true},
|
||||||
|
{` "srvr"<blob>=<NULL>`, "", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 `<bin> show <entry>`; updating rewrites the entry with
|
||||||
|
// `<bin> insert -m -f <entry>`, 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 (`<bin> show <entry>`).
|
||||||
|
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 "<password>\n<key: value>…" 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 `<bin> insert -m -f <entry>` 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// `<entry>.gpg` files whose plaintext content IS the entry body (line 1 = password).
|
||||||
|
// It implements just the three subcommands the adapter uses: `show <entry>` (cat the
|
||||||
|
// file), `insert -m -f <entry>` (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 <entry> ; 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user