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