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>
124 lines
3.1 KiB
Go
124 lines
3.1 KiB
Go
package pwgen
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// read returns the generated password bytes as a string for assertions ONLY.
|
|
// (Tests are the one place we materialize the plaintext to check its shape.)
|
|
func read(t *testing.T, v *vault.Vault, h *vault.Handle) string {
|
|
t.Helper()
|
|
buf, err := v.Open(h)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
return string(buf.Bytes())
|
|
}
|
|
|
|
func TestGenerateLengthAndClasses(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
|
|
h, err := Generate(v, DefaultPolicy())
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
pw := read(t, v, h)
|
|
if len(pw) != 20 {
|
|
t.Errorf("length = %d, want 20", len(pw))
|
|
}
|
|
if !strings.ContainsAny(pw, lowerAll) || !strings.ContainsAny(pw, upperAll) ||
|
|
!strings.ContainsAny(pw, digitsAll) || !strings.ContainsAny(pw, symbolsAll) {
|
|
t.Errorf("password %q missing a required class", pw)
|
|
}
|
|
}
|
|
|
|
func TestGenerateExcludeAmbiguous(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
p := DefaultPolicy()
|
|
p.ExcludeAmbiguous = true
|
|
p.Length = 64
|
|
// Generate several so we'd very likely hit an ambiguous char if not excluded.
|
|
for i := 0; i < 50; i++ {
|
|
h, err := Generate(v, p)
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
pw := read(t, v, h)
|
|
if strings.ContainsAny(pw, ambiguous) {
|
|
t.Fatalf("password %q contains an ambiguous char despite ExcludeAmbiguous", pw)
|
|
}
|
|
v.Forget(h)
|
|
}
|
|
}
|
|
|
|
func TestGenerateCustomSymbolSet(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
p := Policy{Length: 30, Lower: true, Symbols: true, SymbolSet: "#-_"}
|
|
h, err := Generate(v, p)
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
pw := read(t, v, h)
|
|
for _, r := range pw {
|
|
if !strings.ContainsRune(lowerAll+"#-_", r) {
|
|
t.Fatalf("password %q contains %q outside the allowed alphabet", pw, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateUniqueness(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 200; i++ {
|
|
h, err := Generate(v, DefaultPolicy())
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
pw := read(t, v, h)
|
|
if seen[pw] {
|
|
t.Fatalf("duplicate password generated: %q", pw)
|
|
}
|
|
seen[pw] = true
|
|
v.Forget(h)
|
|
}
|
|
}
|
|
|
|
func TestPolicyErrors(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
// No classes enabled.
|
|
if _, err := Generate(v, Policy{Length: 10}); err == nil {
|
|
t.Error("expected error when no classes are enabled")
|
|
}
|
|
// Length shorter than the number of required classes.
|
|
if _, err := Generate(v, Policy{Length: 2, Upper: true, Lower: true, Digits: true, Symbols: true}); err == nil {
|
|
t.Error("expected error when length < number of classes")
|
|
}
|
|
}
|
|
|
|
// TestDistribution is a weak bias smoke check: over many single-char picks from a
|
|
// small alphabet, every symbol should appear (rejection sampling guarantees
|
|
// uniform coverage, so absences would signal a broken sampler).
|
|
func TestDistribution(t *testing.T) {
|
|
counts := map[byte]int{}
|
|
for i := 0; i < 5000; i++ {
|
|
b, err := pick(digitsAll)
|
|
if err != nil {
|
|
t.Fatalf("pick: %v", err)
|
|
}
|
|
counts[b]++
|
|
}
|
|
for i := 0; i < len(digitsAll); i++ {
|
|
if counts[digitsAll[i]] == 0 {
|
|
t.Errorf("digit %c never sampled in 5000 draws", digitsAll[i])
|
|
}
|
|
}
|
|
}
|