quarantine: scrub tailnet ref, add license, ship lab/ repro kit, refresh README for cohort
ci / build-test (push) Has been cancelled
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:
+10
@@ -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
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# incredigo — project directive for Claude sessions
|
||||
|
||||
**incredigo** is a local-first, encrypted, RAM-only credential custody tool (Go).
|
||||
The folder is still named `credrot/`; the module/binary are `incredigo`.
|
||||
|
||||
> Read this first, then [`VISION.md`](VISION.md) (who it's for + the mission),
|
||||
> [`DESIGN.md`](DESIGN.md) (architecture + threat model), [`docs/MARKET.md`](docs/MARKET.md)
|
||||
> (competitive landscape + positioning) and — if present locally — `docs/ROTATION.md`
|
||||
> (rotation design; **gitignored**, shared individually, not on the upstream repo).
|
||||
|
||||
## Vision — catch vibe-coding security disasters before they happen
|
||||
|
||||
The primary user is the **vibe coder**: the fast-moving AI-assisted builder (OpenClaw / Hermes /
|
||||
Claude-Code / Cursor) who ships in a weekend and accumulates dozens of credentials —
|
||||
`.env` keys, `sk_live`, `DATABASE_URL`, GitHub PATs, AWS keys, Supabase `service_role` — each
|
||||
**generated once, pasted in, and never rotated**. incredigo finds that sprawl on the machine,
|
||||
custodies it in RAM, backs it up, **rotates what's scriptable**, and **guides the human through
|
||||
the rest** — before a forgotten key becomes a breach. **Secondary user:** the non-technical
|
||||
password reuser (weak/reused passwords saved in the browser) — same engine, friendlier face, via
|
||||
the browser worklist + change-URL links.
|
||||
|
||||
Tailor every feature to those two. **Do NOT build toward enterprise PAM / machine-identity-at-
|
||||
scale / compliance-cert / SaaS-hub** territory — that's the incumbents' moat. The target
|
||||
credential surface (the driver + scanner roadmap) is the **20-credential list in `VISION.md`**.
|
||||
Full crystallized vision in [`VISION.md`](VISION.md); competitive rationale in
|
||||
[`docs/MARKET.md`](docs/MARKET.md).
|
||||
|
||||
## North-star goal
|
||||
|
||||
A user or organization can rotate their credentials with one tool, two modes:
|
||||
|
||||
- **Mode A — rotate what's already in gopass.**
|
||||
- **Mode B — ingest creds first (scanners / `--path` / browser CSV), then rotate.**
|
||||
|
||||
Both must run **interactively or fully headless** (`$INCREDIGO_PASSPHRASE`, no TTY):
|
||||
do the automatable rotations unattended, and emit a guided worklist (+ links) for the
|
||||
credentials that legitimately require a human. See `docs/ROTATION.md` Part II.
|
||||
|
||||
## Where development is (2026-07-14)
|
||||
|
||||
> Snapshot: [`docs/STATE-2026-07-14.md`](docs/STATE-2026-07-14.md) · plan:
|
||||
> [`docs/ROADMAP.md`](docs/ROADMAP.md) · visual map: `incredigo-map.html`.
|
||||
|
||||
- **v1 shipped:** discover → migrate → expiry → sealed export/import. 9 scanners
|
||||
(aws, env, netrc, ssh, docker, kube, git, tea, file/`--path`); sealers age
|
||||
(default), hmac, openssl. 179 tests green, `-race` clean.
|
||||
- **Rotation: 22 drivers + wired Mode A execute spine.** All drivers self-register;
|
||||
`rotate --execute` runs behind `INCREDIGO_ALLOW_EXECUTE=1` **and** the mandatory
|
||||
verified-backup gate (`Snapshot`). Proof status is DATA (`internal/rotate/proofs.go`
|
||||
mirrored in `docs/ROTATION-PROOFS.md`): **8 LIVE-VM** (postgres, mysql, redis,
|
||||
mongo, wireguard, gitea, appsecret, k8s), **14 MOCK-ONLY**, plus a `LIVE-REAL`
|
||||
level no driver carries yet. `--execute` enforces a proof gate: only drivers
|
||||
with proof ≥ LIVE-VM (`MinExecuteProof`) run; MOCK-ONLY/UNPROVEN are skipped
|
||||
unless `--allow-mock-proven` is passed. **No real (host) credential has been
|
||||
rotated yet** — every real rotation still requires explicit per-credential
|
||||
authorization (safe-candidate ladder, ROADMAP M2).
|
||||
- **Guided manual layer done:** `internal/links` + `internal/worklist` +
|
||||
`internal/tui` → `incredigo worklist` / `incredigo guide` (read-only).
|
||||
- **Phase B scaffolded, not wired:** 12 pwstore adapters, pwgen, staging;
|
||||
`incredigo passwords pw-*` flow incomplete (ROADMAP M4).
|
||||
- **Known weak spots:** policy 66% / tui 57% coverage; coverage floors reported by
|
||||
`make check-cover` but not yet CI-enforced; lab-provision scripts not in repo
|
||||
(ROADMAP M0–M1). (sink 30%→85%, cmd 4.3%→34.5%, gitea CI + Makefile added
|
||||
2026-07-15.)
|
||||
|
||||
## Hard rules (do not violate — this tool holds real credentials)
|
||||
|
||||
1. **Backup before rotate, always.** No rotation may run unless `rotate.Snapshot`
|
||||
has produced a **verified** sealed backup of the affected entries first. The gate
|
||||
aborts the run on any backup/verify failure. This is non-negotiable.
|
||||
2. **Verify-new-before-revoke-old.** Order: backup → rotate → verify the new secret
|
||||
authenticates → store in gopass → re-read → *only then* revoke the old. Never
|
||||
`gopass rm` an old value before the new one is proven (SSH/DB = lockout risk).
|
||||
3. **No plaintext secrets on disk. Ever.** Secrets live only in the memguard vault
|
||||
and stream as bytes. Browser passwords → **worklist of non-secret metadata**
|
||||
(site, username, change-URL), never a `.txt` password dump. Raw export is a
|
||||
loudly-warned, flag-gated opt-in only.
|
||||
4. **gopass test isolation = `GOPASS_HOMEDIR`, NOT `PASSWORD_STORE_DIR`.** gopass
|
||||
ignores `PASSWORD_STORE_DIR`; `gopass init --path …` repoints the user's real
|
||||
root mount and breaks their store. Always isolate tests with
|
||||
`export GOPASS_HOMEDIR=$(mktemp -d)` + a throwaway `GNUPGHOME`. Better: unit-test
|
||||
against a **fake gopass binary** via `sink.Gopass{Bin: …}` (see
|
||||
`internal/sink/bundle_test.go`, `internal/rotate/backup_test.go`).
|
||||
5. **Do no harm / self-owned only.** Browser automation targets **only the user's own
|
||||
accounts**, with their authorization. Never bypass MFA/CAPTCHA — hand off to the
|
||||
human. Nothing here may resemble credential stuffing or account takeover.
|
||||
6. **Dry-run by default; destructive steps are opt-in, audited, reversible.** The
|
||||
redacted audit log records every action. Computer-use/vision automation must
|
||||
inject secrets locally and never stream them into the model loop.
|
||||
7. **The PGP passphrase is the root of trust.** It unlocks every gopass secret. The
|
||||
TUI and docs MUST warn prominently: lose it → everything is unrecoverable;
|
||||
leak it → everything is exposed. Tell users to back up the GPG key and use a
|
||||
strong passphrase. Treat this warning as a required UX element, not optional.
|
||||
|
||||
## Build / test / run
|
||||
|
||||
```sh
|
||||
go build ./... # green required before commit
|
||||
go vet ./...
|
||||
go test ./... # all packages; -race clean on vault/sink/rotate
|
||||
go build -o /tmp/incredigo ./cmd/incredigo
|
||||
```
|
||||
|
||||
Never run `incredigo migrate/export/rotate` against the user's **real** gopass to
|
||||
"test" — use an isolated `GOPASS_HOMEDIR` (rule 4).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
cmd/incredigo cobra CLI: scan|migrate|status|export|import|rotate
|
||||
internal/vault memguard arena (mlock/DONTDUMP, zeroize, handles)
|
||||
internal/discover Scanner registry + one file per source
|
||||
internal/sink gopass streaming, bundle framing, Sealer (age/hmac/openssl)
|
||||
internal/policy expiry engine
|
||||
internal/audit redacted append-only JSONL
|
||||
internal/rotate Rotator interface + MANDATORY backup gate (no drivers yet)
|
||||
```
|
||||
|
||||
Extension points stay one-file-each: a Scanner, a Sealer, a Rotator. Drivers and the
|
||||
backup gate live in Go; the AI router + browser long-tail belong in a skill/MCP layer
|
||||
on top (do not put an LLM in the deterministic destructive hot path).
|
||||
|
||||
## Chosen stack (2026 research, see ROTATION.md §13)
|
||||
|
||||
- **TUI:** Charm Bubble Tea + Bubbles + Lipgloss (masked input via `textinput`).
|
||||
- **Browser automation:** go-rod (default) → playwright-go (reliability) → Anthropic
|
||||
Computer Use (vision fallback, secrets kept out of the loop).
|
||||
- **Per-site links:** `.well-known/change-password` (RFC 8615) + a curated table.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Match surrounding Go style; comment the *why* for any security-relevant choice.
|
||||
- New code ships with tests (table-driven scanners; leak-checks asserting no secret
|
||||
substring reaches `Identity`/`Meta`/logs).
|
||||
- Commits: end messages with the `Co-Authored-By: Claude …` trailer. Push only when
|
||||
asked. Remote is **gitea only, never GitHub**
|
||||
(`https://100.117.177.50:3030/trill-technician/incredigo`).
|
||||
- Design docs (`docs/ROTATION.md`) are kept local/gitignored by request; `VISION.md`,
|
||||
`DESIGN.md`, `README.md`, `docs/MARKET.md`, `docs/SECURITY.md`, `docs/ADDING_A_SCANNER.md`
|
||||
are tracked.
|
||||
-45
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
Executable
+77
@@ -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"
|
||||
Executable
+208
@@ -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
|
||||
Executable
+83
@@ -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
|
||||
Executable
+81
@@ -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"
|
||||
Executable
+99
@@ -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
|
||||
Executable
+84
@@ -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
|
||||
Executable
+106
@@ -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
|
||||
Executable
+117
@@ -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"
|
||||
Executable
+165
@@ -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
|
||||
Executable
+101
@@ -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
|
||||
Executable
+127
@@ -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
|
||||
Executable
+51
@@ -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."
|
||||
Executable
+85
@@ -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
@@ -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"
|
||||
Executable
+68
@@ -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"
|
||||
Executable
+57
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user