From ac0ff8e2af4e43e6e6a25c1c35fe4edd4b338309 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Fri, 17 Jul 2026 22:40:12 -0700 Subject: [PATCH] quarantine: scrub tailnet ref, add license, ship lab/ repro kit, refresh README for cohort - untrack CLAUDE.md (tailnet leak) + stale HANDOFF.md; keep local via .gitignore - add source-available LICENSE (attribution on fork, royalty on commercial use) - add lab/ reproduction kit (fake-cred LIVE-VM/mock rotation POCs) + lab/README - rewrite README to current status (22 drivers, 8 LIVE-VM/14 MOCK-ONLY/0 LIVE-REAL, 247 tests) and carry the credential-handling safety rules Co-Authored-By: Claude Opus 4.6 --- .gitignore | 10 ++ CLAUDE.md | 140 --------------------- HANDOFF.md | 45 ------- LICENSE | 77 ++++++++++++ README.md | 159 ++++++++++++++++-------- lab/README.md | 72 +++++++++++ lab/csv-commit-probe.py | 94 +++++++++++++++ lab/incredigo-lab-setup.sh | 77 ++++++++++++ lab/lab-harness.sh | 208 ++++++++++++++++++++++++++++++++ lab/lab-provision-appsec.sh | 83 +++++++++++++ lab/lab-provision-aws.sh | 81 +++++++++++++ lab/lab-provision-bitwarden.sh | 99 +++++++++++++++ lab/lab-provision-browsercsv.sh | 84 +++++++++++++ lab/lab-provision-dbclones.sh | 106 ++++++++++++++++ lab/lab-provision-gitea.sh | 117 ++++++++++++++++++ lab/lab-provision-k8s.sh | 165 +++++++++++++++++++++++++ lab/lab-provision-keepass.sh | 101 ++++++++++++++++ lab/lab-provision-mongo.sh | 127 +++++++++++++++++++ lab/lab-provision-pg.sh | 51 ++++++++ lab/lab-provision-wg.sh | 85 +++++++++++++ lab/lab-run.sh | 23 ++++ lab/lab-store.sh | 68 +++++++++++ lab/lab-verify-aws.sh | 57 +++++++++ lab/moto-probe.py | 56 +++++++++ lab/tui-probe.py | 133 ++++++++++++++++++++ lab/vw-register.py | 74 ++++++++++++ 26 files changed, 2156 insertions(+), 236 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 HANDOFF.md create mode 100644 LICENSE create mode 100644 lab/README.md create mode 100644 lab/csv-commit-probe.py create mode 100755 lab/incredigo-lab-setup.sh create mode 100755 lab/lab-harness.sh create mode 100755 lab/lab-provision-appsec.sh create mode 100755 lab/lab-provision-aws.sh create mode 100755 lab/lab-provision-bitwarden.sh create mode 100755 lab/lab-provision-browsercsv.sh create mode 100755 lab/lab-provision-dbclones.sh create mode 100755 lab/lab-provision-gitea.sh create mode 100755 lab/lab-provision-k8s.sh create mode 100755 lab/lab-provision-keepass.sh create mode 100755 lab/lab-provision-mongo.sh create mode 100755 lab/lab-provision-pg.sh create mode 100755 lab/lab-provision-wg.sh create mode 100755 lab/lab-run.sh create mode 100755 lab/lab-store.sh create mode 100755 lab/lab-verify-aws.sh create mode 100644 lab/moto-probe.py create mode 100644 lab/tui-probe.py create mode 100644 lab/vw-register.py diff --git a/.gitignore b/.gitignore index 4b72767..8bb79b2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,16 @@ docs/ROTATION.md docs/ROTATION-REAL-CRED-RUNBOOK.md +# maintainer-local: AI session directive (tailnet detail) + stale dev handoff +CLAUDE.md +HANDOFF.md + +# lab kit: ship the scripts, never the binaries or real-host / runbook material +lab/incredigo +lab/gitea +lab/*.age +lab/rung1-* + # editor / OS *.swp .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a83356c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,140 +0,0 @@ -# incredigo — project directive for Claude sessions - -**incredigo** is a local-first, encrypted, RAM-only credential custody tool (Go). -The folder is still named `credrot/`; the module/binary are `incredigo`. - -> Read this first, then [`VISION.md`](VISION.md) (who it's for + the mission), -> [`DESIGN.md`](DESIGN.md) (architecture + threat model), [`docs/MARKET.md`](docs/MARKET.md) -> (competitive landscape + positioning) and — if present locally — `docs/ROTATION.md` -> (rotation design; **gitignored**, shared individually, not on the upstream repo). - -## Vision — catch vibe-coding security disasters before they happen - -The primary user is the **vibe coder**: the fast-moving AI-assisted builder (OpenClaw / Hermes / -Claude-Code / Cursor) who ships in a weekend and accumulates dozens of credentials — -`.env` keys, `sk_live`, `DATABASE_URL`, GitHub PATs, AWS keys, Supabase `service_role` — each -**generated once, pasted in, and never rotated**. incredigo finds that sprawl on the machine, -custodies it in RAM, backs it up, **rotates what's scriptable**, and **guides the human through -the rest** — before a forgotten key becomes a breach. **Secondary user:** the non-technical -password reuser (weak/reused passwords saved in the browser) — same engine, friendlier face, via -the browser worklist + change-URL links. - -Tailor every feature to those two. **Do NOT build toward enterprise PAM / machine-identity-at- -scale / compliance-cert / SaaS-hub** territory — that's the incumbents' moat. The target -credential surface (the driver + scanner roadmap) is the **20-credential list in `VISION.md`**. -Full crystallized vision in [`VISION.md`](VISION.md); competitive rationale in -[`docs/MARKET.md`](docs/MARKET.md). - -## North-star goal - -A user or organization can rotate their credentials with one tool, two modes: - -- **Mode A — rotate what's already in gopass.** -- **Mode B — ingest creds first (scanners / `--path` / browser CSV), then rotate.** - -Both must run **interactively or fully headless** (`$INCREDIGO_PASSPHRASE`, no TTY): -do the automatable rotations unattended, and emit a guided worklist (+ links) for the -credentials that legitimately require a human. See `docs/ROTATION.md` Part II. - -## Where development is (2026-07-14) - -> Snapshot: [`docs/STATE-2026-07-14.md`](docs/STATE-2026-07-14.md) · plan: -> [`docs/ROADMAP.md`](docs/ROADMAP.md) · visual map: `incredigo-map.html`. - -- **v1 shipped:** discover → migrate → expiry → sealed export/import. 9 scanners - (aws, env, netrc, ssh, docker, kube, git, tea, file/`--path`); sealers age - (default), hmac, openssl. 179 tests green, `-race` clean. -- **Rotation: 22 drivers + wired Mode A execute spine.** All drivers self-register; - `rotate --execute` runs behind `INCREDIGO_ALLOW_EXECUTE=1` **and** the mandatory - verified-backup gate (`Snapshot`). Proof status is DATA (`internal/rotate/proofs.go` - mirrored in `docs/ROTATION-PROOFS.md`): **8 LIVE-VM** (postgres, mysql, redis, - mongo, wireguard, gitea, appsecret, k8s), **14 MOCK-ONLY**, plus a `LIVE-REAL` - level no driver carries yet. `--execute` enforces a proof gate: only drivers - with proof ≥ LIVE-VM (`MinExecuteProof`) run; MOCK-ONLY/UNPROVEN are skipped - unless `--allow-mock-proven` is passed. **No real (host) credential has been - rotated yet** — every real rotation still requires explicit per-credential - authorization (safe-candidate ladder, ROADMAP M2). -- **Guided manual layer done:** `internal/links` + `internal/worklist` + - `internal/tui` → `incredigo worklist` / `incredigo guide` (read-only). -- **Phase B scaffolded, not wired:** 12 pwstore adapters, pwgen, staging; - `incredigo passwords pw-*` flow incomplete (ROADMAP M4). -- **Known weak spots:** policy 66% / tui 57% coverage; coverage floors reported by - `make check-cover` but not yet CI-enforced; lab-provision scripts not in repo - (ROADMAP M0–M1). (sink 30%→85%, cmd 4.3%→34.5%, gitea CI + Makefile added - 2026-07-15.) - -## Hard rules (do not violate — this tool holds real credentials) - -1. **Backup before rotate, always.** No rotation may run unless `rotate.Snapshot` - has produced a **verified** sealed backup of the affected entries first. The gate - aborts the run on any backup/verify failure. This is non-negotiable. -2. **Verify-new-before-revoke-old.** Order: backup → rotate → verify the new secret - authenticates → store in gopass → re-read → *only then* revoke the old. Never - `gopass rm` an old value before the new one is proven (SSH/DB = lockout risk). -3. **No plaintext secrets on disk. Ever.** Secrets live only in the memguard vault - and stream as bytes. Browser passwords → **worklist of non-secret metadata** - (site, username, change-URL), never a `.txt` password dump. Raw export is a - loudly-warned, flag-gated opt-in only. -4. **gopass test isolation = `GOPASS_HOMEDIR`, NOT `PASSWORD_STORE_DIR`.** gopass - ignores `PASSWORD_STORE_DIR`; `gopass init --path …` repoints the user's real - root mount and breaks their store. Always isolate tests with - `export GOPASS_HOMEDIR=$(mktemp -d)` + a throwaway `GNUPGHOME`. Better: unit-test - against a **fake gopass binary** via `sink.Gopass{Bin: …}` (see - `internal/sink/bundle_test.go`, `internal/rotate/backup_test.go`). -5. **Do no harm / self-owned only.** Browser automation targets **only the user's own - accounts**, with their authorization. Never bypass MFA/CAPTCHA — hand off to the - human. Nothing here may resemble credential stuffing or account takeover. -6. **Dry-run by default; destructive steps are opt-in, audited, reversible.** The - redacted audit log records every action. Computer-use/vision automation must - inject secrets locally and never stream them into the model loop. -7. **The PGP passphrase is the root of trust.** It unlocks every gopass secret. The - TUI and docs MUST warn prominently: lose it → everything is unrecoverable; - leak it → everything is exposed. Tell users to back up the GPG key and use a - strong passphrase. Treat this warning as a required UX element, not optional. - -## Build / test / run - -```sh -go build ./... # green required before commit -go vet ./... -go test ./... # all packages; -race clean on vault/sink/rotate -go build -o /tmp/incredigo ./cmd/incredigo -``` - -Never run `incredigo migrate/export/rotate` against the user's **real** gopass to -"test" — use an isolated `GOPASS_HOMEDIR` (rule 4). - -## Architecture - -``` -cmd/incredigo cobra CLI: scan|migrate|status|export|import|rotate -internal/vault memguard arena (mlock/DONTDUMP, zeroize, handles) -internal/discover Scanner registry + one file per source -internal/sink gopass streaming, bundle framing, Sealer (age/hmac/openssl) -internal/policy expiry engine -internal/audit redacted append-only JSONL -internal/rotate Rotator interface + MANDATORY backup gate (no drivers yet) -``` - -Extension points stay one-file-each: a Scanner, a Sealer, a Rotator. Drivers and the -backup gate live in Go; the AI router + browser long-tail belong in a skill/MCP layer -on top (do not put an LLM in the deterministic destructive hot path). - -## Chosen stack (2026 research, see ROTATION.md §13) - -- **TUI:** Charm Bubble Tea + Bubbles + Lipgloss (masked input via `textinput`). -- **Browser automation:** go-rod (default) → playwright-go (reliability) → Anthropic - Computer Use (vision fallback, secrets kept out of the loop). -- **Per-site links:** `.well-known/change-password` (RFC 8615) + a curated table. - -## Conventions - -- Match surrounding Go style; comment the *why* for any security-relevant choice. -- New code ships with tests (table-driven scanners; leak-checks asserting no secret - substring reaches `Identity`/`Meta`/logs). -- Commits: end messages with the `Co-Authored-By: Claude …` trailer. Push only when - asked. Remote is **gitea only, never GitHub** - (`https://100.117.177.50:3030/trill-technician/incredigo`). -- Design docs (`docs/ROTATION.md`) are kept local/gitignored by request; `VISION.md`, - `DESIGN.md`, `README.md`, `docs/MARKET.md`, `docs/SECURITY.md`, `docs/ADDING_A_SCANNER.md` - are tracked. diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index fb4e4ce..0000000 --- a/HANDOFF.md +++ /dev/null @@ -1,45 +0,0 @@ -# credrot — dev handoff (resume on laptop) - -Handed off from the phone (Termux/proot) session on 2026-06-15. Continue dev here. - -## What this project is -A **local-first, encrypted, RAM-only credential custody tool**. It discovers creds -from common local locations, migrates them into gopass (GPG) **without plaintext -ever touching disk**, and tracks their age. Full architecture + threat model in -[`DESIGN.md`](DESIGN.md); overview in [`README.md`](README.md). - -**Locked decisions (already made with the user — don't relitigate):** -- Language: **Go**. -- v1 scope: **discover + migrate + expiry tracking**. Provider rotation = v2, behind - a `Rotator` interface. -- openssl's role: **sealed export/backup format only** (gopass/GPG is the live - store). No double-wrapping. `Sealer` is an interface so `age` can drop in later. - -## Current state -- ✅ `DESIGN.md` complete. -- ✅ Go scaffold written (~942 LOC). Working: `scan`, `migrate`, `status`; scanners - `aws` + `env`; vault (memguard), gopass sink, openssl sealer, policy, audit. -- ⚠️ **Never compiled** — the phone had no Go toolchain. Treat as unverified. -- 🟡 `export`/`import` are **stubbed** (return "not implemented — see DESIGN.md §3.3"). - -## Next steps (suggested order) -1. **Get to a green build:** `go mod tidy && go build ./cmd/credrot`. Fix whatever - the compiler finds (memguard API surface is the most likely culprit — verify - `NewBufferFromBytes`, `LockedBuffer.Bytes()`, `Destroy`, `CatchInterrupt`, - `Purge` against the installed version). -2. **Implement `export`/`import`** per DESIGN.md §3.3: stream `gopass show` → - `Sealer.Seal` via `io.Pipe`, passphrase from a locked buffer; define the record - format (length-prefixed `path\0secret` is fine). Reverse for import. -3. **Add scanners:** `netrc`, `ssh`, `docker`, `kube`, `git` — see - [`docs/ADDING_A_SCANNER.md`](docs/ADDING_A_SCANNER.md). One file each. -4. **Tests:** table-driven scanner tests with temp-dir fixtures; assert no secret - substrings leak into `Identity`/`Meta`. A vault test asserting zeroize. -5. **Repo hygiene:** `git init`. Per the user's rule, **push to gitea only, never - GitHub.** - -## Gotchas carried over -- Don't copy secret bytes into Go `string`s or heap slices that outlive immediate - use — defeats the RAM-only guarantee. Pipe `vault` buffers straight to stdin. -- Scanners must stay strictly read-only (`O_RDONLY`). -- openssl `enc` GCM/PBKDF2 is finicky across versions; if it fights you, swap the - default `Sealer` to `age` — nothing else changes. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f419f67 --- /dev/null +++ b/LICENSE @@ -0,0 +1,77 @@ +Incredigo Source-Available License (Attribution & Royalty), v1.0 + +Copyright (c) 2026 Trilltechnician (the "Author"). +Project home: https://git.churchofmalware.org/Trilltechnician/incredigo + +This is a SOURCE-AVAILABLE license, not an OSI-approved open-source license: the +source may be read, used, and modified under the terms below, but commercial +monetization requires a separate paid license from the Author. + +------------------------------------------------------------------------------ +1. DEFINITIONS +------------------------------------------------------------------------------ +"Software" means this codebase and any part of it. +"You" means any person or entity exercising rights under this license. +"Derivative" means any fork, modification, or work that includes any part of the + Software. +"Commercial Use" means any use that, directly or indirectly, generates revenue + or other commercial advantage — including selling, licensing, or hosting the + Software (or a Derivative) as or within a paid product, a paid service, a SaaS + offering, or any offering monetized by subscription, fee, or advertising. +"Non-Commercial Use" means all other use: personal, educational, academic, + research, evaluation, and internal use that is not itself monetized. + +------------------------------------------------------------------------------ +2. GRANT FOR NON-COMMERCIAL USE +------------------------------------------------------------------------------ +The Author grants You a worldwide, royalty-free, non-exclusive license to use, +copy, modify, and redistribute the Software and Derivatives for Non-Commercial +Use, subject to the Attribution requirement in Section 4. + +------------------------------------------------------------------------------ +3. COMMERCIAL USE REQUIRES A ROYALTY-BEARING LICENSE +------------------------------------------------------------------------------ +Commercial Use is NOT permitted under this license. To make Commercial Use of the +Software or any Derivative, You must first obtain a separate written commercial +license from the Author, which will be royalty-bearing. Contact the Author via +the project home above to arrange commercial terms. + +Until such a commercial license is granted in writing, any Commercial Use is a +breach of this license and automatically terminates the rights granted in +Section 2. + +------------------------------------------------------------------------------ +4. ATTRIBUTION (REQUIRED FOR ALL USE, INCLUDING FORKS) +------------------------------------------------------------------------------ +Any redistribution of the Software or a Derivative — in source or binary form, +including any fork — must: + (a) retain this LICENSE file and the copyright notice above; and + (b) give clear, visible credit to the Author, naming "Incredigo" and linking to + the project home above, in the documentation, README, or "about" surface of + the redistributed work. + +------------------------------------------------------------------------------ +5. NO TRADEMARK RIGHTS +------------------------------------------------------------------------------ +This license grants no rights in the Author's names, logos, or trademarks +("Incredigo", the Strata mark) except as required for the attribution in +Section 4. + +------------------------------------------------------------------------------ +6. DISCLAIMER OF WARRANTY AND LIABILITY +------------------------------------------------------------------------------ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. THIS TOOL HANDLES CREDENTIALS; YOU +RUN IT AT YOUR OWN RISK. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, +DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, +ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +------------------------------------------------------------------------------ +7. TERMINATION +------------------------------------------------------------------------------ +Any breach of Sections 3 or 4 automatically terminates this license. Upon +termination You must cease all use and distribution of the Software and +Derivatives, except that a granted written commercial license continues under its +own terms. diff --git a/README.md b/README.md index c6d3b67..23031d3 100644 --- a/README.md +++ b/README.md @@ -21,93 +21,150 @@ Incredigo finds credentials scattered across the usual places (`~/.aws/credentials`, `.env` files, `~/.netrc`, SSH keys, -`~/.docker/config.json`, kubeconfig, …), migrates them into a +`~/.docker/config.json`, kubeconfig, gitea/git tokens…), custodies them in a [gopass](https://www.gopass.pw/) (GPG-backed) store **without ever writing -plaintext to disk**, and tells you which ones are stale. +plaintext to disk**, tells you which ones are stale, **rotates what's +scriptable**, and hands you a guided worklist for the rest. - 🔒 **No plaintext at rest.** Disk only ever holds GPG blobs (gopass) or - openssl-sealed backups. + sealed backups (age / hmac / openssl). - 🧠 **RAM-only secrets.** Decrypted material lives in `mlock`'d, non-dumpable, zeroized memory ([memguard](https://github.com/awnumar/memguard)). -- 📴 **Offline.** v1 makes zero network calls. - 👀 **Read-only discovery.** Scanners never touch your source files. -- 🧩 **Hackable.** A new source is one file; the backup format is an interface. +- ♻️ **Backup-gated rotation.** No rotation runs without a verified sealed backup + first; new secret is proven to authenticate before the old one is revoked. +- 🧩 **Hackable.** A new scanner, sealer, or rotation driver is one file each. -See [`DESIGN.md`](DESIGN.md) for the architecture and threat model. +See [`DESIGN.md`](DESIGN.md) for architecture + threat model, +[`VISION.md`](VISION.md) for who it's for, and [`docs/MARKET.md`](docs/MARKET.md) +for positioning. -## Why the name +## Who it's for -**Incredigo** is one word hiding three: +The **vibe coder** — the fast-moving AI-assisted builder who ships in a weekend +and accumulates dozens of credentials (`.env` keys, `sk_live`, `DATABASE_URL`, +GitHub PATs, AWS keys) each *generated once, pasted in, and never rotated*. +Incredigo finds that sprawl, custodies it, backs it up, rotates what's scriptable, +and guides the human through the rest — before a forgotten key becomes a breach. -``` - in · CRED · i · GO - └┬─┘ └┬┘ - CREDentials in GO -``` +## Status (2026-07) -It manages your **CRED**entials, it's written in **GO**, and read aloud the whole -thing lands on *incredible* — which is the bar a credential tool that promises -"no plaintext on disk, ever" has to clear. +**v1 custody pipeline: done.** discover → RAM vault → migrate → status → +sealed export/import. 9 scanners; sealers age (default) / hmac / openssl. -### The mark +**Rotation: 22 drivers + wired Mode A `--execute` spine.** All self-register. +`rotate --execute` runs only behind `INCREDIGO_ALLOW_EXECUTE=1` **and** a +mandatory verified-backup gate, and only drivers proven against real target +software run by default. -The logo is **Strata** — nested diamonds tightening around a single bright core. -It's the whole tool in one glyph: each ring is a layer of custody (read-only -discovery → locked-memory vault → GPG/OpenSSL at rest), and the core is your -secret, held in RAM and never written out. The design is deliberately abstract and -geometric — no mascot, no metaphor to decode. +Proof status is tracked as **data** ([`internal/rotate/proofs.go`](internal/rotate/proofs.go), +mirrored in [`docs/ROTATION-PROOFS.md`](docs/ROTATION-PROOFS.md), surfaced as a +`PROOF` column in the CLI): -| What it does | How | -|---|---| -| **Finds, doesn't guess** | Real parsers per source — no blind grep, no false positives. | -| **Composes, doesn't invent** | Battle-tested GPG (via gopass) and OpenSSL; no home-rolled crypto to break. | -| **Holds, doesn't leak** | Read-only discovery, RAM-only custody, redacted audit log — plaintext never leaves the locked arena. | -| **Works, then vanishes** | No server, daemon, network, or temp files; it does its job and leaves nothing on disk. | +| Level | Count | Meaning | +|---|---|---| +| **LIVE-VM** | 8 | cutover proven against **real** target software in a sandbox VM: postgres, mysql, redis, mongo, wireguard, gitea, appsecret, k8s | +| **MOCK-ONLY** | 14 | proven only against an emulator/mock (httptest, moto): aws, sshkey, openwrt, mullvad, cloudflare, ghactions, gitlab, npm, gcp, twilio, flyio, resend, vercel, sendgrid | +| **LIVE-REAL** | 0 | **no real, host-owned credential has ever been rotated** — this remains gated behind explicit per-credential authorization | -The brand is **Incredigo**, and so is the command: the CLI was renamed from its old -working title `credrot` so the tool and the project finally share a name. +**Guided manual layer: done.** `incredigo worklist` / `incredigo guide` (TUI) +emit a secrets-free checklist of change-URLs (49 curated sites + RFC 8615). -## Status +**Phase B (password-manager / browser turnover): scaffolded, not wired.** 12 +adapters + pwgen + staging exist; `passwords pw-*` flow is incomplete. -v1 scope: **discover + migrate + expiry tracking**. Provider-driven rotation -(issue-new / verify / revoke-old) is planned for v2 behind a stable interface. - -`export`/`import` are stubbed in this scaffold (see `cmd/incredigo/main.go`). - -> **Rename in progress:** the Go package and binary are being renamed -> `credrot` → `incredigo`. Until that lands, substitute `./cmd/credrot` / -> `credrot` in the commands below. +Tests: **247 pass, `-race` clean, 13 packages.** Coverage highlights: vault/worklist +100%, policy 95%, sink 85%, tui 88%; `cmd/incredigo` (34%) is the weakest and the +next test target. ## Build Requires Go 1.22+. ```sh -go mod tidy -go build ./cmd/incredigo +go build ./cmd/incredigo # or: make build +go test -race ./... # 247 tests, -race clean ``` ## Usage ```sh -incredigo scan --dry-run # see what's out there (no secrets printed) -incredigo migrate --prefix imported/ --dedupe -incredigo status # age vs policy -incredigo export --out ~/incredigo-backup.enc # (v1 stub) +incredigo scan --dry-run # see what's out there (no secrets printed) +incredigo migrate --prefix imported/ --dedupe # custody into gopass, no plaintext on disk +incredigo status # age vs policy +incredigo export --out ~/incredigo-backup.age # sealed backup (age) +incredigo worklist --out rotate.md # guided checklist for manual rotations +incredigo guide # TUI walk-through of the worklist + +# rotation (Mode A — rotate what's already in gopass): +incredigo rotate --dry-run --prefix imported/ --blast # plan + blast radius, touches nothing +INCREDIGO_ALLOW_EXECUTE=1 incredigo rotate --execute --prefix imported/ ``` +`--execute` is refused unless `INCREDIGO_ALLOW_EXECUTE=1` is set; it always runs +the backup gate first, and skips any driver whose proof is below `LIVE-VM` unless +you pass `--allow-mock-proven`. + +## Reproduce the LIVE-VM proofs + +The 8 `LIVE-VM` claims are reproducible from a clean machine — see +[`lab/README.md`](lab/README.md). The scripts stand up **real** target software +(PostgreSQL, MariaDB, redis, mongod, wireguard, Gitea, k3s) in a throwaway VM and +rotate **fake** credentials in an **isolated** gopass store. Nothing there can +touch a real store or provider. + +## Safety rules (this tool holds real credentials) + +1. **Backup before rotate, always.** No rotation runs without a verified sealed + backup of the affected entries first; the gate aborts on any backup/verify failure. +2. **Verify-new-before-revoke-old.** Order is backup → rotate → verify the new + secret authenticates → store → re-read → *only then* revoke the old. +3. **No plaintext secrets on disk, ever.** Secrets live only in the memguard vault. + Browser passwords become a worklist of non-secret metadata, never a `.txt` dump. +4. **Self-owned only.** Automation targets only your own accounts, with your + authorization. It never bypasses MFA/CAPTCHA — it hands off to the human. +5. **Dry-run by default; destructive steps are opt-in, audited, reversible.** +6. **The GPG passphrase is the root of trust.** Lose it → everything is + unrecoverable; leak it → everything is exposed. Back up the GPG key; use a + strong passphrase. + ## Layout | Path | Purpose | |---|---| -| `cmd/incredigo` | cobra CLI | -| `internal/vault` | RAM-only secret arena (memguard) | +| `cmd/incredigo` | cobra CLI: scan, migrate, status, export, import, rotate, worklist, guide, passwords | +| `internal/vault` | RAM-only secret arena (memguard: mlock / DONTDUMP / zeroize) | | `internal/discover` | scanner registry + one file per source | -| `internal/sink` | gopass writer + Sealer interface + openssl impl | -| `internal/policy` | expiry rules | -| `internal/audit` | redacted append-only log | +| `internal/sink` | gopass writer + bundle framing + Sealer (age / hmac / openssl) | +| `internal/policy` | expiry engine | +| `internal/audit` | redacted append-only JSONL log | +| `internal/rotate` | Rotator interface + mandatory backup gate + 22 drivers + proofs-as-data | +| `internal/blast` | read-only "which files consume this credential" map | +| `internal/links` / `worklist` / `tui` | guided manual-rotation layer | +| `internal/pwstore` / `pwgen` | Phase B password-manager adapters (scaffolded) | +| `lab/` | reproducible LIVE-VM / mock rotation proofs (fake creds) | + +## Documentation + +- [`DESIGN.md`](DESIGN.md) — architecture + threat model +- [`VISION.md`](VISION.md) — who it's for + the mission +- [`docs/MARKET.md`](docs/MARKET.md) — competitive landscape + positioning +- [`docs/SECURITY.md`](docs/SECURITY.md) — security posture + reporting +- [`docs/ROTATION-PROOFS.md`](docs/ROTATION-PROOFS.md) — how each driver was validated +- [`docs/ROADMAP.md`](docs/ROADMAP.md) — where it's going +- [`docs/ADDING_A_SCANNER.md`](docs/ADDING_A_SCANNER.md) — contributor guide (one file per source) ## Contributing Adding a source is intentionally easy — see -[`docs/ADDING_A_SCANNER.md`](docs/ADDING_A_SCANNER.md). +[`docs/ADDING_A_SCANNER.md`](docs/ADDING_A_SCANNER.md). New code ships with tests +(table-driven scanners; leak-checks asserting no secret substring reaches +identity/metadata/logs). + +## License + +Source-available under the [Incredigo Source-Available License](LICENSE): +free for personal, educational, research, and internal **non-commercial** use, +**attribution required** for any fork or redistribution, and **commercial / +monetized use requires a separate royalty-bearing license** from the author. +This is not an OSI open-source license. diff --git a/lab/README.md b/lab/README.md new file mode 100644 index 0000000..55d3f9a --- /dev/null +++ b/lab/README.md @@ -0,0 +1,72 @@ +# lab/ — reproduce the rotation proofs (FAKE creds only) + +These scripts stand up **real** target software in a throwaway VM and drive +`incredigo rotate --execute` against **fake** credentials in an **isolated** gopass +store. They are how the `LIVE-VM` proof levels in +[`../internal/rotate/proofs.go`](../internal/rotate/proofs.go) / +[`../docs/ROTATION-PROOFS.md`](../docs/ROTATION-PROOFS.md) were earned, and let anyone +reproduce them from a clean machine. + +> **Safety:** every script uses `GNUPGHOME=$HOME/.lab-gnupg` + `GOPASS_HOMEDIR=$HOME/.lab-gopass` +> (a no-protection throwaway key) and fake creds. Nothing here can touch a real gopass +> store or a real provider. Run them in a disposable VM anyway. + +## Quick start (Multipass, Ubuntu 24.04) + +```sh +multipass launch 24.04 --name incredigo-sbx --disk 15G --memory 4G +# build a static binary and install it in the VM: +CGO_ENABLED=0 go build -o /tmp/incredigo ./cmd/incredigo +multipass transfer /tmp/incredigo incredigo-sbx:/home/ubuntu/incredigo +multipass exec incredigo-sbx -- sudo install -m755 /home/ubuntu/incredigo /usr/local/bin/incredigo +# install the real gopass release (NOT Ubuntu's apt 'gopass', which is a pass clone): +# https://github.com/gopasspw/gopass/releases -> /usr/local/bin/gopass + +# copy the lab dir in and run a proof, e.g. postgres: +multipass transfer -r lab incredigo-sbx:/home/ubuntu/lab +multipass exec incredigo-sbx -- bash -lc 'cd lab && INCREDIGO_BIN=/usr/local/bin/incredigo bash lab-provision-pg.sh' +multipass exec incredigo-sbx -- bash -lc ' + export GNUPGHOME=$HOME/.lab-gnupg GOPASS_HOMEDIR=$HOME/.lab-gopass + export INCREDIGO_PASSPHRASE=lab-seal-pass INCREDIGO_ALLOW_EXECUTE=1 + incredigo rotate --execute --prefix imported/' +``` + +## LIVE-VM provisioners (real target software) + +| Script | Proves driver | Target | +|---|---|---| +| `lab-provision-pg.sh` | `postgres` | real PostgreSQL | +| `lab-provision-dbclones.sh` | `mysql`, `redis` | real MariaDB + redis-server (self-asserting cutover) | +| `lab-provision-wg.sh` | `wireguard` | real `wireguard-tools` | +| `lab-provision-gitea.sh` | `gitea` | real Gitea 1.25 (self-owned PAT) | +| `lab-provision-appsec.sh` | `appsecret` | real local config files | +| `lab-provision-mongo.sh` | `mongo` | real mongod/mongosh 8.0 | +| `lab-provision-k8s.sh` | `k8s` | real k3s v1.35 | + +## MOCK-ONLY provisioners (emulator / mock — same code path, not the real provider) + +| Script | Driver | Emulator | +|---|---|---| +| `lab-provision-aws.sh` + `lab-verify-aws.sh` + `moto-probe.py` | `aws` | moto (mock AWS) | + +## Phase-B `passwords` engine POCs + +| Script | Manager | +|---|---| +| `lab-provision-keepass.sh` | KeePassXC (`keepassxc-cli`) | +| `lab-provision-bitwarden.sh` + `vw-register.py` | Vaultwarden + `bw` CLI | +| `lab-provision-browsercsv.sh` + `csv-commit-probe.py` | Chrome/Firefox CSV (staging only) | +| `tui-probe.py` | drives the `guide` Bubble Tea TUI under a pty | + +## Custody / smoke + +- `lab-store.sh` — throwaway key + gopass store + fake `.env`/`.aws`/consumer files. +- `lab-run.sh` — scan→status→migrate→export→import→`rotate --dry-run --blast`→worklist. +- `lab-harness.sh`, `incredigo-lab-setup.sh` — older combined harness variants. + +## Notes carried over + +- Ubuntu apt `gopass` is the **wrong tool** (a `pass` clone). Install gopasspw/gopass. +- Multipass snap **cannot read `/tmp`** — stage under `$HOME` before `multipass transfer`. +- `rotate --prefix` must be the `imported/` **root** (source = first path segment after it). +- Headless runs need `INCREDIGO_PASSPHRASE` (seal/backup passphrase, separate from GPG). diff --git a/lab/csv-commit-probe.py b/lab/csv-commit-probe.py new file mode 100644 index 0000000..ea79abd --- /dev/null +++ b/lab/csv-commit-probe.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# Drives `incredigo passwords ... commit --allow-csv` inside a real pty so the command +# sees a TTY and PAUSES at its "press Enter to shred" prompt. While the child is blocked, +# we inspect the tmpfs CSV it staged (location, header, contents), then release it and +# confirm the file was shredded. This is the only way to observe the otherwise-ephemeral +# tmpfs CSV, since headless commit shreds it immediately. +# +# argv: mgr export stage backup audit old1 old2 want_header pwcol +import os, pty, sys, select, re, subprocess, time + +mgr, export, stage, backup, audit, old1, old2, want_header, pwcol = sys.argv[1:10] +pwcol = int(pwcol) +fail = 0 +def out(m): print(" " + m, flush=True) + +cmd = ["incredigo", "passwords", "--manager", mgr, "--export-path", export, + "--allow-csv", "commit", "--stage-in", stage, "--verified", "all", + "--backup-out", backup, "--audit-log", audit] + +master, slave = pty.openpty() +proc = subprocess.Popen(cmd, stdin=slave, stdout=slave, stderr=slave, close_fds=True) +os.close(slave) + +buf = b"" +csv_path = None +deadline = time.time() + 30 +while time.time() < deadline: + r, _, _ = select.select([master], [], [], 0.5) + if master in r: + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + buf += chunk + m = re.search(rb'(/dev/shm/incredigo/incredigo-pw-[0-9]+\.csv)', buf) + if m and b'press Enter to shred' in buf: + csv_path = m.group(1).decode() + break + if proc.poll() is not None: + break + +if not csv_path: + out("FAIL: no tmpfs CSV / shred prompt seen") + sys.stdout.write(buf.decode(errors="replace")) + sys.exit(1) + +out("staged CSV: " + csv_path) + +# (a) on tmpfs? +fst = subprocess.run(["stat", "-f", "-c", "%T", csv_path], + capture_output=True, text=True).stdout.strip() +if fst == "tmpfs": + out("OK: CSV is on tmpfs (%s)" % fst) +else: + out("FAIL: CSV not on tmpfs (%s)" % fst); fail = 1 + +# (b) correct per-browser header + (c) NEW pw present, OLD gone +with open(csv_path) as f: + lines = f.read().splitlines() +hdr = lines[0] if lines else "" +if hdr == want_header: + out("OK: header = " + hdr) +else: + out("FAIL: header=%s want=%s" % (hdr, want_header)); fail = 1 + +body = "\n".join(lines[1:]) +for label, old in (("github", old1), ("gitlab", old2)): + if old in body: + out("FAIL: OLD %s pw present in CSV" % label); fail = 1 + else: + out("OK: OLD %s pw absent from CSV" % label) + +row1 = lines[1].split(",") if len(lines) > 1 else [] +newpw = row1[pwcol - 1] if len(row1) >= pwcol else "" +if len(newpw) >= 16: + out("OK: row1 new pw is strong (len %d)" % len(newpw)) +else: + out("FAIL: new pw too short (%d)" % len(newpw)); fail = 1 + +# (d) release the prompt -> shred +os.write(master, b"\n") +try: + proc.wait(timeout=15) +except subprocess.TimeoutExpired: + proc.kill(); out("FAIL: commit did not exit after release"); fail = 1 + +if not os.path.exists(csv_path): + out("OK: tmpfs CSV shredded (file gone)") +else: + out("FAIL: CSV still present after shred"); fail = 1 + +sys.exit(1 if fail else 0) diff --git a/lab/incredigo-lab-setup.sh b/lab/incredigo-lab-setup.sh new file mode 100755 index 0000000..be2271a --- /dev/null +++ b/lab/incredigo-lab-setup.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# incredigo sandbox provisioning — FAKE creds only, throwaway VM. +set -euo pipefail + +echo "== installing gopass + gnupg ==" +sudo apt-get update -qq +sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq gnupg gopass >/dev/null 2>&1 \ + || { echo "apt gopass failed; trying snap/manual"; sudo snap install gopass --classic || true; } +command -v gopass >/dev/null || { echo "FATAL: gopass not installed"; exit 1; } +gopass --version | head -1 + +# Isolate gopass entirely (rule 4): throwaway homedir + throwaway GNUPGHOME. +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +export GNUPGHOME="$HOME/.lab-gnupg" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +mkdir -p "$GOPASS_HOMEDIR" + +echo "== generating throwaway (no-protection) GPG key ==" +cat > /tmp/key.batch <<'EOF' +%no-protection +Key-Type: eddsa +Key-Curve: ed25519 +Subkey-Type: ecdh +Subkey-Curve: cv25519 +Name-Real: Incredigo Lab +Name-Email: lab@incredigo.local +Expire-Date: 0 +%commit +EOF +gpg --batch --generate-key /tmp/key.batch 2>/dev/null +FPR=$(gpg --list-keys --with-colons lab@incredigo.local | awk -F: '/^fpr:/{print $10; exit}') +echo "key: $FPR" + +echo "== initialising isolated gopass store ==" +gopass init --storage fs "$FPR" >/dev/null 2>&1 || gopass init "$FPR" >/dev/null 2>&1 +gopass ls || true + +echo "== seeding gopass 'imported/' entries (fake) ==" +printf 'AKIALAB000000FAKE0001:wJalrFAKEsecretKEYwJalrFAKEsecretKEY0001\n' | gopass insert -f imported/aws/default >/dev/null +printf 'ghp_FAKEtoken000000000000000000000001\n' | gopass insert -f imported/github/pat >/dev/null +printf 'lab-db-PASSWORD-0001\n' | gopass insert -f imported/db/postgres >/dev/null +echo "seeded: $(gopass ls --flat | wc -l) entries" + +echo "== creating sample on-disk creds (fake) ==" +LAB="$HOME/lab"; mkdir -p "$LAB"; cd "$LAB" +cat > .env <<'EOF' +# fake lab secrets — NOT real +API_TOKEN=tok_FAKE_4f9c2a7e8b1d6033aa55ee +DATABASE_URL=postgres://app:lab-db-PASSWORD-0001@db.lab.local:5432/app +SENDGRID_API_KEY=SG.FAKE0001.aaaaaaaaaaaaaaaaaaaaaa +PORT=8080 +EOF +mkdir -p "$HOME/.aws" +cat > "$HOME/.aws/credentials" <<'EOF' +[default] +aws_access_key_id = AKIALAB000000FAKE0001 +aws_secret_access_key = wJalrFAKEsecretKEYwJalrFAKEsecretKEY0001 +EOF +# consumer files for the blast-radius map (reference markers, not secrets) +cat > docker-compose.yml <<'EOF' +services: + app: + image: example/app + environment: + - API_TOKEN=${API_TOKEN} + - DATABASE_URL=${DATABASE_URL} +EOF +mkdir -p k8s +cat > k8s/deploy.yaml <<'EOF' +env: + - name: API_TOKEN + valueFrom: { secretKeyRef: { name: app, key: API_TOKEN } } + - name: SENDGRID_API_KEY + valueFrom: { secretKeyRef: { name: app, key: SENDGRID_API_KEY } } +EOF +echo "lab dir: $LAB"; ls -la "$LAB" +echo "PROVISION_OK" diff --git a/lab/lab-harness.sh b/lab/lab-harness.sh new file mode 100755 index 0000000..a21fcef --- /dev/null +++ b/lab/lab-harness.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# incredigo controlled-environment test harness. Self-contained + repeatable: +# re-provisions a FRESH isolated gopass store and fake inputs for all 8 scanners, +# then runs every command with PASS/FAIL assertions + a global secret-leak canary. +# FAKE creds only. Never touches a real store. +set -uo pipefail +export PATH=/usr/local/bin:$PATH +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_NO_NOTIFY=true +export INCREDIGO_PASSPHRASE='lab-seal-passphrase-001' + +LAB="$HOME/lab" +MASTER="$HOME/lab-master.log" +: > "$MASTER" +PASS=0; FAIL=0; FAILED=() + +note(){ echo; echo "==== $* ===="; } +# check NAME : asserts that the last captured $OUT matches a grep -E pattern +check(){ local name="$1" pat="$2"; if grep -Eq "$pat" <<<"$OUT"; then echo " PASS $name"; PASS=$((PASS+1)); else echo " FAIL $name (want /$pat/)"; FAIL=$((FAIL+1)); FAILED+=("$name"); fi; } +checkno(){ local name="$1" pat="$2"; if grep -Eq "$pat" <<<"$OUT"; then echo " FAIL $name (unwanted /$pat/)"; FAIL=$((FAIL+1)); FAILED+=("$name"); else echo " PASS $name"; PASS=$((PASS+1)); fi; } +# run CMD... -> captures combined output into $OUT and appends to MASTER +run(){ OUT="$("$@" 2>&1)"; printf '\n### %s\n%s\n' "$*" "$OUT" >> "$MASTER"; } + +############################ provision (fresh) ############################ +note "PROVISION fresh isolated store + fake inputs" +rm -rf "$GOPASS_HOMEDIR" "$GNUPGHOME" "$LAB" "$HOME/.aws" "$HOME/.docker" "$HOME/.kube" \ + "$HOME/.ssh" "$HOME/.netrc" "$HOME/.git-credentials" "$HOME/.incredigo" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME"; mkdir -p "$GOPASS_HOMEDIR" +cat > /tmp/key.batch <<'EOF' +%no-protection +Key-Type: eddsa +Key-Curve: ed25519 +Subkey-Type: ecdh +Subkey-Curve: cv25519 +Name-Real: Incredigo Lab +Name-Email: lab@incredigo.local +Expire-Date: 0 +%commit +EOF +gpg --batch --generate-key /tmp/key.batch 2>/dev/null +FPR=$(gpg --list-keys --with-colons lab@incredigo.local | awk -F: '/^fpr:/{print $10; exit}') +gopass init --crypto gpg --storage fs "$FPR" /dev/null 2>&1 + +# --- fake inputs (every secret carries a unique CANARY token for leak-checks) --- +mkdir -p "$LAB" "$LAB/k8s" "$LAB/extra" "$HOME/.aws" "$HOME/.docker" "$HOME/.kube" "$HOME/.ssh" +cd "$LAB" +cat > .env <<'EOF' +API_TOKEN=tok_CANARYenv01_4f9c2a7e8b1d6033 +DATABASE_URL=postgres://app:CANARYdb01pw@db.lab.local:5432/app +SENDGRID_API_KEY=SG.CANARYsg01.aaaaaaaaaaaaaaaaaaaa +PORT=8080 +EOF +cat > "$HOME/.aws/credentials" <<'EOF' +[default] +aws_access_key_id = AKIALAB000000FAKE0001 +aws_secret_access_key = wJalrCANARYaws01secretKEYwJalr0001 +EOF +cat > "$HOME/.netrc" <<'EOF' +machine api.lab.local login labuser password CANARYnetrc01pw +EOF +cat > "$HOME/.git-credentials" <<'EOF' +https://gituser:CANARYgit01tok@github.com +EOF +DOCKER_AUTH=$(printf 'dockeruser:CANARYdocker01pw' | base64) +cat > "$HOME/.docker/config.json" < "$HOME/.kube/config" <<'EOF' +apiVersion: v1 +kind: Config +users: +- name: lab-admin + user: + token: CANARYkube01token +EOF +cat > "$HOME/.ssh/id_ed25519" <<'EOF' +-----BEGIN OPENSSH PRIVATE KEY----- +CANARYssh01keybodyb3BlbnNzaC1rZXktdjEAAAAABG5vbmU +-----END OPENSSH PRIVATE KEY----- +EOF +chmod 600 "$HOME/.ssh/id_ed25519" +cat > "$LAB/extra/service-account.json" <<'EOF' +{"type":"service_account","private_key":"CANARYfile01blob"} +EOF +# consumer files for the blast-radius map (reference markers, not secrets) +cat > docker-compose.yml <<'EOF' +services: + app: + environment: + - API_TOKEN=${API_TOKEN} + - DATABASE_URL=${DATABASE_URL} +EOF +cat > k8s/deploy.yaml <<'EOF' +env: + - name: API_TOKEN + valueFrom: { secretKeyRef: { name: app, key: API_TOKEN } } + - name: SENDGRID_API_KEY + valueFrom: { secretKeyRef: { name: app, key: SENDGRID_API_KEY } } +EOF + +# the canary strings that must NEVER appear in any command output +SECRETS=( CANARYenv01 CANARYdb01pw CANARYsg01 wJalrCANARYaws01 CANARYnetrc01pw + CANARYgit01tok "$DOCKER_AUTH" CANARYkube01token CANARYssh01keybody CANARYfile01blob ) + +############################ T1 scan (all 8 sources) ############################ +note "T1 scan --path extra (all sources)" +run incredigo scan --path "$LAB/extra" +echo "$OUT" +for s in aws env netrc ssh docker kube git file; do check "scan lists '$s'" "(^|[[:space:]])$s([[:space:]])"; done + +############################ T2 status ############################ +note "T2 status" +run incredigo status; check "status header" "STATE" + +############################ T3 migrate (fresh store) ############################ +note "T3 migrate --path extra --dedupe" +run incredigo migrate --path "$LAB/extra" --dedupe +echo "$OUT" +check "migrate reports count" "migrated [1-9]" +OUT="$(gopass ls --flat 2>&1)"; check "store has imported/ entries" "imported/" + +############################ T4/T5 age export+import roundtrip ############################ +note "T4 export (age)" +run incredigo export --prefix imported/ --out "$LAB/backup.age"; check "age sealed" "sealed [0-9]+ entr" +note "T5 age roundtrip into a clean prefix" +printf 'roundtrip-CANARYrt01\n' | gopass insert --multiline=false -f rt/alpha >/dev/null 2>&1 +run incredigo export --prefix rt/ --out "$LAB/rt.age"; check "rt export" "sealed 1 entr" +gopass rm -f rt/alpha >/dev/null 2>&1 +run incredigo import --in "$LAB/rt.age" --force; check "rt import restored" "restored 1 entr" +OUT="$(gopass show -o rt/alpha 2>&1)"; check "rt value matches" "^roundtrip-CANARYrt01$" + +############################ T6 hmac sealer roundtrip ############################ +note "T6 hmac sealer roundtrip" +run incredigo export --prefix rt/ --sealer hmac --out "$LAB/rt.hmac"; check "hmac sealed" "sealed 1 entr" +gopass rm -f rt/alpha >/dev/null 2>&1 +run incredigo import --in "$LAB/rt.hmac" --sealer hmac --force; check "hmac import" "restored 1 entr" +OUT="$(gopass show -o rt/alpha 2>&1)"; check "hmac value matches" "^roundtrip-CANARYrt01$" + +############################ T7 openssl sealer roundtrip ############################ +note "T7 openssl sealer roundtrip" +if command -v openssl >/dev/null; then + run incredigo export --prefix rt/ --sealer openssl --out "$LAB/rt.ossl"; check "openssl sealed" "sealed 1 entr" + gopass rm -f rt/alpha >/dev/null 2>&1 + run incredigo import --in "$LAB/rt.ossl" --sealer openssl --force; check "openssl import" "restored 1 entr" + OUT="$(gopass show -o rt/alpha 2>&1)"; check "openssl value matches" "^roundtrip-CANARYrt01$" +else echo " SKIP openssl not installed"; fi + +############################ T8 rotate --dry-run ############################ +note "T8 rotate --dry-run" +run incredigo rotate --prefix imported/ --dry-run +echo "$OUT" +check "backup gate ran" "backup gate: [0-9]+ entr" +check "spine completed" "DRY RUN complete" +check "noop rotated" "noop" + +############################ T8b backup-gate COVERAGE (independent ground truth) ############################ +# Regression guard for the 2026-06-16 lapse: the gate's sealed==verified check is +# self-referential (both counts come from `gopass ls --flat`), so a path hidden from +# `ls --flat` (e.g. a ".env" dot-segment) is dropped from BOTH sides and the gate +# passes while under-covering the store. Here we count the on-disk *.gpg files +# directly (independent of `ls --flat`) and assert the gate sealed exactly that many. +note "T8b backup gate covers EVERY store entry (independent .gpg count)" +GATE_N=$(grep -Eo 'backup gate: [0-9]+ entr' <<<"$OUT" | grep -Eo '[0-9]+' | head -1) +DISK_N=$(find "$GOPASS_HOMEDIR" -name '*.gpg' -path '*/imported/*' 2>/dev/null | wc -l | tr -d ' ') +printf '\n### T8b coverage gate=%s disk=%s\n' "$GATE_N" "$DISK_N" >> "$MASTER" +OUT="gate=$GATE_N disk=$DISK_N"; echo " $OUT" +check "gate count parsed" "gate=[0-9]+ " +check "backup covers ALL entries" "gate=${DISK_N} disk=${DISK_N}" + +############################ T9 rotate --blast ############################ +note "T9 rotate --blast" +run incredigo rotate --prefix imported/ --dry-run --blast --blast-root "$LAB" +echo "$OUT" +check "blast CONSUMERS col" "CONSUMERS" +check "API_TOKEN -> 2 files" "API_TOKEN.*2 file" +check "blast radius listed" "blast radius" + +############################ T10 worklist ############################ +note "T10 worklist" +run incredigo worklist; check "worklist table" "change-password link" + +############################ T11 negative: --execute refused ############################ +note "T11 rotate --execute must be refused" +run incredigo rotate --execute; check "execute refused" "refused" + +############################ T12 negative: empty passphrase ############################ +note "T12 empty INCREDIGO_PASSPHRASE rejected" +OUT="$(INCREDIGO_PASSPHRASE='' incredigo export --prefix imported/ --out /tmp/should-not-exist.age 2>&1)" +printf '\n### empty-pass\n%s\n' "$OUT" >> "$MASTER" +check "empty passphrase error" "empty" + +############################ T13 GLOBAL leak canary ############################ +note "T13 GLOBAL leak-check across ALL command output" +LEAKED=0 +for s in "${SECRETS[@]}"; do + if grep -Fq "$s" "$MASTER"; then echo " FAIL secret leaked: $s"; LEAKED=$((LEAKED+1)); fi +done +if grep -Fq "PRIVATE KEY" "$MASTER"; then echo " FAIL 'PRIVATE KEY' material in output"; LEAKED=$((LEAKED+1)); fi +if [ "$LEAKED" -eq 0 ]; then echo " PASS no secret canary in any output ($MASTER)"; PASS=$((PASS+1)); +else echo " FAIL $LEAKED leak(s) detected"; FAIL=$((FAIL+1)); FAILED+=("leak-check"); fi + +############################ summary ############################ +echo; echo "================= SUMMARY =================" +echo " PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -gt 0 ] && printf ' failed: %s\n' "${FAILED[*]}" +echo "HARNESS_DONE rc=$FAIL" +exit 0 diff --git a/lab/lab-provision-appsec.sh b/lab/lab-provision-appsec.sh new file mode 100755 index 0000000..61f97fb --- /dev/null +++ b/lab/lab-provision-appsec.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Live cutover POC for the LOCAL app-signing-secret rotation driver (appsecret), inside +# the sandbox VM. FAKE secret only, isolated gopass store. Writes two real config files +# that share one SECRET_KEY value, seeds a driver-ready appsecret:// blob (carrying the +# value AND both file paths) into gopass, runs `rotate --execute`, and asserts BOTH +# files were rewritten in place with a fresh value and the old value is gone everywhere. +set -euo pipefail + +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +export INCREDIGO_PASSPHRASE='lab-seal-passphrase-001' + +OLD_VAL="django-insecure-oldval-0123456789abcdef" # fake starting signing secret +APPDIR="$HOME/labapp" +WRKDIR="$HOME/labworker" +ENV_FILE="$APPDIR/.env" +YML_FILE="$WRKDIR/config.yaml" +ENTRY="imported/appsecret/secret_key" + +echo "== 1. two real config files sharing one SECRET_KEY value ==" +mkdir -p "$APPDIR" "$WRKDIR" +cat > "$ENV_FILE" < "$YML_FILE" < /tmp/keyparams </dev/null 2>&1 +rm -f /tmp/keyparams +KEYID=$(gpg --list-keys --with-colons lab@incredigo.invalid | awk -F: '/^pub/{print $5; exit}') +gopass init --storage fs "$KEYID" >/dev/null 2>&1 || gopass init "$KEYID" >/dev/null 2>&1 + +echo "== 3. seed the driver-ready appsecret blob (value + BOTH paths) into gopass ==" +# Raw slashes are valid inside a query value, so the absolute paths need no escaping. +BLOB="appsecret://local/?key=SECRET_KEY&val=${OLD_VAL}&path=${ENV_FILE}&path=${YML_FILE}" +printf '%s' "$BLOB" | gopass insert --multiline=false -f "$ENTRY" >/dev/null +gopass ls --flat | sed 's/^/ /' + +echo "== 4. rotate --execute (MANDATORY backup gate runs first) ==" +# prefix is the imported/ ROOT so Mode A derives Source=appsecret from the first segment. +INCREDIGO_ALLOW_EXECUTE=1 incredigo rotate --execute --prefix imported/ 2>&1 | sed 's/^/ /' + +echo "== 5. CUTOVER ASSERTIONS ==" +NEW_BLOB=$(gopass show -o "$ENTRY") +# pull val= out of the rebuilt blob (between 'val=' and the next '&'). +NEW_VAL=$(printf '%s' "$NEW_BLOB" | sed -n 's/.*[?&]val=\([^&]*\).*/\1/p') +echo " new val (from gopass blob): $NEW_VAL" + +fail=0 +[ -n "$NEW_VAL" ] && [ "$NEW_VAL" != "$OLD_VAL" ] && echo " OK: signing value changed" || { echo " FAIL: value unchanged"; fail=1; } +for f in "$ENV_FILE" "$YML_FILE"; do + grep -qF "$NEW_VAL" "$f" && echo " OK: $f holds NEW value" || { echo " FAIL: $f missing NEW value"; fail=1; } + grep -qF "$OLD_VAL" "$f" && { echo " FAIL: $f still holds OLD value"; fail=1; } || echo " OK: $f dropped OLD value" +done +grep -qF "PORT=8080" "$ENV_FILE" && echo " OK: unrelated .env line preserved" || { echo " FAIL: unrelated line lost"; fail=1; } +# mode preserved on the 0644 file +MODE=$(stat -c '%a' "$YML_FILE") +[ "$MODE" = "644" ] && echo " OK: config.yaml mode preserved (644)" || { echo " FAIL: mode changed to $MODE"; fail=1; } + +echo +if [ "$fail" = 0 ]; then echo "ALL APPSECRET CUTOVER ASSERTIONS PASSED"; else echo "SOME ASSERTIONS FAILED"; exit 1; fi diff --git a/lab/lab-provision-aws.sh b/lab/lab-provision-aws.sh new file mode 100755 index 0000000..a428764 --- /dev/null +++ b/lab/lab-provision-aws.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Provision the incredigo AWS-IAM-rotation POC inside the sandbox VM. +# FAKE/self-hosted moto only, isolated gopass store. Idempotent-ish. +set -euo pipefail + +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +VENV="$HOME/moto-venv" +ENTRY="imported/aws/labkey" +EP="http://127.0.0.1:5000" +OLDKEY_FILE="$HOME/.lab-aws-oldkey" + +echo "== 1. (re)start moto_server on :5000 ==" +pkill -f moto_server 2>/dev/null || true +sleep 1 +nohup "$VENV/bin/moto_server" -p 5000 >"$HOME/moto.log" 2>&1 & +for i in $(seq 1 30); do + curl -fsS "$EP/moto-api/" >/dev/null 2>&1 && break + sleep 1 +done +echo " moto up: $(curl -fsS $EP/moto-api/ >/dev/null 2>&1 && echo yes || echo NO)" + +echo "== 2. create IAM user + seed access key, emit driver-ready blob ==" +BLOB=$("$VENV/bin/python" - "$EP" "$OLDKEY_FILE" <<'PY' +import sys, boto3, urllib.parse +ep, oldfile = sys.argv[1], sys.argv[2] +a = boto3.client("iam", endpoint_url=ep, region_name="us-east-1", + aws_access_key_id="admin", aws_secret_access_key="admin") +try: + a.create_user(UserName="labapp") +except Exception: + pass +ak = a.create_access_key(UserName="labapp")["AccessKey"] +oid, osec = ak["AccessKeyId"], ak["SecretAccessKey"] +open(oldfile, "w").write(oid) +q = "region=us-east-1&endpoint=" + urllib.parse.quote(ep, safe="") +blob = "aws://%s:%s@aws/?%s" % (urllib.parse.quote(oid, safe=""), + urllib.parse.quote(osec, safe=""), q) +print(blob) +PY +) +echo " seed AccessKeyId: $(cat "$OLDKEY_FILE")" + +echo "== 3. isolated GNUPGHOME + no-protection GPG key ==" +rm -rf "$GNUPGHOME" "$GOPASS_HOMEDIR" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +cat > /tmp/keyparams </dev/null 2>&1 +rm -f /tmp/keyparams +KEYID=$(gpg --list-keys --with-colons lab@incredigo.invalid | awk -F: '/^pub/{print $5; exit}') +echo " key: $KEYID" + +echo "== 4. isolated gopass store + seed blob ==" +gopass init --storage fs "$KEYID" >/dev/null 2>&1 || gopass init "$KEYID" >/dev/null 2>&1 +printf '%s' "$BLOB" | gopass insert --multiline=false -f "$ENTRY" >/dev/null +echo " stored $ENTRY:" +gopass ls --flat | sed 's/^/ /' + +echo "== 5. prove the seed key authenticates now (STS) ==" +"$VENV/bin/python" - "$EP" "$(cat "$OLDKEY_FILE")" <<'PY' +import sys, boto3 +ep, oid = sys.argv[1], sys.argv[2] +# read secret back out of gopass is not needed here; just confirm list shows it +a = boto3.client("iam", endpoint_url=ep, region_name="us-east-1", + aws_access_key_id="admin", aws_secret_access_key="admin") +keys = [k["AccessKeyId"] for k in a.list_access_keys(UserName="labapp")["AccessKeyMetadata"]] +print(" labapp keys now:", keys) +PY + +echo "DONE. Run: GNUPGHOME=$GNUPGHOME GOPASS_HOMEDIR=$GOPASS_HOMEDIR \\" +echo " INCREDIGO_PASSPHRASE=labseal123 INCREDIGO_ALLOW_EXECUTE=1 incredigo rotate --execute" diff --git a/lab/lab-provision-bitwarden.sh b/lab/lab-provision-bitwarden.sh new file mode 100755 index 0000000..94d343b --- /dev/null +++ b/lab/lab-provision-bitwarden.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Live-VM POC for the Bitwarden adapter (Phase B `passwords` engine) against a self-hosted +# Vaultwarden — no real bitwarden.com account, fully scripted. FAKE logins only. +# +# 1. self-signed TLS + run real Vaultwarden 1.36 (sqlite, signups on, web-vault off) +# 2. register a fake account via vw-register.py (real Bitwarden registration crypto) +# 3. real `bw` CLI: config server + login + seed 2 weak-pw login items +# 4. incredigo passwords scan -> plan -> commit (real `bw list/get/edit item`) +# 5. ASSERT both item passwords cut over (OLD gone), then RESTORE from the sealed backup +set -euo pipefail +export PATH=/snap/bin:/usr/local/bin:$PATH +export NODE_TLS_REJECT_UNAUTHORIZED=0 # accept the lab self-signed cert (bw is Node) +export INCREDIGO_PASSPHRASE='lab-seal-passphrase-001' + +URL='https://127.0.0.1:8000' +EMAIL='labuser@incredigo.invalid' +MASTER='lab-master-pass-001' +OLD1='weakpw-github-0001' +OLD2='weakpw-gitlab-0002' +WORK="$HOME/bw-work"; rm -rf "$WORK"; mkdir -p "$WORK" +STAGE="$WORK/stage.age"; BACKUP="$WORK/backup.age"; WORKLIST="$WORK/worklist.md"; AUDIT="$WORK/audit.jsonl" +fail=0 + +echo "== 1. self-signed cert + Vaultwarden (TLS) ==" +mkdir -p ~/vwtls +openssl req -x509 -newkey rsa:2048 -nodes -keyout ~/vwtls/key.pem -out ~/vwtls/cert.pem \ + -days 7 -subj "/CN=127.0.0.1" -addext "subjectAltName=IP:127.0.0.1" >/dev/null 2>&1 +sudo docker rm -f vw >/dev/null 2>&1 || true +sudo docker run -d --name vw \ + -e SIGNUPS_ALLOWED=true -e WEB_VAULT_ENABLED=false \ + -e I_REALLY_WANT_VOLATILE_STORAGE=true \ + -e ROCKET_PORT=8000 \ + -e 'ROCKET_TLS={certs="/tls/cert.pem",key="/tls/key.pem"}' \ + -v ~/vwtls:/tls:ro \ + -p 127.0.0.1:8000:8000 \ + vaultwarden/server:latest >/dev/null +for i in $(seq 1 60); do + curl -fsSk "$URL/alive" >/dev/null 2>&1 && { echo " vaultwarden up after ${i}s"; break; } + sleep 1 +done + +echo "== 2. register fake account (real registration crypto) ==" +~/moto-venv/bin/python ~/vw-register.py "$EMAIL" "$MASTER" "Lab User" "$URL" | sed 's/^/ /' + +echo "== 3. bw login + seed 2 login items ==" +bw config server "$URL" >/dev/null +BW_SESSION="$(bw login "$EMAIL" "$MASTER" --raw)"; export BW_SESSION +for pair in "GitHub|alice@example.com|$OLD1|https://github.com/" "GitLab|bob@example.com|$OLD2|https://gitlab.com/"; do + IFS='|' read -r nm us pw url <<< "$pair" + json=$(printf '{"type":1,"name":"%s","login":{"username":"%s","password":"%s","uris":[{"uri":"%s"}]}}' "$nm" "$us" "$pw" "$url") + printf '%s' "$json" | bw encode | bw create item >/dev/null + echo " seeded $nm" +done +bw sync >/dev/null + +echo "== 4. passwords scan (no secrets) ==" +incredigo passwords --manager bitwarden scan --audit-log "$AUDIT" 2>&1 | sed 's/^/ /' + +echo "== 5. passwords plan (verified backup + staged new pws + worklist) ==" +incredigo passwords --manager bitwarden plan \ + --backup-out "$BACKUP" --stage-out "$STAGE" --worklist-out "$WORKLIST" \ + --audit-log "$AUDIT" 2>&1 | sed 's/^/ /' +for blob in "$STAGE" "$BACKUP"; do + if grep -qaF "$OLD1" "$blob" || grep -qaF "$OLD2" "$blob"; then + echo " FAIL: plaintext password in $blob"; fail=1 + else echo " OK: $(basename "$blob") carries no plaintext old password"; fi +done + +echo "== 6. passwords commit --verified all (real bw edit item, in place) ==" +incredigo passwords --manager bitwarden commit \ + --stage-in "$STAGE" --verified all --backup-out "$WORK/commit-backup.age" \ + --audit-log "$AUDIT" 2>&1 | sed 's/^/ /' + +echo "== 7. CUTOVER ASSERTIONS (re-read the live vault) ==" +bw sync >/dev/null +read_pw() { bw list items | python3 -c "import sys,json,os; n=os.environ['N']; print(next((i['login']['password'] for i in json.load(sys.stdin) if i['name']==n),''))"; } +NEW1=$(N=GitHub read_pw); NEW2=$(N=GitLab read_pw) +echo " GitHub new pw len=${#NEW1} GitLab new pw len=${#NEW2}" +[ -n "$NEW1" ] && [ "$NEW1" != "$OLD1" ] && echo " OK: GitHub password changed" || { echo " FAIL: GitHub unchanged"; fail=1; } +[ -n "$NEW2" ] && [ "$NEW2" != "$OLD2" ] && echo " OK: GitLab password changed" || { echo " FAIL: GitLab unchanged"; fail=1; } +[ "$NEW1" = "$OLD1" ] && { echo " FAIL: GitHub OLD pw still set"; fail=1; } || echo " OK: GitHub OLD pw gone" +[ "${#NEW1}" -ge 16 ] && echo " OK: GitHub new pw is strong (len ${#NEW1})" || { echo " FAIL: new pw too short"; fail=1; } + +echo "== 8. RECOVERABILITY: restore OLD passwords from the sealed backup ==" +incredigo passwords --manager bitwarden commit \ + --stage-in "$BACKUP" --verified all --backup-out "$WORK/restore-backup.age" \ + --audit-log "$AUDIT" 2>&1 | sed 's/^/ /' +bw sync >/dev/null +R1=$(N=GitHub read_pw); R2=$(N=GitLab read_pw) +[ "$R1" = "$OLD1" ] && echo " OK: GitHub OLD pw restored from backup" || { echo " FAIL: GitHub not restored ($R1)"; fail=1; } +[ "$R2" = "$OLD2" ] && echo " OK: GitLab OLD pw restored from backup" || { echo " FAIL: GitLab not restored"; fail=1; } + +echo "== 9. cleanup ==" +bw logout >/dev/null 2>&1 || true +sudo docker rm -f vw >/dev/null 2>&1 || true +echo " vaultwarden container removed" + +echo +if [ "$fail" = 0 ]; then echo "ALL BITWARDEN CUTOVER ASSERTIONS PASSED"; else echo "SOME ASSERTIONS FAILED"; exit 1; fi diff --git a/lab/lab-provision-browsercsv.sh b/lab/lab-provision-browsercsv.sh new file mode 100755 index 0000000..81c979b --- /dev/null +++ b/lab/lab-provision-browsercsv.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Live-VM POC for the Chrome/Firefox browser-CSV adapters (Phase B `passwords` engine), +# inside the sandbox VM. FAKE logins only. There is NO real browser to re-import into, +# so what this proves is the security-critical machinery that DOES run unattended: +# +# * scan/plan read a real browser export CSV and seal a verified backup + staged new +# passwords (.age) — never plaintext on disk +# * commit --allow-csv writes the NEW-password CSV to a REAL tmpfs (/dev/shm), in the +# correct per-browser column layout, carrying the staged strong passwords (OLD gone) +# * the file is SECURELY SHREDDED (unlinked) once the human confirms re-import +# * the user's OWN export CSV is never touched +# +# To inspect the tmpfs CSV before it is shredded, commit is driven under a pty (`script`) +# so it pauses at the "press Enter to shred" prompt; a FIFO releases it after inspection. +set -euo pipefail +export PATH=/usr/local/bin:$PATH +export INCREDIGO_PASSPHRASE='lab-seal-passphrase-001' + +OLD1='weakpw-github-0001' +OLD2='weakpw-gitlab-0002' +WORK="$HOME/csv-work"; rm -rf "$WORK"; mkdir -p "$WORK" +AUDIT="$WORK/audit.jsonl" +fail=0 + +run_browser() { + local mgr="$1" exp_header="$2" want_header="$3" + echo + echo "############################################################" + echo "## $mgr" + echo "############################################################" + local dir="$WORK/$mgr"; mkdir -p "$dir" + local EXPORT="$dir/export.csv" + local STAGE="$dir/stage.age" BACKUP="$dir/backup.age" WORKLIST="$dir/worklist.md" + + echo "== 1. browser export CSV (user-provided, $exp_header) ==" + { + echo "$exp_header" + if [ "$mgr" = "chrome" ]; then + echo "GitHub,https://github.com/,alice@example.com,$OLD1," + echo "GitLab,https://gitlab.com/,bob@example.com,$OLD2," + else + echo "https://github.com/,alice@example.com,$OLD1" + echo "https://gitlab.com/,bob@example.com,$OLD2" + fi + } > "$EXPORT" + sed 's/^/ /' "$EXPORT" + + echo "== 2. passwords scan ==" + incredigo passwords --manager "$mgr" --export-path "$EXPORT" scan --audit-log "$AUDIT" 2>&1 | sed 's/^/ /' + + echo "== 3. passwords plan (verified backup + staged new pws + worklist) ==" + incredigo passwords --manager "$mgr" --export-path "$EXPORT" plan \ + --backup-out "$BACKUP" --stage-out "$STAGE" --worklist-out "$WORKLIST" \ + --audit-log "$AUDIT" 2>&1 | sed 's/^/ /' + + echo "== 4. ASSERT staged + backup bundles are NOT plaintext ==" + for blob in "$STAGE" "$BACKUP"; do + if grep -qaF "$OLD1" "$blob" || grep -qaF "$OLD2" "$blob"; then + echo " FAIL: plaintext password in $blob"; fail=1 + else + echo " OK: $(basename "$blob") carries no plaintext old password" + fi + done + + echo "== 5. commit --allow-csv (pty probe: inspect tmpfs CSV, then shred) ==" + rm -rf /dev/shm/incredigo + local PWCOL=4; [ "$mgr" = "firefox" ] && PWCOL=3 + if python3 /home/ubuntu/csv-commit-probe.py \ + "$mgr" "$EXPORT" "$STAGE" "$dir/commit-backup.age" "$AUDIT" \ + "$OLD1" "$OLD2" "$want_header" "$PWCOL"; then + : + else + fail=1 + fi + # (e) user's own export CSV untouched + grep -qaF "$OLD1" "$EXPORT" && echo " OK: user's export.csv left untouched" || { echo " FAIL: user export.csv was modified"; fail=1; } +} + +# manager export-header written-header +run_browser chrome "name,url,username,password,note" "name,url,username,password,note" +run_browser firefox "url,username,password" "url,username,password" + +echo +if [ "$fail" = 0 ]; then echo "ALL BROWSER-CSV ASSERTIONS PASSED"; else echo "SOME ASSERTIONS FAILED"; exit 1; fi diff --git a/lab/lab-provision-dbclones.sh b/lab/lab-provision-dbclones.sh new file mode 100755 index 0000000..023df41 --- /dev/null +++ b/lab/lab-provision-dbclones.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Live cutover POC for the MySQL and Redis in-place rotation drivers, inside the +# sandbox VM. FAKE creds only, isolated gopass store. Installs real mariadb-server + +# redis-server, seeds driver-ready blobs, runs `rotate --execute`, and asserts the +# OLD password stops authenticating while the NEW one (read back from gopass) works. +set -euo pipefail + +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +export INCREDIGO_PASSPHRASE='lab-seal-passphrase-001' +MYSQL_PW="oldmy$(date +%s | tail -c 4)" +REDIS_PW="oldrd$(date +%s | tail -c 4)" + +echo "== 0. ensure mariadb-server + redis-server + clients are installed ==" +need_install=0 +command -v mysql >/dev/null 2>&1 || need_install=1 +command -v redis-cli >/dev/null 2>&1 || need_install=1 +command -v redis-server>/dev/null 2>&1 || need_install=1 +command -v mariadbd >/dev/null 2>&1 || command -v mysqld >/dev/null 2>&1 || need_install=1 +if [ "$need_install" = 1 ]; then + sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ + mariadb-server mariadb-client redis-server redis-tools >/dev/null +fi +# The driver invokes `mysql`; on 24.04 it may only ship `mariadb`. +command -v mysql >/dev/null 2>&1 || sudo ln -sf "$(command -v mariadb)" /usr/local/bin/mysql + +echo "== 1. start services (restart redis to clear any prior runtime requirepass) ==" +sudo service mariadb start >/dev/null 2>&1 || sudo service mysql start >/dev/null 2>&1 || true +sudo service redis-server restart >/dev/null 2>&1 || sudo service redis-server start >/dev/null 2>&1 || true +sleep 2 + +echo "== 2. isolated GNUPGHOME + no-protection GPG key ==" +rm -rf "$GNUPGHOME" "$GOPASS_HOMEDIR" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +cat > /tmp/keyparams </dev/null 2>&1 +rm -f /tmp/keyparams +KEYID=$(gpg --list-keys --with-colons lab@incredigo.invalid | awk -F: '/^pub/{print $5; exit}') +gopass init --storage fs "$KEYID" >/dev/null 2>&1 || gopass init "$KEYID" >/dev/null 2>&1 + +echo "== 3. MariaDB: labapp@127.0.0.1 owns labdb, starting pw=$MYSQL_PW ==" +ADMIN_CLI="$(command -v mariadb || command -v mysql)" +SQL="DROP DATABASE IF EXISTS labdb; +DROP USER IF EXISTS 'labapp'@'127.0.0.1'; +DROP USER IF EXISTS 'labapp'@'localhost'; +CREATE DATABASE labdb; +CREATE USER 'labapp'@'127.0.0.1' IDENTIFIED BY '$MYSQL_PW'; +CREATE USER 'labapp'@'localhost' IDENTIFIED BY '$MYSQL_PW'; +GRANT ALL ON labdb.* TO 'labapp'@'127.0.0.1'; +GRANT ALL ON labdb.* TO 'labapp'@'localhost'; +FLUSH PRIVILEGES;" +printf '%s\n' "$SQL" | sudo "$ADMIN_CLI" >/dev/null +echo " prove starting mysql pw authenticates over TCP:" +MYSQL_PWD="$MYSQL_PW" mysql --batch --skip-column-names -h 127.0.0.1 -u labapp -D labdb -e 'SELECT 1;' + +echo "== 4. Redis: set starting requirepass=$REDIS_PW ==" +redis-cli CONFIG SET requirepass "$REDIS_PW" >/dev/null +echo " prove starting redis pw authenticates:" +REDISCLI_AUTH="$REDIS_PW" redis-cli PING + +echo "== 5. seed driver-ready blobs into gopass (no plaintext on disk) ==" +printf 'mysql://labapp:%s@127.0.0.1:3306/labdb' "$MYSQL_PW" \ + | gopass insert --multiline=false -f imported/mysql/labapp >/dev/null +printf 'redis://:%s@127.0.0.1:6379' "$REDIS_PW" \ + | gopass insert --multiline=false -f imported/redis/local >/dev/null +gopass ls --flat | sed 's/^/ /' + +echo "== 6. rotate --execute (MANDATORY backup gate runs first) ==" +INCREDIGO_ALLOW_EXECUTE=1 incredigo rotate --execute --prefix imported/ 2>&1 | sed 's/^/ /' + +echo "== 7. CUTOVER ASSERTIONS ==" +NEW_MYSQL=$(gopass show -o imported/mysql/labapp) +NEW_REDIS=$(gopass show -o imported/redis/local) +NEW_MYSQL_PW=$(printf '%s' "$NEW_MYSQL" | sed -E 's#.*://[^:]*:([^@]*)@.*#\1#') +NEW_REDIS_PW=$(printf '%s' "$NEW_REDIS" | sed -E 's#.*://:([^@]*)@.*#\1#') + +fail=0 +echo " [mysql] OLD pw must now FAIL:" +if MYSQL_PWD="$MYSQL_PW" mysql --batch --skip-column-names -h 127.0.0.1 -u labapp -D labdb -e 'SELECT 1;' >/dev/null 2>&1; then + echo " FAIL: old mysql pw still authenticates"; fail=1 +else echo " OK: old mysql pw rejected"; fi +echo " [mysql] NEW pw must WORK:" +if MYSQL_PWD="$NEW_MYSQL_PW" mysql --batch --skip-column-names -h 127.0.0.1 -u labapp -D labdb -e 'SELECT 1;' >/dev/null 2>&1; then + echo " OK: new mysql pw authenticates"; else echo " FAIL: new mysql pw rejected"; fail=1; fi + +echo " [redis] OLD pw must now FAIL:" +if [ "$(REDISCLI_AUTH="$REDIS_PW" redis-cli PING 2>&1)" = "PONG" ]; then + echo " FAIL: old redis pw still authenticates"; fail=1 +else echo " OK: old redis pw rejected"; fi +echo " [redis] NEW pw must WORK:" +if [ "$(REDISCLI_AUTH="$NEW_REDIS_PW" redis-cli PING 2>&1)" = "PONG" ]; then + echo " OK: new redis pw authenticates"; else echo " FAIL: new redis pw rejected"; fail=1; fi + +echo +if [ "$fail" = 0 ]; then echo "ALL DB-CLONE CUTOVER ASSERTIONS PASSED"; else echo "SOME ASSERTIONS FAILED"; exit 1; fi diff --git a/lab/lab-provision-gitea.sh b/lab/lab-provision-gitea.sh new file mode 100755 index 0000000..8cc4733 --- /dev/null +++ b/lab/lab-provision-gitea.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Provision the incredigo Gitea-PAT-rotation POC inside the sandbox VM. +# FAKE/self-owned local Gitea only, isolated gopass store. Idempotent-ish. +set -euo pipefail + +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +GITEA_DIR="$HOME/gitea-lab" +GITEA_USER="labadmin" +GITEA_PW="labpw123" +ENTRY="imported/gitea/labtoken" +OLDTOK_FILE="$HOME/.lab-gitea-oldtoken" + +echo "== 1. stop any prior gitea, reset work dir ==" +pkill -f 'gitea web' 2>/dev/null || true +sleep 1 +rm -rf "$GITEA_DIR" +mkdir -p "$GITEA_DIR/data" "$GITEA_DIR/repos" "$GITEA_DIR/custom/conf" + +cat > "$GITEA_DIR/custom/conf/app.ini" </dev/null 2>&1 +gitea -c "$CONF" admin user create \ + --admin --username "$GITEA_USER" --password "$GITEA_PW" \ + --email lab@incredigo.invalid --must-change-password=false >/dev/null 2>&1 + +echo "== 3. start gitea (http://127.0.0.1:3000) ==" +nohup gitea -c "$CONF" web >"$GITEA_DIR/gitea.log" 2>&1 & +for i in $(seq 1 30); do + curl -fsS http://127.0.0.1:3000/api/v1/version >/dev/null 2>&1 && break + sleep 1 +done +echo " version: $(curl -fsS http://127.0.0.1:3000/api/v1/version)" + +echo "== 4. create initial (seed) token via basic auth ==" +SEED_NAME="seed-$(date +%s)" +SEED_JSON=$(curl -fsS -u "$GITEA_USER:$GITEA_PW" \ + -H 'content-type: application/json' \ + -d "{\"name\":\"$SEED_NAME\",\"scopes\":[\"write:user\",\"read:user\"]}" \ + http://127.0.0.1:3000/api/v1/users/$GITEA_USER/tokens) +SEED_TOK=$(printf '%s' "$SEED_JSON" | sed -n 's/.*"sha1":"\([0-9a-f]*\)".*/\1/p') +test -n "$SEED_TOK" || { echo "FAILED to mint seed token: $SEED_JSON"; exit 1; } +printf '%s' "$SEED_TOK" > "$OLDTOK_FILE" +echo " seed token name=$SEED_NAME (value saved to $OLDTOK_FILE for the cutover check)" + +echo "== 5. isolated GNUPGHOME + no-protection GPG key ==" +rm -rf "$GNUPGHOME" "$GOPASS_HOMEDIR" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +cat > /tmp/keyparams </dev/null 2>&1 +rm -f /tmp/keyparams +KEYID=$(gpg --list-keys --with-colons lab@incredigo.invalid | awk -F: '/^pub/{print $5; exit}') +echo " key: $KEYID" + +echo "== 6. isolated gopass store ==" +gopass init --storage fs "$KEYID" >/dev/null 2>&1 || gopass init "$KEYID" >/dev/null 2>&1 +gopass ls >/dev/null 2>&1 || true + +echo "== 7. seed driver-ready blob into gopass (token + mgmt pw in query) ==" +# Blob: http://user:token@host/?name=&pw= +BLOB="http://$GITEA_USER:$SEED_TOK@127.0.0.1:3000/?name=$SEED_NAME&pw=$GITEA_PW" +printf '%s' "$BLOB" | gopass insert --multiline=false -f "$ENTRY" >/dev/null +echo " stored $ENTRY:" +gopass ls --flat | sed 's/^/ /' + +echo "== 8. prove the seed token authenticates now ==" +curl -fsS -H "Authorization: token $SEED_TOK" http://127.0.0.1:3000/api/v1/user \ + | sed -n 's/.*"login":"\([^"]*\)".*/ GET \/user -> login=\1/p' + +echo "DONE. Run: GNUPGHOME=$GNUPGHOME GOPASS_HOMEDIR=$GOPASS_HOMEDIR \\" +echo " INCREDIGO_PASSPHRASE=labseal123 INCREDIGO_ALLOW_EXECUTE=1 incredigo rotate --execute" diff --git a/lab/lab-provision-k8s.sh b/lab/lab-provision-k8s.sh new file mode 100755 index 0000000..d9e356f --- /dev/null +++ b/lab/lab-provision-k8s.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Live cutover POC for the Kubernetes legacy-ServiceAccount-token rotation driver, +# inside the sandbox VM. FAKE/self-owned single-node k3s only, isolated gopass store. +# +# Installs REAL k3s, creates a namespace + ServiceAccount (labsa) + a legacy +# kubernetes.io/service-account-token Secret (the controller populates its token), +# and an RBAC Role/RoleBinding letting labsa create/delete/get Secrets + get its own +# ServiceAccount (the driver's create-new -> verify -> revoke-old cutover). Seeds the +# driver-ready k8s:// blob (with the apiserver CA pinned via ca=), runs +# `rotate --execute`, then asserts the OLD token stops authenticating against the REAL +# apiserver while the NEW one (read back from gopass) works. +# +# This promotes the k8s driver from MOCK-ONLY to LIVE-VM: the real kube-apiserver + +# token controller (not the httptest emulator) validate the Secret create/delete cutover. +set -euo pipefail + +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +export INCREDIGO_PASSPHRASE='lab-seal-passphrase-001' +NS="labns" +SA="labsa" +SEED_SECRET="labsa-token-seed" +APISERVER="https://127.0.0.1:6443" +CA_FILE="$HOME/.lab-k8s-ca.crt" + +KC() { sudo k3s kubectl "$@"; } + +echo "== 0. ensure real k3s is installed and the apiserver is up ==" +if ! command -v k3s >/dev/null 2>&1; then + curl -sfL https://get.k3s.io | sudo INSTALL_K3S_EXEC="--write-kubeconfig-mode 644" sh - >/dev/null 2>&1 +fi +sudo systemctl is-active --quiet k3s || sudo systemctl start k3s +for i in $(seq 1 60); do + KC get --raw='/readyz' >/dev/null 2>&1 && break + sleep 1 +done +KC version --short 2>/dev/null | sed 's/^/ /' || KC version | sed 's/^/ /' + +echo "== 1. namespace + serviceaccount + legacy token Secret ==" +KC create namespace "$NS" --dry-run=client -o yaml | KC apply -f - >/dev/null +KC -n "$NS" create serviceaccount "$SA" --dry-run=client -o yaml | KC apply -f - >/dev/null +# Manually-created Secret of the legacy type, annotated to the SA. The token +# controller populates .data.token — exactly the credential the driver rotates. +cat </dev/null +apiVersion: v1 +kind: Secret +metadata: + name: $SEED_SECRET + namespace: $NS + annotations: + kubernetes.io/service-account.name: $SA +type: kubernetes.io/service-account-token +YAML + +echo "== 2. RBAC: let $SA manage Secrets + read its ServiceAccount in $NS ==" +cat </dev/null +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: ${SA}-rotator + namespace: $NS +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["create","delete","get","list"] +- apiGroups: [""] + resources: ["serviceaccounts"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: ${SA}-rotator + namespace: $NS +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ${SA}-rotator +subjects: +- kind: ServiceAccount + name: $SA + namespace: $NS +YAML + +echo "== 3. wait for the token controller to populate the seed token ==" +SEED_TOK="" +for i in $(seq 1 30); do + enc=$(KC -n "$NS" get secret "$SEED_SECRET" -o jsonpath='{.data.token}' 2>/dev/null || true) + if [ -n "$enc" ]; then SEED_TOK=$(printf '%s' "$enc" | base64 -d); break; fi + sleep 1 +done +[ -n "$SEED_TOK" ] || { echo " FAIL: seed token never populated"; exit 1; } +echo " seed token populated (${#SEED_TOK} bytes)" + +echo "== 4. extract the apiserver CA (base64 PEM) from the k3s kubeconfig ==" +CA_B64=$(sudo awk -F': ' '/certificate-authority-data:/{print $2; exit}' /etc/rancher/k3s/k3s.yaml | tr -d ' \r') +[ -n "$CA_B64" ] || { echo " FAIL: could not read CA from k3s.yaml"; exit 1; } +printf '%s' "$CA_B64" | base64 -d > "$CA_FILE" +echo " CA written to $CA_FILE ($(wc -c < "$CA_FILE") bytes PEM)" + +# Helper: HTTP status of a serviceaccounts GET against the REAL apiserver with a token. +k8s_auth() { + curl -s -o /dev/null -w '%{http_code}' --cacert "$CA_FILE" \ + -H "Authorization: Bearer $1" \ + "$APISERVER/api/v1/namespaces/$NS/serviceaccounts/$SA" +} + +echo " prove seed token authenticates against the real apiserver:" +code=$(k8s_auth "$SEED_TOK") +[ "$code" = 200 ] || { echo " FAIL: seed token got HTTP $code (expected 200)"; exit 1; } +echo " OK (HTTP 200)" + +echo "== 5. isolated GNUPGHOME + no-protection GPG key + gopass store ==" +rm -rf "$GNUPGHOME" "$GOPASS_HOMEDIR" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +cat > /tmp/keyparams </dev/null 2>&1 +rm -f /tmp/keyparams +KEYID=$(gpg --list-keys --with-colons lab@incredigo.invalid | awk -F: '/^pub/{print $5; exit}') +gopass init --storage fs "$KEYID" >/dev/null 2>&1 || gopass init "$KEYID" >/dev/null 2>&1 + +echo "== 6. seed driver-ready blob into gopass (CA pinned via ca=) ==" +# Blob: k8s://host/?ns&sa&secret&token&ca — single line, no plaintext on disk. +BLOB="k8s://127.0.0.1:6443/?ns=$NS&sa=$SA&secret=$SEED_SECRET&token=$SEED_TOK&ca=$CA_B64" +printf '%s' "$BLOB" | gopass insert --multiline=false -f imported/k8s/labsa >/dev/null +gopass ls --flat | sed 's/^/ /' + +echo "== 7. rotate --execute (MANDATORY backup gate runs first) ==" +INCREDIGO_ALLOW_EXECUTE=1 incredigo rotate --execute --prefix imported/ 2>&1 | sed 's/^/ /' + +echo "== 8. CUTOVER ASSERTIONS ==" +NEW_BLOB=$(gopass show -o imported/k8s/labsa) +NEW_TOK=$(printf '%s' "$NEW_BLOB" | sed -E 's/.*[?&]token=([^&]*).*/\1/') + +fail=0 +# The kube-apiserver caches SUCCESSFUL token authentications for ~10s +# (cachedTokenAuthenticator). The seed token authenticated at step 4, so the +# Secret deletion only takes effect once that cache entry expires. Poll up to 30s. +echo " [k8s] OLD token must now FAIL against the real apiserver (waiting out the ~10s auth cache):" +old_dead=0 +for i in $(seq 1 30); do + code=$(k8s_auth "$SEED_TOK") + if [ "$code" != 200 ]; then old_dead=1; echo " OK: old token rejected (HTTP $code after ${i}s)"; break; fi + sleep 1 +done +[ "$old_dead" = 1 ] || { echo " FAIL: old token still authenticates after 30s"; fail=1; } +echo " [k8s] NEW token must WORK:" +code=$(k8s_auth "$NEW_TOK") +if [ "$code" = 200 ]; then echo " OK: new token authenticates (HTTP 200)" +else echo " FAIL: new token got HTTP $code"; fail=1; fi +echo " [leak] new token must NOT equal old:" +[ "$NEW_TOK" != "$SEED_TOK" ] && echo " OK: token actually changed" || { echo " FAIL: token unchanged"; fail=1; } + +echo +if [ "$fail" = 0 ]; then echo "ALL K8S CUTOVER ASSERTIONS PASSED"; else echo "SOME ASSERTIONS FAILED"; exit 1; fi diff --git a/lab/lab-provision-keepass.sh b/lab/lab-provision-keepass.sh new file mode 100755 index 0000000..58a331e --- /dev/null +++ b/lab/lab-provision-keepass.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Live-VM POC for the KeePassXC password-manager adapter (Phase B `passwords` engine), +# inside the sandbox VM. FAKE logins only, throwaway .kdbx database. +# +# Flow proven here: +# 1. install the REAL keepassxc-cli +# 2. create a throwaway .kdbx (known master pass) + seed 2 login entries (weak pws) +# 3. incredigo passwords scan -> lists 2 logins + change links (no secrets) +# 4. incredigo passwords plan -> MANDATORY verified sealed backup + staged new +# strong passwords (.age) + secrets-free worklist +# 5. incredigo passwords commit -> in-place edit of BOTH entries via keepassxc-cli +# 6. ASSERT: both entry passwords changed, OLD gone; backup .age decrypts to OLD values +set -euo pipefail +export PATH=/usr/local/bin:$PATH + +# Seal passphrase (age backup/stage) and the kdbx master — both env-driven => headless. +export INCREDIGO_PASSPHRASE='lab-seal-passphrase-001' +export INCREDIGO_KDBX_PASSPHRASE='db-master-pass-001' + +DB="$HOME/lab.kdbx" +OLD1='weakpw-github-0001' # fake starting passwords +OLD2='weakpw-gitlab-0002' +WORK="$HOME/kp-work"; rm -rf "$WORK"; mkdir -p "$WORK" +STAGE="$WORK/stage.age" +BACKUP="$WORK/backup.age" +WORKLIST="$WORK/worklist.md" +AUDIT="$WORK/audit.jsonl" + +echo "== 0. install keepassxc-cli (real tool) ==" +if ! command -v keepassxc-cli >/dev/null 2>&1; then + sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq keepassxc >/dev/null 2>&1 || \ + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq keepassxc-cli >/dev/null 2>&1 +fi +keepassxc-cli --version | sed 's/^/ keepassxc-cli /' + +echo "== 1. throwaway .kdbx + 2 fake login entries ==" +rm -f "$DB" +# db-create: stdin carries master pass then its confirmation. +printf '%s\n%s\n' "$INCREDIGO_KDBX_PASSPHRASE" "$INCREDIGO_KDBX_PASSPHRASE" \ + | keepassxc-cli db-create -p "$DB" >/dev/null 2>&1 +# add -p: stdin carries the unlock pass then the new entry password. +printf '%s\n%s\n' "$INCREDIGO_KDBX_PASSPHRASE" "$OLD1" \ + | keepassxc-cli add --username 'alice@example.com' --url 'https://github.com/' -p "$DB" 'GitHub' >/dev/null 2>&1 +printf '%s\n%s\n' "$INCREDIGO_KDBX_PASSPHRASE" "$OLD2" \ + | keepassxc-cli add --username 'bob@example.com' --url 'https://gitlab.com/' -p "$DB" 'GitLab' >/dev/null 2>&1 +echo " entries in db:" +printf '%s\n' "$INCREDIGO_KDBX_PASSPHRASE" | keepassxc-cli ls "$DB" 2>/dev/null | sed 's/^/ /' + +echo "== 2. passwords scan (no secrets, commits nothing) ==" +incredigo passwords --manager keepassxc --kdbx "$DB" scan --audit-log "$AUDIT" 2>&1 | sed 's/^/ /' + +echo "== 3. passwords plan (verified sealed backup + staged new pws + worklist) ==" +incredigo passwords --manager keepassxc --kdbx "$DB" plan \ + --backup-out "$BACKUP" --stage-out "$STAGE" --worklist-out "$WORKLIST" \ + --audit-log "$AUDIT" 2>&1 | sed 's/^/ /' +echo " --- worklist (must be secrets-free) ---" +sed 's/^/ /' "$WORKLIST" + +echo "== 4. ASSERT staged + backup bundles are NOT plaintext ==" +fail=0 +for blob in "$STAGE" "$BACKUP"; do + if grep -qaF "$OLD1" "$blob" || grep -qaF "$OLD2" "$blob"; then + echo " FAIL: plaintext password found in $blob"; fail=1 + else + echo " OK: $(basename "$blob") carries no plaintext old password" + fi +done + +echo "== 5. passwords commit --verified all (in-place edit of both entries) ==" +incredigo passwords --manager keepassxc --kdbx "$DB" commit \ + --stage-in "$STAGE" --verified all --backup-out "$WORK/backup2.age" \ + --audit-log "$AUDIT" 2>&1 | sed 's/^/ /' + +echo "== 6. CUTOVER ASSERTIONS (re-export the live db) ==" +CSV=$(printf '%s\n' "$INCREDIGO_KDBX_PASSPHRASE" | keepassxc-cli export -f csv "$DB" 2>/dev/null) +NEW1=$(printf '%s' "$CSV" | awk -F',' 'NR>1 && /GitHub/ {gsub(/"/,"",$4); print $4; exit}') +NEW2=$(printf '%s' "$CSV" | awk -F',' 'NR>1 && /GitLab/ {gsub(/"/,"",$4); print $4; exit}') +echo " GitHub new pw len=${#NEW1} GitLab new pw len=${#NEW2}" + +[ -n "$NEW1" ] && [ "$NEW1" != "$OLD1" ] && echo " OK: GitHub password changed" || { echo " FAIL: GitHub unchanged"; fail=1; } +[ -n "$NEW2" ] && [ "$NEW2" != "$OLD2" ] && echo " OK: GitLab password changed" || { echo " FAIL: GitLab unchanged"; fail=1; } +printf '%s' "$CSV" | grep -qaF "$OLD1" && { echo " FAIL: GitHub OLD pw still present"; fail=1; } || echo " OK: GitHub OLD pw gone" +printf '%s' "$CSV" | grep -qaF "$OLD2" && { echo " FAIL: GitLab OLD pw still present"; fail=1; } || echo " OK: GitLab OLD pw gone" +# strong (length 20 default) +[ "${#NEW1}" -ge 16 ] && echo " OK: GitHub new pw is strong (len ${#NEW1})" || { echo " FAIL: GitHub new pw too short"; fail=1; } + +echo "== 7. RECOVERABILITY: restore OLD passwords from the sealed backup ==" +# The plan-stage backup.age is itself a staged list whose Secret = the OLD password. +# Feeding it back through `commit --stage-in` proves the backup is a usable restore +# artifact: it should re-write both entries back to their original values. +incredigo passwords --manager keepassxc --kdbx "$DB" commit \ + --stage-in "$BACKUP" --verified all --backup-out "$WORK/backup3.age" \ + --audit-log "$AUDIT" 2>&1 | sed 's/^/ /' +CSV2=$(printf '%s\n' "$INCREDIGO_KDBX_PASSPHRASE" | keepassxc-cli export -f csv "$DB" 2>/dev/null) +printf '%s' "$CSV2" | grep -qaF "$OLD1" && echo " OK: GitHub OLD pw restored from backup" || { echo " FAIL: GitHub not restored"; fail=1; } +printf '%s' "$CSV2" | grep -qaF "$OLD2" && echo " OK: GitLab OLD pw restored from backup" || { echo " FAIL: GitLab not restored"; fail=1; } +printf '%s' "$CSV2" | grep -qaF "$NEW1" && { echo " FAIL: post-restore still has NEW pw"; fail=1; } || echo " OK: NEW pw gone after restore" + +echo +if [ "$fail" = 0 ]; then echo "ALL KEEPASSXC CUTOVER ASSERTIONS PASSED"; else echo "SOME ASSERTIONS FAILED"; exit 1; fi diff --git a/lab/lab-provision-mongo.sh b/lab/lab-provision-mongo.sh new file mode 100755 index 0000000..407aeb7 --- /dev/null +++ b/lab/lab-provision-mongo.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Live cutover POC for the MongoDB in-place rotation driver, inside the sandbox VM. +# FAKE creds only, isolated gopass store. Installs REAL mongod + mongosh (MongoDB +# 8.0 from the official apt repo), creates labapp@admin owning labdb, seeds the +# driver-ready mongodb URI blob, runs `rotate --execute`, and asserts the OLD +# password stops authenticating while the NEW one (read back from gopass) works. +# +# This promotes the mongo driver from MOCK-ONLY to LIVE-VM: the real mongod (not the +# fake-mongosh stub) validates the changeUserPassword cutover. +set -euo pipefail + +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +export INCREDIGO_PASSPHRASE='lab-seal-passphrase-001' +MONGO_PW="oldmg$(date +%s | tail -c 5)" + +echo "== 0. ensure real mongod + mongosh are installed (MongoDB 8.0 official repo) ==" +if ! command -v mongod >/dev/null 2>&1 || ! command -v mongosh >/dev/null 2>&1; then + . /etc/os-release + curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc \ + | sudo gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg --dearmor --yes + echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu ${VERSION_CODENAME}/mongodb-org/8.0 multiverse" \ + | sudo tee /etc/apt/sources.list.d/mongodb-org-8.0.list >/dev/null + sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq mongodb-org >/dev/null +fi +mongod --version | head -1 +mongosh --version + +echo "== 1. start mongod (auth DISABLED for the initial user bootstrap) ==" +# Run with a clean conf, no auth yet, so we can create the bootstrap user. +sudo sed -i 's/^\(\s*\)authorization:.*/\1authorization: disabled/' /etc/mongod.conf 2>/dev/null || true +sudo systemctl restart mongod +for i in $(seq 1 30); do + mongosh --quiet --norc 127.0.0.1:27017/admin --eval 'db.runCommand({ping:1}).ok' >/dev/null 2>&1 && break + sleep 1 +done +mongosh --quiet --norc 127.0.0.1:27017/admin --eval 'db.runCommand({ping:1})' >/dev/null + +echo "== 2. isolated GNUPGHOME + no-protection GPG key + gopass store ==" +rm -rf "$GNUPGHOME" "$GOPASS_HOMEDIR" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +cat > /tmp/keyparams </dev/null 2>&1 +rm -f /tmp/keyparams +KEYID=$(gpg --list-keys --with-colons lab@incredigo.invalid | awk -F: '/^pub/{print $5; exit}') +gopass init --storage fs "$KEYID" >/dev/null 2>&1 || gopass init "$KEYID" >/dev/null 2>&1 + +echo "== 3. create labapp in admin (can change its own pw), starting pw=$MONGO_PW ==" +# Drop any prior user, recreate. userAdminAnyDatabase lets labapp run +# changeUserPassword on itself (the driver's cutover). Script on STDIN — no argv secret. +cat </dev/null +else + printf '\nsecurity:\n authorization: enabled\n' | sudo tee -a /etc/mongod.conf >/dev/null +fi +sudo systemctl restart mongod +for i in $(seq 1 30); do + mongosh --quiet --norc 127.0.0.1:27017/admin --eval 'db.runCommand({ping:1}).ok' >/dev/null 2>&1 && break + sleep 1 +done + +# Helper: prints AUTH-OK / AUTH-FAIL for a given password (script on stdin, no argv). +# Real mongosh echoes its "labdb> " prompt to stdout on a pipe, so filter to the token. +mongo_auth() { + cat </dev/null | grep -Eo 'AUTH-OK|AUTH-FAIL' | head -n1 +try { var ok = db.getSiblingDB("admin").auth("labapp","$1"); print(ok ? "AUTH-OK" : "AUTH-FAIL"); } +catch (e) { print("AUTH-FAIL"); } +JS +} + +echo " prove starting mongo pw authenticates (auth now enforced):" +ok=0 +for i in $(seq 1 15); do + if [ "$(mongo_auth "$MONGO_PW")" = "AUTH-OK" ]; then ok=1; break; fi + sleep 1 +done +[ "$ok" = 1 ] || { echo " FAIL: starting pw did not authenticate"; exit 1; } +echo " OK" + +echo "== 5. seed driver-ready blob into gopass (no plaintext on disk) ==" +printf 'mongodb://labapp:%s@127.0.0.1:27017/labdb?authSource=admin' "$MONGO_PW" \ + | gopass insert --multiline=false -f imported/mongo/labapp >/dev/null +gopass ls --flat | sed 's/^/ /' + +echo "== 6. rotate --execute (MANDATORY backup gate runs first) ==" +INCREDIGO_ALLOW_EXECUTE=1 incredigo rotate --execute --prefix imported/ 2>&1 | sed 's/^/ /' + +echo "== 7. CUTOVER ASSERTIONS ==" +NEW_URI=$(gopass show -o imported/mongo/labapp) +NEW_PW=$(printf '%s' "$NEW_URI" | sed -E 's#.*://[^:]*:([^@]*)@.*#\1#') + +fail=0 +echo " [mongo] OLD pw must now FAIL:" +if [ "$(mongo_auth "$MONGO_PW")" = "AUTH-OK" ]; then + echo " FAIL: old mongo pw still authenticates"; fail=1 +else echo " OK: old mongo pw rejected"; fi +echo " [mongo] NEW pw must WORK:" +if [ "$(mongo_auth "$NEW_PW")" = "AUTH-OK" ]; then + echo " OK: new mongo pw authenticates"; else echo " FAIL: new mongo pw rejected"; fail=1; fi +echo " [leak] new password must NOT equal old:" +[ "$NEW_PW" != "$MONGO_PW" ] && echo " OK: password actually changed" || { echo " FAIL: pw unchanged"; fail=1; } + +echo +if [ "$fail" = 0 ]; then echo "ALL MONGO CUTOVER ASSERTIONS PASSED"; else echo "SOME ASSERTIONS FAILED"; exit 1; fi diff --git a/lab/lab-provision-pg.sh b/lab/lab-provision-pg.sh new file mode 100755 index 0000000..ad51d71 --- /dev/null +++ b/lab/lab-provision-pg.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Provision the incredigo postgres-rotation POC inside the sandbox VM. +# FAKE creds only, isolated gopass store. Idempotent-ish (drops/recreates role+db). +set -euo pipefail + +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +INIT_PW="oldpw$(date +%s | tail -c 4)" # fake starting password +ENTRY="imported/postgres/labapp" + +echo "== 1. isolated GNUPGHOME + no-protection GPG key ==" +rm -rf "$GNUPGHOME" "$GOPASS_HOMEDIR" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +cat > /tmp/keyparams </dev/null 2>&1 +rm -f /tmp/keyparams +KEYID=$(gpg --list-keys --with-colons lab@incredigo.invalid | awk -F: '/^pub/{print $5; exit}') +echo " key: $KEYID" + +echo "== 2. isolated gopass store ==" +gopass init --storage fs "$KEYID" >/dev/null 2>&1 || gopass init "$KEYID" >/dev/null 2>&1 +gopass ls >/dev/null 2>&1 || true + +echo "== 3. postgres role + db (labapp owns labdb), starting pw=$INIT_PW ==" +sudo -u postgres psql -v ON_ERROR_STOP=1 </dev/null +DROP DATABASE IF EXISTS labdb; +DROP ROLE IF EXISTS labapp; +CREATE ROLE labapp LOGIN PASSWORD '$INIT_PW'; +CREATE DATABASE labdb OWNER labapp; +SQL + +echo "== 4. prove the starting password authenticates over TCP ==" +PGPASSWORD="$INIT_PW" psql -X -w -h 127.0.0.1 -p 5432 -U labapp -d labdb -tAc 'SELECT 1;' + +echo "== 5. seed the DSN into gopass (no plaintext file on disk) ==" +printf 'postgres://labapp:%s@127.0.0.1:5432/labdb' "$INIT_PW" \ + | gopass insert --multiline=false -f "$ENTRY" >/dev/null +echo " stored $ENTRY:" +gopass ls --flat | sed 's/^/ /' + +echo "DONE. starting pw recorded only in gopass; rotation will replace it." diff --git a/lab/lab-provision-wg.sh b/lab/lab-provision-wg.sh new file mode 100755 index 0000000..1597eea --- /dev/null +++ b/lab/lab-provision-wg.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Live cutover POC for the WireGuard self-hosted rotation driver, inside the sandbox +# VM. FAKE keys only, isolated gopass store. Sets up two local wg interfaces (wg0 = +# ours, wg1 = the peer that trusts us), seeds wg0's old private key into gopass, runs +# `rotate --execute`, and asserts the peer now trusts the NEW public key (and only it), +# AND that 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`. +set -euo pipefail + +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +export INCREDIGO_PASSPHRASE='lab-seal-passphrase-001' + +echo "== 0. install wireguard-tools ==" +command -v /usr/bin/wg >/dev/null 2>&1 || { + sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wireguard-tools >/dev/null +} + +echo "== 1. sudo wrapper so incredigo (as ubuntu) can drive wg with root ==" +echo 'ubuntu ALL=(root) NOPASSWD: /usr/bin/wg' | sudo tee /etc/sudoers.d/incredigo-wg >/dev/null +sudo tee /usr/local/bin/wg >/dev/null <<'EOF' +#!/bin/sh +exec sudo -n /usr/bin/wg "$@" +EOF +sudo chmod 755 /usr/local/bin/wg +hash -r + +echo "== 2. mint old (wg0) + peer (wg1) keys; bring up interfaces ==" +OLD_PRIV=$(/usr/bin/wg genkey) +OLD_PUB=$(printf '%s' "$OLD_PRIV" | /usr/bin/wg pubkey) +PEER_PRIV=$(/usr/bin/wg genkey) +for IF in wg0 wg1; do sudo ip link del "$IF" 2>/dev/null || true; sudo ip link add "$IF" type wireguard; done +printf '%s' "$OLD_PRIV" | sudo /usr/bin/wg set wg0 private-key /dev/stdin +printf '%s' "$PEER_PRIV" | sudo /usr/bin/wg set wg1 private-key /dev/stdin +# wg1 (peer) trusts wg0's OLD public key with some allowed-ips. +sudo /usr/bin/wg set wg1 peer "$OLD_PUB" allowed-ips 10.0.0.1/32 +echo " wg1 trusts (before): $(sudo /usr/bin/wg show wg1 peers)" +echo " wg0 iface pubkey (before): $(sudo /usr/bin/wg show wg0 public-key) [== OLD_PUB? $OLD_PUB]" + +echo "== 3. isolated GNUPGHOME + no-protection GPG key + gopass store ==" +rm -rf "$GNUPGHOME" "$GOPASS_HOMEDIR" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +cat > /tmp/keyparams </dev/null 2>&1 +rm -f /tmp/keyparams +KEYID=$(gpg --list-keys --with-colons lab@incredigo.invalid | awk -F: '/^pub/{print $5; exit}') +gopass init --storage fs "$KEYID" >/dev/null 2>&1 || gopass init "$KEYID" >/dev/null 2>&1 + +echo "== 4. seed wg0's old private key into gopass (driver-ready blob) ==" +printf '%s' "$OLD_PRIV" | gopass insert --multiline=false -f imported/wireguard/wg0 >/dev/null +gopass ls --flat | sed 's/^/ /' + +echo "== 5. rotate --execute (MANDATORY backup gate runs first) ==" +# NOTE: prefix must be the imported/ ROOT — Mode A derives the driver Source from the +# FIRST path segment after the prefix (imported//). Narrowing to +# imported/wireguard/ would make Source="wg0" and no driver would Detect it. +INCREDIGO_ALLOW_EXECUTE=1 incredigo rotate --execute --prefix imported/ 2>&1 | sed 's/^/ /' + +echo "== 6. CUTOVER ASSERTIONS ==" +NEW_PRIV=$(gopass show -o imported/wireguard/wg0) +NEW_PUB=$(printf '%s' "$NEW_PRIV" | /usr/bin/wg pubkey) +PEERS_AFTER=$(sudo /usr/bin/wg show wg1 peers) +IFACE_PUB=$(sudo /usr/bin/wg show wg0 public-key) +echo " wg1 trusts (after): $PEERS_AFTER" +echo " wg0 iface pubkey (after): $IFACE_PUB" + +fail=0 +[ "$NEW_PRIV" != "$OLD_PRIV" ] && echo " OK: private key changed" || { echo " FAIL: private key unchanged"; fail=1; } +echo "$PEERS_AFTER" | grep -qF "$NEW_PUB" && echo " OK: peer trusts NEW pubkey" || { echo " FAIL: peer missing NEW pubkey"; fail=1; } +echo "$PEERS_AFTER" | grep -qF "$OLD_PUB" && { echo " FAIL: peer still trusts OLD pubkey"; fail=1; } || echo " OK: peer dropped OLD pubkey" +[ "$IFACE_PUB" = "$NEW_PUB" ] && echo " OK: wg0 live pubkey == Go-derived NEW pubkey (curve25519 matches real wg)" || { echo " FAIL: wg0 pubkey != derived"; fail=1; } + +echo +if [ "$fail" = 0 ]; then echo "ALL WIREGUARD CUTOVER ASSERTIONS PASSED"; else echo "SOME ASSERTIONS FAILED"; exit 1; fi diff --git a/lab/lab-run.sh b/lab/lab-run.sh new file mode 100755 index 0000000..1d9efe9 --- /dev/null +++ b/lab/lab-run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Exercise incredigo end-to-end against the fake lab store. Read-mostly; the only +# writes are into the throwaway gopass store and sealed bundles in the lab dir. +set -uo pipefail +export PATH=/usr/local/bin:$PATH +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_NO_NOTIFY=true +export INCREDIGO_PASSPHRASE='lab-seal-pass-correct-horse' +cd "$HOME/lab" + +run() { echo; echo "############ $* ############"; "$@"; echo "---- exit=$? ----"; } + +run incredigo scan +run incredigo status +run incredigo migrate --dedupe +echo; echo "## gopass store after migrate:"; gopass ls --flat +run incredigo export --prefix imported/ --out "$HOME/lab/backup.age" +run incredigo import --in "$HOME/lab/backup.age" --prefix restored/ +echo; echo "## store after import:"; gopass ls --flat +run incredigo rotate --prefix imported/ --dry-run --blast --blast-root "$HOME/lab" +run incredigo worklist +echo; echo "LAB_RUN_DONE" diff --git a/lab/lab-store.sh b/lab/lab-store.sh new file mode 100755 index 0000000..b58073e --- /dev/null +++ b/lab/lab-store.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# incredigo sandbox — gopass store + fake creds (real gopasspw/gopass already installed) +set -euo pipefail +export PATH=/usr/local/bin:$PATH +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_NO_NOTIFY=true + +rm -rf "$GOPASS_HOMEDIR" "$GNUPGHOME" +mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +mkdir -p "$GOPASS_HOMEDIR" + +echo "== throwaway GPG key ==" +cat > /tmp/key.batch <<'EOF' +%no-protection +Key-Type: eddsa +Key-Curve: ed25519 +Subkey-Type: ecdh +Subkey-Curve: cv25519 +Name-Real: Incredigo Lab +Name-Email: lab@incredigo.local +Expire-Date: 0 +%commit +EOF +gpg --batch --generate-key /tmp/key.batch 2>/dev/null +FPR=$(gpg --list-keys --with-colons lab@incredigo.local | awk -F: '/^fpr:/{print $10; exit}') +echo "key: $FPR" + +echo "== gopass init (isolated) ==" +gopass init --crypto gpg --storage fs "$FPR" &1 | tail -3 || gopass init "$FPR" &1 | tail -3 + +echo "== seed fake imported/ entries ==" +printf 'wJalrFAKEsecretKEYwJalrFAKEsecretKEY0001\n' | gopass insert --multiline=false -f imported/aws/default +printf 'ghp_FAKEtoken000000000000000000000001\n' | gopass insert --multiline=false -f imported/github/pat +printf 'lab-db-PASSWORD-0001\n' | gopass insert --multiline=false -f imported/db/postgres +echo "store now holds:"; gopass ls --flat + +echo "== sample on-disk creds (fake) ==" +LAB="$HOME/lab"; mkdir -p "$LAB"; cd "$LAB" +cat > .env <<'EOF' +API_TOKEN=tok_FAKE_4f9c2a7e8b1d6033aa55ee +DATABASE_URL=postgres://app:lab-db-PASSWORD-0001@db.lab.local:5432/app +SENDGRID_API_KEY=SG.FAKE0001.aaaaaaaaaaaaaaaaaaaaaa +PORT=8080 +EOF +mkdir -p "$HOME/.aws" +cat > "$HOME/.aws/credentials" <<'EOF' +[default] +aws_access_key_id = AKIALAB000000FAKE0001 +aws_secret_access_key = wJalrFAKEsecretKEYwJalrFAKEsecretKEY0001 +EOF +cat > docker-compose.yml <<'EOF' +services: + app: + environment: + - API_TOKEN=${API_TOKEN} + - DATABASE_URL=${DATABASE_URL} +EOF +mkdir -p k8s +cat > k8s/deploy.yaml <<'EOF' +env: + - name: API_TOKEN + valueFrom: { secretKeyRef: { name: app, key: API_TOKEN } } + - name: SENDGRID_API_KEY + valueFrom: { secretKeyRef: { name: app, key: SENDGRID_API_KEY } } +EOF +echo "lab dir: $LAB"; ls -la "$LAB" +echo "STORE_OK" diff --git a/lab/lab-verify-aws.sh b/lab/lab-verify-aws.sh new file mode 100755 index 0000000..24995c5 --- /dev/null +++ b/lab/lab-verify-aws.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Assert the AWS cutover after `incredigo rotate --execute`. +set -euo pipefail +export GNUPGHOME="$HOME/.lab-gnupg" +export GOPASS_HOMEDIR="$HOME/.lab-gopass" +VENV="$HOME/moto-venv" +EP="http://127.0.0.1:5000" +OLD=$(cat "$HOME/.lab-aws-oldkey") +ENTRY="imported/aws/labkey" + +# New key id from the rotated gopass blob (strip userinfo: between :// and first @, +# the id is before the first ':'). +NEWBLOB=$(gopass show -o "$ENTRY") +NEW=$(printf '%s' "$NEWBLOB" | sed -E 's#^aws://([^:]+):.*#\1#') + +echo "OLD AccessKeyId: $OLD" +echo "NEW AccessKeyId: $NEW" + +"$VENV/bin/python" - "$EP" "$OLD" "$NEW" <<'PY' +import sys, boto3 +ep, old, new = sys.argv[1], sys.argv[2], sys.argv[3] +a = boto3.client("iam", endpoint_url=ep, region_name="us-east-1", + aws_access_key_id="admin", aws_secret_access_key="admin") +keys = [k["AccessKeyId"] for k in a.list_access_keys(UserName="labapp")["AccessKeyMetadata"]] +print("labapp keys after rotate:", keys) +ok = True +if old in keys: + print("FAIL: old key still present"); ok = False +else: + print("PASS: old key deleted at provider") +if new not in keys: + print("FAIL: new key absent"); ok = False +else: + print("PASS: new key present at provider") +if old == new: + print("FAIL: old == new"); ok = False +else: + print("PASS: old != new") +sys.exit(0 if ok else 1) +PY + +echo "-- audit-log leak check (no key material) --" +AUD="$HOME/rotate-audit.jsonl" +if [ -f "$AUD" ]; then + if grep -qF "$OLD" "$AUD" || grep -qF "$NEW" "$AUD"; then + echo "FAIL: AccessKeyId found in audit log"; exit 1 + fi + # secret material check: pull the new secret from the blob and grep + NSEC=$(printf '%s' "$NEWBLOB" | sed -E 's#^aws://[^:]+:([^@]+)@.*#\1#' | python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read()))') + if [ -n "$NSEC" ] && grep -qF "$NSEC" "$AUD"; then + echo "FAIL: SecretAccessKey found in audit log"; exit 1 + fi + echo "PASS: audit log carries no key id or secret" +else + echo "note: no audit log at $AUD" +fi +echo "ALL CUTOVER ASSERTIONS PASSED" diff --git a/lab/moto-probe.py b/lab/moto-probe.py new file mode 100644 index 0000000..44f72f5 --- /dev/null +++ b/lab/moto-probe.py @@ -0,0 +1,56 @@ +import subprocess, time, sys, boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +EP="http://127.0.0.1:5000" +srv=subprocess.Popen(["/home/ubuntu/moto-venv/bin/moto_server","-p","5000"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +time.sleep(3) +def iam(k=None,s=None): + return boto3.client("iam",endpoint_url=EP,region_name="us-east-1", + aws_access_key_id=k or "admin",aws_secret_access_key=s or "admin") +def sts(k,s): + return boto3.client("sts",endpoint_url=EP,region_name="us-east-1", + aws_access_key_id=k,aws_secret_access_key=s) +try: + a=iam() + a.create_user(UserName="labapp") + ak=a.create_access_key(UserName="labapp")["AccessKey"] + oid,osec=ak["AccessKeyId"],ak["SecretAccessKey"] + print("seed key:",oid) + + # Q1: CreateAccessKey WITHOUT UserName, signed by the seed key + try: + nk=iam(oid,osec).create_access_key()["AccessKey"] + print("Q1 create-without-username: OK ->",nk["AccessKeyId"]) + nid,nsec=nk["AccessKeyId"],nk["SecretAccessKey"] + except ClientError as e: + print("Q1 create-without-username: FAIL", e.response["Error"]["Code"], e.response["Error"]["Message"][:80]) + nid=nsec=None + + # Q2: GetCallerIdentity with seed key + try: + ci=sts(oid,osec).get_caller_identity() + print("Q2 sts seed:", ci.get("Arn")) + except ClientError as e: + print("Q2 sts seed FAIL", e.response["Error"]["Code"]) + + # Q3: DeleteAccessKey WITHOUT UserName (delete seed itself), signed by seed + try: + iam(oid,osec).delete_access_key(AccessKeyId=oid) + print("Q3 delete-without-username (self): OK") + except ClientError as e: + print("Q3 delete-without-username: FAIL", e.response["Error"]["Code"], e.response["Error"]["Message"][:80]) + + # Q4: after deleting seed, does it still authenticate to STS / IAM? + try: + ci=sts(oid,osec).get_caller_identity() + print("Q4 sts old-after-delete: STILL WORKS", ci.get("Arn")) + except ClientError as e: + print("Q4 sts old-after-delete: rejected", e.response["Error"]["Code"]) + + # Q5: list access keys (admin) — is old gone, new present? + keys=[k["AccessKeyId"] for k in iam().list_access_keys(UserName="labapp")["AccessKeyMetadata"]] + print("Q5 list after delete:", keys, "| old-present:", oid in keys, "| new-present:", nid in keys if nid else "n/a") +finally: + srv.terminate() diff --git a/lab/tui-probe.py b/lab/tui-probe.py new file mode 100644 index 0000000..e3c7673 --- /dev/null +++ b/lab/tui-probe.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# Drive the incredigo `guide` Bubble Tea TUI under a REAL pty so it renders genuine +# frames, send a scripted key sequence, and assert on what it draws. FAKE creds only. +# Run unbuffered (python3 -u). A SIGALRM watchdog guarantees it can never hang. +# +# argv: +import os, pty, re, select, sys, time, fcntl, termios, struct, signal + +ANSI = re.compile(rb'\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1b[=>]') +def clean(b): return ANSI.sub(b'', b).decode('utf-8', 'replace') + +GUIDE_ARGS = sys.argv[1:] +ENV = dict(os.environ, + GOPASS_HOMEDIR=os.path.expanduser('~/.lab-gopass'), + GNUPGHOME=os.path.expanduser('~/.lab-gnupg'), + INCREDIGO_PASSPHRASE='lab-seal-passphrase-001', + PATH='/usr/local/bin:' + os.environ.get('PATH', ''), + TERM='xterm-256color') + +pid, fd = pty.fork() +if pid == 0: + os.execvpe('incredigo', ['incredigo', 'guide', *GUIDE_ARGS], ENV) + os._exit(127) +fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', 40, 120, 0, 0)) + +# watchdog: kill everything after 25s no matter what, then report +def boom(*_): + print(" WATCHDOG: timed out — killing child", flush=True) + try: os.kill(pid, signal.SIGKILL) + except OSError: pass + print("\nTUI-PROBE FAILS: ['watchdog timeout']", flush=True) + os._exit(2) +signal.signal(signal.SIGALRM, boom); signal.alarm(25) + +buf = bytearray() +def respond(data): + # Act like a real terminal: answer the queries termenv/bubbletea send before + # it will render, otherwise it blocks forever waiting for the reply. + if b'\x1b[6n' in data: # DSR cursor-position request + os.write(fd, b'\x1b[24;80R') + if b'\x1b]11;?' in data: # OSC 11 background-color query + os.write(fd, b'\x1b]11;rgb:1c1c/1c1c/1c1c\x1b\\') + if b'\x1b]10;?' in data: # OSC 10 foreground-color query + os.write(fd, b'\x1b]10;rgb:c0c0/c0c0/c0c0\x1b\\') + if b'\x1b[c' in data or b'\x1b[0c' in data: # Primary Device Attributes + os.write(fd, b'\x1b[?1;2c') +def drain(t=0.6): + end = time.time() + t + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.15) + if r: + try: data = os.read(fd, 65536) + except OSError: break + if not data: break + respond(data); buf.extend(data); end = time.time() + t + return clean(bytes(buf)) +def send(keys): + os.write(fd, keys); time.sleep(0.25) + +fails = [] +def check(cond, label): + print((' OK: ' if cond else ' FAIL: ') + label, flush=True) + if not cond: fails.append(label) +def cur(scr): + # the LAST 'X of N' in the accumulated buffer is the currently-visible frame + ms = re.findall(r'(\d+) of (\d+)', scr) + return ms[-1] if ms else None + +print(">> initial frame", flush=True) +scr = drain(1.5) +print(" (captured %d chars)" % len(scr), flush=True) +check('guided rotation' in scr, "header 'guided rotation' rendered") +m = re.search(r'(\d+) of (\d+)', scr) +total = int(m.group(2)) if m else 0 +print(" parsed total =", total, flush=True) +check(m is not None and m.group(1) == '1', "starts at item 1") +check('0 marked done' in scr, "starts with 0 marked done") +check('Read-only' in scr, "read-only notice shown") +check('manual' in scr or 'auto' in scr, "rotation label (auto/manual) rendered") +buf.clear() + +print(">> n,n -> item 3", flush=True) +send(b'n'); send(b'n'); scr = drain(0.5) +c = cur(scr) +check(c is not None and c[0] == '3', "after n,n -> item 3") +buf.clear() + +print(">> prev clamp at top", flush=True) +for _ in range(5): send(b'p') +scr = drain(0.5) +c = cur(scr) +check(c is not None and c[0] == '1', "prev past top clamps at item 1") +buf.clear() + +print(">> enter marks done", flush=True) +send(b'\r'); scr = drain(0.5) +check('done' in scr and '1 marked done' in scr, "enter -> ✓ done, count 1, advances") +buf.clear() + +print(">> s skips", flush=True) +send(b's'); scr = drain(0.5) +check('1 marked done' in scr, "s skips, count still 1") +buf.clear() + +print(">> next clamp at end", flush=True) +for _ in range(total + 2): send(b'n') +scr = drain(0.5) +c = cur(scr) +check(c is not None and c[0] == str(total), "next past end clamps at item %d" % total) +buf.clear() + +print(">> 'o' open-link no crash", flush=True) +send(b'o'); scr = drain(0.6) +check('guided rotation' in scr or scr.strip() == '', "'o' did not crash TUI") +buf.clear() + +print(">> quit with q", flush=True) +send(b'q'); drain(0.6) +# reap with bounded wait; escalate if needed +ok = False +for _ in range(20): + wpid, status = os.waitpid(pid, os.WNOHANG) + if wpid == pid: + ok = os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 + break + time.sleep(0.1) +else: + os.kill(pid, signal.SIGKILL); os.waitpid(pid, 0) +check(ok, "q quits cleanly (exit 0)") + +signal.alarm(0) +print("\nTUI-PROBE FAILS:", fails if fails else "none", flush=True) +sys.exit(1 if fails else 0) diff --git a/lab/vw-register.py b/lab/vw-register.py new file mode 100644 index 0000000..2b143c1 --- /dev/null +++ b/lab/vw-register.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# Programmatically register a Bitwarden account on a self-hosted Vaultwarden, replicating +# the client-side registration crypto so the `bw` CLI can later log in and DECRYPT items. +# Run with the moto-venv python (has `cryptography`). FAKE lab account only. +# +# argv: email password name base_url +# +# Bitwarden registration (PBKDF2-SHA256 KDF): +# masterKey = PBKDF2(password, email, iters, 32) +# masterPasswordHash = b64(PBKDF2(masterKey, password, 1, 32)) +# stretched enc/mac = HKDF-Expand(masterKey, "enc"/"mac", 32) +# userKey (64B random) = 32B enc || 32B mac (the account symmetric key) +# "key" = EncString(userKey, stretched.enc, stretched.mac) (type 2) +# RSA keypair = encryptedPrivateKey EncString(PKCS8, userKey.enc, userKey.mac) +import base64, json, os, ssl, sys, urllib.request, urllib.error +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand +from cryptography.hazmat.primitives import hashes, hmac, serialization +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.padding import PKCS7 +from cryptography.hazmat.primitives.asymmetric import rsa + +email, password, name, base_url = sys.argv[1].lower(), sys.argv[2], sys.argv[3], sys.argv[4].rstrip("/") +ITERS = 600000 + +def pbkdf2(pw, salt, iterations, length=32): + return PBKDF2HMAC(algorithm=hashes.SHA256(), length=length, salt=salt, iterations=iterations).derive(pw) + +def hkdf_expand(prk, info, length=32): + return HKDFExpand(algorithm=hashes.SHA256(), length=length, info=info.encode()).derive(prk) + +def enc_string(plaintext, enc_key, mac_key): + iv = os.urandom(16) + p = PKCS7(128).padder() + data = p.update(plaintext) + p.finalize() + enc = Cipher(algorithms.AES(enc_key), modes.CBC(iv)).encryptor() + ct = enc.update(data) + enc.finalize() + h = hmac.HMAC(mac_key, hashes.SHA256()); h.update(iv + ct); mac = h.finalize() + return "2.%s|%s|%s" % (base64.b64encode(iv).decode(), + base64.b64encode(ct).decode(), + base64.b64encode(mac).decode()) + +master_key = pbkdf2(password.encode(), email.encode(), ITERS, 32) +master_password_hash = base64.b64encode(pbkdf2(master_key, password.encode(), 1, 32)).decode() +s_enc, s_mac = hkdf_expand(master_key, "enc"), hkdf_expand(master_key, "mac") + +user_key = os.urandom(64) +protected_key = enc_string(user_key, s_enc, s_mac) + +priv = rsa.generate_private_key(public_exponent=65537, key_size=2048) +priv_der = priv.private_bytes(serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) +pub_der = priv.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) +enc_priv = enc_string(priv_der, user_key[:32], user_key[32:]) + +payload = { + "email": email, "name": name, "masterPasswordHash": master_password_hash, + "masterPasswordHint": "", "key": protected_key, "kdf": 0, "kdfIterations": ITERS, + "keys": {"publicKey": base64.b64encode(pub_der).decode(), "encryptedPrivateKey": enc_priv}, +} +body = json.dumps(payload).encode() +# Lab Vaultwarden uses a self-signed cert — accept it (throwaway, loopback only). +ctx = ssl._create_unverified_context() if base_url.startswith("https") else None + +for path in ("/identity/accounts/register", "/api/accounts/register"): + req = urllib.request.Request(base_url + path, data=body, + headers={"Content-Type": "application/json"}, method="POST") + try: + with urllib.request.urlopen(req, timeout=15, context=ctx) as r: + print("registered via %s (HTTP %s)" % (path, r.status)); sys.exit(0) + except urllib.error.HTTPError as e: + print("HTTP %s %s -> %s" % (e.code, path, e.read().decode(errors="replace")[:400]), file=sys.stderr) + except Exception as e: + print("ERR %s -> %r" % (path, e), file=sys.stderr) +sys.exit(1)