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>
137 lines
4.5 KiB
Go
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
|
|
}
|