quarantine: scrub tailnet ref, add license, ship lab/ repro kit, refresh README for cohort
ci / build-test (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-17 22:40:12 -07:00
parent 2459cd4fd8
commit ac0ff8e2af
26 changed files with 2156 additions and 236 deletions
+10
View File
@@ -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
-140
View File
@@ -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 M0M1). (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.
-45
View File
@@ -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.
+77
View File
@@ -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.
+106 -49
View File
@@ -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 migrate --prefix imported/ --dedupe # custody into gopass, no plaintext on disk
incredigo status # age vs policy
incredigo export --out ~/incredigo-backup.enc # (v1 stub)
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.
+72
View File
@@ -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).
+94
View File
@@ -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)
+77
View File
@@ -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"
+208
View File
@@ -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 >/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" <<EOF
{ "auths": { "registry.lab.local": { "auth": "$DOCKER_AUTH" } } }
EOF
cat > "$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
+83
View File
@@ -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" <<EOF
PORT=8080
DEBUG=false
SECRET_KEY=$OLD_VAL
EOF
chmod 600 "$ENV_FILE"
cat > "$YML_FILE" <<EOF
service:
name: worker
secret_key: $OLD_VAL
EOF
chmod 644 "$YML_FILE"
echo " $ENV_FILE (0600), $YML_FILE (0644) both seeded with OLD_VAL"
echo "== 2. isolated GNUPGHOME + no-protection GPG key + gopass store =="
rm -rf "$GNUPGHOME" "$GOPASS_HOMEDIR"
mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME"
cat > /tmp/keyparams <<EOF
%no-protection
Key-Type: eddsa
Key-Curve: ed25519
Subkey-Type: ecdh
Subkey-Curve: cv25519
Name-Real: incredigo lab
Name-Email: lab@incredigo.invalid
Expire-Date: 0
%commit
EOF
gpg --batch --gen-key /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
+81
View File
@@ -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 <<EOF
%no-protection
Key-Type: eddsa
Key-Curve: ed25519
Subkey-Type: ecdh
Subkey-Curve: cv25519
Name-Real: incredigo lab
Name-Email: lab@incredigo.invalid
Expire-Date: 0
%commit
EOF
gpg --batch --gen-key /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"
+99
View File
@@ -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
+84
View File
@@ -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
+106
View File
@@ -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 <<EOF
%no-protection
Key-Type: eddsa
Key-Curve: ed25519
Subkey-Type: ecdh
Subkey-Curve: cv25519
Name-Real: incredigo lab
Name-Email: lab@incredigo.invalid
Expire-Date: 0
%commit
EOF
gpg --batch --gen-key /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
+117
View File
@@ -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" <<INI
APP_NAME = incredigo-lab
RUN_USER = $(whoami)
RUN_MODE = prod
WORK_PATH = $GITEA_DIR
[server]
PROTOCOL = http
HTTP_ADDR = 127.0.0.1
HTTP_PORT = 3000
ROOT_URL = http://127.0.0.1:3000/
DISABLE_SSH = true
OFFLINE_MODE = true
[database]
DB_TYPE = sqlite3
PATH = $GITEA_DIR/data/gitea.db
[repository]
ROOT = $GITEA_DIR/repos
[security]
INSTALL_LOCK = true
SECRET_KEY = labsecretkeylabsecretkey
PASSWORD_HASH_ALGO = argon2
[service]
DISABLE_REGISTRATION = true
[log]
MODE = console
LEVEL = warn
INI
export GITEA_WORK_DIR="$GITEA_DIR"
CONF="$GITEA_DIR/custom/conf/app.ini"
echo "== 2. migrate db + create admin user ($GITEA_USER) =="
gitea -c "$CONF" migrate >/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 <<EOF
%no-protection
Key-Type: eddsa
Key-Curve: ed25519
Subkey-Type: ecdh
Subkey-Curve: cv25519
Name-Real: incredigo lab
Name-Email: lab@incredigo.invalid
Expire-Date: 0
%commit
EOF
gpg --batch --gen-key /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=<tokenname>&pw=<mgmt-password>
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"
+165
View File
@@ -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 <<YAML | KC apply -f - >/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 <<YAML | KC apply -f - >/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 <<EOF
%no-protection
Key-Type: eddsa
Key-Curve: ed25519
Subkey-Type: ecdh
Subkey-Curve: cv25519
Name-Real: incredigo lab
Name-Email: lab@incredigo.invalid
Expire-Date: 0
%commit
EOF
gpg --batch --gen-key /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
+101
View File
@@ -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
+127
View File
@@ -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 <<EOF
%no-protection
Key-Type: eddsa
Key-Curve: ed25519
Subkey-Type: ecdh
Subkey-Curve: cv25519
Name-Real: incredigo lab
Name-Email: lab@incredigo.invalid
Expire-Date: 0
%commit
EOF
gpg --batch --gen-key /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 <<JS | mongosh --quiet --norc 127.0.0.1:27017/admin
try { db.getSiblingDB("admin").dropUser("labapp"); } catch (e) {}
db.getSiblingDB("admin").createUser({
user: "labapp",
pwd: "$MONGO_PW",
roles: [ { role: "userAdminAnyDatabase", db: "admin" }, { role: "readWrite", db: "labdb" } ]
});
print("USER-CREATED");
JS
echo "== 4. ENABLE auth and restart mongod =="
if grep -q '^\s*security:' /etc/mongod.conf; then
sudo sed -i 's/^\(\s*\)authorization:.*/\1authorization: enabled/' /etc/mongod.conf
grep -q 'authorization:' /etc/mongod.conf || printf '\nsecurity:\n authorization: enabled\n' | sudo tee -a /etc/mongod.conf >/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 <<JS | mongosh --quiet --norc 127.0.0.1:27017/labdb 2>/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
+51
View File
@@ -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 <<EOF
%no-protection
Key-Type: eddsa
Key-Curve: ed25519
Subkey-Type: ecdh
Subkey-Curve: cv25519
Name-Real: incredigo lab
Name-Email: lab@incredigo.invalid
Expire-Date: 0
%commit
EOF
gpg --batch --gen-key /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 <<SQL >/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."
+85
View File
@@ -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 <<EOF
%no-protection
Key-Type: eddsa
Key-Curve: ed25519
Subkey-Type: ecdh
Subkey-Curve: cv25519
Name-Real: incredigo lab
Name-Email: lab@incredigo.invalid
Expire-Date: 0
%commit
EOF
gpg --batch --gen-key /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/<source>/<slug>). 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
Executable
+23
View File
@@ -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"
+68
View File
@@ -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" </dev/null 2>&1 | tail -3 || gopass init "$FPR" </dev/null 2>&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"
+57
View File
@@ -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"
+56
View File
@@ -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()
+133
View File
@@ -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: <guide-args...>
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)
+74
View File
@@ -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)