From 4f5939a3e351196aa386c53baf3d84ce85f843f8 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sat, 20 Jun 2026 11:31:28 -0700 Subject: [PATCH] pwstore: generic csvManager for 8 password-manager CSV adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One data-driven csvManager (layout = header + record func) covers edge/brave (chrome layout), safari, lastpass, dashlane, protonpass, nordpass, roboform, and read/guide-only enpass — replacing 8 near-identical files. Export is header-name driven (parseLoginCSV + new aliases); import stages a per-manager-layout CSV on tmpfs and shreds it, gated by --allow-csv. Notes now round-trip through Meta. Reimporter exposes per-manager re-import hints. Column layouts are MOCK-ONLY (unit-validated) on the same LIVE-VM staging machinery; recorded as DATA in BROWSER-ROTATION.md §8. Co-Authored-By: Claude Opus 4.6 --- cmd/incredigo/passwords.go | 37 +++-- docs/BROWSER-ROTATION.md | 34 ++++- internal/pwstore/csv.go | 19 ++- internal/pwstore/csvmanagers.go | 177 ++++++++++++++++++++++ internal/pwstore/csvmanagers_test.go | 214 +++++++++++++++++++++++++++ internal/pwstore/stage.go | 7 + 6 files changed, 467 insertions(+), 21 deletions(-) create mode 100644 internal/pwstore/csvmanagers.go create mode 100644 internal/pwstore/csvmanagers_test.go diff --git a/cmd/incredigo/passwords.go b/cmd/incredigo/passwords.go index a2e2281..cddfb6e 100644 --- a/cmd/incredigo/passwords.go +++ b/cmd/incredigo/passwords.go @@ -53,15 +53,19 @@ func passwordsCmd() *cobra.Command { "passwords -> guided handoff (you change the site + complete MFA) -> commit the new\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" + - "--manager bitwarden|1password|keepassxc|chrome|firefox selects the backend.\n" + - "CLI managers update one item in place over stdin (no plaintext file). Browser stores\n" + - "can only be re-imported via a CSV, so chrome/firefox require --allow-csv (the file is\n" + - "written to tmpfs and securely shredded).", + "--manager selects the backend:\n" + + " in-place (CLI, no plaintext file): bitwarden | 1password | keepassxc\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" + + "CLI managers update one item in place over stdin (no plaintext file). CSV stores can\n" + + "only be re-imported via a CSV, so they require --allow-csv (the file is written to\n" + + "tmpfs and securely shredded after you confirm re-import).", } pf := c.PersistentFlags() - pf.StringVar(&flagPwManager, "manager", "", "backend: bitwarden|1password|keepassxc|chrome|firefox") - pf.BoolVar(&flagAllowCSV, "allow-csv", false, "allow the flag-gated browser-CSV plaintext path (tmpfs + shred)") - pf.StringVar(&flagExportPath, "export-path", "", "chrome/firefox: CSV you exported from the browser") + pf.StringVar(&flagPwManager, "manager", "", "backend: bitwarden|1password|keepassxc|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") c.AddCommand(pwScanCmd(), pwPlanCmd(), pwGuideCmd(), pwCommitCmd()) return c @@ -106,8 +110,17 @@ func buildManager(v *vault.Vault) (pwstore.Manager, error) { } return &pwstore.FirefoxCSV{ExportPath: flagExportPath}, nil case "": - return nil, fmt.Errorf("--manager is required (bitwarden|1password|keepassxc|chrome|firefox)") + return nil, fmt.Errorf("--manager is required (bitwarden|1password|keepassxc|chrome|firefox|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. + name := strings.ToLower(strings.TrimSpace(flagPwManager)) + if pwstore.IsCSV(name) { + if flagExportPath == "" { + return nil, fmt.Errorf("%s requires --export-path exported from %s", name, name) + } + return pwstore.NewCSV(name, flagExportPath) + } return nil, fmt.Errorf("unknown --manager %q", flagPwManager) } } @@ -522,7 +535,13 @@ func pwCommitCmd() *cobra.Command { defer cleanup() log.Write(audit.Entry{Action: "pw-import", Source: m.Name(), Location: path, Outcome: fmt.Sprintf("staged %d account(s); plaintext CSV on tmpfs, will shred", len(sub))}) - fmt.Printf("Re-import this CSV into the browser, then press Enter to shred it:\n %s\n", path) + where := "Re-import this CSV into the browser/manager" + if r, ok := m.(pwstore.Reimporter); ok { + if h := r.ReimportHint(); h != "" { + where = h + } + } + fmt.Printf("%s, then press Enter to shred it:\n %s\n", where, path) if term.IsTerminal(int(os.Stdin.Fd())) { in := bufio.NewScanner(os.Stdin) in.Scan() diff --git a/docs/BROWSER-ROTATION.md b/docs/BROWSER-ROTATION.md index 794923c..1517679 100644 --- a/docs/BROWSER-ROTATION.md +++ b/docs/BROWSER-ROTATION.md @@ -5,10 +5,12 @@ non-technical user has reused across dozens of sites and saved in a browser or p manager. It extends the same safe spine as the developer-credential drivers (discover → custody → **mandatory backup** → rotate/verify/commit → guide) — it is *not* a new product. -Status: **Phase B (propagation engine) — built.** `pwgen` + `pwstore` (all five adapters) + -the sealed staged-list + the `incredigo passwords scan|plan|guide|commit` surface are -implemented and tested (bitwarden + keepassxc + chrome/firefox staging are LIVE-VM proven; -only 1password is still MOCK-ONLY — see §8). Phases +Status: **Phase B (propagation engine) — built.** `pwgen` + `pwstore` (CLI adapters + +the generic `csvManager` covering chrome/firefox/edge/brave/safari/lastpass/dashlane/ +protonpass/nordpass/roboform/enpass) + the sealed staged-list + the +`incredigo passwords scan|plan|guide|commit` surface are implemented and tested +(bitwarden + keepassxc + chrome/firefox staging are LIVE-VM proven; 1password and the +CSV-manager column layouts are MOCK-ONLY — see §8). Phases A-tier-1 / A-tier-2 (site automation) are specified here but **not yet built**. Nothing in this doc changes a password **at a website** automatically — the human still performs the site change + MFA; incredigo only propagates the new value into the *manager* afterward. @@ -183,8 +185,11 @@ incredigo passwords guide # interactive (TTY): per account → link + reveal incredigo passwords commit # headless: open sealed --stage-in, re-verify backup, commit --verified <1,2,…|all> ``` -Shared flags: `--manager bitwarden|1password|keepassxc|chrome|firefox` selects the backend; -`--export-path ` feeds chrome/firefox; `--kdbx ` feeds keepassxc (passphrase from +Shared flags: `--manager` selects the backend — in-place CLI managers +`bitwarden|1password|keepassxc`, or CSV import/export stores +`chrome|firefox|edge|brave|safari|lastpass|dashlane|protonpass|nordpass|roboform|enpass` +(all share one generic `csvManager`, differing only by import column layout); +`--export-path ` feeds every CSV manager; `--kdbx ` feeds keepassxc (passphrase from `$INCREDIGO_KDBX_PASSPHRASE` or a no-echo prompt); `--allow-csv` is the loud opt-in required for the browser-CSV plaintext path; `--length/--exclude-ambiguous/--symbols` tune the generator; `$INCREDIGO_PASSPHRASE` enables the headless (no-TTY) backup/seal flows. @@ -230,6 +235,15 @@ 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. +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) +is the *same proven code*; what is **MOCK-ONLY** for each is the per-manager column mapping, +validated by unit tests (`csvmanagers_test.go`) against representative real-world export/import +headers — there is no LIVE-VM round-trip into the actual managers yet. `enpass` imports via JSON +(not CSV) so it is **read/guide-only**: it parses an export for the worklist but refuses +`ImportStaged`, pointing the human at `incredigo passwords guide`. + | Adapter | Write shape | Secret path off-argv? | Validated against | |-----------|------------------------|-----------------------|-------------------| | bitwarden | `bw edit item` (stdin) | **yes** — base64 on stdin | **real bw 2026.5.0 + Vaultwarden 1.36 (LIVE-VM)** | @@ -237,6 +251,14 @@ the `incredigo-sbx` sandbox VM. | 1password | `op item edit pw=…` | **no** — argv assignment¹ | fake `op` (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) | +| safari | tmpfs CSV (macOS Passwords layout) → re-import | n/a | shared staging code (LIVE-VM²); layout MOCK-ONLY (unit) | +| lastpass | tmpfs CSV (Generic CSV layout) → re-import | n/a | shared staging code (LIVE-VM²); layout MOCK-ONLY (unit) | +| dashlane | tmpfs CSV (Dashlane layout) → re-import | n/a | shared staging code (LIVE-VM²); layout MOCK-ONLY (unit) | +| protonpass| tmpfs CSV (Proton layout) → re-import | n/a | shared staging code (LIVE-VM²); layout MOCK-ONLY (unit) | +| nordpass | tmpfs CSV (NordPass layout) → re-import | n/a | shared staging code (LIVE-VM²); layout MOCK-ONLY (unit) | +| roboform | tmpfs CSV (RoboForm layout) → re-import | n/a | shared staging code (LIVE-VM²); layout MOCK-ONLY (unit) | +| enpass | read/guide-only (JSON import, no CSV) | n/a | export parse only (unit); `ImportStaged` refuses | ² **chrome/firefox LIVE-VM scope:** there is no real browser to re-import into, so what is proven LIVE-VM (`lab-provision-browsercsv.sh` + `csv-commit-probe.py`) is the security-critical diff --git a/internal/pwstore/csv.go b/internal/pwstore/csv.go index 000d32c..6fc246e 100644 --- a/internal/pwstore/csv.go +++ b/internal/pwstore/csv.go @@ -35,11 +35,13 @@ func colsFromHeader(header []string) csvCols { } return csvCols{ name: idx("name", "title"), - url: idx("url", "login_uri", "web site", "website"), - username: idx("username", "login_username", "user"), - password: idx("password", "login_password", "pass"), - note: idx("note", "notes"), - group: idx("group"), // KeePassXC export column; absent elsewhere (-1) + url: idx("url", "login_uri", "web site", "website", "matchurl"), + username: idx("username", "login_username", "user", "login"), + password: idx("password", "login_password", "pass", "pwd"), + note: idx("note", "notes", "extra"), + // group: KeePassXC "group"; LastPass "grouping"; Dashlane "category"; + // NordPass/RoboForm "folder"; Proton "vault". Absent elsewhere (-1). + group: idx("group", "grouping", "category", "folder", "vault"), } } @@ -85,6 +87,11 @@ func parseLoginCSV(v *vault.Vault, data []byte) ([]Account, error) { if g := get(rec, cols.group); g != "" { a.Meta["group"] = g } + // Preserve notes so a manager round-trip (export -> import CSV) does not wipe + // the user's existing note on the entry. Notes are non-secret metadata. + if nt := get(rec, cols.note); nt != "" { + a.Meta["note"] = nt + } pw := get(rec, cols.password) a.Secret = v.Store([]byte(pw)) out = append(out, a) @@ -110,7 +117,7 @@ type csvLayout struct { var chromeLayout = csvLayout{ header: chromeCSVHeader, record: func(a Account, pw string) []string { - return []string{a.Meta["name"], a.URL, a.Username, pw, ""} + return []string{a.Meta["name"], a.URL, a.Username, pw, a.Meta["note"]} }, } diff --git a/internal/pwstore/csvmanagers.go b/internal/pwstore/csvmanagers.go new file mode 100644 index 0000000..15ca336 --- /dev/null +++ b/internal/pwstore/csvmanagers.go @@ -0,0 +1,177 @@ +package pwstore + +import ( + "context" + "fmt" + "os" + + "incredigo/internal/vault" +) + +// csvManager is the shared adapter for every password manager / browser whose ONLY +// supported propagation path is a plaintext CSV the user re-imports by hand. It is the +// generalization of the Chrome/Firefox staging path (docs/BROWSER-ROTATION.md §3) to the +// rest of the CSV-import field (LastPass, Dashlane, Proton Pass, NordPass, RoboForm, +// Safari, Edge, Brave, …): read = the CSV the user exported; write = a tmpfs CSV in that +// manager's own import column layout, securely shredded after re-import, gated behind the +// caller's --allow-csv opt-in. No plaintext secret ever rests on stable storage (hard +// rule 3) — identical guarantees to ChromeCSV/FirefoxCSV, just data-driven by layout. +// +// Each backend differs ONLY in (1) its name, (2) the column layout its import expects, and +// (3) the reimport hint (where the human pastes the file). Export uses the shared, +// header-name-driven parseLoginCSV, so one type serves them all. A manager with no clean +// CSV import (e.g. Enpass, whose import is JSON) registers with a nil layout: it is +// read/guide-only and ImportStaged refuses with a pointer to the guided worklist. +type csvManager struct { + name string + layout csvLayout // import layout; record==nil => CSV import unsupported (read-only) + reimport string // human instruction: where to re-import the staged CSV + + // ExportPath is the CSV the user exported from this manager (read side). Optional; + // if empty, Export returns an error and Available() is false. + ExportPath string + importPath string // last staged path written by ImportStaged +} + +// csvManagerSpec is one backend's import layout + reimport hint, keyed by name. +type csvManagerSpec struct { + layout csvLayout + reimport string +} + +// csvManagerSpecs is the table of CSV-based backends. Adding a manager is one entry here +// (and, if its export header uses new column names, an alias in colsFromHeader). +var csvManagerSpecs = map[string]csvManagerSpec{ + // Chromium browsers re-import Chrome's exact CSV shape. + "edge": {chromeLayout, "re-import at edge://wallet/passwords/settings ▸ Import"}, + "brave": {chromeLayout, "re-import at brave://password-manager/passwords ▸ Import"}, + + "lastpass": {lastpassLayout, "import at LastPass ▸ Advanced Options ▸ Import ▸ Generic CSV"}, + "dashlane": {dashlaneLayout, "import at Dashlane ▸ My Account ▸ Import data ▸ CSV"}, + "protonpass": {protonLayout, "import at Proton Pass ▸ Settings ▸ Import ▸ CSV"}, + "nordpass": {nordpassLayout, "import at NordPass ▸ Settings ▸ Import/Export ▸ CSV"}, + "roboform": {roboformLayout, "import at RoboForm ▸ Options ▸ Account & Data ▸ Import"}, + "safari": {safariLayout, "re-import in the macOS Passwords app ▸ File ▸ Import Passwords"}, + + // Enpass imports via JSON, not CSV — read/guide-only here (nil layout). + "enpass": {csvLayout{}, ""}, +} + +func init() { + for name, spec := range csvManagerSpecs { + Register(&csvManager{name: name, layout: spec.layout, reimport: spec.reimport}) + } +} + +// NewCSV builds a configured CSV-based adapter for the named manager, reading from +// exportPath. It is the constructor the CLI uses (the registry singletons are zero-valued +// for listing only). Unknown names return an error so the caller can fall through. +func NewCSV(name, exportPath string) (Manager, error) { + spec, ok := csvManagerSpecs[name] + if !ok { + return nil, fmt.Errorf("pwstore: %q is not a CSV-based manager", name) + } + return &csvManager{name: name, layout: spec.layout, reimport: spec.reimport, ExportPath: exportPath}, nil +} + +// IsCSV reports whether name is a registered CSV-based manager. +func IsCSV(name string) bool { + _, ok := csvManagerSpecs[name] + return ok +} + +func (c *csvManager) Name() string { return c.name } + +// Available reports whether an export CSV was supplied and exists. +func (c *csvManager) Available() bool { + if c.ExportPath == "" { + return false + } + _, err := os.Stat(c.ExportPath) + return err == nil +} + +// Export reads the user-provided export CSV into Accounts (passwords land in the vault as +// handles). It never shreds the user's own export — that file is the user's to manage; we +// only own the file we ourselves write in ImportStaged. +func (c *csvManager) Export(ctx context.Context, v *vault.Vault) ([]Account, error) { + if c.ExportPath == "" { + return nil, fmt.Errorf("%s: no export CSV path set", c.name) + } + data, err := os.ReadFile(c.ExportPath) + if err != nil { + return nil, fmt.Errorf("%s: read export: %w", c.name, err) + } + return parseLoginCSV(v, data) +} + +// ImportStaged materializes accts (carrying NEW passwords already swapped into Secret, or +// via newPw by index) as a CSV in this manager's import layout on tmpfs, returning its path +// plus a cleanup func the caller MUST defer after the human confirms re-import. A manager +// without a CSV import format refuses here and directs the user to the guided worklist. +func (c *csvManager) ImportStaged(v *vault.Vault, accts []Account, newPw map[int]*vault.Handle) (string, func(), error) { + if c.layout.record == nil { + return "", nil, fmt.Errorf("%s: no CSV import format — rotate via `incredigo passwords guide` (worklist + change-URLs) instead", c.name) + } + path, cleanup, err := writeShreddableCSVLayout(v, accts, newPw, c.layout) + if err != nil { + return "", nil, err + } + c.importPath = path + return path, cleanup, nil +} + +// ReimportHint returns the manager-specific instruction for where to re-import the staged +// CSV, so the commit path can tell the user exactly where to go. +func (c *csvManager) ReimportHint() string { return c.reimport } + +// ---- per-manager import layouts (the read side is header-driven via colsFromHeader) ---- + +// LastPass "Generic CSV" import: url,username,password,totp,extra,name,grouping,fav. +var lastpassLayout = csvLayout{ + header: []string{"url", "username", "password", "totp", "extra", "name", "grouping", "fav"}, + record: func(a Account, pw string) []string { + return []string{a.URL, a.Username, pw, "", a.Meta["note"], a.Meta["name"], a.Meta["group"], ""} + }, +} + +// Dashlane CSV import: username,username2,username3,title,password,note,url,category,otpSecret. +var dashlaneLayout = csvLayout{ + header: []string{"username", "username2", "username3", "title", "password", "note", "url", "category", "otpSecret"}, + record: func(a Account, pw string) []string { + return []string{a.Username, "", "", a.Meta["name"], pw, a.Meta["note"], a.URL, a.Meta["group"], ""} + }, +} + +// Proton Pass CSV import: name,url,email,username,password,note,totp,vault. Proton keeps +// email and username separate; we map the resolved login into username and leave email blank. +var protonLayout = csvLayout{ + header: []string{"name", "url", "email", "username", "password", "note", "totp", "vault"}, + record: func(a Account, pw string) []string { + return []string{a.Meta["name"], a.URL, "", a.Username, pw, a.Meta["note"], "", a.Meta["group"]} + }, +} + +// NordPass CSV import: name,url,username,password,note,folder. +var nordpassLayout = csvLayout{ + header: []string{"name", "url", "username", "password", "note", "folder"}, + record: func(a Account, pw string) []string { + return []string{a.Meta["name"], a.URL, a.Username, pw, a.Meta["note"], a.Meta["group"]} + }, +} + +// RoboForm CSV import: Name,Url,MatchUrl,Login,Pwd,Note,Folder. +var roboformLayout = csvLayout{ + header: []string{"Name", "Url", "MatchUrl", "Login", "Pwd", "Note", "Folder"}, + record: func(a Account, pw string) []string { + return []string{a.Meta["name"], a.URL, a.URL, a.Username, pw, a.Meta["note"], a.Meta["group"]} + }, +} + +// Safari / macOS Passwords app CSV import: Title,URL,Username,Password,Notes,OTPAuth. +var safariLayout = csvLayout{ + header: []string{"Title", "URL", "Username", "Password", "Notes", "OTPAuth"}, + record: func(a Account, pw string) []string { + return []string{a.Meta["name"], a.URL, a.Username, pw, a.Meta["note"], ""} + }, +} diff --git a/internal/pwstore/csvmanagers_test.go b/internal/pwstore/csvmanagers_test.go new file mode 100644 index 0000000..23f3b98 --- /dev/null +++ b/internal/pwstore/csvmanagers_test.go @@ -0,0 +1,214 @@ +package pwstore + +import ( + "context" + "encoding/csv" + "os" + "path/filepath" + "strings" + "testing" + + "incredigo/internal/vault" +) + +// writeCSV writes body to a temp file and returns its path. +func writeCSV(t *testing.T, name, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +// TestColsFromHeaderAliases proves the new header aliases resolve the right columns so +// each manager's export parses without a bespoke parser. +func TestColsFromHeaderAliases(t *testing.T) { + cases := []struct { + name string + header []string + // expected resolved indices (-1 if absent) + user, pass, note, group int + }{ + {"roboform", []string{"Name", "Url", "MatchUrl", "Login", "Pwd", "Note", "Folder"}, 3, 4, 5, 6}, + {"lastpass", []string{"url", "username", "password", "totp", "extra", "name", "grouping", "fav"}, 1, 2, 4, 6}, + {"dashlane", []string{"username", "title", "password", "note", "url", "category"}, 0, 2, 3, 5}, + {"nordpass", []string{"name", "url", "username", "password", "note", "folder"}, 2, 3, 4, 5}, + {"proton", []string{"name", "url", "email", "username", "password", "note", "vault"}, 3, 4, 5, 6}, + } + for _, c := range cases { + got := colsFromHeader(c.header) + if got.username != c.user || got.password != c.pass || got.note != c.note || got.group != c.group { + t.Errorf("%s: cols=%+v want user=%d pass=%d note=%d group=%d", + c.name, got, c.user, c.pass, c.note, c.group) + } + } +} + +// TestCSVManagerExportParsesAliases reads a representative export for each manager and +// asserts the login fields (incl. note via Meta) come through, with the password as a +// vault handle. +func TestCSVManagerExportParsesAliases(t *testing.T) { + v := vault.New() + defer v.Purge() + + cases := []struct { + mgr, file, body string + }{ + {"roboform", "rf.csv", + "Name,Url,MatchUrl,Login,Pwd,Note,Folder\n" + + "GitHub,https://github.com/,https://github.com/,alice@example.com,old1,my note,Dev\n"}, + {"lastpass", "lp.csv", + "url,username,password,totp,extra,name,grouping,fav\n" + + "https://github.com/,alice@example.com,old1,,my note,GitHub,Dev,0\n"}, + {"protonpass", "pp.csv", + "name,url,email,username,password,note,totp,vault\n" + + "GitHub,https://github.com/,alice@personal.com,alice@example.com,old1,my note,,Personal\n"}, + } + for _, c := range cases { + path := writeCSV(t, c.file, c.body) + m, err := NewCSV(c.mgr, path) + if err != nil { + t.Fatalf("%s: NewCSV: %v", c.mgr, err) + } + if !m.Available() { + t.Errorf("%s: Available should be true with an existing export", c.mgr) + } + accts, err := m.Export(context.Background(), v) + if err != nil { + t.Fatalf("%s: Export: %v", c.mgr, err) + } + if len(accts) != 1 { + t.Fatalf("%s: got %d accounts, want 1", c.mgr, len(accts)) + } + a := accts[0] + if a.Site != "github.com" { + t.Errorf("%s: site = %q, want github.com", c.mgr, a.Site) + } + if a.Username != "alice@example.com" { + t.Errorf("%s: username = %q (alias must resolve the login column, not email)", c.mgr, a.Username) + } + if a.Meta["note"] != "my note" { + t.Errorf("%s: note = %q, want preserved 'my note'", c.mgr, a.Meta["note"]) + } + if pw := handleStr(t, v, a.Secret); pw != "old1" { + t.Errorf("%s: password = %q, want old1", c.mgr, pw) + } + } +} + +// TestCSVManagerImportStagedLayout proves each manager's staged CSV uses ITS import header, +// carries the substituted NEW password, preserves name/group/note, lands on tmpfs, and is +// shredded on cleanup. +func TestCSVManagerImportStagedLayout(t *testing.T) { + v := vault.New() + defer v.Purge() + + accts := []Account{{ + URL: "https://github.com/", + Username: "alice@example.com", + Secret: v.Store([]byte("OLD")), + Meta: map[string]string{"name": "GitHub", "group": "Dev", "note": "my note"}, + }} + newPw := map[int]*vault.Handle{0: v.Store([]byte("NEW-PASS-1"))} + + wantHeader := map[string][]string{ + "lastpass": {"url", "username", "password", "totp", "extra", "name", "grouping", "fav"}, + "dashlane": {"username", "username2", "username3", "title", "password", "note", "url", "category", "otpSecret"}, + "protonpass": {"name", "url", "email", "username", "password", "note", "totp", "vault"}, + "nordpass": {"name", "url", "username", "password", "note", "folder"}, + "roboform": {"Name", "Url", "MatchUrl", "Login", "Pwd", "Note", "Folder"}, + "safari": {"Title", "URL", "Username", "Password", "Notes", "OTPAuth"}, + "edge": {"name", "url", "username", "password", "note"}, + "brave": {"name", "url", "username", "password", "note"}, + } + + for mgr, hdr := range wantHeader { + m, err := NewCSV(mgr, "ignored") + if err != nil { + t.Fatalf("%s: NewCSV: %v", mgr, err) + } + si, ok := m.(StagedImporter) + if !ok { + t.Fatalf("%s: not a StagedImporter", mgr) + } + path, cleanup, err := si.ImportStaged(v, accts, newPw) + if err != nil { + t.Fatalf("%s: ImportStaged: %v", mgr, err) + } + if !strings.HasPrefix(path, "/dev/shm/") && !strings.Contains(path, "incredigo") { + t.Errorf("%s: staged path %q not under a tmpfs/incredigo dir", mgr, path) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: read staged: %v", mgr, err) + } + recs, err := csv.NewReader(strings.NewReader(string(raw))).ReadAll() + if err != nil { + t.Fatalf("%s: parse staged: %v", mgr, err) + } + if len(recs) < 2 { + t.Fatalf("%s: staged csv has no data row:\n%s", mgr, raw) + } + if strings.Join(recs[0], ",") != strings.Join(hdr, ",") { + t.Errorf("%s: header = %v, want %v", mgr, recs[0], hdr) + } + row := strings.Join(recs[1], ",") + if !strings.Contains(row, "NEW-PASS-1") { + t.Errorf("%s: row missing new password:\n%s", mgr, row) + } + if strings.Contains(row, "OLD") { + t.Errorf("%s: row must carry the NEW password, not OLD:\n%s", mgr, row) + } + if !strings.Contains(row, "GitHub") { + t.Errorf("%s: row dropped the entry name:\n%s", mgr, row) + } + if !strings.Contains(row, "my note") { + t.Errorf("%s: row dropped the note (round-trip would wipe it):\n%s", mgr, row) + } + cleanup() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("%s: staged csv not shredded after cleanup: %v", mgr, err) + } + } +} + +// TestEnpassImportRefuses proves a manager with no CSV import format refuses ImportStaged +// and points at the guided worklist, while still being readable for the guide. +func TestEnpassImportRefuses(t *testing.T) { + v := vault.New() + defer v.Purge() + m, err := NewCSV("enpass", "ignored") + if err != nil { + t.Fatalf("NewCSV enpass: %v", err) + } + si := m.(StagedImporter) + if _, _, err := si.ImportStaged(v, []Account{{Secret: v.Store([]byte("x"))}}, nil); err == nil { + t.Error("enpass ImportStaged must refuse (no CSV import format)") + } +} + +// TestReimportHint proves the manager-specific re-import instruction is exposed. +func TestReimportHint(t *testing.T) { + m, _ := NewCSV("lastpass", "x") + r, ok := m.(Reimporter) + if !ok { + t.Fatal("csv manager should implement Reimporter") + } + if !strings.Contains(strings.ToLower(r.ReimportHint()), "lastpass") { + t.Errorf("reimport hint = %q, want a LastPass-specific instruction", r.ReimportHint()) + } +} + +// TestNewCSVUnknownRejected proves an unknown name is rejected so the CLI can fall through. +func TestNewCSVUnknownRejected(t *testing.T) { + if _, err := NewCSV("nonsense", "x"); err == nil { + t.Error("NewCSV must reject an unknown manager name") + } + if IsCSV("bitwarden") { + t.Error("bitwarden is a CLI manager, not a CSV one") + } + if !IsCSV("lastpass") { + t.Error("lastpass should be a CSV manager") + } +} diff --git a/internal/pwstore/stage.go b/internal/pwstore/stage.go index 70b9953..fc5ffa0 100644 --- a/internal/pwstore/stage.go +++ b/internal/pwstore/stage.go @@ -18,6 +18,13 @@ type StagedImporter interface { ImportStaged(v *vault.Vault, accts []Account, newPw map[int]*vault.Handle) (path string, cleanup func(), err error) } +// Reimporter is optionally implemented by StagedImporters to provide a manager-specific +// instruction telling the human exactly where to re-import the staged CSV (e.g. a browser +// settings URL or a manager's import menu path). The commit path uses it when present. +type Reimporter interface { + ReimportHint() string +} + // stagedRecord is one line of a sealed staged list. It carries the manager-native id // (so a headless commit can target the right item) plus the non-secret labels and the // password. The password is plaintext ONLY inside this struct in RAM; on disk this