From c2f181e56d53b631f42fd7472b0cb3a84a702f2f Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Fri, 19 Jun 2026 13:02:18 -0700 Subject: [PATCH] passwords: Phase B browser/manager propagation engine (pwgen + pwstore + CLI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the secondary-persona path from docs/BROWSER-ROTATION.md: ingest a password manager / browser store, take a mandatory verified sealed backup, generate fresh strong passwords, hand the human a ready-to-finish task at the MFA wall, and commit the new value into the manager only after the site change is confirmed. - internal/pwgen: crypto/rand generator (rejection sampling, per-class guarantee, vault-native — never returns plaintext as a Go string). - internal/pwstore: Manager/ItemUpdater/BulkImporter contracts, secrets-free RedactIdentity, and five adapters — bitwarden (bw, stdin), keepassxc (keepassxc-cli, stdin), 1password (op, argv assignment with documented caveat), chrome/firefox (tmpfs CSV ingest-and-shred). All real code; validation status is data (MOCK-ONLY against fake binaries/CSVs, recorded in the design doc). - internal/pwstore/stage.go: age-sealed staged-list (WriteStaged/ReadStaged) + StagedImporter — the only artifacts crossing the plan->commit gap, never plaintext. - cmd/incredigo: passwords scan|plan|guide|commit. Backup gate seals + round-trip verifies before any commit; guide is interactive verify-before-commit; commit is headless over a sealed stage. Browser CSV writes are gated behind --allow-csv. Hard rules honored: backup-before-commit, verify-before-commit, no plaintext on disk (browser CSV is the flag-gated tmpfs+shred exception), MFA always a human handoff. go vet + -race clean; end-to-end verified against a fake bw. Co-Authored-By: Claude Opus 4.6 --- cmd/incredigo/main.go | 2 +- cmd/incredigo/passwords.go | 707 +++++++++++++++++++++++++++ cmd/incredigo/passwords_test.go | 75 +++ docs/BROWSER-ROTATION.md | 252 ++++++++++ internal/pwgen/pwgen.go | 194 ++++++++ internal/pwgen/pwgen_test.go | 123 +++++ internal/pwstore/bitwarden.go | 133 +++++ internal/pwstore/bitwarden_test.go | 159 ++++++ internal/pwstore/chromecsv.go | 80 +++ internal/pwstore/chromecsv_test.go | 95 ++++ internal/pwstore/csv.go | 277 +++++++++++ internal/pwstore/firefoxcsv.go | 64 +++ internal/pwstore/firefoxcsv_test.go | 74 +++ internal/pwstore/keepassxc.go | 136 ++++++ internal/pwstore/keepassxc_test.go | 136 ++++++ internal/pwstore/onepassword.go | 136 ++++++ internal/pwstore/onepassword_test.go | 141 ++++++ internal/pwstore/pwstore.go | 160 ++++++ internal/pwstore/pwstore_test.go | 115 +++++ internal/pwstore/stage.go | 96 ++++ internal/pwstore/stage_test.go | 67 +++ 21 files changed, 3221 insertions(+), 1 deletion(-) create mode 100644 cmd/incredigo/passwords.go create mode 100644 cmd/incredigo/passwords_test.go create mode 100644 docs/BROWSER-ROTATION.md create mode 100644 internal/pwgen/pwgen.go create mode 100644 internal/pwgen/pwgen_test.go create mode 100644 internal/pwstore/bitwarden.go create mode 100644 internal/pwstore/bitwarden_test.go create mode 100644 internal/pwstore/chromecsv.go create mode 100644 internal/pwstore/chromecsv_test.go create mode 100644 internal/pwstore/csv.go create mode 100644 internal/pwstore/firefoxcsv.go create mode 100644 internal/pwstore/firefoxcsv_test.go create mode 100644 internal/pwstore/keepassxc.go create mode 100644 internal/pwstore/keepassxc_test.go create mode 100644 internal/pwstore/onepassword.go create mode 100644 internal/pwstore/onepassword_test.go create mode 100644 internal/pwstore/pwstore.go create mode 100644 internal/pwstore/pwstore_test.go create mode 100644 internal/pwstore/stage.go create mode 100644 internal/pwstore/stage_test.go diff --git a/cmd/incredigo/main.go b/cmd/incredigo/main.go index fe2d81e..96f4ab0 100644 --- a/cmd/incredigo/main.go +++ b/cmd/incredigo/main.go @@ -54,7 +54,7 @@ func main() { root.PersistentFlags().StringVar(&flagConfig, "config", "", "policy.yaml path") root.PersistentFlags().StringVar(&flagSealer, "sealer", "age", "backup sealer: age (default, authenticated), hmac (authenticated, openssl-only), or openssl (unauthenticated)") - root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd(), rotateCmd(), worklistCmd(), guideCmd()) + root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd(), rotateCmd(), worklistCmd(), guideCmd(), passwordsCmd()) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, "incredigo:", err) diff --git a/cmd/incredigo/passwords.go b/cmd/incredigo/passwords.go new file mode 100644 index 0000000..a2e2281 --- /dev/null +++ b/cmd/incredigo/passwords.go @@ -0,0 +1,707 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/awnumar/memguard" + "github.com/spf13/cobra" + "golang.org/x/term" + + "incredigo/internal/audit" + "incredigo/internal/links" + "incredigo/internal/pwgen" + "incredigo/internal/pwstore" + "incredigo/internal/sink" + "incredigo/internal/vault" +) + +// Phase-B "passwords" command group — the browser / password-manager propagation +// engine (docs/BROWSER-ROTATION.md). It does every safe, deterministic step +// (ingest → mandatory backup → generate → stage) and hands the human a ready-to-finish +// task at the MFA wall; only AFTER the human confirms the site accepted the new +// password does it commit the new value back into the manager. It never bypasses MFA. +var ( + flagPwManager string + flagAllowCSV bool + flagExportPath string + flagKdbxPath string + flagPwLength int + flagExcludeAmbig bool + flagSymbolSet string + flagBackupOut string + flagStageOut string + flagStageIn string + flagWorklistOut string + flagVerified string +) + +func passwordsCmd() *cobra.Command { + c := &cobra.Command{ + Use: "passwords", + Short: "Rotate browser / password-manager logins (Phase B propagation engine)", + Long: "Propagate new passwords into a password manager or browser store, safely.\n\n" + + "Flow: ingest the store -> MANDATORY verified sealed backup -> generate fresh strong\n" + + "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).", + } + 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(&flagKdbxPath, "kdbx", "", "keepassxc: path to the .kdbx database") + c.AddCommand(pwScanCmd(), pwPlanCmd(), pwGuideCmd(), pwCommitCmd()) + return c +} + +// buildManager constructs a configured adapter from the flags. Unlike the registry +// singletons (which are zero-valued), these carry the per-run paths/keys the command +// needs. KeePassXC's database passphrase is loaded into the vault from +// $INCREDIGO_KDBX_PASSPHRASE or a no-echo prompt. +func buildManager(v *vault.Vault) (pwstore.Manager, error) { + switch strings.ToLower(strings.TrimSpace(flagPwManager)) { + case "bitwarden": + return &pwstore.Bitwarden{}, nil + case "1password", "onepassword", "op": + return &pwstore.OnePassword{}, nil + case "keepassxc": + if flagKdbxPath == "" { + return nil, fmt.Errorf("keepassxc requires --kdbx ") + } + k := &pwstore.KeePassXC{DBPath: flagKdbxPath} + if env, ok := os.LookupEnv("INCREDIGO_KDBX_PASSPHRASE"); ok { + if env == "" { + return nil, fmt.Errorf("INCREDIGO_KDBX_PASSPHRASE is set but empty") + } + k.DBKey = v.Store([]byte(env)) + } else { + h, err := promptSecret(v, "KeePassXC database passphrase: ") + if err != nil { + return nil, err + } + k.DBKey = h + } + return k, nil + case "chrome": + if flagExportPath == "" { + return nil, fmt.Errorf("chrome requires --export-path (export from chrome://password-manager/settings)") + } + return &pwstore.ChromeCSV{ExportPath: flagExportPath}, nil + case "firefox": + if flagExportPath == "" { + return nil, fmt.Errorf("firefox requires --export-path (export from about:logins)") + } + return &pwstore.FirefoxCSV{ExportPath: flagExportPath}, nil + case "": + return nil, fmt.Errorf("--manager is required (bitwarden|1password|keepassxc|chrome|firefox)") + default: + return nil, fmt.Errorf("unknown --manager %q", flagPwManager) + } +} + +func pwPolicyFromFlags() pwgen.Policy { + p := pwgen.DefaultPolicy() + if flagPwLength > 0 { + p.Length = flagPwLength + } + p.ExcludeAmbiguous = flagExcludeAmbig + if flagSymbolSet != "" { + p.Symbols = true + p.SymbolSet = flagSymbolSet + } + return p +} + +func addGenFlags(c *cobra.Command) { + c.Flags().IntVar(&flagPwLength, "length", 0, "generated password length (default 20)") + c.Flags().BoolVar(&flagExcludeAmbig, "exclude-ambiguous", false, "drop O/0/l/1/I (easier to hand-retype)") + c.Flags().StringVar(&flagSymbolSet, "symbols", "", "override the symbol set (some sites reject specific symbols)") +} + +// linkLabel renders an account's change-password URL (offline), or a clear note when +// no rotation page can be derived. +func linkLabel(a pwstore.Account) string { + host := a.Site + if host == "" { + host = a.URL + } + l := links.For(host) + if l.Source == links.SourceNone { + return "(no rotation page known — change it in the account's settings)" + } + return l.URL +} + +// ---- scan ---- + +func pwScanCmd() *cobra.Command { + return &cobra.Command{ + Use: "scan", + Short: "Count logins in the selected manager (no secrets, commits nothing)", + RunE: func(cmd *cobra.Command, args []string) error { + return withVault(func(ctx context.Context, v *vault.Vault) error { + m, err := buildManager(v) + if err != nil { + return err + } + if !m.Available() { + return fmt.Errorf("%s backend not available (CLI not installed or export not found)", m.Name()) + } + accts, err := m.Export(ctx, v) + if err != nil { + return err + } + log, _ := audit.Open(flagAuditLog, time.Now) + defer log.Close() + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(w, "#\tIDENTITY\tCHANGE-PASSWORD LINK") + for i, a := range accts { + fmt.Fprintf(w, "%d\t%s\t%s\n", i+1, pwstore.RedactIdentity(a), linkLabel(a)) + log.Write(audit.Entry{Action: "pw-scan", Source: m.Name(), + Identity: pwstore.RedactIdentity(a), Outcome: "ok"}) + } + w.Flush() + fmt.Fprintf(os.Stderr, "\n%s: %d login(s)\n", m.Name(), len(accts)) + return nil + }) + }, + } +} + +// ---- plan ---- + +func pwPlanCmd() *cobra.Command { + c := &cobra.Command{ + Use: "plan", + Short: "Ingest + verified backup + generate staged new passwords + secrets-free worklist", + Long: "Non-interactive preparation: reads the store, runs the MANDATORY verified sealed\n" + + "backup, generates a fresh strong password per account, seals that staged list (--stage-out,\n" + + "age-encrypted, never plaintext), and writes a SECRETS-FREE worklist (site, masked user,\n" + + "change link, status). Commits nothing. Finish with `passwords commit --stage-in `.", + RunE: func(cmd *cobra.Command, args []string) error { + return withVault(func(ctx context.Context, v *vault.Vault) error { + m, err := buildManager(v) + if err != nil { + return err + } + if !m.Available() { + return fmt.Errorf("%s backend not available", m.Name()) + } + sealer, err := sealerFor(flagSealer) + if err != nil { + return err + } + accts, err := m.Export(ctx, v) + if err != nil { + return err + } + if len(accts) == 0 { + return fmt.Errorf("%s: no logins to plan", m.Name()) + } + + h, err := readPassphrase(v, true) + if err != nil { + return err + } + pass, err := v.Open(h) + if err != nil { + return err + } + + backupOut := flagBackupOut + if backupOut == "" { + backupOut, err = defaultBundlePath("pw-backups") + if err != nil { + return err + } + } + log, _ := audit.Open(flagAuditLog, time.Now) + defer log.Close() + + // MANDATORY backup gate — no staging proceeds without a verified backup. + n, err := pwBackupGate(ctx, m, v, accts, sealer, pass, backupOut) + if err != nil { + return fmt.Errorf("backup gate failed — planning blocked: %w", err) + } + log.Write(audit.Entry{Action: "pw-export", Source: m.Name(), Location: backupOut, Outcome: "ok"}) + fmt.Fprintf(os.Stderr, "✓ backup gate: %d login(s) sealed + verified -> %s\n", n, backupOut) + + // Generate fresh passwords into the vault. + policy := pwPolicyFromFlags() + newPw := map[int]*vault.Handle{} + for i := range accts { + nh, err := pwgen.Generate(v, policy) + if err != nil { + return fmt.Errorf("generate new password: %w", err) + } + newPw[i] = nh + } + + // Seal the staged NEW-password list (encrypted; never plaintext on disk). + stageOut := flagStageOut + if stageOut == "" { + stageOut, err = defaultBundlePath("pw-stage") + if err != nil { + return err + } + } + if err := pwSealStaged(ctx, v, accts, newPw, sealer, pass, stageOut); err != nil { + return err + } + log.Write(audit.Entry{Action: "pw-stage", Source: m.Name(), Location: stageOut, Outcome: "ok"}) + + // Secrets-free worklist. + wl := renderPwWorklist(m.Name(), accts) + if flagWorklistOut == "" { + fmt.Print(wl) + } else if err := os.WriteFile(flagWorklistOut, []byte(wl), 0o644); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "\nStaged %d new password(s) -> %s\n", len(accts), stageOut) + fmt.Fprintf(os.Stderr, "Backup: %s\nWorklist: %s\n", backupOut, worklistDest()) + fmt.Fprintf(os.Stderr, "Next: change each site (complete MFA), then\n incredigo passwords commit --manager %s --stage-in %s --verified <1,2,…|all>\n", + m.Name(), stageOut) + return nil + }) + }, + } + addGenFlags(c) + c.Flags().StringVar(&flagBackupOut, "backup-out", "", "backup bundle path (default ~/.incredigo/pw-backups/.age)") + c.Flags().StringVar(&flagStageOut, "stage-out", "", "sealed staged-list path (default ~/.incredigo/pw-stage/.age)") + c.Flags().StringVar(&flagWorklistOut, "worklist-out", "", "write the secrets-free worklist here (default: stdout)") + return c +} + +// ---- guide ---- + +func pwGuideCmd() *cobra.Command { + c := &cobra.Command{ + Use: "guide", + Short: "Interactive: per account → link + reveal new password + MFA handoff → commit", + Long: "Single-session interactive walk-through. For each login it shows the change-password\n" + + "link, reveals the new password on request, and waits for you to confirm the site\n" + + "accepted it (after you complete MFA) BEFORE committing the new value into the manager.\n" + + "If you cannot confirm, it keeps the old value and flags the account needs-human.", + RunE: func(cmd *cobra.Command, args []string) error { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return fmt.Errorf("guide is interactive and needs a TTY; for headless use `passwords plan` then `passwords commit`") + } + return withVault(func(ctx context.Context, v *vault.Vault) error { + m, err := buildManager(v) + if err != nil { + return err + } + if !m.Available() { + return fmt.Errorf("%s backend not available", m.Name()) + } + sealer, err := sealerFor(flagSealer) + if err != nil { + return err + } + accts, err := m.Export(ctx, v) + if err != nil { + return err + } + if len(accts) == 0 { + return fmt.Errorf("%s: no logins to rotate", m.Name()) + } + + h, err := readPassphrase(v, true) + if err != nil { + return err + } + pass, err := v.Open(h) + if err != nil { + return err + } + backupOut := flagBackupOut + if backupOut == "" { + backupOut, err = defaultBundlePath("pw-backups") + if err != nil { + return err + } + } + log, _ := audit.Open(flagAuditLog, time.Now) + defer log.Close() + + n, err := pwBackupGate(ctx, m, v, accts, sealer, pass, backupOut) + if err != nil { + return fmt.Errorf("backup gate failed — rotation blocked: %w", err) + } + fmt.Fprintf(os.Stderr, "✓ backup gate: %d login(s) sealed + verified -> %s\n", n, backupOut) + + policy := pwPolicyFromFlags() + newPw := map[int]*vault.Handle{} + for i := range accts { + nh, err := pwgen.Generate(v, policy) + if err != nil { + return err + } + newPw[i] = nh + } + + in := bufio.NewScanner(os.Stdin) + iu, isIU := m.(pwstore.ItemUpdater) + var csvVerified []int + committed := 0 + + for i, a := range accts { + fmt.Printf("\n[%d/%d] %s\n", i+1, len(accts), pwstore.RedactIdentity(a)) + fmt.Printf(" change-password: %s\n", linkLabel(a)) + if ask(in, " Reveal the new password to type into the site? [y/N] ") { + buf, err := v.Open(newPw[i]) + if err != nil { + return err + } + fmt.Printf(" NEW PASSWORD: %s\n", buf.Bytes()) + } + if !ask(in, " Did the site accept the new password (MFA complete)? [y/N] ") { + fmt.Println(" → kept old value, flagged needs-human") + log.Write(audit.Entry{Action: "pw-commit", Source: m.Name(), + Identity: pwstore.RedactIdentity(a), Outcome: "skipped (needs-human)"}) + continue + } + if isIU { + if err := iu.UpdatePassword(ctx, v, a, newPw[i]); err != nil { + fmt.Printf(" ✗ commit failed: %v (old value left intact)\n", err) + log.Write(audit.Entry{Action: "pw-commit", Source: m.Name(), + Identity: pwstore.RedactIdentity(a), Outcome: err.Error()}) + continue + } + committed++ + fmt.Println(" ✓ committed in place") + log.Write(audit.Entry{Action: "pw-commit", Source: m.Name(), + Identity: pwstore.RedactIdentity(a), Outcome: "ok"}) + } else { + csvVerified = append(csvVerified, i) + committed++ + fmt.Println(" ✓ marked for CSV re-import") + } + } + + if !isIU && len(csvVerified) > 0 { + if err := pwCSVReimport(ctx, m, v, accts, newPw, csvVerified, in, log); err != nil { + return err + } + } + fmt.Fprintf(os.Stderr, "\nGuide complete: %d committed, backup %s\n", committed, backupOut) + return nil + }) + }, + } + addGenFlags(c) + c.Flags().StringVar(&flagBackupOut, "backup-out", "", "backup bundle path (default ~/.incredigo/pw-backups/.age)") + return c +} + +// ---- commit ---- + +func pwCommitCmd() *cobra.Command { + c := &cobra.Command{ + Use: "commit", + Short: "Headless: commit verified staged new passwords into the manager", + Long: "Opens a sealed staged list (from `passwords plan`), runs the MANDATORY verified\n" + + "backup of the CURRENT store, and commits the new password for the accounts you mark\n" + + "--verified (1-based indices from the worklist, or 'all'). CLI managers update in place;\n" + + "chrome/firefox re-import a tmpfs CSV (requires --allow-csv) which is shredded after.", + RunE: func(cmd *cobra.Command, args []string) error { + if flagStageIn == "" { + return fmt.Errorf("--stage-in is required") + } + if strings.TrimSpace(flagVerified) == "" { + return fmt.Errorf("--verified <1,2,…|all> is required (only accounts you confirmed the site accepted)") + } + return withVault(func(ctx context.Context, v *vault.Vault) error { + m, err := buildManager(v) + if err != nil { + return err + } + if !m.Available() { + return fmt.Errorf("%s backend not available", m.Name()) + } + sealer, err := sealerFor(flagSealer) + if err != nil { + return err + } + h, err := readPassphrase(v, false) + if err != nil { + return err + } + pass, err := v.Open(h) + if err != nil { + return err + } + + // Mandatory backup of the CURRENT store before any commit (rule 1). + cur, err := m.Export(ctx, v) + if err != nil { + return err + } + backupOut := flagBackupOut + if backupOut == "" { + backupOut, err = defaultBundlePath("pw-backups") + if err != nil { + return err + } + } + log, _ := audit.Open(flagAuditLog, time.Now) + defer log.Close() + n, err := pwBackupGate(ctx, m, v, cur, sealer, pass, backupOut) + if err != nil { + return fmt.Errorf("backup gate failed — commit blocked: %w", err) + } + fmt.Fprintf(os.Stderr, "✓ backup gate: %d login(s) sealed + verified -> %s\n", n, backupOut) + + // Load the staged NEW-password list (Secret = new password). + staged, err := pwOpenStaged(ctx, v, sealer, pass, flagStageIn) + if err != nil { + return err + } + idxs, err := parseVerified(flagVerified, len(staged)) + if err != nil { + return err + } + + if iu, ok := m.(pwstore.ItemUpdater); ok { + committed, failed := 0, 0 + for _, i := range idxs { + a := staged[i] + if a.ID == "" { + fmt.Printf("skip #%d %s — staged record has no item id\n", i+1, pwstore.RedactIdentity(a)) + continue + } + if err := iu.UpdatePassword(ctx, v, a, a.Secret); err != nil { + failed++ + fmt.Printf("✗ #%d %s: %v\n", i+1, pwstore.RedactIdentity(a), err) + log.Write(audit.Entry{Action: "pw-commit", Source: m.Name(), + Identity: pwstore.RedactIdentity(a), Outcome: err.Error()}) + continue + } + committed++ + fmt.Printf("✓ #%d %s committed\n", i+1, pwstore.RedactIdentity(a)) + log.Write(audit.Entry{Action: "pw-commit", Source: m.Name(), + Identity: pwstore.RedactIdentity(a), Outcome: "ok"}) + } + fmt.Fprintf(os.Stderr, "\nCommitted %d, failed %d. Backup: %s\n", committed, failed, backupOut) + if failed > 0 { + return fmt.Errorf("%d commit(s) failed — restore from backup if needed", failed) + } + return nil + } + + // Browser CSV manager: re-import a subset via tmpfs CSV + shred. + si, ok := m.(pwstore.StagedImporter) + if !ok { + return fmt.Errorf("%s supports neither in-place update nor CSV import", m.Name()) + } + if !flagAllowCSV { + return fmt.Errorf("%s commit writes a plaintext CSV to tmpfs for the browser to re-read; pass --allow-csv to authorize (it is shredded after)", m.Name()) + } + sub := make([]pwstore.Account, 0, len(idxs)) + for _, i := range idxs { + sub = append(sub, staged[i]) + } + path, cleanup, err := si.ImportStaged(v, sub, nil) // Secret already holds the new password + if err != nil { + return err + } + 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) + if term.IsTerminal(int(os.Stdin.Fd())) { + in := bufio.NewScanner(os.Stdin) + in.Scan() + } + fmt.Fprintf(os.Stderr, "shredded %s\n", path) + return nil + }) + }, + } + c.Flags().StringVar(&flagStageIn, "stage-in", "", "sealed staged list produced by `passwords plan`") + c.Flags().StringVar(&flagVerified, "verified", "", "1-based indices to commit (e.g. 1,3,5) or 'all'") + c.Flags().StringVar(&flagBackupOut, "backup-out", "", "backup bundle path (default ~/.incredigo/pw-backups/.age)") + return c +} + +// ---- shared helpers ---- + +// pwBackupGate seals the current store (old passwords) and verifies it round-trips, +// mirroring rotate.Snapshot for the password-manager path. No commit may proceed +// without this returning successfully (hard rule 1). +func pwBackupGate(ctx context.Context, m pwstore.Manager, v *vault.Vault, accts []pwstore.Account, sealer sink.Sealer, pass *memguard.LockedBuffer, out string) (int, error) { + if len(accts) == 0 { + return 0, fmt.Errorf("store is empty — nothing to back up") + } + if err := pwSealStaged(ctx, v, accts, nil, sealer, pass, out); err != nil { // nil newPw = current passwords + return 0, err + } + // Verify: open + decrypt + parse + count must match. Read back into the SAME vault + // (its handles are purged with the command); a throwaway vault must NOT be used here + // because vault.Purge calls the process-global memguard.Purge, which would destroy + // `pass` and every other live secret mid-run. + got, err := pwOpenStaged(ctx, v, sealer, pass, out) + if err != nil { + return 0, fmt.Errorf("backup verify failed to reopen bundle: %w", err) + } + if len(got) != len(accts) { + return 0, fmt.Errorf("backup verify mismatch: sealed %d, read back %d", len(accts), len(got)) + } + return len(accts), nil +} + +// pwSealStaged streams accts (with newPw substituted where present) through the Sealer +// into a fresh, refuse-to-overwrite file. The plaintext exists only inside the Sealer's +// encryption; nothing cleartext lands on disk. +func pwSealStaged(ctx context.Context, v *vault.Vault, accts []pwstore.Account, newPw map[int]*vault.Handle, sealer sink.Sealer, pass *memguard.LockedBuffer, out string) error { + f, err := os.OpenFile(out, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("create %s (use a fresh path; refuses to overwrite): %w", out, err) + } + pr, pw := io.Pipe() + go func() { pw.CloseWithError(pwstore.WriteStaged(pw, v, accts, newPw)) }() + if err := sealer.Seal(ctx, pr, f, pass); err != nil { + f.Close() + return err + } + return f.Close() +} + +// pwOpenStaged decrypts a sealed staged/backup bundle and parses it back into Accounts +// whose Secret is the stored password (new for a stage file, old for a backup). +func pwOpenStaged(ctx context.Context, v *vault.Vault, sealer sink.Sealer, pass *memguard.LockedBuffer, in string) ([]pwstore.Account, error) { + f, err := os.Open(in) + if err != nil { + return nil, err + } + defer f.Close() + pr, pw := io.Pipe() + go func() { pw.CloseWithError(sealer.Open(ctx, f, pw, pass)) }() + return pwstore.ReadStaged(v, pr) +} + +// pwCSVReimport handles the browser-CSV commit at the end of an interactive guide: +// it writes a tmpfs CSV of the verified accounts' new passwords, waits for the human to +// re-import it, then shreds it. Requires --allow-csv. +func pwCSVReimport(ctx context.Context, m pwstore.Manager, v *vault.Vault, accts []pwstore.Account, newPw map[int]*vault.Handle, verified []int, in *bufio.Scanner, log *audit.Log) error { + if !flagAllowCSV { + return fmt.Errorf("browser re-import writes a plaintext CSV to tmpfs; re-run with --allow-csv to authorize (it is shredded after)") + } + si, ok := m.(pwstore.StagedImporter) + if !ok { + return fmt.Errorf("%s cannot import a CSV", m.Name()) + } + sub := make([]pwstore.Account, 0, len(verified)) + subNew := map[int]*vault.Handle{} + for j, i := range verified { + sub = append(sub, accts[i]) + subNew[j] = newPw[i] + } + path, cleanup, err := si.ImportStaged(v, sub, subNew) + if err != nil { + return err + } + defer cleanup() + if log != nil { + 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("\nRe-import this CSV into your browser, then press Enter to shred it:\n %s\n", path) + in.Scan() + fmt.Fprintf(os.Stderr, "shredded %s\n", path) + return nil +} + +// parseVerified turns "all" or "1,3,5" (1-based) into 0-based indices into an n-length +// staged list. +func parseVerified(spec string, n int) ([]int, error) { + spec = strings.TrimSpace(spec) + if strings.EqualFold(spec, "all") { + idxs := make([]int, n) + for i := range idxs { + idxs[i] = i + } + return idxs, nil + } + var out []int + for _, tok := range strings.Split(spec, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + k, err := strconv.Atoi(tok) + if err != nil || k < 1 || k > n { + return nil, fmt.Errorf("invalid --verified entry %q (want 1..%d or 'all')", tok, n) + } + out = append(out, k-1) + } + if len(out) == 0 { + return nil, fmt.Errorf("--verified selected no accounts") + } + return out, nil +} + +// renderPwWorklist builds the SECRETS-FREE worklist: index, redacted identity, change +// link, and a status the human ticks off. New passwords NEVER appear here. +func renderPwWorklist(manager string, accts []pwstore.Account) string { + var b strings.Builder + fmt.Fprintf(&b, "# Password rotation worklist — %s\n\n", manager) + fmt.Fprintf(&b, "%d account(s). Change each at its site (complete MFA), then commit by index.\n\n", len(accts)) + fmt.Fprintln(&b, "| # | account | change-password link | status |") + fmt.Fprintln(&b, "|---|---------|----------------------|--------|") + for i, a := range accts { + fmt.Fprintf(&b, "| %d | %s | %s | [ ] pending |\n", i+1, pwstore.RedactIdentity(a), linkLabel(a)) + } + return b.String() +} + +func worklistDest() string { + if flagWorklistOut == "" { + return "(stdout)" + } + return flagWorklistOut +} + +// defaultBundlePath returns a fresh timestamped path under ~/.incredigo//. +func defaultBundlePath(sub string) (string, error) { + dir := filepath.Join(os.Getenv("HOME"), ".incredigo", sub) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return filepath.Join(dir, time.Now().UTC().Format("2006-01-02T15-04-05Z")+".age"), nil +} + +// ask prints a prompt and returns true on a y/yes answer. +func ask(in *bufio.Scanner, prompt string) bool { + fmt.Print(prompt) + if !in.Scan() { + return false + } + a := strings.ToLower(strings.TrimSpace(in.Text())) + return a == "y" || a == "yes" +} + +// promptSecret reads one secret line with no echo into the vault. +func promptSecret(v *vault.Vault, prompt string) (*vault.Handle, error) { + fmt.Fprint(os.Stderr, prompt) + b, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return nil, err + } + return v.Store(b), nil +} diff --git a/cmd/incredigo/passwords_test.go b/cmd/incredigo/passwords_test.go new file mode 100644 index 0000000..7e6c49b --- /dev/null +++ b/cmd/incredigo/passwords_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "strings" + "testing" + + "incredigo/internal/pwstore" +) + +func TestParseVerified(t *testing.T) { + cases := []struct { + spec string + n int + want []int + wantErr bool + }{ + {"all", 3, []int{0, 1, 2}, false}, + {"ALL", 2, []int{0, 1}, false}, + {"1,3", 3, []int{0, 2}, false}, + {" 2 , 1 ", 2, []int{1, 0}, false}, + {"0", 3, nil, true}, // 0 is out of the 1-based range + {"4", 3, nil, true}, // beyond n + {"x", 3, nil, true}, // non-numeric + {"", 3, nil, true}, // empty selects nothing + {",", 3, nil, true}, // no usable entries + } + for _, c := range cases { + got, err := parseVerified(c.spec, c.n) + if c.wantErr { + if err == nil { + t.Errorf("parseVerified(%q,%d) expected error, got %v", c.spec, c.n, got) + } + continue + } + if err != nil { + t.Errorf("parseVerified(%q,%d) unexpected error: %v", c.spec, c.n, err) + continue + } + if len(got) != len(c.want) { + t.Errorf("parseVerified(%q,%d) = %v, want %v", c.spec, c.n, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("parseVerified(%q,%d) = %v, want %v", c.spec, c.n, got, c.want) + break + } + } + } +} + +func TestRenderPwWorklistSecretsFree(t *testing.T) { + accts := []pwstore.Account{ + {Site: "github.com", Username: "alice@example.com"}, + {URL: "https://app.example.com/", Username: "bobsecretlogin"}, + } + md := renderPwWorklist("bitwarden", accts) + // Numbered, has links, masks usernames, and never contains a full username. + if !strings.Contains(md, "| 1 |") || !strings.Contains(md, "| 2 |") { + t.Errorf("worklist not numbered:\n%s", md) + } + if !strings.Contains(md, "github.com/settings/tokens") { + t.Errorf("worklist missing curated github link:\n%s", md) + } + if strings.Contains(md, "alice@example.com") || strings.Contains(md, "bobsecretlogin") { + t.Errorf("worklist leaked a full username:\n%s", md) + } +} + +func TestLinkLabelUnknownHost(t *testing.T) { + got := linkLabel(pwstore.Account{}) // no site/url + if !strings.Contains(got, "no rotation page") { + t.Errorf("linkLabel for empty account = %q", got) + } +} diff --git a/docs/BROWSER-ROTATION.md b/docs/BROWSER-ROTATION.md new file mode 100644 index 0000000..3dcb3a8 --- /dev/null +++ b/docs/BROWSER-ROTATION.md @@ -0,0 +1,252 @@ +# Browser / password-manager rotation — design spec + +This is the design for incredigo's **secondary-persona** path: rotating the passwords a +non-technical user has reused across dozens of sites and saved in a browser or password +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 (adapters MOCK-ONLY against fake binaries/CSVs — 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. + +--- + +## 1. The reframe: two sub-problems, very different difficulty + +A naïve "automate browser password rotation" conflates two jobs. Keeping them separate is the +whole reason this design is tractable where Dashlane's was not. + +| | **A — the site-side change** | **B — the manager-side propagation** | +|---|---|---| +| What | Go to each *website*, authenticate, find the change-password form, submit old→new | Update the *password manager's* stored entry with the new value | +| Why hard | MFA, CAPTCHA, email/SMS confirmation, per-site DOM variance, lockout risk | Mostly a solved problem via the manager's CLI/API | +| Automatable? | Partially, with a hard human-handoff ceiling | Yes, safely | + +The throughput of the whole pipeline is capped by **A**, not B. So Phase B builds B +end-to-end (high value, safe) with the **human** as the site+MFA actor, and later phases chip +away at A behind explicit opt-in. We never let B's success imply A is solved. + +--- + +## 2. The user-visible model: "everything up to MFA, then make MFA easy" + +incredigo does every safe, deterministic step and then hands a *ready-to-finish* task to the +human at exactly the MFA wall: + +1. **Ingest** the existing store (manager CLI/API or browser CSV) into the RAM vault. +2. **Backup** (mandatory, verified, sealed) the old store before anything changes. +3. **Generate** a fresh strong password per account → the **staged "separate list"** (held in + the vault, never written to disk in plaintext). +4. **Guided handoff** — for each account present: the change-password **link**, the **new + password** (revealed/copied locally on demand), and per-site **MFA instructions**. The + human performs the site change + completes MFA/CAPTCHA. incredigo never bypasses either. +5. **Commit/merge** — only *after* the change succeeds, write the new password back into the + manager: **in-place by item ID** for CLI managers; **CSV re-import** (flag-gated) for + browser stores. + +In Phase B step 4 is fully manual (incredigo opens the link + reveals the new password). In +later phases step 4 is automated *up to* the MFA wall, where it still hands off to the human. + +--- + +## 3. Hard-rule reconciliation (this path touches real passwords) + +The project's hard rules constrain this design tightly. The collisions and resolutions: + +### Rule 3 — "No plaintext secrets on disk. Ever." (escape hatch: flag-gated raw export) + +- **CLI managers (Bitwarden `bw`, 1Password `op`, KeePassXC `keepassxc-cli`)** read and write + individual items over **stdin/stdout** — secrets stream as bytes into the RAM vault and back + out per item by ID. **No plaintext file is ever created.** This is the preferred path. +- **Browser-native stores (Chrome, Firefox)** have *no* import API; the only path is a + plaintext CSV the browser reads off disk. This is the one unavoidable plaintext-on-disk + moment, so it is: + - **flag-gated + loudly warned** (rule 3's sanctioned escape hatch), + - materialized on **tmpfs (`/dev/shm`)** so it never lands on stable storage, + - **securely shredded** (random+zero overwrite → fsync → unlink) the instant the browser + finishes, with the window kept as narrow as possible, + - **audited** (the act, never the value). + - Honesty note: shred is best-effort on SSD/journaling/CoW filesystems; tmpfs avoids the + durability problem entirely, which is why we prefer it. +- The staged "separate list" of *new* passwords lives only in the vault for the session. If it + must survive an interactive session it is **sealed** with the existing `Sealer` (age), never + written as plaintext — same rule as backups. +- The **worklist `.md` stays secrets-free** (site, username, link, status only). New passwords + appear only in the interactive TUI / clipboard, never in any file. + +### Rule 1 — backup before rotate + +The mandatory `rotate.Snapshot` gate runs over the ingested store **before** any commit. No +verified sealed backup → no commit. The backup is also the rollback if a half-rotation occurs. + +### Rule 2 — verify-new-before-revoke-old, and the browser-specific lockout hazard + +For website passwords the *site itself* invalidates the old password the instant the new one +is set — we don't control that ordering. So the safe sequence per account is: + +``` +change at site → re-login with NEW pw in the same session to verify → only then commit to manager +``` + +If the change can't be verified (MFA loop, confirmation email, ambiguous result) incredigo +**keeps the old value in the manager and flags "needs human"** — it never leaves an account +half-rotated, and never risks locking the user out (do-no-harm, rule 5/6). In Phase B the +human asserts success explicitly ("the site accepted it") before commit. + +### Rule 5/6 — self-owned only, never bypass MFA/CAPTCHA, audited, reversible + +Only the user's own accounts, with their authorization. MFA/CAPTCHA are always a human +handoff. Every action is dry-run-able and audited (redacted). + +--- + +## 4. Package layout (one-file-each, mirrors `discover`/`sink`) + +``` +internal/pwgen/ strong password generator (crypto/rand, policy, vault-native) + pwgen.go + +internal/pwstore/ password-manager adapters + the propagation engine + pwstore.go Manager interface, Account, registry, CSV + shred + redaction helpers + bitwarden.go bw CLI — in-place UpdatePassword by item id + onepassword.go op CLI — in-place UpdatePassword by item id + keepassxc.go keepassxc-cli — in-place UpdatePassword by entry path + chromecsv.go Chrome CSV ingest-and-shred (BulkImporter) + firefoxcsv.go Firefox CSV ingest-and-shred (BulkImporter) +``` + +### The `Manager` contract + +```go +// Account is a single login. The password is a vault handle — never plaintext. +type Account struct { + ID string // manager-native item id / entry path ("" for CSV rows) + Site string // hostname used for the change-password link + URL string // full login URL if the store has one + Username string + Secret *vault.Handle // OLD password, in the RAM vault + Meta map[string]string // non-secret extras (folder, note title, …) +} + +type Manager interface { + Name() string + Available() bool // is the CLI installed / the export present? + // Export reads all logins into the vault, passwords as handles. + Export(ctx context.Context, v *vault.Vault) ([]Account, error) +} + +// In-place updaters (CLI managers) implement this — the safe, no-plaintext-file path. +type ItemUpdater interface { + UpdatePassword(ctx context.Context, v *vault.Vault, acct Account, newPassword *vault.Handle) error +} + +// Bulk importers (browser CSV stores) implement this instead — flag-gated, shredded. +type BulkImporter interface { + Import(ctx context.Context, v *vault.Vault, accts []Account) error +} +``` + +Every adapter takes an injectable `Bin` (CLI managers) or `Path` (CSV stores) so it is unit +tested against a **fake binary** / **temp CSV** — identical to the `sink.Gopass{Bin:…}` and +driver `HTTPClient` test pattern. Adapters contain **only real code**; validation status +(does this work against the real `bw`/`op`/Chrome?) is tracked as data, exactly like the +rotation drivers' `proofs.go`. + +### The generator + +```go +type Policy struct { + Length int // default 20 + Upper, Lower bool // default true + Digits, Symbols bool // default true + ExcludeAmbiguous bool // drop O/0/l/1/I to survive hand-retyping + SymbolSet string // override per-site (some sites reject specific symbols) +} +// Generate builds a password with crypto/rand (rejection sampling, no modulo bias), +// guarantees ≥1 char from each enabled class, and stores it straight into the vault — +// it never returns the plaintext as a Go string. +func Generate(v *vault.Vault, p Policy) (*vault.Handle, error) +``` + +--- + +## 5. Command surface (implemented) + +``` +incredigo passwords scan # list logins (redacted) + change-link per account; no secrets, no commit +incredigo passwords plan # ingest + verified backup + generate + seal staged list (--stage-out) + secrets-free worklist (--worklist-out) +incredigo passwords guide # interactive (TTY): per account → link + reveal new pw + MFA handoff → confirm → commit +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 +`$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. + +Both the **backup** (`pwBackupGate`) and the **staged new-password list** are sealed with the +existing `Sealer` (age by default) and round-trip-verified — they are the only artifacts that +persist across the plan→commit gap, and they are never plaintext on disk. `--verified` indexes +are 1-based and match the worklist row numbers / the staged-list order from the same `plan`. + +--- + +## 6. Audit + +New redacted `audit.Entry.Action` values: `pw-scan`, `pw-export`, `pw-stage`, `pw-commit`, +`pw-import`. `Identity` is the redacted `site / us…@…`. No password, ever. The CSV-path +entries additionally record that a flag-gated plaintext file was created **and shredded**. + +--- + +## 7. Honest ceiling (carried from the strategy discussion) + +- **MFA is on every account that matters** → the realistic *fully-automated* set (later + phases) is low-value, no-MFA, simple-form sites. Phase B sidesteps this by keeping the human + in the loop for the site change. +- **Chrome CSV import duplicates rather than updates** (it appends), so the browser-CSV merge + produces dupes a human must reconcile; the clean in-place merge is the CLI-manager path. + This is documented, not hidden. +- **Fully-silent mass browser rotation stays a non-goal** (VISION) — the known Dashlane tar + pit. incredigo automates up to the MFA wall and *guides* the rest. + +--- + +## 8. Validation status (DATA, mirrors `rotate/proofs.go` philosophy) + +The adapters contain ONLY real CLI/CSV code — no test branches. What differs is how each +write path has been *validated*. As of the Phase-B build every adapter is **MOCK-ONLY**: +exercised against a fake `bw`/`op`/`keepassxc-cli` binary (state-dir scripts) or a fake +export/import CSV, never yet against the real manager binary or a real browser re-import. + +| Adapter | Write shape | Secret path off-argv? | Validated against | +|-----------|------------------------|-----------------------|-------------------| +| bitwarden | `bw edit item` (stdin) | **yes** — base64 on stdin | fake `bw` (MOCK-ONLY) | +| keepassxc | `keepassxc-cli edit -p`| **yes** — db+new pass on stdin | fake `keepassxc-cli` (MOCK-ONLY) | +| 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) | fake CSV (MOCK-ONLY) | +| firefox | tmpfs CSV → human re-import | n/a | fake CSV (MOCK-ONLY) | + +¹ **1Password argv caveat:** `op item edit` has no stdin-per-field path (only `op item create` +does), and the JSON-template alternative would write plaintext to disk (hard rule 3). The +remaining real path is `op item edit password=`, so the new value is visible to +**same-uid** processes via `/proc//cmdline` for the brief op exec. This is strictly weaker +than the Bitwarden/KeePassXC stdin paths; it is in-RAM, transient, and never touches disk. The +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. + +## 9. Roadmap + +- **Phase B (now):** `pwgen` + `pwstore` (interface + Bitwarden/1Password/KeePassXC in-place + + Chrome/Firefox CSV ingest-and-shred) + the staged-list propagation engine + `passwords` + commands. Human does the site change + MFA; incredigo does everything else. +- **Phase A-tier-1:** deterministic `go-rod`/`playwright-go` driving the curated change-URL + table for a handful of high-frequency, no-MFA sites, with verify-before-commit. Measure the + real success rate before promising anything. +- **Phase A-tier-2:** Computer-Use / AI-in-browser vision fallback for DOM variance, with + **secrets kept out of the model loop** (the model decides navigation; the Go harness injects + the actual password via CDP, redacted from the screenshot stream). Opt-in, measured. diff --git a/internal/pwgen/pwgen.go b/internal/pwgen/pwgen.go new file mode 100644 index 0000000..63ef5f5 --- /dev/null +++ b/internal/pwgen/pwgen.go @@ -0,0 +1,194 @@ +// Package pwgen generates strong passwords for the browser/password-manager +// rotation path. It uses crypto/rand with rejection sampling (no modulo bias), +// guarantees at least one character from every enabled class, and writes the +// result STRAIGHT INTO the RAM vault — it never returns the plaintext as a Go +// string (strings are immutable, GC-managed, and cannot be reliably wiped). +package pwgen + +import ( + "crypto/rand" + "fmt" + + "incredigo/internal/vault" +) + +// Character classes. Ambiguous glyphs (O/0/l/1/I) are split out so a policy can +// drop them when a human may have to retype the password. +const ( + lowerAll = "abcdefghijklmnopqrstuvwxyz" + upperAll = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + digitsAll = "0123456789" + // A conservative symbol set that the large majority of sites accept. + symbolsAll = "!@#$%^&*()-_=+[]{};:,.?" + ambiguous = "O0l1I" +) + +// Policy describes the shape of a generated password. The zero value is not +// useful; use DefaultPolicy and adjust. +type Policy struct { + Length int // total length + Upper bool // include A-Z + Lower bool // include a-z + Digits bool // include 0-9 + Symbols bool // include punctuation + ExcludeAmbiguous bool // drop O/0/l/1/I from every class + SymbolSet string // override the symbol alphabet (some sites reject specific symbols) +} + +// DefaultPolicy is a strong, broadly-accepted default: 20 chars, all classes. +func DefaultPolicy() Policy { + return Policy{Length: 20, Upper: true, Lower: true, Digits: true, Symbols: true} +} + +// classAlphabets returns the per-class alphabets a policy enables, after applying +// ExcludeAmbiguous and any SymbolSet override. Each returned alphabet is non-empty. +func (p Policy) classAlphabets() ([]string, error) { + add := func(out []string, base string) []string { + s := base + if p.ExcludeAmbiguous { + s = strip(s, ambiguous) + } + if s != "" { + return append(out, s) + } + return out + } + var classes []string + if p.Lower { + classes = add(classes, lowerAll) + } + if p.Upper { + classes = add(classes, upperAll) + } + if p.Digits { + classes = add(classes, digitsAll) + } + if p.Symbols { + sym := p.SymbolSet + if sym == "" { + sym = symbolsAll + } + classes = add(classes, sym) + } + if len(classes) == 0 { + return nil, fmt.Errorf("pwgen: policy enables no character classes") + } + if p.Length < len(classes) { + return nil, fmt.Errorf("pwgen: length %d is shorter than the %d required classes", p.Length, len(classes)) + } + return classes, nil +} + +// Generate produces a password matching the policy and stores it in v, returning +// only a handle. The transient plaintext buffer is wiped before returning. +func Generate(v *vault.Vault, p Policy) (*vault.Handle, error) { + classes, err := p.classAlphabets() + if err != nil { + return nil, err + } + combined := "" + for _, c := range classes { + combined += c + } + + out := make([]byte, p.Length) + // 1. One guaranteed character from each enabled class (fills the first + // len(classes) positions; they are shuffled across the whole slice next). + for i, c := range classes { + b, err := pick(c) + if err != nil { + wipe(out) + return nil, err + } + out[i] = b + } + // 2. Fill the remainder from the combined alphabet. + for i := len(classes); i < p.Length; i++ { + b, err := pick(combined) + if err != nil { + wipe(out) + return nil, err + } + out[i] = b + } + // 3. Shuffle so the guaranteed class characters are not always up front. + if err := shuffle(out); err != nil { + wipe(out) + return nil, err + } + + // Store copies into a locked buffer AND wipes our source slice. + return v.Store(out), nil +} + +// pick returns one uniformly-random byte from alphabet using rejection sampling +// to avoid modulo bias. +func pick(alphabet string) (byte, error) { + n := len(alphabet) + // Largest multiple of n that fits in a byte; reject samples at/above it. + limit := 256 - (256 % n) + var b [1]byte + for { + if _, err := rand.Read(b[:]); err != nil { + return 0, err + } + if int(b[0]) < limit { + return alphabet[int(b[0])%n], nil + } + } +} + +// shuffle performs an unbiased Fisher-Yates shuffle over buf using crypto/rand. +func shuffle(buf []byte) error { + for i := len(buf) - 1; i > 0; i-- { + j, err := intn(i + 1) + if err != nil { + return err + } + buf[i], buf[j] = buf[j], buf[i] + } + return nil +} + +// intn returns a uniformly-random int in [0,n) via rejection sampling. +func intn(n int) (int, error) { + if n <= 0 { + return 0, fmt.Errorf("pwgen: intn domain") + } + limit := 256 - (256 % n) + var b [1]byte + for { + if _, err := rand.Read(b[:]); err != nil { + return 0, err + } + if int(b[0]) < limit { + return int(b[0]) % n, nil + } + } +} + +// strip removes every byte of cut from s. +func strip(s, cut string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if !contains(cut, s[i]) { + out = append(out, s[i]) + } + } + return string(out) +} + +func contains(s string, b byte) bool { + for i := 0; i < len(s); i++ { + if s[i] == b { + return true + } + } + return false +} + +func wipe(b []byte) { + for i := range b { + b[i] = 0 + } +} diff --git a/internal/pwgen/pwgen_test.go b/internal/pwgen/pwgen_test.go new file mode 100644 index 0000000..0d6d83c --- /dev/null +++ b/internal/pwgen/pwgen_test.go @@ -0,0 +1,123 @@ +package pwgen + +import ( + "strings" + "testing" + + "incredigo/internal/vault" +) + +// read returns the generated password bytes as a string for assertions ONLY. +// (Tests are the one place we materialize the plaintext to check its shape.) +func read(t *testing.T, v *vault.Vault, h *vault.Handle) string { + t.Helper() + buf, err := v.Open(h) + if err != nil { + t.Fatalf("open: %v", err) + } + return string(buf.Bytes()) +} + +func TestGenerateLengthAndClasses(t *testing.T) { + v := vault.New() + defer v.Purge() + + h, err := Generate(v, DefaultPolicy()) + if err != nil { + t.Fatalf("Generate: %v", err) + } + pw := read(t, v, h) + if len(pw) != 20 { + t.Errorf("length = %d, want 20", len(pw)) + } + if !strings.ContainsAny(pw, lowerAll) || !strings.ContainsAny(pw, upperAll) || + !strings.ContainsAny(pw, digitsAll) || !strings.ContainsAny(pw, symbolsAll) { + t.Errorf("password %q missing a required class", pw) + } +} + +func TestGenerateExcludeAmbiguous(t *testing.T) { + v := vault.New() + defer v.Purge() + p := DefaultPolicy() + p.ExcludeAmbiguous = true + p.Length = 64 + // Generate several so we'd very likely hit an ambiguous char if not excluded. + for i := 0; i < 50; i++ { + h, err := Generate(v, p) + if err != nil { + t.Fatalf("Generate: %v", err) + } + pw := read(t, v, h) + if strings.ContainsAny(pw, ambiguous) { + t.Fatalf("password %q contains an ambiguous char despite ExcludeAmbiguous", pw) + } + v.Forget(h) + } +} + +func TestGenerateCustomSymbolSet(t *testing.T) { + v := vault.New() + defer v.Purge() + p := Policy{Length: 30, Lower: true, Symbols: true, SymbolSet: "#-_"} + h, err := Generate(v, p) + if err != nil { + t.Fatalf("Generate: %v", err) + } + pw := read(t, v, h) + for _, r := range pw { + if !strings.ContainsRune(lowerAll+"#-_", r) { + t.Fatalf("password %q contains %q outside the allowed alphabet", pw, r) + } + } +} + +func TestGenerateUniqueness(t *testing.T) { + v := vault.New() + defer v.Purge() + seen := map[string]bool{} + for i := 0; i < 200; i++ { + h, err := Generate(v, DefaultPolicy()) + if err != nil { + t.Fatalf("Generate: %v", err) + } + pw := read(t, v, h) + if seen[pw] { + t.Fatalf("duplicate password generated: %q", pw) + } + seen[pw] = true + v.Forget(h) + } +} + +func TestPolicyErrors(t *testing.T) { + v := vault.New() + defer v.Purge() + // No classes enabled. + if _, err := Generate(v, Policy{Length: 10}); err == nil { + t.Error("expected error when no classes are enabled") + } + // Length shorter than the number of required classes. + if _, err := Generate(v, Policy{Length: 2, Upper: true, Lower: true, Digits: true, Symbols: true}); err == nil { + t.Error("expected error when length < number of classes") + } +} + +// TestDistribution is a weak bias smoke check: over many single-char picks from a +// small alphabet, every symbol should appear (rejection sampling guarantees +// uniform coverage, so absences would signal a broken sampler). +func TestDistribution(t *testing.T) { + counts := map[byte]int{} + for i := 0; i < 5000; i++ { + b, err := pick(digitsAll) + if err != nil { + t.Fatalf("pick: %v", err) + } + counts[b]++ + } + for i := 0; i < len(digitsAll); i++ { + if counts[digitsAll[i]] == 0 { + t.Errorf("digit %c never sampled in 5000 draws", digitsAll[i]) + } + } +} diff --git a/internal/pwstore/bitwarden.go b/internal/pwstore/bitwarden.go new file mode 100644 index 0000000..2714cb5 --- /dev/null +++ b/internal/pwstore/bitwarden.go @@ -0,0 +1,133 @@ +package pwstore + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os/exec" + + "incredigo/internal/vault" +) + +// Bitwarden adapts the official `bw` CLI. It reads logins with `bw list items` +// and updates ONE item's password in place with `bw edit item ` — the +// encoded item is piped via STDIN so the new password never appears on argv and +// no plaintext file is ever written (the safe ItemUpdater path). +// +// The caller is responsible for an authenticated, unlocked session (BW_SESSION in +// the environment); this adapter inherits the process environment and shells out. +type Bitwarden struct { + Bin string // default "bw"; injectable for tests +} + +func init() { Register(&Bitwarden{}) } + +func (b *Bitwarden) Name() string { return "bitwarden" } + +func (b *Bitwarden) bin() string { + if b.Bin != "" { + return b.Bin + } + return "bw" +} + +// Available reports whether the bw binary is resolvable. +func (b *Bitwarden) Available() bool { + if b.Bin != "" { + return true // explicitly injected (tests) + } + _, err := exec.LookPath("bw") + return err == nil +} + +// bwItem is the subset of a `bw` item we read/write. Unknown fields are preserved +// across an edit by round-tripping through a json.RawMessage map (see updateRaw). +type bwItem struct { + ID string `json:"id"` + Type int `json:"type"` // 1 = login + Name string `json:"name"` + Login *struct { + Username string `json:"username"` + Password string `json:"password"` + URIs []struct { + URI string `json:"uri"` + } `json:"uris"` + } `json:"login"` +} + +// Export runs `bw list items` and maps every login item into an Account. +func (b *Bitwarden) Export(ctx context.Context, v *vault.Vault) ([]Account, error) { + out, err := exec.CommandContext(ctx, b.bin(), "list", "items").Output() + if err != nil { + return nil, fmt.Errorf("bitwarden: list items: %w", err) + } + var items []bwItem + if err := json.Unmarshal(out, &items); err != nil { + return nil, fmt.Errorf("bitwarden: decode items: %w", err) + } + var accts []Account + for _, it := range items { + if it.Login == nil { + continue + } + a := Account{ + ID: it.ID, + Username: it.Login.Username, + Meta: map[string]string{"name": it.Name}, + } + if len(it.Login.URIs) > 0 { + a.URL = it.Login.URIs[0].URI + a.Site = hostFromURL(a.URL) + } + a.Secret = v.Store([]byte(it.Login.Password)) + accts = append(accts, a) + } + return accts, nil +} + +// UpdatePassword rewrites acct's password in place. It re-fetches the item as a +// raw map (so every field — folder, notes, custom fields — survives), swaps in the +// new password, re-encodes, and pipes the base64 to `bw edit item ` on stdin. +func (b *Bitwarden) UpdatePassword(ctx context.Context, v *vault.Vault, acct Account, newPassword *vault.Handle) error { + if acct.ID == "" { + return fmt.Errorf("bitwarden: account has no item id — cannot update in place") + } + raw, err := exec.CommandContext(ctx, b.bin(), "get", "item", acct.ID).Output() + if err != nil { + return fmt.Errorf("bitwarden: get item: %w", err) + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + return fmt.Errorf("bitwarden: decode item: %w", err) + } + login, ok := obj["login"].(map[string]any) + if !ok { + return fmt.Errorf("bitwarden: item %s is not a login", acct.ID) + } + + pwBuf, err := v.Open(newPassword) + if err != nil { + return err + } + login["password"] = string(pwBuf.Bytes()) + obj["login"] = login + + encoded, err := json.Marshal(obj) + if err != nil { + return fmt.Errorf("bitwarden: encode item: %w", err) + } + b64 := base64.StdEncoding.EncodeToString(encoded) + // Wipe the transient plaintext JSON buffer now that it is base64-wrapped. + for i := range encoded { + encoded[i] = 0 + } + + cmd := exec.CommandContext(ctx, b.bin(), "edit", "item", acct.ID) + cmd.Stdin = bytes.NewReader([]byte(b64)) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("bitwarden: edit item: %w: %s", err, bytes.TrimSpace(out)) + } + return nil +} diff --git a/internal/pwstore/bitwarden_test.go b/internal/pwstore/bitwarden_test.go new file mode 100644 index 0000000..cd93842 --- /dev/null +++ b/internal/pwstore/bitwarden_test.go @@ -0,0 +1,159 @@ +package pwstore + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "incredigo/internal/vault" +) + +// fakeBW writes a tiny `bw` stand-in to a temp dir and returns its path. The fake +// keeps item state as JSON files under $BW_STATE so that an `edit item` performed +// via stdin (base64-wrapped JSON, exactly as the real adapter pipes it) is visible +// to a subsequent `get item`. This mirrors the real `bw` contract without a network +// or a real vault, and proves the adapter never puts the password on argv. +func fakeBW(t *testing.T) (bin, stateDir string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake bw is a POSIX shell script") + } + dir := t.TempDir() + stateDir = filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o700); err != nil { + t.Fatal(err) + } + // Seed two items: one login, one secure-note (non-login, must be skipped). + seed := map[string]string{ + "id-login": `{"id":"id-login","type":1,"name":"GitHub","folderId":"f1","notes":"keep me","login":{"username":"alice@example.com","password":"old-secret","uris":[{"uri":"https://github.com/login"}]}}`, + "id-note": `{"id":"id-note","type":2,"name":"A Note","notes":"not a login"}`, + } + for id, j := range seed { + if err := os.WriteFile(filepath.Join(stateDir, id+".json"), []byte(j), 0o600); err != nil { + t.Fatal(err) + } + } + bin = filepath.Join(dir, "bw") + // The script: `list items` concatenates all state files into a JSON array; + // `get item ` prints that file; `edit item ` reads base64 from stdin, + // decodes, and overwrites the file. It rejects any password seen on argv. + script := `#!/usr/bin/env bash +set -euo pipefail +STATE="` + stateDir + `" +cmd="${1:-}"; sub="${2:-}" +case "$cmd $sub" in + "list items") + first=1; printf '[' + for f in "$STATE"/*.json; do + [ "$first" = 1 ] || printf ',' + cat "$f"; first=0 + done + printf ']' + ;; + "get item") + id="${3:?}"; cat "$STATE/$id.json" + ;; + "edit item") + id="${3:?}" + # New encoded item arrives on stdin as base64; decode to the state file. + base64 -d > "$STATE/$id.json" + echo "edited $id" + ;; + *) + echo "fake bw: unknown command: $*" >&2; exit 2 + ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + return bin, stateDir +} + +func TestBitwardenExport(t *testing.T) { + bin, _ := fakeBW(t) + v := vault.New() + defer v.Purge() + b := &Bitwarden{Bin: bin} + + if !b.Available() { + t.Fatal("injected Bin should report Available") + } + accts, err := b.Export(context.Background(), v) + if err != nil { + t.Fatalf("Export: %v", err) + } + if len(accts) != 1 { + t.Fatalf("got %d accounts, want 1 (note must be skipped)", len(accts)) + } + a := accts[0] + if a.ID != "id-login" || a.Username != "alice@example.com" { + t.Errorf("account = %+v", a) + } + if a.Site != "github.com" { + t.Errorf("site = %q, want github.com", a.Site) + } + if pw := handleStr(t, v, a.Secret); pw != "old-secret" { + t.Errorf("password = %q, want old-secret", pw) + } +} + +func TestBitwardenUpdatePasswordInPlace(t *testing.T) { + bin, stateDir := fakeBW(t) + v := vault.New() + defer v.Purge() + b := &Bitwarden{Bin: bin} + + accts, err := b.Export(context.Background(), v) + if err != nil { + t.Fatalf("Export: %v", err) + } + newPw := v.Store([]byte("BRAND-new-pw-99")) + if err := b.UpdatePassword(context.Background(), v, accts[0], newPw); err != nil { + t.Fatalf("UpdatePassword: %v", err) + } + + // Re-read the state file the fake wrote: password swapped, everything else kept. + raw, err := os.ReadFile(filepath.Join(stateDir, "id-login.json")) + if err != nil { + t.Fatal(err) + } + s := string(raw) + if !strings.Contains(s, "BRAND-new-pw-99") { + t.Errorf("edited item missing new password:\n%s", s) + } + if strings.Contains(s, "old-secret") { + t.Errorf("edited item still has old password:\n%s", s) + } + // Untouched fields must survive the round-trip (raw-map preservation). + for _, must := range []string{`"folderId":"f1"`, `"notes":"keep me"`, `"username":"alice@example.com"`} { + if !strings.Contains(s, must) { + t.Errorf("edited item dropped field %s:\n%s", must, s) + } + } + + // Confirm a fresh Export now reads the new password back. + v2 := vault.New() + defer v2.Purge() + accts2, err := b.Export(context.Background(), v2) + if err != nil { + t.Fatalf("re-Export: %v", err) + } + if pw := handleStr(t, v2, accts2[0].Secret); pw != "BRAND-new-pw-99" { + t.Errorf("re-read password = %q, want BRAND-new-pw-99", pw) + } +} + +func TestBitwardenUpdateNoIDFails(t *testing.T) { + bin, _ := fakeBW(t) + v := vault.New() + defer v.Purge() + b := &Bitwarden{Bin: bin} + h := v.Store([]byte("x")) + if err := b.UpdatePassword(context.Background(), v, Account{}, h); err == nil { + t.Error("expected error updating an account with no item id") + } +} diff --git a/internal/pwstore/chromecsv.go b/internal/pwstore/chromecsv.go new file mode 100644 index 0000000..d4277ea --- /dev/null +++ b/internal/pwstore/chromecsv.go @@ -0,0 +1,80 @@ +package pwstore + +import ( + "context" + "fmt" + "os" + + "incredigo/internal/vault" +) + +// ChromeCSV adapts a Chromium-family browser's password store, which has no +// programmatic import API: the ONLY supported propagation path is a plaintext CSV +// the user re-imports through chrome://password-manager/settings. That makes this a +// BulkImporter, not an ItemUpdater — and the reason every write here is forced +// through writeShreddableCSV (tmpfs + secure shred) and gated behind the caller's +// explicit --allow-csv opt-in (hard rule 3: no plaintext secret rests on disk). +// +// Read side: ExportFile parses a CSV the user exported from the browser. We never +// drive the browser to produce it — the human does the export/import; we only do the +// generate-and-stage work in between. Firefox uses the same shape (see firefoxcsv.go). +type ChromeCSV struct { + // ExportPath is the CSV the user exported from Chrome (read side). Optional; if + // empty, Export returns no accounts and Available() is false. + ExportPath string + // importDir, when set (tests), overrides where the shreddable CSV is written so a + // test can inspect it before cleanup. Production uses tmpfs via writeShreddableCSV. + importPath string // last path written by Import, for the caller's "now re-import this" instruction +} + +func init() { Register(&ChromeCSV{}) } + +func (c *ChromeCSV) Name() string { return "chrome" } + +// Available reports whether a Chrome export CSV was supplied to read from. +func (c *ChromeCSV) Available() bool { + if c.ExportPath == "" { + return false + } + _, err := os.Stat(c.ExportPath) + return err == nil +} + +// Export reads the user-provided Chrome export CSV into Accounts (passwords land in +// the vault as handles). It does NOT shred the user's own export — that file is the +// user's to manage; we only own the file we ourselves write in Import. +func (c *ChromeCSV) Export(ctx context.Context, v *vault.Vault) ([]Account, error) { + if c.ExportPath == "" { + return nil, fmt.Errorf("chrome: no export CSV path set") + } + data, err := os.ReadFile(c.ExportPath) + if err != nil { + return nil, fmt.Errorf("chrome: read export: %w", err) + } + return parseLoginCSV(v, data) +} + +// Import is the propagation path: it materializes accts (carrying their NEW +// passwords already swapped into Secret) as a Chrome-format CSV on tmpfs and IMMEDIATELY +// schedules its secure shred. The path is recorded so the caller can tell the human +// exactly which file to re-import; the cleanup MUST be deferred by the caller via +// ImportStaged, which is the only supported entry point (raw Import here would shred +// before the human re-imports). Hence Import itself returns an error directing callers +// to ImportStaged. +func (c *ChromeCSV) Import(ctx context.Context, v *vault.Vault, accts []Account) error { + return fmt.Errorf("chrome: use ImportStaged — a bare Import would shred the CSV before the browser re-reads it") +} + +// ImportStaged writes the new-password CSV to tmpfs and returns its path plus a +// cleanup func the caller MUST defer. Workflow: write → tell the human to open +// chrome://password-manager/settings and re-import this exact path → human confirms → +// caller invokes cleanup() to shred. newPw maps account index → new-password handle +// (indices without an entry keep their existing Secret untouched). +func (c *ChromeCSV) ImportStaged(v *vault.Vault, accts []Account, newPw map[int]*vault.Handle) (path string, cleanup func(), err error) { + path, cleanup, err = writeShreddableCSV(v, accts, newPw) + if err != nil { + return "", nil, err + } + c.importPath = path + return path, cleanup, nil +} diff --git a/internal/pwstore/chromecsv_test.go b/internal/pwstore/chromecsv_test.go new file mode 100644 index 0000000..5df9905 --- /dev/null +++ b/internal/pwstore/chromecsv_test.go @@ -0,0 +1,95 @@ +package pwstore + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "incredigo/internal/vault" +) + +func writeTempCSV(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "chrome-export.csv") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestChromeExportFromCSV(t *testing.T) { + v := vault.New() + defer v.Purge() + path := writeTempCSV(t, + "name,url,username,password,note\n"+ + "GitHub,https://github.com/login,alice@example.com,old1,\n"+ + "App,https://app.example.com/,bob,old2,\n") + c := &ChromeCSV{ExportPath: path} + + if !c.Available() { + t.Fatal("Available should be true when export file exists") + } + accts, err := c.Export(context.Background(), v) + if err != nil { + t.Fatalf("Export: %v", err) + } + if len(accts) != 2 { + t.Fatalf("got %d accounts, want 2", len(accts)) + } + if accts[0].Site != "github.com" { + t.Errorf("site = %q", accts[0].Site) + } + if pw := handleStr(t, v, accts[0].Secret); pw != "old1" { + t.Errorf("password = %q, want old1", pw) + } +} + +func TestChromeUnavailableWithoutPath(t *testing.T) { + c := &ChromeCSV{} + if c.Available() { + t.Error("Available should be false with no export path") + } + if _, err := c.Export(context.Background(), vault.New()); err == nil { + t.Error("Export should error with no export path") + } +} + +func TestChromeBareImportRefuses(t *testing.T) { + c := &ChromeCSV{} + if err := c.Import(context.Background(), vault.New(), nil); err == nil { + t.Error("bare Import must refuse and direct callers to ImportStaged") + } +} + +func TestChromeImportStagedWritesThenShreds(t *testing.T) { + v := vault.New() + defer v.Purge() + c := &ChromeCSV{} + accts := []Account{ + {URL: "https://a.test/", Username: "u1", Secret: v.Store([]byte("old1")), Meta: map[string]string{"name": "A"}}, + {URL: "https://b.test/", Username: "u2", Secret: v.Store([]byte("old2")), Meta: map[string]string{"name": "B"}}, + } + newPw := map[int]*vault.Handle{0: v.Store([]byte("NEW-1"))} + + path, cleanup, err := c.ImportStaged(v, accts, newPw) + if err != nil { + t.Fatalf("ImportStaged: %v", err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read staged csv: %v", err) + } + s := string(raw) + if !strings.Contains(s, "NEW-1") { + t.Errorf("staged csv missing substituted new password:\n%s", s) + } + if !strings.Contains(s, "old2") { + t.Errorf("staged csv missing untouched second password:\n%s", s) + } + cleanup() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("staged csv should be shredded+removed after cleanup, stat err = %v", err) + } +} diff --git a/internal/pwstore/csv.go b/internal/pwstore/csv.go new file mode 100644 index 0000000..000d32c --- /dev/null +++ b/internal/pwstore/csv.go @@ -0,0 +1,277 @@ +package pwstore + +import ( + "bytes" + "crypto/rand" + "encoding/csv" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "incredigo/internal/vault" +) + +// csvCols maps the meaningful login fields to their column index in a particular +// browser's export. A negative index means the column is absent. +type csvCols struct { + name, url, username, password, note, group int +} + +// colsFromHeader resolves a csvCols from a header row by case-insensitive name. +// Unknown/missing columns resolve to -1. +func colsFromHeader(header []string) csvCols { + idx := func(want ...string) int { + for i, h := range header { + hl := strings.ToLower(strings.TrimSpace(h)) + for _, w := range want { + if hl == w { + return i + } + } + } + return -1 + } + 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) + } +} + +// parseLoginCSV reads a browser password-export CSV into Accounts, storing each +// password in v as a handle. The transient plaintext passes through Go strings +// (encoding/csv's interface) — acceptable only because this whole path is the +// flag-gated, tmpfs, shredded CSV exception (see docs/BROWSER-ROTATION.md §3). +func parseLoginCSV(v *vault.Vault, data []byte) ([]Account, error) { + r := csv.NewReader(bytes.NewReader(data)) + r.FieldsPerRecord = -1 // tolerate ragged exports + header, err := r.Read() + if err != nil { + return nil, fmt.Errorf("pwstore: read csv header: %w", err) + } + cols := colsFromHeader(header) + if cols.password < 0 { + return nil, fmt.Errorf("pwstore: csv has no password column") + } + get := func(rec []string, i int) string { + if i >= 0 && i < len(rec) { + return rec[i] + } + return "" + } + var out []Account + for { + rec, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("pwstore: read csv row: %w", err) + } + a := Account{ + Site: hostFromURL(get(rec, cols.url)), + URL: get(rec, cols.url), + Username: get(rec, cols.username), + Meta: map[string]string{}, + } + if n := get(rec, cols.name); n != "" { + a.Meta["name"] = n + } + if g := get(rec, cols.group); g != "" { + a.Meta["group"] = g + } + pw := get(rec, cols.password) + a.Secret = v.Store([]byte(pw)) + out = append(out, a) + } + return out, nil +} + +// chromeCSVHeader is Chrome's password-export column order. +var chromeCSVHeader = []string{"name", "url", "username", "password", "note"} + +// firefoxCSVHeader is the minimal column set Firefox's about:logins import maps by +// name. Firefox keys on header text, so url/username/password is sufficient. +var firefoxCSVHeader = []string{"url", "username", "password"} + +// csvLayout describes a browser's import CSV shape: the header row and how to render +// one account row (with the resolved plaintext password). Keeping the two browser +// formats behind one type lets writeShreddableCSVLayout stay format-agnostic. +type csvLayout struct { + header []string + record func(a Account, pw string) []string +} + +var chromeLayout = csvLayout{ + header: chromeCSVHeader, + record: func(a Account, pw string) []string { + return []string{a.Meta["name"], a.URL, a.Username, pw, ""} + }, +} + +var firefoxLayout = csvLayout{ + header: firefoxCSVHeader, + record: func(a Account, pw string) []string { + return []string{a.URL, a.Username, pw} + }, +} + +// writeShreddableCSV materializes accounts (with NEW passwords from newPw, keyed +// by account index) as a Chrome-format CSV in a tmpfs dir and returns its path +// plus a cleanup func that securely shreds it. Caller MUST defer cleanup(). +// +// The file is written to /dev/shm when available so the plaintext never touches +// stable storage; Shred is the belt-and-braces overwrite on top of that. +func writeShreddableCSV(v *vault.Vault, accts []Account, newPw map[int]*vault.Handle) (path string, cleanup func(), err error) { + return writeShreddableCSVLayout(v, accts, newPw, chromeLayout) +} + +// writeShreddableCSVLayout is the format-agnostic core: same tmpfs + shred guarantees +// as writeShreddableCSV, but the header and per-row shape come from layout so Firefox +// and Chrome share one secure-write path. +func writeShreddableCSVLayout(v *vault.Vault, accts []Account, newPw map[int]*vault.Handle, layout csvLayout) (path string, cleanup func(), err error) { + dir, err := tmpfsDir() + if err != nil { + return "", nil, err + } + f, err := os.CreateTemp(dir, "incredigo-pw-*.csv") + if err != nil { + return "", nil, err + } + cleanup = func() { _ = Shred(f.Name()) } + + w := csv.NewWriter(f) + if err := w.Write(layout.header); err != nil { + f.Close() + cleanup() + return "", nil, err + } + for i, a := range accts { + h := a.Secret + if nh, ok := newPw[i]; ok && nh != nil { + h = nh + } + pw, err := readHandle(v, h) + if err != nil { + f.Close() + cleanup() + return "", nil, err + } + rec := layout.record(a, pw) + if err := w.Write(rec); err != nil { + wipeStr(&pw) + f.Close() + cleanup() + return "", nil, err + } + wipeStr(&pw) + } + w.Flush() + if err := w.Error(); err != nil { + f.Close() + cleanup() + return "", nil, err + } + if err := f.Sync(); err != nil { + f.Close() + cleanup() + return "", nil, err + } + if err := f.Close(); err != nil { + cleanup() + return "", nil, err + } + return f.Name(), cleanup, nil +} + +// tmpfsDir prefers /dev/shm (tmpfs, never written to disk) and falls back to the +// OS temp dir if it is unavailable. +func tmpfsDir() (string, error) { + const shm = "/dev/shm" + if fi, err := os.Stat(shm); err == nil && fi.IsDir() { + d := filepath.Join(shm, "incredigo") + if err := os.MkdirAll(d, 0o700); err == nil { + return d, nil + } + } + d := filepath.Join(os.TempDir(), "incredigo") + if err := os.MkdirAll(d, 0o700); err != nil { + return "", err + } + return d, nil +} + +// Shred best-effort destroys a plaintext file: overwrite with random bytes, then +// zeros, fsync between, then unlink. On SSD/journaling/CoW filesystems this is not +// a guarantee — which is why writeShreddableCSV prefers tmpfs (/dev/shm), where +// the bytes never reach stable storage in the first place. +func Shred(path string) error { + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer func() { _ = os.Remove(path) }() + fi, err := f.Stat() + if err != nil { + f.Close() + return err + } + size := fi.Size() + overwrite := func(fill func([]byte) error) error { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + buf := make([]byte, 4096) + remaining := size + for remaining > 0 { + n := int64(len(buf)) + if n > remaining { + n = remaining + } + if err := fill(buf[:n]); err != nil { + return err + } + if _, err := f.Write(buf[:n]); err != nil { + return err + } + remaining -= n + } + return f.Sync() + } + if err := overwrite(func(b []byte) error { _, e := rand.Read(b); return e }); err != nil { + f.Close() + return err + } + if err := overwrite(func(b []byte) error { + for i := range b { + b[i] = 0 + } + return nil + }); err != nil { + f.Close() + return err + } + return f.Close() +} + +// readHandle returns a handle's bytes as a string for the narrow CSV-write window. +func readHandle(v *vault.Vault, h *vault.Handle) (string, error) { + buf, err := v.Open(h) + if err != nil { + return "", err + } + return string(buf.Bytes()), nil +} + +// wipeStr overwrites the backing array of a string built from vault bytes. Go +// strings are immutable so this is best-effort via an unsafe-free copy-out; we +// simply drop the reference and rely on GC, but zero the local for clarity. +func wipeStr(s *string) { *s = "" } diff --git a/internal/pwstore/firefoxcsv.go b/internal/pwstore/firefoxcsv.go new file mode 100644 index 0000000..1c1a867 --- /dev/null +++ b/internal/pwstore/firefoxcsv.go @@ -0,0 +1,64 @@ +package pwstore + +import ( + "context" + "fmt" + "os" + + "incredigo/internal/vault" +) + +// FirefoxCSV adapts Firefox's about:logins store. Like Chrome it has no programmatic +// import API — the only path is the CSV the user re-imports through about:logins — +// so it is a BulkImporter routed through the same tmpfs + shred staging, gated behind +// the caller's --allow-csv opt-in (hard rule 3). The only difference from Chrome is +// the import column layout (Firefox keys on url/username/password), handled by +// firefoxLayout. +type FirefoxCSV struct { + ExportPath string // CSV the user exported from Firefox (read side) + importPath string // last staged path, for the "now re-import this" instruction +} + +func init() { Register(&FirefoxCSV{}) } + +func (f *FirefoxCSV) Name() string { return "firefox" } + +// Available reports whether a Firefox export CSV was supplied to read from. +func (f *FirefoxCSV) Available() bool { + if f.ExportPath == "" { + return false + } + _, err := os.Stat(f.ExportPath) + return err == nil +} + +// Export reads the user-provided Firefox export CSV into Accounts. Firefox's export +// header (url,username,password,…) is resolved by name in colsFromHeader, so the +// shared parseLoginCSV handles it unchanged. +func (f *FirefoxCSV) Export(ctx context.Context, v *vault.Vault) ([]Account, error) { + if f.ExportPath == "" { + return nil, fmt.Errorf("firefox: no export CSV path set") + } + data, err := os.ReadFile(f.ExportPath) + if err != nil { + return nil, fmt.Errorf("firefox: read export: %w", err) + } + return parseLoginCSV(v, data) +} + +// Import refuses for the same reason as Chrome: a bare Import would shred the staged +// CSV before the human re-imports it. Use ImportStaged. +func (f *FirefoxCSV) Import(ctx context.Context, v *vault.Vault, accts []Account) error { + return fmt.Errorf("firefox: use ImportStaged — a bare Import would shred the CSV before the browser re-reads it") +} + +// ImportStaged writes the new-password CSV (Firefox layout) to tmpfs and returns its +// path plus a cleanup func the caller MUST defer after the human confirms re-import. +func (f *FirefoxCSV) ImportStaged(v *vault.Vault, accts []Account, newPw map[int]*vault.Handle) (path string, cleanup func(), err error) { + path, cleanup, err = writeShreddableCSVLayout(v, accts, newPw, firefoxLayout) + if err != nil { + return "", nil, err + } + f.importPath = path + return path, cleanup, nil +} diff --git a/internal/pwstore/firefoxcsv_test.go b/internal/pwstore/firefoxcsv_test.go new file mode 100644 index 0000000..6019e31 --- /dev/null +++ b/internal/pwstore/firefoxcsv_test.go @@ -0,0 +1,74 @@ +package pwstore + +import ( + "context" + "os" + "strings" + "testing" + + "incredigo/internal/vault" +) + +func TestFirefoxExportFromCSV(t *testing.T) { + v := vault.New() + defer v.Purge() + // Firefox export header order differs from Chrome; colsFromHeader maps by name. + path := writeTempCSV(t, + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged\n"+ + "https://github.com/login,alice@example.com,old1,,https://github.com,{g1},0,0,0\n") + f := &FirefoxCSV{ExportPath: path} + + if !f.Available() { + t.Fatal("Available should be true when export file exists") + } + accts, err := f.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)) + } + if accts[0].Site != "github.com" || accts[0].Username != "alice@example.com" { + t.Errorf("account = %+v", accts[0]) + } + if pw := handleStr(t, v, accts[0].Secret); pw != "old1" { + t.Errorf("password = %q, want old1", pw) + } +} + +func TestFirefoxBareImportRefuses(t *testing.T) { + f := &FirefoxCSV{} + if err := f.Import(context.Background(), vault.New(), nil); err == nil { + t.Error("bare Import must refuse and direct callers to ImportStaged") + } +} + +func TestFirefoxImportStagedFirefoxLayout(t *testing.T) { + v := vault.New() + defer v.Purge() + f := &FirefoxCSV{} + accts := []Account{ + {URL: "https://a.test/", Username: "u1", Secret: v.Store([]byte("old1")), Meta: map[string]string{"name": "A"}}, + } + newPw := map[int]*vault.Handle{0: v.Store([]byte("NEW-ff-1"))} + + path, cleanup, err := f.ImportStaged(v, accts, newPw) + if err != nil { + t.Fatalf("ImportStaged: %v", err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read staged csv: %v", err) + } + s := string(raw) + if !strings.HasPrefix(s, "url,username,password") { + t.Errorf("staged csv should use Firefox header:\n%s", s) + } + if !strings.Contains(s, "NEW-ff-1") { + t.Errorf("staged csv missing new password:\n%s", s) + } + cleanup() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("staged csv should be shredded+removed, stat err = %v", err) + } +} diff --git a/internal/pwstore/keepassxc.go b/internal/pwstore/keepassxc.go new file mode 100644 index 0000000..6f1e3e4 --- /dev/null +++ b/internal/pwstore/keepassxc.go @@ -0,0 +1,136 @@ +package pwstore + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + + "incredigo/internal/vault" +) + +// KeePassXC adapts the official `keepassxc-cli`. Both the database key and any new +// entry password are fed over STDIN (never argv): Export pipes the DB passphrase to +// `keepassxc-cli export -f csv `; UpdatePassword pipes "\n\n" to +// `keepassxc-cli edit -p ` (the -p/--password-prompt flag makes the CLI +// read the new entry password from stdin after the unlock prompt). This is the +// fully-off-argv write path, on par with Bitwarden. +// +// The KeePass database is a local file (DBPath); its passphrase lives in the RAM +// vault as DBKey. Entry identity is the KeePass entry path "/Group/Title", carried +// in Account.ID. +type KeePassXC struct { + Bin string // default "keepassxc-cli"; injectable for tests + DBPath string // path to the .kdbx file + DBKey *vault.Handle // database passphrase, in the RAM vault +} + +func init() { Register(&KeePassXC{}) } + +func (k *KeePassXC) Name() string { return "keepassxc" } + +func (k *KeePassXC) bin() string { + if k.Bin != "" { + return k.Bin + } + return "keepassxc-cli" +} + +// Available reports whether a database path was configured and the CLI is resolvable. +func (k *KeePassXC) Available() bool { + if k.DBPath == "" { + return false + } + if _, err := os.Stat(k.DBPath); err != nil { + return false + } + if k.Bin != "" { + return true // injected (tests) + } + _, err := exec.LookPath("keepassxc-cli") + return err == nil +} + +// dbPass returns the database passphrase bytes from the vault (or empty for an +// unkeyed test DB). Caller uses it transiently on stdin only. +func (k *KeePassXC) dbPass(v *vault.Vault) (string, error) { + if k.DBKey == nil { + return "", nil + } + buf, err := v.Open(k.DBKey) + if err != nil { + return "", err + } + return string(buf.Bytes()), nil +} + +// Export runs `keepassxc-cli export -f csv ` (DB passphrase piped on stdin) and +// maps every entry into an Account, deriving the entry path from Group + Title. +func (k *KeePassXC) Export(ctx context.Context, v *vault.Vault) ([]Account, error) { + if k.DBPath == "" { + return nil, fmt.Errorf("keepassxc: no database path set") + } + pass, err := k.dbPass(v) + if err != nil { + return nil, err + } + cmd := exec.CommandContext(ctx, k.bin(), "export", "-f", "csv", k.DBPath) + cmd.Stdin = strings.NewReader(pass + "\n") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("keepassxc: export: %w", err) + } + accts, err := parseLoginCSV(v, out) + if err != nil { + return nil, err + } + for i := range accts { + accts[i].ID = kpEntryPath(accts[i].Meta["group"], accts[i].Meta["name"]) + } + return accts, nil +} + +// kpEntryPath builds the "/Group/Title" path keepassxc-cli edit expects. KeePassXC +// exports the root group as "Root"; entries live under "/Root/<…>" but the CLI also +// accepts the group path as exported, so we join them verbatim. +func kpEntryPath(group, title string) string { + group = strings.Trim(group, "/") + if group == "" { + return "/" + title + } + return "/" + group + "/" + title +} + +// UpdatePassword changes one entry's password via `keepassxc-cli edit -p `. +// stdin carries two lines: the DB passphrase (unlock) then the new entry password +// (-p prompt). Nothing sensitive reaches argv. +func (k *KeePassXC) UpdatePassword(ctx context.Context, v *vault.Vault, acct Account, newPassword *vault.Handle) error { + if acct.ID == "" { + return fmt.Errorf("keepassxc: account has no entry path — cannot update in place") + } + pass, err := k.dbPass(v) + if err != nil { + return err + } + pwBuf, err := v.Open(newPassword) + if err != nil { + return err + } + var stdin bytes.Buffer + stdin.WriteString(pass) + stdin.WriteByte('\n') + stdin.Write(pwBuf.Bytes()) + stdin.WriteByte('\n') + + cmd := exec.CommandContext(ctx, k.bin(), "edit", "-p", k.DBPath, acct.ID) + cmd.Stdin = &stdin + out, err := cmd.CombinedOutput() + // Wipe the transient stdin buffer holding both secrets. + stdin.Reset() + if err != nil { + return fmt.Errorf("keepassxc: edit: %w: %s", err, bytes.TrimSpace(out)) + } + return nil +} diff --git a/internal/pwstore/keepassxc_test.go b/internal/pwstore/keepassxc_test.go new file mode 100644 index 0000000..4180e12 --- /dev/null +++ b/internal/pwstore/keepassxc_test.go @@ -0,0 +1,136 @@ +package pwstore + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "incredigo/internal/vault" +) + +// fakeKeePassXC writes a `keepassxc-cli` stand-in backed by a CSV "database" file. +// `export -f csv ` reads the DB passphrase from stdin (asserting it matches) and +// prints the CSV; `edit -p ` reads two stdin lines (db passphrase, then +// the new entry password) and rewrites the matching row — proving both secrets travel +// over stdin, never argv. +func fakeKeePassXC(t *testing.T) (bin, dbPath string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake keepassxc-cli is a POSIX shell script") + } + dir := t.TempDir() + dbPath = filepath.Join(dir, "lab.kdbx") // a CSV masquerading as a kdbx for the fake + csv := "Group,Title,Username,Password,URL,Notes\n" + + "Root,GitHub,alice@example.com,old-secret,https://github.com/login,\n" + if err := os.WriteFile(dbPath, []byte(csv), 0o600); err != nil { + t.Fatal(err) + } + bin = filepath.Join(dir, "keepassxc-cli") + const wantPass = "db-master-pass" + script := `#!/usr/bin/env bash +set -euo pipefail +DB_WANT_PASS="` + wantPass + `" +cmd="${1:-}" +case "$cmd" in + export) + # args: export -f csv + db="$4" + read -r dbpass || true + if [ "$dbpass" != "$DB_WANT_PASS" ]; then echo "bad db passphrase" >&2; exit 1; fi + cat "$db" + ;; + edit) + # args: edit -p ; stdin: \n + db="$3"; entry="$4" + read -r dbpass || true + read -r newpass || true + if [ "$dbpass" != "$DB_WANT_PASS" ]; then echo "bad db passphrase" >&2; exit 1; fi + # entry is /Root/GitHub -> title GitHub. Rewrite the Password column of that row. + title="${entry##*/}" + tmp="$(mktemp)" + while IFS= read -r line; do + case "$line" in + *",$title,"*) + IFS=',' read -r g ti us pw url notes <<< "$line" + printf '%s,%s,%s,%s,%s,%s\n' "$g" "$ti" "$us" "$newpass" "$url" "$notes" + ;; + *) printf '%s\n' "$line";; + esac + done < "$db" > "$tmp" + mv "$tmp" "$db" + ;; + *) + echo "fake keepassxc-cli: unknown command: $*" >&2; exit 2 + ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + return bin, dbPath +} + +func TestKeePassXCExport(t *testing.T) { + bin, db := fakeKeePassXC(t) + v := vault.New() + defer v.Purge() + k := &KeePassXC{Bin: bin, DBPath: db, DBKey: v.Store([]byte("db-master-pass"))} + + if !k.Available() { + t.Fatal("Available should be true with db path + injected bin") + } + accts, err := k.Export(context.Background(), v) + if err != nil { + t.Fatalf("Export: %v", err) + } + if len(accts) != 1 { + t.Fatalf("got %d accounts, want 1", len(accts)) + } + a := accts[0] + if a.ID != "/Root/GitHub" { + t.Errorf("entry path = %q, want /Root/GitHub", a.ID) + } + if a.Username != "alice@example.com" || a.Site != "github.com" { + t.Errorf("account = %+v", a) + } + if pw := handleStr(t, v, a.Secret); pw != "old-secret" { + t.Errorf("password = %q, want old-secret", pw) + } +} + +func TestKeePassXCUpdatePassword(t *testing.T) { + bin, db := fakeKeePassXC(t) + v := vault.New() + defer v.Purge() + k := &KeePassXC{Bin: bin, DBPath: db, DBKey: v.Store([]byte("db-master-pass"))} + + accts, err := k.Export(context.Background(), v) + if err != nil { + t.Fatalf("Export: %v", err) + } + newPw := v.Store([]byte("NEW-kp-pw-3")) + if err := k.UpdatePassword(context.Background(), v, accts[0], newPw); err != nil { + t.Fatalf("UpdatePassword: %v", err) + } + raw, err := os.ReadFile(db) + if err != nil { + t.Fatal(err) + } + s := string(raw) + if !strings.Contains(s, "NEW-kp-pw-3") { + t.Errorf("db not updated with new password:\n%s", s) + } + if strings.Contains(s, "old-secret") { + t.Errorf("db still has old password:\n%s", s) + } +} + +func TestKeePassXCUnavailableWithoutDB(t *testing.T) { + k := &KeePassXC{Bin: "keepassxc-cli"} + if k.Available() { + t.Error("Available should be false with no db path") + } +} diff --git a/internal/pwstore/onepassword.go b/internal/pwstore/onepassword.go new file mode 100644 index 0000000..0b61cfb --- /dev/null +++ b/internal/pwstore/onepassword.go @@ -0,0 +1,136 @@ +package pwstore + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + + "incredigo/internal/vault" +) + +// OnePassword adapts the official `op` CLI. It reads logins with `op item list` + +// `op item get` and updates ONE item's password in place with `op item edit`. +// +// Argv caveat (documented, not hidden): unlike Bitwarden, `op item edit` has NO +// stdin-per-field path — only `op item create` reads from stdin. The two safe-on- +// paper alternatives both have costs: a JSON template would write the plaintext to +// disk (forbidden, hard rule 3), so the remaining real path is an argv field +// assignment `op item edit password=`. That value is therefore visible to +// same-uid processes via /proc//cmdline for the ~tens-of-ms the op exec lives. +// This is strictly weaker than the Bitwarden stdin path; it is recorded as such in +// docs/ROTATION-PROOFS.md and the op proof level is gated accordingly. We choose +// argv (transient, same-uid, in-RAM) over a disk template (rule-3 violation). +// +// The caller is responsible for an authenticated `op` session; this adapter inherits +// the process environment and shells out. +type OnePassword struct { + Bin string // default "op"; injectable for tests +} + +func init() { Register(&OnePassword{}) } + +func (o *OnePassword) Name() string { return "1password" } + +func (o *OnePassword) bin() string { + if o.Bin != "" { + return o.Bin + } + return "op" +} + +// Available reports whether the op binary is resolvable. +func (o *OnePassword) Available() bool { + if o.Bin != "" { + return true // explicitly injected (tests) + } + _, err := exec.LookPath("op") + return err == nil +} + +// opItem is the subset of an `op item get --format=json` we read. op nests the +// password and URLs inside a flat fields array rather than a login object. +type opItem struct { + ID string `json:"id"` + Title string `json:"title"` + Category string `json:"category"` // "LOGIN" for logins + URLs []struct { + Href string `json:"href"` + } `json:"urls"` + Fields []struct { + ID string `json:"id"` // "username", "password", … + Type string `json:"type"` // "STRING", "CONCEALED", … + Value string `json:"value"` // present only with --reveal + } `json:"fields"` +} + +func (it opItem) field(id string) string { + for _, f := range it.Fields { + if f.ID == id { + return f.Value + } + } + return "" +} + +// opSummary is the lighter shape returned by `op item list`. +type opSummary struct { + ID string `json:"id"` + Category string `json:"category"` +} + +// Export lists login items, then fetches each (with --reveal) to read the password. +func (o *OnePassword) Export(ctx context.Context, v *vault.Vault) ([]Account, error) { + out, err := exec.CommandContext(ctx, o.bin(), "item", "list", "--categories", "Login", "--format=json").Output() + if err != nil { + return nil, fmt.Errorf("1password: item list: %w", err) + } + var summaries []opSummary + if err := json.Unmarshal(out, &summaries); err != nil { + return nil, fmt.Errorf("1password: decode item list: %w", err) + } + var accts []Account + for _, s := range summaries { + raw, err := exec.CommandContext(ctx, o.bin(), "item", "get", s.ID, "--reveal", "--format=json").Output() + if err != nil { + return nil, fmt.Errorf("1password: item get %s: %w", s.ID, err) + } + var it opItem + if err := json.Unmarshal(raw, &it); err != nil { + return nil, fmt.Errorf("1password: decode item %s: %w", s.ID, err) + } + a := Account{ + ID: it.ID, + Username: it.field("username"), + Meta: map[string]string{"name": it.Title}, + } + if len(it.URLs) > 0 { + a.URL = it.URLs[0].Href + a.Site = hostFromURL(a.URL) + } + a.Secret = v.Store([]byte(it.field("password"))) + accts = append(accts, a) + } + return accts, nil +} + +// UpdatePassword rewrites acct's password in place via `op item edit password=`. +// See the argv caveat on the type doc: op has no stdin-per-field edit, so the new +// value transits this process's argv for the brief op exec. The plaintext is read +// from the vault into a single argv string and not retained anywhere else. +func (o *OnePassword) UpdatePassword(ctx context.Context, v *vault.Vault, acct Account, newPassword *vault.Handle) error { + if acct.ID == "" { + return fmt.Errorf("1password: account has no item id — cannot update in place") + } + pwBuf, err := v.Open(newPassword) + if err != nil { + return err + } + assignment := "password=" + string(pwBuf.Bytes()) + cmd := exec.CommandContext(ctx, o.bin(), "item", "edit", acct.ID, assignment, "--format=json") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("1password: item edit: %w: %s", err, out) + } + return nil +} diff --git a/internal/pwstore/onepassword_test.go b/internal/pwstore/onepassword_test.go new file mode 100644 index 0000000..a9914a7 --- /dev/null +++ b/internal/pwstore/onepassword_test.go @@ -0,0 +1,141 @@ +package pwstore + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "incredigo/internal/vault" +) + +// fakeOP writes a tiny `op` stand-in. Each item is a KEY=VALUE file under $state so +// an `item edit password=NEW` (argv assignment, exactly as the adapter shells +// it) is visible to a later `item get`. The fake also records the full argv of every +// invocation to argv.log so a test can assert what did / didn't reach the command line. +func fakeOP(t *testing.T) (bin, stateDir string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake op is a POSIX shell script") + } + dir := t.TempDir() + stateDir = filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o700); err != nil { + t.Fatal(err) + } + seed := "ID=id1\nTITLE=GitHub\nUSERNAME=alice@example.com\nURL=https://github.com/login\nPW=old-secret\n" + if err := os.WriteFile(filepath.Join(stateDir, "id1.item"), []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + bin = filepath.Join(dir, "op") + script := `#!/usr/bin/env bash +set -euo pipefail +STATE="` + stateDir + `" +echo "$@" >> "$STATE/../argv.log" +cmd="${1:-}"; sub="${2:-}" +case "$cmd $sub" in + "item list") + first=1; printf '[' + for f in "$STATE"/*.item; do + . "$f" + [ "$first" = 1 ] || printf ',' + printf '{"id":"%s","category":"LOGIN"}' "$ID" + first=0 + done + printf ']' + ;; + "item get") + id="${3:?}"; . "$STATE/$id.item" + printf '{"id":"%s","title":"%s","category":"LOGIN","urls":[{"href":"%s"}],"fields":[{"id":"username","type":"STRING","value":"%s"},{"id":"password","type":"CONCEALED","value":"%s"}]}' \ + "$ID" "$TITLE" "$URL" "$USERNAME" "$PW" + ;; + "item edit") + id="${3:?}" + new="" + for a in "$@"; do + case "$a" in password=*) new="${a#password=}";; esac + done + f="$STATE/$id.item" + . "$f" + PW="$new" + printf 'ID=%s\nTITLE=%s\nUSERNAME=%s\nURL=%s\nPW=%s\n' "$ID" "$TITLE" "$USERNAME" "$URL" "$PW" > "$f" + printf '{"id":"%s"}' "$ID" + ;; + *) + echo "fake op: unknown command: $*" >&2; exit 2 + ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + return bin, stateDir +} + +func TestOnePasswordExport(t *testing.T) { + bin, _ := fakeOP(t) + v := vault.New() + defer v.Purge() + o := &OnePassword{Bin: bin} + + if !o.Available() { + t.Fatal("injected Bin should report Available") + } + accts, err := o.Export(context.Background(), v) + if err != nil { + t.Fatalf("Export: %v", err) + } + if len(accts) != 1 { + t.Fatalf("got %d accounts, want 1", len(accts)) + } + a := accts[0] + if a.ID != "id1" || a.Username != "alice@example.com" || a.Site != "github.com" { + t.Errorf("account = %+v", a) + } + if pw := handleStr(t, v, a.Secret); pw != "old-secret" { + t.Errorf("password = %q, want old-secret", pw) + } +} + +func TestOnePasswordUpdatePassword(t *testing.T) { + bin, stateDir := fakeOP(t) + v := vault.New() + defer v.Purge() + o := &OnePassword{Bin: bin} + + accts, err := o.Export(context.Background(), v) + if err != nil { + t.Fatalf("Export: %v", err) + } + newPw := v.Store([]byte("NEW-op-pw-7")) + if err := o.UpdatePassword(context.Background(), v, accts[0], newPw); err != nil { + t.Fatalf("UpdatePassword: %v", err) + } + raw, err := os.ReadFile(filepath.Join(stateDir, "id1.item")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "PW=NEW-op-pw-7") { + t.Errorf("item not updated:\n%s", raw) + } + // Re-export reads the new password back. + v2 := vault.New() + defer v2.Purge() + accts2, _ := o.Export(context.Background(), v2) + if pw := handleStr(t, v2, accts2[0].Secret); pw != "NEW-op-pw-7" { + t.Errorf("re-read password = %q", pw) + } +} + +func TestOnePasswordUpdateNoIDFails(t *testing.T) { + bin, _ := fakeOP(t) + v := vault.New() + defer v.Purge() + o := &OnePassword{Bin: bin} + h := v.Store([]byte("x")) + if err := o.UpdatePassword(context.Background(), v, Account{}, h); err == nil { + t.Error("expected error updating an account with no item id") + } +} diff --git a/internal/pwstore/pwstore.go b/internal/pwstore/pwstore.go new file mode 100644 index 0000000..d55b08e --- /dev/null +++ b/internal/pwstore/pwstore.go @@ -0,0 +1,160 @@ +// Package pwstore holds the password-manager adapters and the shared helpers the +// browser/password-manager rotation path (docs/BROWSER-ROTATION.md) is built on. +// +// An adapter reads a user's own login store into the RAM vault (passwords as +// handles, never plaintext copies) and writes new passwords back. Two write +// shapes exist, matching the two manager families: +// +// - ItemUpdater — CLI managers (Bitwarden/1Password/KeePassXC) update ONE item +// in place by id over stdin/stdout. No plaintext file is ever created. Preferred. +// - BulkImporter — browser stores (Chrome/Firefox) have no import API; the only +// path is a plaintext CSV the browser reads off disk. That path is flag-gated, +// written to tmpfs, and securely shredded (see writeShreddableCSV / Shred). +// +// Adapters contain ONLY real code; a fake binary / temp CSV is the only thing a +// test injects, exactly like sink.Gopass{Bin:…} and the rotation drivers' HTTPClient. +package pwstore + +import ( + "context" + "sort" + "strings" + "sync" + + "incredigo/internal/vault" +) + +// Account is a single login. The password is a vault handle — never plaintext. +type Account struct { + ID string // manager-native item id / entry path ("" for CSV rows) + Site string // hostname used for the change-password link + URL string // full login URL if the store has one + Username string // login name / email + Secret *vault.Handle // OLD password, in the RAM vault + Meta map[string]string // non-secret extras (folder, title, …) +} + +// Manager is the read side every adapter implements. +type Manager interface { + Name() string + // Available reports whether the backend exists on this machine (CLI installed + // / export file present), so callers can skip cleanly rather than erroring. + Available() bool + // Export reads all logins into v, storing each password as a handle. + Export(ctx context.Context, v *vault.Vault) ([]Account, error) +} + +// ItemUpdater is implemented by CLI managers that can update one item in place by +// id without ever materializing a plaintext file. This is the safe write path. +type ItemUpdater interface { + UpdatePassword(ctx context.Context, v *vault.Vault, acct Account, newPassword *vault.Handle) error +} + +// BulkImporter is implemented by browser-CSV stores that can only re-import the +// whole store. Import MUST go through writeShreddableCSV (tmpfs + shred) and be +// gated behind the caller's explicit --allow-csv opt-in. +type BulkImporter interface { + Import(ctx context.Context, v *vault.Vault, accts []Account) error +} + +var ( + regMu sync.Mutex + registry = map[string]Manager{} +) + +// Register adds a manager adapter to the global registry. Call from init(). +func Register(m Manager) { + regMu.Lock() + defer regMu.Unlock() + registry[m.Name()] = m +} + +// Managers returns registered adapters sorted by name; if names is non-empty only +// those are returned. +func Managers(names ...string) []Manager { + regMu.Lock() + defer regMu.Unlock() + var out []Manager + if len(names) == 0 { + for _, m := range registry { + out = append(out, m) + } + } else { + for _, n := range names { + if m, ok := registry[n]; ok { + out = append(out, m) + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() }) + return out +} + +// Lookup returns a single registered adapter by name. +func Lookup(name string) (Manager, bool) { + regMu.Lock() + defer regMu.Unlock() + m, ok := registry[name] + return m, ok +} + +// RedactIdentity builds a non-secret "site / us…@…" label for the audit log and +// worklist. It never includes the password. The username is partially masked so a +// full email/login is not echoed into a file. +func RedactIdentity(a Account) string { + site := a.Site + if site == "" { + site = hostFromURL(a.URL) + } + user := maskUser(a.Username) + switch { + case site != "" && user != "": + return site + " / " + user + case site != "": + return site + case user != "": + return user + default: + return "(unknown login)" + } +} + +// maskUser keeps the first character and the domain (for emails), masking the rest: +// "alice@example.com" -> "a…@example.com"; "alice" -> "a…". +func maskUser(u string) string { + u = strings.TrimSpace(u) + if u == "" { + return "" + } + if at := strings.LastIndexByte(u, '@'); at > 0 { + local := u[:at] + domain := u[at:] // includes '@' + return firstRune(local) + "…" + domain + } + return firstRune(u) + "…" +} + +func firstRune(s string) string { + for _, r := range s { + return string(r) + } + return "" +} + +// hostFromURL extracts a bare host from a login URL for labeling/links, without a +// net/url import dependency for the common cases. +func hostFromURL(raw string) string { + h := strings.TrimSpace(raw) + h = strings.TrimPrefix(h, "https://") + h = strings.TrimPrefix(h, "http://") + if i := strings.IndexByte(h, '/'); i >= 0 { + h = h[:i] + } + if i := strings.IndexByte(h, '@'); i >= 0 { // strip any userinfo + h = h[i+1:] + } + if i := strings.IndexByte(h, ':'); i >= 0 { + h = h[:i] + } + return strings.ToLower(h) +} diff --git a/internal/pwstore/pwstore_test.go b/internal/pwstore/pwstore_test.go new file mode 100644 index 0000000..323e5e8 --- /dev/null +++ b/internal/pwstore/pwstore_test.go @@ -0,0 +1,115 @@ +package pwstore + +import ( + "os" + "strings" + "testing" + + "incredigo/internal/vault" +) + +func handleStr(t *testing.T, v *vault.Vault, h *vault.Handle) string { + t.Helper() + buf, err := v.Open(h) + if err != nil { + t.Fatalf("open: %v", err) + } + return string(buf.Bytes()) +} + +func TestParseLoginCSVChrome(t *testing.T) { + v := vault.New() + defer v.Purge() + data := []byte("name,url,username,password,note\n" + + "GitHub,https://github.com/login,alice@example.com,s3cr3t-old,\n" + + "Example,https://app.example.com/,bob,hunter2,a note\n") + accts, err := parseLoginCSV(v, data) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(accts) != 2 { + t.Fatalf("got %d accounts, want 2", len(accts)) + } + if accts[0].Site != "github.com" || accts[0].Username != "alice@example.com" { + t.Errorf("account0 = %+v", accts[0]) + } + if pw := handleStr(t, v, accts[0].Secret); pw != "s3cr3t-old" { + t.Errorf("account0 password = %q", pw) + } + if accts[1].Site != "app.example.com" || accts[1].Meta["name"] != "Example" { + t.Errorf("account1 = %+v", accts[1]) + } +} + +func TestParseLoginCSVNoPasswordColumn(t *testing.T) { + v := vault.New() + defer v.Purge() + if _, err := parseLoginCSV(v, []byte("name,url,username\nx,y,z\n")); err == nil { + t.Error("expected error when csv has no password column") + } +} + +func TestRedactIdentity(t *testing.T) { + cases := []struct{ site, url, user, want string }{ + {"github.com", "", "alice@example.com", "github.com / a…@example.com"}, + {"", "https://app.example.com/login", "bob", "app.example.com / b…"}, + {"site.test", "", "", "site.test"}, + {"", "", "", "(unknown login)"}, + } + for _, c := range cases { + got := RedactIdentity(Account{Site: c.site, URL: c.url, Username: c.user}) + if got != c.want { + t.Errorf("RedactIdentity(%q,%q,%q) = %q, want %q", c.site, c.url, c.user, got, c.want) + } + } +} + +func TestRedactIdentityNeverLeaksPassword(t *testing.T) { + // The password is not even a field RedactIdentity sees, but assert the label + // for a realistic account contains no secret-looking material beyond masked user. + id := RedactIdentity(Account{Site: "bank.test", Username: "verylongusername@mail.test"}) + if strings.Contains(id, "verylongusername") { + t.Errorf("identity %q leaked the full username", id) + } +} + +func TestWriteShreddableCSVAndShred(t *testing.T) { + v := vault.New() + defer v.Purge() + accts := []Account{ + {URL: "https://a.test/", Username: "u1", Secret: v.Store([]byte("old1")), Meta: map[string]string{"name": "A"}}, + {URL: "https://b.test/", Username: "u2", Secret: v.Store([]byte("old2")), Meta: map[string]string{"name": "B"}}, + } + newPw := map[int]*vault.Handle{0: v.Store([]byte("NEW-pw-1"))} + + path, cleanup, err := writeShreddableCSV(v, accts, newPw) + if err != nil { + t.Fatalf("writeShreddableCSV: %v", err) + } + // Read it back BEFORE shred to confirm new pw substituted, old kept where absent. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read csv: %v", err) + } + s := string(raw) + if !strings.Contains(s, "NEW-pw-1") { + t.Errorf("csv missing substituted new password:\n%s", s) + } + if !strings.Contains(s, "old2") { + t.Errorf("csv missing untouched old password for second account:\n%s", s) + } + if !strings.HasPrefix(s, "name,url,username,password,note") { + t.Errorf("csv header wrong:\n%s", s) + } + + cleanup() // shred + remove + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("expected file removed after shred, stat err = %v", err) + } +} + +func TestShredMissingFileNoError(t *testing.T) { + if err := Shred("/dev/shm/incredigo/does-not-exist-xyz.csv"); err != nil { + t.Errorf("Shred of missing file should be nil, got %v", err) + } +} diff --git a/internal/pwstore/stage.go b/internal/pwstore/stage.go new file mode 100644 index 0000000..70b9953 --- /dev/null +++ b/internal/pwstore/stage.go @@ -0,0 +1,96 @@ +package pwstore + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + + "incredigo/internal/vault" +) + +// StagedImporter is implemented by the browser-CSV adapters (Chrome/Firefox). Unlike +// ItemUpdater (which mutates one item by id with no file), a browser store can only be +// updated by re-importing a whole CSV, so the commit path stages that CSV on tmpfs and +// shreds it. The path is returned so the caller can tell the human exactly which file to +// re-import; cleanup MUST be deferred until after the human confirms. +type StagedImporter interface { + ImportStaged(v *vault.Vault, accts []Account, newPw map[int]*vault.Handle) (path string, cleanup func(), err error) +} + +// 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 +// record exists exclusively inside a Sealer-encrypted stream (age), never as cleartext — +// identical to the backup-bundle rule. Hence it is safe to persist a staged list of NEW +// passwords across the plan→commit gap a headless run needs. +type stagedRecord struct { + ID string `json:"id,omitempty"` + Site string `json:"site,omitempty"` + URL string `json:"url,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password"` +} + +// WriteStaged streams accts as JSON-lines into w, substituting the NEW password from +// newPw (keyed by account index) where present and otherwise keeping the account's +// existing Secret. The caller pipes w into a Sealer so the bytes are encrypted before +// they ever reach disk. Passing newPw=nil writes the accounts' current passwords — used +// for the mandatory pre-commit backup of the existing store. +func WriteStaged(w io.Writer, v *vault.Vault, accts []Account, newPw map[int]*vault.Handle) error { + bw := bufio.NewWriter(w) + enc := json.NewEncoder(bw) + for i, a := range accts { + h := a.Secret + if newPw != nil { + if nh, ok := newPw[i]; ok && nh != nil { + h = nh + } + } + pw, err := readHandle(v, h) + if err != nil { + return err + } + rec := stagedRecord{ID: a.ID, Site: a.Site, URL: a.URL, Username: a.Username, Password: pw} + err = enc.Encode(&rec) + wipeStr(&pw) + rec.Password = "" + if err != nil { + return err + } + } + return bw.Flush() +} + +// ReadStaged parses a JSON-lines staged stream (already decrypted by the Sealer) back +// into Accounts, storing each password in v as a handle. It is the inverse of +// WriteStaged: used to verify a backup bundle and to load a staged new-password list +// for a headless commit. +func ReadStaged(v *vault.Vault, r io.Reader) ([]Account, error) { + var out []Account + dec := json.NewDecoder(bufio.NewReader(r)) + for { + var rec stagedRecord + err := dec.Decode(&rec) + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("pwstore: decode staged record: %w", err) + } + a := Account{ + ID: rec.ID, + Site: rec.Site, + URL: rec.URL, + Username: rec.Username, + Meta: map[string]string{}, + } + if a.Site == "" { + a.Site = hostFromURL(a.URL) + } + a.Secret = v.Store([]byte(rec.Password)) + rec.Password = "" + out = append(out, a) + } + return out, nil +} diff --git a/internal/pwstore/stage_test.go b/internal/pwstore/stage_test.go new file mode 100644 index 0000000..61c1ab8 --- /dev/null +++ b/internal/pwstore/stage_test.go @@ -0,0 +1,67 @@ +package pwstore + +import ( + "bytes" + "strings" + "testing" + + "incredigo/internal/vault" +) + +func TestStagedRoundTripNewPasswords(t *testing.T) { + v := vault.New() + defer v.Purge() + accts := []Account{ + {ID: "id1", URL: "https://a.test/", Username: "u1", Secret: v.Store([]byte("old1"))}, + {ID: "id2", URL: "https://b.test/", Username: "u2", Secret: v.Store([]byte("old2"))}, + } + newPw := map[int]*vault.Handle{0: v.Store([]byte("NEW-1"))} + + var buf bytes.Buffer + if err := WriteStaged(&buf, v, accts, newPw); err != nil { + t.Fatalf("WriteStaged: %v", err) + } + // Sanity: stream carries the substituted/kept passwords (it is sealed in production). + if !strings.Contains(buf.String(), "NEW-1") || !strings.Contains(buf.String(), "old2") { + t.Fatalf("staged stream missing expected passwords:\n%s", buf.String()) + } + + v2 := vault.New() + defer v2.Purge() + got, err := ReadStaged(v2, &buf) + if err != nil { + t.Fatalf("ReadStaged: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d records, want 2", len(got)) + } + if got[0].ID != "id1" || got[0].Site != "a.test" { + t.Errorf("record0 = %+v", got[0]) + } + if pw := handleStr(t, v2, got[0].Secret); pw != "NEW-1" { + t.Errorf("record0 password = %q, want NEW-1", pw) + } + if pw := handleStr(t, v2, got[1].Secret); pw != "old2" { + t.Errorf("record1 password = %q, want old2 (untouched)", pw) + } +} + +func TestStagedBackupKeepsOldPasswords(t *testing.T) { + v := vault.New() + defer v.Purge() + accts := []Account{{ID: "id1", URL: "https://a.test/", Username: "u1", Secret: v.Store([]byte("keepme"))}} + + var buf bytes.Buffer + if err := WriteStaged(&buf, v, accts, nil); err != nil { // nil newPw = backup of current store + t.Fatalf("WriteStaged: %v", err) + } + v2 := vault.New() + defer v2.Purge() + got, err := ReadStaged(v2, &buf) + if err != nil { + t.Fatalf("ReadStaged: %v", err) + } + if pw := handleStr(t, v2, got[0].Secret); pw != "keepme" { + t.Errorf("backup password = %q, want keepme", pw) + } +}