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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user