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>
This commit is contained in:
leetcrypt
2026-06-18 14:48:56 -07:00
parent 12192a8423
commit f6d145669c
23 changed files with 800 additions and 96 deletions
+280
View File
@@ -0,0 +1,280 @@
# 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`/`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 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
`.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:
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.