Adds VISION.md (target personas + 20-credential surface), docs/MARKET.md (competitive positioning), and docs/FINDINGS-2026-06-16.md (controlled-env test run: live cutover POCs for postgres/gitea/aws/mysql/redis/wireguard, the backup-gate defect + fix, and the MariaDB unprivileged-rotation gap). Swaps the placeholder _igo_options assets for the banner/icon set. Refreshes the project CLAUDE.md directive. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
17 KiB
Findings — controlled-environment test run (2026-06-16)
Source: ~/incredigo-lab-stage/lab-harness.sh (fresh isolated gopass store + fake,
canary-tagged inputs for all 8 scanners). FAKE creds only; never touches a real store.
Live rotation POC — first real driver (postgres), 2026-06-16
Result: real end-to-end cutover proven in the incredigo-sbx Multipass VM (real
gopasspw/gopass 1.15.16, real PostgreSQL 16, isolated GOPASS_HOMEDIR/GNUPGHOME,
FAKE labapp@labdb role).
Flow exercised by the shipped binary via incredigo rotate --execute
(gated: INCREDIGO_ALLOW_EXECUTE=1 + --execute):
- Backup gate sealed + verified 1 entry →
~/.incredigo/rotation-backups/<ts>.agebefore any change (Hard Rule #1). - Rotate ran
ALTER USER labapp WITH PASSWORD '<48-hex>'over the OLD connection. - Verify(new) →
SELECT 1with the new secret passed. - Store the rebuilt DSN back into gopass at the same entry; re-read + Verify(stored) passed.
- RevokeOld = no-op (in-place ALTER is itself the cutover).
Cutover assertions (all passed):
- OLD password (
oldpw835) rejected after rotation —FATAL: password authentication failed. - NEW 24-byte hex password (read back from gopass) authenticates.
- Audit log (
rotate-execute) contains no password substring (old or new).
Spine: internal/rotate/execute.go (Execute), driver internal/rotate/postgres.go
(self-registers via init), sourcing cmd/incredigo gopassCredsForExecute
(Mode A — reconstructs Source from the gopass path segment), persistence
sink.Gopass.Show/InsertAt. Unit proof of the same cutover (no live DB):
internal/rotate/postgres_test.go:TestPostgresRotateRealCutover. Full suite: 45 green.
Live rotation POC — gitea PAT driver (provider-API pattern), 2026-06-17
Result: real create-new → verify → revoke-old cutover proven against a real
Gitea 1.25.0 (sqlite, http on loopback) inside the incredigo-sbx VM, isolated
gopass store, self-owned labadmin account. This is the first provider-API
driver and the first whose RevokeOld is a real destructive API call.
Provision: ~/incredigo-lab-stage/lab-provision-gitea.sh (stands up Gitea, mints a
seed token via basic auth, seeds the driver-ready blob into gopass). Flow exercised
by the shipped binary via incredigo rotate --execute (gated):
- Backup gate sealed + verified 1 entry before any change (Hard Rule #1).
- Rotate
POST /api/v1/users/labadmin/tokens(basic auth) → new token. - Verify(new)
GET /api/v1/userwith the new token → 200,login=labadmin. - Store the rebuilt blob back into gopass; re-read + Verify(stored) passed.
- RevokeOld
DELETE …/tokens/<old-name>— a real token deletion.
Cutover assertions (all passed):
- OLD token →
GET /userreturns 401 after rotation (revoked). - NEW token (read back from gopass) → 200,
login=labadmin. - Gitea token list now shows only
incredigo-rotated-<ts>; the seed token is gone. - Audit log (
rotate-execute) contains no token substring (old or new); old ≠ new.
Spine: internal/rotate/gitea.go (single-line https://user:token@host/?name=&pw=
blob so it round-trips the existing single-line gopass sink; authManage prefers
basic auth when a mgmt password is present, else token auth). Discovery for the
gitea PAT type: internal/discover/tea.go (Gitea tea CLI config → driver-ready
blob, Source gitea). Unit proof of the same enforced cutover (httptest Gitea
emulator that 401s a revoked token): internal/rotate/gitea_test.go.
SSH key driver (unit-proven cutover), 2026-06-17
internal/rotate/sshkey.go mints a new ed25519 keypair entirely in memory
(golang.org/x/crypto/ssh; private key never touches disk — Hard Rule #3), installs
only the new PUBLIC key into authorized_keys, verifies by SSH-dialing the
authority, and RevokeOld removes the old public key (atomic rewrite). Proven by an
in-process SSH server test: old key rejected after revoke, new key works, no
PRIVATE KEY material in authorized_keys (internal/rotate/sshkey_test.go).
Discovery broadened (internal/discover/ssh.go): content-based key detection (not
just id_*) + ~/.ssh/config IdentityFile parsing. Live VM POC not yet run.
Full suite after all three drivers: 50 green, -race clean on
rotate/sink/vault/discover.
Live rotation POC — AWS IAM access key driver (cloud-key pattern), 2026-06-17
Result: real create-new → verify → revoke-old cutover proven against a
self-hosted moto 5.2.2 mock AWS (moto_server on http://127.0.0.1:5000) inside
the incredigo-sbx VM, isolated gopass store, self-created IAM user labapp. This
is the cleanest provider-API driver: the OLD credential self-identifies — an
access key's AccessKeyId is non-secret and rides in the secret blob, so RevokeOld
deletes exactly the right key (no name-capture dance Gitea needed).
Implementation note: the driver speaks the AWS Query protocol (form-POST
Action=…&Version=…) signed with hand-rolled SigV4 (crypto/hmac) — zero new
go.mod deps, keeping the static binary lean (the AWS SDK v2 would have added ~50
indirect deps). Spine: internal/rotate/aws.go. Secret blob is the single line
aws://<AccessKeyId>:<SecretAccessKey>@aws/?region=<r>[&endpoint=<url>] so it
round-trips the single-line gopass sink; endpoint= overrides the API base for
moto/LocalStack, absent → real iam/sts.amazonaws.com. Discovery broadened:
internal/discover/aws.go now emits that driver-ready blob (was the bare secret) and
resolves region from env / ~/.aws/config.
Flow exercised by the shipped binary via incredigo rotate --execute (gated):
- Backup gate sealed + verified 1 entry before any change (Hard Rule #1).
- Rotate IAM
CreateAccessKey(no UserName → moto resolves the calling identity from the signing key) → new key pair. - Verify(new) STS
GetCallerIdentitywith the new key → ARN returned. - Store the rebuilt blob into gopass; re-read + Verify(stored) passed.
- RevokeOld IAM
DeleteAccessKeytargeting the OLDAccessKeyId— a real delete.
Cutover assertions (all passed, lab-verify-aws.sh):
- After rotate, IAM
ListAccessKeys(labapp)shows only the new key; the oldAccessKeyIdis gone at the provider. old ≠ new. - New key (read back from gopass) authenticates; audit log (
rotate-execute) carries no AccessKeyId and no SecretAccessKey.
moto enforcement caveat (investigated, important). moto's STS
GetCallerIdentity does not validate access-key existence — a deleted key
still returns a (generic user/moto) ARN rather than a 403. So against moto, the
driver's STS-based Verify cannot itself prove "old key dead"; the live cutover proof
is therefore IAM ListAccessKeys (ground truth that the delete took effect),
asserted in lab-verify-aws.sh. The enforced 403-on-revoked-key path (what real
AWS does) is proven instead by the unit test: internal/rotate/aws_test.go's
awsEmu reads the AccessKeyId from the SigV4 Credential= header and rejects any
key not currently present, so TestAWSRotateRealCutover asserts the old key returns
403 after revoke, the new key still authenticates, and old ≠ new. The driver's Verify
(plain "ARN non-empty") is correct for real AWS, which does 403 an invalid key;
no moto-specific leniency was baked into the driver.
Provision: ~/incredigo-lab-stage/lab-provision-aws.sh (starts moto, creates
labapp + seed key via boto3, seeds the driver-ready blob into isolated gopass);
verify: ~/incredigo-lab-stage/lab-verify-aws.sh. Unit proofs:
TestAWSRotateRealCutover (enforced cutover), TestAWSSecretRoundTrip (blob carries
/+= secrets intact), TestSigV4HeadersStable (Authorization shape).
Full suite after four drivers: 53 green, -race clean on
rotate/sink/vault/discover.
VPN / router / DB-clone batch — 5 drivers, 3 live cutovers, 2026-06-18
Approved expansion (VPN + router secondary persona, plus Tier-1 DB clones). Five new
one-file drivers, each with a table-driven unit test (cutover proof + leak-check). Three
were additionally proven live in the incredigo-sbx VM against real services; one is
unit-proven with a live QEMU POC deferred; one is unit-only by design.
| Driver | Pattern | Live POC | Proof |
|---|---|---|---|
mysql |
in-place DB password | PASS (real MariaDB) | lab-provision-dbclones.sh |
redis |
in-place CONFIG SET requirepass |
PASS (real redis-server) | lab-provision-dbclones.sh |
wireguard |
local keypair regen + peer propagate | PASS (real wg) |
lab-provision-wg.sh |
openwrt |
in-place SSH passwd |
deferred (QEMU on 1-CPU VM) | unit: in-process SSH server |
mullvad |
provider-API device key | n/a (needs paid acct) | unit: httptest TLS emulator |
MySQL + Redis live cutover (lab-provision-dbclones.sh, all assertions PASS). Real
mariadb-server + redis-server, isolated gopass, fake labapp@labdb and a fake redis
requirepass. Backup gate sealed+verified first; rotate --execute flipped both; the
OLD password is rejected and the NEW one (read back via gopass show -o)
authenticates for each engine.
Driver gap found & fixed — MariaDB unprivileged self-rotation. MariaDB rejects
ALTER USER CURRENT_USER() IDENTIFIED BY …for a non-admin (ERROR 1227 — it demands the globalCREATE USERprivilege even for one's own account), which is exactly the target persona's situation (a vibe coder's DB user is scoped to one schema withGRANT ALL ON app.*). The MySQL-onlyUSER()spelling also fails on MariaDB (ERROR 1064). Fix (internal/rotate/mysql.go): try a fallback chain —ALTER USER CURRENT_USER()(MySQL self-service) →SET PASSWORD = PASSWORD('…')(MariaDB / MySQL 5.7) →SET PASSWORD = '…'(MySQL 8 / MariaDB ≥10.4); first to succeed wins. After the fix the live POC passed on MariaDB.
WireGuard live cutover (lab-provision-wg.sh, all assertions PASS). Real
wireguard-tools: wg0 = ours, wg1 = the peer that trusts us. Seeds wg0's old
private key into isolated gopass, rotate --execute mints a new clamped curve25519
keypair, applies the new private key to wg0 (via stdin → wg set wg0 private-key /dev/stdin, never argv), and re-points the peer to the new public key (carrying over
allowed-ips). Assertions: private key changed; peer trusts NEW pubkey; peer dropped
OLD pubkey; and the headline compatibility proof — wg0's live interface public key
equals the public key our Go code derived, i.e. our in-process curve25519 derivation
matches real wg pubkey.
POC scripting gotcha (Mode A prefix). Mode A reconstructs the driver
Sourcefrom the first path segment after--prefix(imported/<source>/<slug>). The WG POC first ran with--prefix imported/wireguard/, which madeSource="wg0"(no driver Detected it → ROTATED 0). Fix: point--prefixat theimported/root soimported/wireguard/wg0resolvesSource="wireguard". This is a harness fix, not a driver bug, but worth recording: narrowing the prefix below the source dir breaks source recovery.
OpenWrt (unit-proven, live deferred). internal/rotate/openwrt.go dials the router
with the OLD password and runs BusyBox passwd, feeding the new password twice on the
SSH-encrypted stdin (never argv); setting the password IS the cutover (RevokeOld no-op).
Proven by an in-process SSH server (openwrt_test.go) with a mutable current
password: old password stops authenticating after rotate, new works, no secret in argv or
error strings. A live OpenWrt-in-QEMU POC is the heaviest (full-system emulation on the
1-CPU sandbox VM) and is deferred.
Mullvad (unit-only by design). internal/rotate/mullvad.go is the provider-API
device-key pattern: fetch an access token from the account number, POST /accounts/v1/devices {pubkey} to register a freshly-derived WireGuard key, verify the
device lists that exact pubkey, then DELETE the old device. Proven against an httptest
TLS emulator (mullvad_test.go) that inspects every request body for the private key
(never sent) and 404s a deleted device. No live POC — that needs a real paid Mullvad
account; the API contract is faithfully emulated instead.
Discovery broadened (Hard-rule standing directive — dynamic discovery per type). The
env scanner (internal/discover/env.go) now tags DB connection URLs by scheme:
postgres/postgresql → postgres, mysql/mariadb → mysql, redis/rediss →
redis (requires a user:password authority), so DATABASE_URL/REDIS_URL/MYSQL_URL
in a .env route straight to the matching in-place driver, kept regardless of the
secret-name heuristic. Regression: scanners_test.go:TestEnvScanner.
Full suite after the batch: 68 green, -race clean on rotate/sink/vault/discover.
Summary
- Harness result: 31/31 PASS, including the global secret-leak canary (no CANARY
token or
PRIVATE KEYmaterial reached any command output). - One real defect found by manual triage of the backup gate, since fixed.
Defect 1 — backup gate silently under-covered dot-segment entries (FIXED)
Severity: high — a silent violation of Hard Rule #1 (backup before rotate).
Symptom. After migrate, the store held 10 imported credentials, but the rotate
dry-run backup gate sealed/verified only 7. The 3 missing were the .env-derived
entries: imported/env/.env/{api_token,database_url,sendgrid_api_key}.
Root cause. internal/sink/gopass.go:slug() lower-cased and space-collapsed the
credential Identity but kept leading dots. An env credential has
Identity = ".env / API_TOKEN", so StorePath became
imported/env/.env/api_token. The .env path segment is dot-prefixed, and gopass
hides dot-prefixed directories/leaves from ls --flat. bundle.ListPaths (and the
backup gate that consumes it) enumerate via ls --flat, so those 3 entries were
invisible — never sealed, never verified.
Why it was silent. The gate's integrity check compares sealed == verified counts. Both were 7, so 7==7 passed. The gate had no notion of expected count, so under-coverage produced no error.
Fix. slug() now strips leading dots per /-separated path segment, so
.env → env (imported/env/env/api_token). Entries are now enumerable and the gate
covers all of them. Regression test: internal/sink/bundle_test.go:TestStorePathNoDotSegment
asserts no produced StorePath contains a dot-prefixed segment. Full suite: 42 tests
green, -race clean on sink/rotate.
Root cause of the lapse (why it stayed silent) and prevention
The slug bug was the trigger, but it stayed silent because of a deeper flaw in the gate:
rotate.Snapshot asserts verified == res.n, and both counts descend from the same
gopass ls --flat enumeration (ExportTo → ListPaths, bundle.go). A blind spot in
that enumeration is inherited by both sides, so the comparison can never catch it. The
check proves round-trip integrity (the bundle re-reads to the same count it sealed), not
coverage (the bundle holds every credential in the store). A safety gate that compares
two values derived from the same source cannot detect an error in that source.
Prevention, layered:
- Source fix (done).
slug()strips leading dots → incredigo can no longer write any path thatls --flathides.TestStorePathNoDotSegmentlocks it in. This removes the only trigger incredigo controls and is backend-agnostic. - Independent coverage cross-check (done, harness).
lab-harness.shT8b counts the on-disk*.gpgfiles underimported/— ground truth that does not go throughls --flat— and asserts the gate sealed exactly that many. This is where an independent count is cheap and reliable (GOPASS_HOMEDIRis known/controlled). It would have failed loudly on the original 7-vs-10.
Production gate — deliberately not changed. Adding the same on-disk .gpg walk to
rotate.Snapshot would couple the safety-critical gate to gopass's fs-backend layout
(store-dir resolution varies by gopass version/mount and breaks for non-fs backends). That
trades one fragility for another and could make the gate falsely abort or pass. The
two-layer defense above (write-side invariant + harness cross-check) closes the class
without that coupling. If we later want an in-gate coverage assertion, the clean path is a
backend-agnostic enumeration primitive in sink (not an fs-layout assumption) — a separate
design task.
ICM evaluation (does the methodology help here?)
Recommendation: do not adopt ICM for the incredigo codebase now. ICM
(icm-scaffold skill) structures an LLM-orchestrated workflow as numbered
markdown stages — "folders are the program, one agent reads the right file at the
right moment." incredigo is a deterministic compiled Go tool with a package
architecture, an interface/registry extension model, and a real test suite. ICM's
folders-as-program model does not map onto a binary, and the project directive
explicitly bans an LLM in the destructive hot path. Adopting ICM here would add
scaffolding with no control-flow benefit. The one place it could fit later is the
AI-router / browser long-tail rotation layer (ROTATION.md step 4), which is by
design a skill/MCP layer on top of the Go core — revisit ICM only when that layer
is built.