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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user