passwords: Phase B browser/manager propagation engine (pwgen + pwstore + CLI)

Add the secondary-persona path from docs/BROWSER-ROTATION.md: ingest a password
manager / browser store, take a mandatory verified sealed backup, generate fresh
strong passwords, hand the human a ready-to-finish task at the MFA wall, and commit
the new value into the manager only after the site change is confirmed.

- internal/pwgen: crypto/rand generator (rejection sampling, per-class guarantee,
  vault-native — never returns plaintext as a Go string).
- internal/pwstore: Manager/ItemUpdater/BulkImporter contracts, secrets-free
  RedactIdentity, and five adapters — bitwarden (bw, stdin), keepassxc
  (keepassxc-cli, stdin), 1password (op, argv assignment with documented caveat),
  chrome/firefox (tmpfs CSV ingest-and-shred). All real code; validation status is
  data (MOCK-ONLY against fake binaries/CSVs, recorded in the design doc).
- internal/pwstore/stage.go: age-sealed staged-list (WriteStaged/ReadStaged) +
  StagedImporter — the only artifacts crossing the plan->commit gap, never plaintext.
- cmd/incredigo: passwords scan|plan|guide|commit. Backup gate seals + round-trip
  verifies before any commit; guide is interactive verify-before-commit; commit is
  headless over a sealed stage. Browser CSV writes are gated behind --allow-csv.

Hard rules honored: backup-before-commit, verify-before-commit, no plaintext on
disk (browser CSV is the flag-gated tmpfs+shred exception), MFA always a human
handoff. go vet + -race clean; end-to-end verified against a fake bw.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-19 13:02:18 -07:00
parent 9109da7ae4
commit c2f181e56d
21 changed files with 3221 additions and 1 deletions
+1 -1
View File
@@ -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)
+707
View File
@@ -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 <database.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 <csv> (export from chrome://password-manager/settings)")
}
return &pwstore.ChromeCSV{ExportPath: flagExportPath}, nil
case "firefox":
if flagExportPath == "" {
return nil, fmt.Errorf("firefox requires --export-path <csv> (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 <file>`.",
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/<ts>.age)")
c.Flags().StringVar(&flagStageOut, "stage-out", "", "sealed staged-list path (default ~/.incredigo/pw-stage/<ts>.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/<ts>.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 <sealed staged list from `passwords plan`> 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/<ts>.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/<sub>/.
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
}
+75
View File
@@ -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)
}
}
+252
View File
@@ -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 <csv>` feeds chrome/firefox; `--kdbx <db>` 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 <id> password=<new>`, so the new value is visible to
**same-uid** processes via `/proc/<pid>/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.
+194
View File
@@ -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
}
}
+123
View File
@@ -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])
}
}
}
+133
View File
@@ -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 <id>` — 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 <id>` 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
}
+159
View File
@@ -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 <id>` prints that file; `edit item <id>` 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")
}
}
+80
View File
@@ -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
}
+95
View File
@@ -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)
}
}
+277
View File
@@ -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 = "" }
+64
View File
@@ -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
}
+74
View File
@@ -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)
}
}
+136
View File
@@ -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 <db>`; UpdatePassword pipes "<dbpass>\n<newpass>\n" to
// `keepassxc-cli edit -p <db> <entry>` (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>` (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 <db> <entry>`.
// 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
}
+136
View File
@@ -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 <db>` reads the DB passphrase from stdin (asserting it matches) and
// prints the CSV; `edit -p <db> <entry>` 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>
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 <db> <entry> ; stdin: <dbpass>\n<newpass>
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")
}
}
+136
View File
@@ -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 <id> password=<new>`. That value is therefore visible to
// same-uid processes via /proc/<pid>/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 <id> password=<new>`.
// 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
}
+141
View File
@@ -0,0 +1,141 @@
package pwstore
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"incredigo/internal/vault"
)
// fakeOP writes a tiny `op` stand-in. Each item is a KEY=VALUE file under $state so
// an `item edit <id> password=NEW` (argv assignment, exactly as the adapter shells
// it) is visible to a later `item get`. The fake also records the full argv of every
// invocation to argv.log so a test can assert what did / didn't reach the command line.
func fakeOP(t *testing.T) (bin, stateDir string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("fake op is a POSIX shell script")
}
dir := t.TempDir()
stateDir = filepath.Join(dir, "state")
if err := os.MkdirAll(stateDir, 0o700); err != nil {
t.Fatal(err)
}
seed := "ID=id1\nTITLE=GitHub\nUSERNAME=alice@example.com\nURL=https://github.com/login\nPW=old-secret\n"
if err := os.WriteFile(filepath.Join(stateDir, "id1.item"), []byte(seed), 0o600); err != nil {
t.Fatal(err)
}
bin = filepath.Join(dir, "op")
script := `#!/usr/bin/env bash
set -euo pipefail
STATE="` + stateDir + `"
echo "$@" >> "$STATE/../argv.log"
cmd="${1:-}"; sub="${2:-}"
case "$cmd $sub" in
"item list")
first=1; printf '['
for f in "$STATE"/*.item; do
. "$f"
[ "$first" = 1 ] || printf ','
printf '{"id":"%s","category":"LOGIN"}' "$ID"
first=0
done
printf ']'
;;
"item get")
id="${3:?}"; . "$STATE/$id.item"
printf '{"id":"%s","title":"%s","category":"LOGIN","urls":[{"href":"%s"}],"fields":[{"id":"username","type":"STRING","value":"%s"},{"id":"password","type":"CONCEALED","value":"%s"}]}' \
"$ID" "$TITLE" "$URL" "$USERNAME" "$PW"
;;
"item edit")
id="${3:?}"
new=""
for a in "$@"; do
case "$a" in password=*) new="${a#password=}";; esac
done
f="$STATE/$id.item"
. "$f"
PW="$new"
printf 'ID=%s\nTITLE=%s\nUSERNAME=%s\nURL=%s\nPW=%s\n' "$ID" "$TITLE" "$USERNAME" "$URL" "$PW" > "$f"
printf '{"id":"%s"}' "$ID"
;;
*)
echo "fake op: unknown command: $*" >&2; exit 2
;;
esac
`
if err := os.WriteFile(bin, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
return bin, stateDir
}
func TestOnePasswordExport(t *testing.T) {
bin, _ := fakeOP(t)
v := vault.New()
defer v.Purge()
o := &OnePassword{Bin: bin}
if !o.Available() {
t.Fatal("injected Bin should report Available")
}
accts, err := o.Export(context.Background(), v)
if err != nil {
t.Fatalf("Export: %v", err)
}
if len(accts) != 1 {
t.Fatalf("got %d accounts, want 1", len(accts))
}
a := accts[0]
if a.ID != "id1" || a.Username != "alice@example.com" || a.Site != "github.com" {
t.Errorf("account = %+v", a)
}
if pw := handleStr(t, v, a.Secret); pw != "old-secret" {
t.Errorf("password = %q, want old-secret", pw)
}
}
func TestOnePasswordUpdatePassword(t *testing.T) {
bin, stateDir := fakeOP(t)
v := vault.New()
defer v.Purge()
o := &OnePassword{Bin: bin}
accts, err := o.Export(context.Background(), v)
if err != nil {
t.Fatalf("Export: %v", err)
}
newPw := v.Store([]byte("NEW-op-pw-7"))
if err := o.UpdatePassword(context.Background(), v, accts[0], newPw); err != nil {
t.Fatalf("UpdatePassword: %v", err)
}
raw, err := os.ReadFile(filepath.Join(stateDir, "id1.item"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "PW=NEW-op-pw-7") {
t.Errorf("item not updated:\n%s", raw)
}
// Re-export reads the new password back.
v2 := vault.New()
defer v2.Purge()
accts2, _ := o.Export(context.Background(), v2)
if pw := handleStr(t, v2, accts2[0].Secret); pw != "NEW-op-pw-7" {
t.Errorf("re-read password = %q", pw)
}
}
func TestOnePasswordUpdateNoIDFails(t *testing.T) {
bin, _ := fakeOP(t)
v := vault.New()
defer v.Purge()
o := &OnePassword{Bin: bin}
h := v.Store([]byte("x"))
if err := o.UpdatePassword(context.Background(), v, Account{}, h); err == nil {
t.Error("expected error updating an account with no item id")
}
}
+160
View File
@@ -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)
}
+115
View File
@@ -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)
}
}
+96
View File
@@ -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
}
+67
View File
@@ -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)
}
}