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