Files
incredigo/docs/FINDINGS-2026-06-16.md
T
leetcrypt f6d145669c docs: VISION/MARKET/FINDINGS, brand assets, CLAUDE directive refresh
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>
2026-06-18 14:48:56 -07:00

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):

  1. Backup gate sealed + verified 1 entry → ~/.incredigo/rotation-backups/<ts>.age before any change (Hard Rule #1).
  2. Rotate ran ALTER USER labapp WITH PASSWORD '<48-hex>' over the OLD connection.
  3. Verify(new)SELECT 1 with the new secret passed.
  4. Store the rebuilt DSN back into gopass at the same entry; re-read + Verify(stored) passed.
  5. 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):

  1. Backup gate sealed + verified 1 entry before any change (Hard Rule #1).
  2. Rotate POST /api/v1/users/labadmin/tokens (basic auth) → new token.
  3. Verify(new) GET /api/v1/user with the new token → 200, login=labadmin.
  4. Store the rebuilt blob back into gopass; re-read + Verify(stored) passed.
  5. RevokeOld DELETE …/tokens/<old-name> — a real token deletion.

Cutover assertions (all passed):

  • OLD token → GET /user returns 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):

  1. Backup gate sealed + verified 1 entry before any change (Hard Rule #1).
  2. Rotate IAM CreateAccessKey (no UserName → moto resolves the calling identity from the signing key) → new key pair.
  3. Verify(new) STS GetCallerIdentity with the new key → ARN returned.
  4. Store the rebuilt blob into gopass; re-read + Verify(stored) passed.
  5. RevokeOld IAM DeleteAccessKey targeting the OLD AccessKeyId — a real delete.

Cutover assertions (all passed, lab-verify-aws.sh):

  • After rotate, IAM ListAccessKeys(labapp) shows only the new key; the old AccessKeyId is 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 global CREATE USER privilege 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 with GRANT ALL ON app.*). The MySQL-only USER() 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 Source from the first path segment after --prefix (imported/<source>/<slug>). The WG POC first ran with --prefix imported/wireguard/, which made Source="wg0" (no driver Detected it → ROTATED 0). Fix: point --prefix at the imported/ root so imported/wireguard/wg0 resolves Source="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/postgresqlpostgres, mysql/mariadbmysql, redis/redissredis (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 KEY material 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 .envenv (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 (ExportToListPaths, 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:

  1. Source fix (done). slug() strips leading dots → incredigo can no longer write any path that ls --flat hides. TestStorePathNoDotSegment locks it in. This removes the only trigger incredigo controls and is backend-agnostic.
  2. Independent coverage cross-check (done, harness). lab-harness.sh T8b counts the on-disk *.gpg files under imported/ — ground truth that does not go through ls --flat — and asserts the gate sealed exactly that many. This is where an independent count is cheap and reliable (GOPASS_HOMEDIR is 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.