c2f181e56d
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>
134 lines
3.9 KiB
Go
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
|
|
}
|