Files
incredigo/internal/pwstore/bitwarden.go
T
leetcrypt c2f181e56d 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>
2026-06-19 13:02:18 -07:00

134 lines
3.9 KiB
Go

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
}