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