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
+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
}