Files
incredigo/internal/pwstore/onepassword.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

137 lines
4.5 KiB
Go

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
}