Each driver is real-service code only (no test awareness; tests inject just an HTTPClient/Bin), following the established patterns: - mongo: in-place changeUserPassword over the old connection; no secret on argv (host/db on the URI, both passwords on stdin, output scrubbed). - npm: create token -> /-/whoami -> delete-by-key; blob records the key. - gcp: service-account KEY rotation; mints an RS256 JWT signed with the SA's own private key (crypto/rsa, stdlib) to self-auth the IAM create/verify/delete. - twilio: API Key create -> get -> delete, Basic auth (KeySid/secret). - flyio: GraphQL — a managing token mints/deletes the rotated deploy token. - k8s: create a service-account-token Secret, await the populated token, verify against the SA, delete the old Secret (invalidates the old token). All 6 register their honest proof level (MOCK-ONLY) in proofs.go + ROTATION-PROOFS.md; mongo/npm/k8s are LIVE-VM candidates pending real-target VM POCs. Emulators enforce credential validity (gcp verifies the JWT signature), tests assert Verify(old) fails after revoke + a secret-leak canary. Full suite: 103 green, -race clean on rotate/sink/vault. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
24 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.
SaaS-token / app-signing batch — 4 drivers + proof manifest, 2026-06-18
Four more drivers, each one-file + table-driven test (cutover proof + leak-check). User
direction this batch: keep real-rotation code unmistakably distinct from mock code, and
record the real-vs-mock distinction as DATA, not behaviour. Implemented as a single Go
table (internal/rotate/proofs.go) + a human mirror (docs/ROTATION-PROOFS.md), surfaced
as a PROOF column in incredigo rotate so a mock-only driver can never be mistaken for
a live one.
- gitlab (
gitlab.go, MOCK-ONLY) — GitLab PAT viaPOST /api/v4/personal_access_tokens/self/rotate(PRIVATE-TOKENheader). GitLab mints the new token and revokes the caller's old one in the same call → in-place,RevokeOldno-op. Blobgitlab://<host>/?tok=<token>. Thehttptestemulator enforces the revoke, so the unit test'sVerify(old)-fails assertion proves a real cutover. GitLab CE is too heavy for the VM → MOCK-ONLY. - cloudflare (
cloudflare.go, MOCK-ONLY) — Cloudflare API token value roll viaPUT /client/v4/user/tokens/{id}/value(Bearer). Same token id, fresh value, previous value dead → in-place,RevokeOldno-op; keeps id/policies/name stable. Blobcloudflare://<host>/?id=<id>&token=<val>. Verify via/tokens/verify(status:active). - ghactions (
ghactions.go, MOCK-ONLY) — GitHub Actions repo secret. Fetches the repo libsodium public key (actions/secrets/public-key), seals a fresh value withcrypto_box_seal(golang.org/x/crypto/nacl/box.SealAnonymous, no new go.mod dep), andPUTs it over the existing secret name (overwrite = cutover) →RevokeOldno-op. The rotated material is the secret value (val=), NOT the managementpat=that authenticates the call. GitHub never returns secret values, so Verify is an existence/authorization check (200 on the secret), not a value match. The emulator owns the box keypair and decrypts the sealed PUT, asserting the decrypted value equals the driver's new blob value — proving the encryption is real, not simulated. - appsecret (
appsecret.go, LIVE-VM) — LOCAL app signing secret (DjangoSECRET_KEY, Railssecret_key_base, JWT/session secrets, …). Generates a fresh 64-hex value and rewrites it in place across every target file (atomic temp+rename, mode preserved), keyed on the literal old value so it is file-format-agnostic (.env/yaml/json/toml). In-place ⇒RevokeOldno-op; Verify re-reads every file asserting new-present / old-absent. Blobappsecret://local/?key=&val=&path=…[&path=…]. AminSecretLen(12) guard refuses to key a replacement on a short/common string (mass-corruption guard), and a zero-replacement result is an error (never claim a rotation that didn't happen). Errors scrub the value viaredactErr.
appsecret discovery (env.go). An exact-match set of app-signing key names
(SECRET_KEY, SECRET_KEY_BASE, JWT_SECRET, APP_KEY, …) now tags those .env entries
Source="appsecret" with a self-contained blob carrying the value AND the file path, so the
local rotator is driver-ready straight from a scan. Generic API keys (STRIPE_API_KEY) stay
plain env secrets. Regression: scanners_test.go:TestEnvScannerAppSecret.
appsecret live VM cutover (LIVE-VM). lab-provision-appsec.sh wrote two real config
files (labapp/.env 0600, labworker/config.yaml 0644) sharing one SECRET_KEY, seeded
the blob into isolated gopass, and ran the shipped binary incredigo rotate --execute --prefix imported/. All assertions PASSED: backup gate sealed+verified first; both files
rewritten with a fresh 64-hex value; OLD value gone from both; unrelated PORT=8080
preserved; 0644 mode preserved. The run's table showed appsecret LIVE-VM — confirming the
new PROOF column renders in the real binary.
Honesty taxonomy (the point of the manifest). Running in the VM does NOT make a driver
LIVE-VM — only the real target software validating the cutover does. So this batch is
3× MOCK-ONLY (gitlab/cloudflare/ghactions: no self-host / heavy CE) + 1× LIVE-VM
(appsecret: real local files). AWS remains the canonical example: it ran in the VM but only
against moto (a mock), so it is MOCK-ONLY. Promoting a MOCK-ONLY driver needs only a real
service + re-run — zero driver code changes (the drivers contain no test awareness).
Full suite after the batch: 82 green, -race clean on rotate/sink/vault.
Service-API batch 2 — 6 drivers (mongo, npm, gcp, twilio, flyio, k8s), 2026-06-18
Six more rotation drivers, each real-service code only (no test awareness; tests inject
just an HTTPClient/Bin), validated against protocol-enforcing emulators:
- mongo (in-place DB) —
changeUserPasswordover the OLD authenticated connection is itself the cutover;RevokeOldis a no-op. NO secret on argv: the URI handed tomongoshcarries host/db only, both passwords go in on stdin, captured output is scrubbed. Fakemongoshstub authenticates against a state-file password → proves old password dies. - npm (provider-API create→verify→revoke) —
POST /-/npm/v1/tokens(Bearer old) →/-/whoami(Bearer new) →DELETE …/tokens/token/<key>. Blob records the tokenkey=for deletion; degrades to guided when absent. - gcp (provider-API, service-account KEY) — mints an RS256 JWT signed with the SA's own
private key (
crypto/rsa, stdlib, no new dep), exchanges it for an access token, thenCreateServiceAccountKey→ GET serviceAccount (verify) →DeleteServiceAccountKey. The emulator verifies the JWT signature against the key's public key, so the test proves genuine signing, not a stub. SA JSON round-trips base64-wrapped in the one-line blob. - twilio (provider-API) —
POST …/Keys.json(Basic old) → GET key (Basic new) →DELETE …/Keys/<sid>.json. Basic-auth username=KeySid, password=secret. - flyio (provider-API, GraphQL) — a managing token mints/deletes the deploy token
(the rotated value) via
createLimitedAccessToken/deleteLimitedAccessToken; verify runs the app query as the new deploy token. Same "auth rotates a value" shape as ghactions. - k8s (provider-API) — creates a new
kubernetes.io/service-account-tokenSecret, waits for the controller to populate.data.token, GETs the SA to verify, then DELETEs the old Secret (which invalidates the old token).
All 6 are MOCK-ONLY — honestly, because they were proven only against emulators. mongo,
npm and k8s are LIVE-VM candidates (real MongoDB / a throwaway npm registry / a k3s cluster
would promote them with zero driver-code change), but per the standing rule a driver is only
LIVE-VM after the real target validates the cutover, so they stay MOCK-ONLY until those VM
POCs run. Each emulator enforces credential validity and the tests assert Verify(old) fails
after revoke, plus a leak canary (no token/secret/PRIVATE KEY in Identity/Source/Location).
Full suite after batch 2: 103 green, -race clean on rotate/sink/vault.
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.