diff --git a/services/secrets-broker/README.md b/services/secrets-broker/README.md new file mode 100644 index 0000000..d0abd28 --- /dev/null +++ b/services/secrets-broker/README.md @@ -0,0 +1,65 @@ +# secrets-broker + +Per-dev-box credential **store + backup** over the PFI Vaultwarden +(`vaultwarden.phasefinal.com`). Lets the CC sessions on a dev box stash and look up +secrets — API keys, tokens, `env.sh` / `.env` files, TLS keys — that shouldn't live +in git and are otherwise single-copy on the box. + +**Not a fleet service.** Each dev box runs its own copy of this stack against its own +local secrets; items are hostname-namespaced (`/…`) in the shared +`infra-ops` org so they don't collide. No daemon — the `secret` CLI shells out to +`bw` per call (~a few seconds; fine for occasional session use). + +## Files + +| File | What | +|---|---| +| `secret` | the CLI (`put` / `get` / `list` / `backfill`) — copy this to each box | +| `secrets-broker.contract.md` | the spec | +| `~/.config/secrets-broker/bootstrap.env` | **per-box** service-account creds, `0600`, **never committed** | + +## Set up on a new dev box + +1. **Install `bw`** (user-prefix, no sudo): + ```bash + npm install -g @bitwarden/cli --prefix "$HOME/.local" # -> ~/.local/bin/bw + ``` +2. **Provision creds** (operator): the service account already exists + (`infra-ops@phasefinal.com`); drop its creds into the bootstrap file: + ```bash + mkdir -p ~/.config/secrets-broker && chmod 700 ~/.config/secrets-broker + $EDITOR ~/.config/secrets-broker/bootstrap.env # RBW_EMAIL, RBW_MASTER_PW, BW_CLIENTID, BW_CLIENTSECRET + chmod 600 ~/.config/secrets-broker/bootstrap.env + ``` + (`RBW_*` names are historical — bw reads them the same.) +3. **Copy the CLI** and point at the vault: + ```bash + cp secret ~/.local/bin/secret # or run in place + bw config server https://vaultwarden.phasefinal.com + ``` +4. **Back up this box's secrets:** + ```bash + secret backfill --dry-run # review what it would store + secret backfill # write + round-trip-verify each + ``` + +## Usage + +```bash +secret put myproj/.env --file ./env.sh --folder $(hostname) # upsert a secret +secret put api/some-token --stdin # from stdin +secret get myproj/.env # -> note body (stdout) +secret get certs/foo.pem --field content_b64 --file foo.pem # binary -> 0600 file +secret list --prefix $(hostname)/ # names + metadata only +secret backfill [--dry-run] # this box's local secrets +``` + +## Notes + +- **`bootstrap.env` is the one secret that can't be vaulted** (secrets-zero) — it's + excluded from backfill. Keep it `0600`; it's this box's crown jewel. +- Values are only ever printed by an explicit `get`; `list` / `--dry-run` show + names + metadata (`sha256`, `synced_at`, `source_path`) only. +- Upsert is idempotent (keyed by name) — re-running `backfill` refreshes, never + duplicates. Safe to re-run if a run is interrupted. +- The Vaultwarden DB is in the pg_dump backup set; the store itself is durable. diff --git a/services/secrets-broker/secret b/services/secrets-broker/secret index 442d3f1..b0316af 100755 --- a/services/secrets-broker/secret +++ b/services/secrets-broker/secret @@ -1,29 +1,34 @@ #!/usr/bin/env python3 -"""secret — fleet credential store & retrieve over Vaultwarden (bw-backed). +"""secret — per-box credential store & retrieve over Vaultwarden (bw-backed). -Stores each secret as an item in the `infra-ops` org's Default collection (so the -operator's primary account, an org member, sees it too), organised by folder + -a `//` name convention. Text secrets (env files, tokens) live -in the item note; binary secrets (keys/certs) are base64'd into a hidden field. +Serves the CC sessions on this dev box. Stores each secret as an item in the +`infra-ops` org's Default collection (so the operator's primary account, an org +member, sees it too), organised by folder (named for the hostname) + a +`/` name convention. Small text -> item note; small binary -> base64 +hidden field; anything over the note cap -> a bw attachment. Metadata fields carry +sha256 + source for drift detection. -Auth bootstraps from ~/.config/secrets-broker/bootstrap.env (0600): apikey login -+ master-password unlock -> per-invocation session. Secret VALUES are never printed +Auth bootstraps from ~/.config/secrets-broker/bootstrap.env (0600): apikey login + +master-password unlock -> per-invocation session. Secret VALUES are never printed except by an explicit `get`. Commands: secret put (--file P | --stdin) [--folder C] [--field k=v]... secret get [--field F] [--file OUT] secret list [--prefix P] - secret backfill --host H [--dry-run] # enumerate a host's .env/env.sh, upsert + secret backfill [--dry-run] # scan THIS box's local secrets, upsert each """ import argparse import base64 +import glob import hashlib import json import os -import shlex +import shutil +import socket import subprocess import sys +import tempfile import time from pathlib import Path @@ -33,6 +38,11 @@ BOOTSTRAP = CFG / "bootstrap.env" SERVER = "https://vaultwarden.phasefinal.com" ORG_ID = "d30c6b58-773c-4e39-916e-8e33ca7f8b81" COLLECTION_ID = "b829376a-9db1-4091-a4ae-9a36132ad4c2" +NOTE_MAX = 6000 # plaintext bytes; larger -> attachment (Vaultwarden note cap ~10000 encrypted chars) + + +class BwError(Exception): + pass def die(msg): @@ -61,7 +71,7 @@ def bw(args, env, session=None, stdin=None, check=True): cmd = [BW] + args + (["--session", session] if session else []) r = subprocess.run(cmd, env=env, input=stdin, capture_output=True) if check and r.returncode != 0: - die(f"bw {args[0]} failed: {r.stderr.decode(errors='replace').strip()}") + raise BwError(f"bw {args[0]}: {r.stderr.decode(errors='replace').strip()}") return r @@ -86,8 +96,7 @@ def encode(obj, env): def find(env, s, name): - r = bw(["list", "items", "--search", name], env, s) - for it in json.loads(r.stdout.decode() or "[]"): + for it in json.loads(bw(["list", "items", "--search", name], env, s).stdout.decode() or "[]"): if it.get("name") == name: return it return None @@ -103,6 +112,67 @@ def folder_id(env, s, cls): return json.loads(r.stdout.decode())["id"] +def store_secret(env, s, name, data, folder_name, extra_fields): + """Upsert a secret item; route by size: note / base64-field / attachment. + Returns (item_id, sha256, mode).""" + sha = hashlib.sha256(data).hexdigest() + try: + text, is_text = data.decode("utf-8"), True + except UnicodeDecodeError: + text, is_text = None, False + large = len(data) > NOTE_MAX + fields = [{"name": "sha256", "value": sha, "type": 0}, + {"name": "synced_at", "value": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "type": 0}] + fields += extra_fields + attach_name = None + if large: + attach_name = name.split("/")[-1] or "secret" + notes = f"<{len(data)} bytes — stored as attachment '{attach_name}'>" + fields.append({"name": "storage", "value": "attachment", "type": 0}) + elif is_text: + notes = text + else: + notes = "" + fields.append({"name": "content_b64", "value": base64.b64encode(data).decode(), "type": 1}) + item = {"type": 2, "name": name, "notes": notes, "organizationId": ORG_ID, + "collectionIds": [COLLECTION_ID], "folderId": folder_id(env, s, folder_name), + "fields": fields, "secureNote": {"type": 0}} + existing = find(env, s, name) + if existing: + item["id"] = existing["id"] + bw(["edit", "item", existing["id"]], env, s, stdin=encode(item, env)) + item_id = existing["id"] + full = json.loads(bw(["get", "item", item_id], env, s).stdout.decode()) + for att in full.get("attachments") or []: # clear stale attachments (no dupes on re-store) + bw(["delete", "attachment", att["id"], "--itemid", item_id], env, s) + else: + item_id = json.loads(bw(["create", "item"], env, s, stdin=encode(item, env)).stdout.decode())["id"] + if large: + d = tempfile.mkdtemp() + try: + fp = os.path.join(d, attach_name) + Path(fp).write_bytes(data) + bw(["create", "attachment", "--itemid", item_id, "--file", fp], env, s) + finally: + shutil.rmtree(d, ignore_errors=True) + return item_id, sha, ("attachment" if large else ("field" if not is_text else "note")) + + +def read_secret(env, s, name): + """Return the stored secret bytes for `name` (attachment / base64-field / note).""" + it = find(env, s, name) + if not it: + return None + full = json.loads(bw(["get", "item", it["id"]], env, s).stdout.decode()) + atts = full.get("attachments") or [] + if atts: + return bw(["get", "attachment", atts[0]["fileName"], "--itemid", full["id"], "--raw"], env, s).stdout + for f in full.get("fields") or []: + if f["name"] == "content_b64": + return base64.b64decode(f["value"]) + return (full.get("notes") or "").encode() + + def cmd_put(a): env = load_env() s = session(env) @@ -112,35 +182,12 @@ def cmd_put(a): data = sys.stdin.buffer.read() else: die("put needs --file or --stdin") - sha = hashlib.sha256(data).hexdigest() - try: - notes, binary = data.decode("utf-8"), False - except UnicodeDecodeError: - notes, binary = "", True - fields = [ - {"name": "sha256", "value": sha, "type": 0}, - {"name": "synced_at", "value": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "type": 0}, - ] + extra = [] for kv in (a.field or []): k, v = kv.split("=", 1) - fields.append({"name": k, "value": v, "type": 0}) - if binary: - fields.append({"name": "content_b64", "value": base64.b64encode(data).decode(), "type": 1}) - item = { - "type": 2, "name": a.name, "notes": notes, - "organizationId": ORG_ID, "collectionIds": [COLLECTION_ID], - "folderId": folder_id(env, s, a.folder), "fields": fields, - "secureNote": {"type": 0}, - } - existing = find(env, s, a.name) - if existing: - item["id"] = existing["id"] - bw(["edit", "item", existing["id"]], env, s, stdin=encode(item, env)) - action = "updated" - else: - bw(["create", "item"], env, s, stdin=encode(item, env)) - action = "created" - print(f"{action}: {a.name} (sha256 {sha[:12]}, {len(data)} bytes{', binary' if binary else ''})") + extra.append({"name": k, "value": v, "type": 0}) + _, sha, mode = store_secret(env, s, a.name, data, a.folder, extra) + print(f"stored: {a.name} (sha256 {sha[:12]}, {len(data)} bytes, {mode})") def cmd_get(a): @@ -151,19 +198,15 @@ def cmd_get(a): if a.field: for f in full.get("fields") or []: if f["name"] == a.field: - if a.field == "content_b64" and a.file: - Path(a.file).write_bytes(base64.b64decode(f["value"])) - os.chmod(a.file, 0o600) - return print(f"wrote {a.file} (0600)") return print(f["value"]) die(f"no field '{a.field}' on {a.name}") - content = full.get("notes") or "" + data = read_secret(env, s, a.name) if a.file: - Path(a.file).write_text(content) + Path(a.file).write_bytes(data) os.chmod(a.file, 0o600) print(f"wrote {a.file} (0600)") else: - sys.stdout.write(content if content.endswith("\n") else content + "\n") + sys.stdout.buffer.write(data if data.endswith(b"\n") else data + b"\n") def cmd_list(a): @@ -181,63 +224,68 @@ def cmd_list(a): print(f"# {n} item(s)", file=sys.stderr) -def _host_secret_files(host): - find_cmd = (r"find /opt/docker -maxdepth 4 \( -name .env -o -name env.sh \) " - r"! -name '*.example' -type f 2>/dev/null") - r = subprocess.run(["ssh", host, find_cmd], capture_output=True, text=True) - return [p for p in r.stdout.split("\n") if p.strip()] +LOCAL_GLOBS = [ + "development/*/env.sh", "development/*/*/env.sh", + "development/*/.env", "development/*/*/.env", + ".config/secrets/env.sh", + ".config/*/*.env", ".config/*/*token*", ".config/*/*-key*", + ".config/*/*.pem", ".config/*/*.key", ".config/*/secrets.env", ".config/*/cred*", +] +LOCAL_EXCLUDE = ("secrets-broker/bootstrap.env", ".example", "/node_modules/", + "/.cargo/", "/.cache/", "/AIPA-Data/") -def _name_for(host, path): - parts = Path(path).parts # e.g. /opt/docker/compose/gitea/.env - stack = parts[-2] if len(parts) >= 2 else "root" - return f"{host}/{stack}/{Path(path).name}" +def _local_secret_files(): + home = str(Path.home()) + hits = set() + for g in LOCAL_GLOBS: + hits.update(glob.glob(f"{home}/{g}")) + return [p for p in sorted(hits) + if not any(e in p for e in LOCAL_EXCLUDE) and Path(p).is_file()] + + +def _local_name(path): + home = str(Path.home()) + rel = path[len(home) + 1:] if path.startswith(home + "/") else path.lstrip("/") + return f"{socket.gethostname()}/{rel}" def cmd_backfill(a): - host = a.host - files = _host_secret_files(host) + files = _local_secret_files() + host = socket.gethostname() if not files: - return print(f"{host}: no .env/env.sh found under /opt/docker") - env = load_env() - s = None if a.dry_run else session(env) + return print(f"{host}: no local secret files matched") print(f"{host}: {len(files)} secret file(s)") + if a.dry_run: + for path in files: + data = Path(path).read_bytes() + tag = "attachment" if len(data) > NOTE_MAX else "note" + print(f" WOULD store {_local_name(path)}\t({len(data)} bytes, " + f"sha {hashlib.sha256(data).hexdigest()[:12]}, {tag})") + print(" (dry-run — nothing written; bootstrap.env / *.example / AIPA-Data excluded)") + return + env = load_env() + s = session(env) + ok, failed = 0, [] for path in files: - name = _name_for(host, path) - data = subprocess.run(["ssh", host, f"cat {shlex.quote(path)}"], capture_output=True).stdout - sha = hashlib.sha256(data).hexdigest() - if a.dry_run: - print(f" WOULD store {name}\t({len(data)} bytes, sha {sha[:12]}) <- {path}") - continue + name = _local_name(path) + data = Path(path).read_bytes() try: - notes = data.decode("utf-8") - except UnicodeDecodeError: - notes = "" - fields = [ - {"name": "sha256", "value": sha, "type": 0}, - {"name": "source_host", "value": host, "type": 0}, - {"name": "source_path", "value": path, "type": 0}, - {"name": "synced_at", "value": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "type": 0}, - ] - item = {"type": 2, "name": name, "notes": notes, "organizationId": ORG_ID, - "collectionIds": [COLLECTION_ID], "folderId": folder_id(env, s, "hosts"), - "fields": fields, "secureNote": {"type": 0}} - existing = find(env, s, name) - if existing: - item["id"] = existing["id"] - bw(["edit", "item", existing["id"]], env, s, stdin=encode(item, env)) - verb = "updated" - else: - bw(["create", "item"], env, s, stdin=encode(item, env)) - verb = "stored " - # round-trip verify - back = json.loads(bw(["get", "item", find(env, s, name)["id"]], env, s).stdout.decode()) - ok = hashlib.sha256((back.get("notes") or "").encode()).hexdigest() == sha - print(f" {verb} {name}\t({len(data)}b) verify={'OK' if ok else 'MISMATCH'}") + _, sha, mode = store_secret(env, s, name, data, host, + [{"name": "source_host", "value": host, "type": 0}, + {"name": "source_path", "value": path, "type": 0}]) + back = read_secret(env, s, name) + v = back is not None and hashlib.sha256(back).hexdigest() == sha + print(f" stored {name}\t({len(data)}b, {mode}) verify={'OK' if v else 'MISMATCH'}") + ok += 1 + except BwError as e: + print(f" FAILED {name}: {e}") + failed.append(name) + print(f"{host}: {ok}/{len(files)} stored" + (f"; {len(failed)} FAILED: {', '.join(failed)}" if failed else "")) def main(): - p = argparse.ArgumentParser(prog="secret", description="fleet credential store over Vaultwarden") + p = argparse.ArgumentParser(prog="secret", description="per-box credential store over Vaultwarden") sub = p.add_subparsers(dest="cmd", required=True) pp = sub.add_parser("put"); pp.add_argument("name") pp.add_argument("--file"); pp.add_argument("--stdin", action="store_true") @@ -246,10 +294,13 @@ def main(): pg = sub.add_parser("get"); pg.add_argument("name") pg.add_argument("--field"); pg.add_argument("--file"); pg.set_defaults(fn=cmd_get) pl = sub.add_parser("list"); pl.add_argument("--prefix"); pl.set_defaults(fn=cmd_list) - pb = sub.add_parser("backfill"); pb.add_argument("--host", required=True) + pb = sub.add_parser("backfill", help="scan THIS box's local secret files and upsert each") pb.add_argument("--dry-run", action="store_true"); pb.set_defaults(fn=cmd_backfill) a = p.parse_args() - a.fn(a) + try: + a.fn(a) + except BwError as e: + die(str(e)) if __name__ == "__main__": diff --git a/services/secrets-broker/secrets-broker.contract.md b/services/secrets-broker/secrets-broker.contract.md index 0b7d915..84188de 100644 --- a/services/secrets-broker/secrets-broker.contract.md +++ b/services/secrets-broker/secrets-broker.contract.md @@ -21,18 +21,21 @@ agents, scripts, and the operator use. Solves two problems at once: **durability (secrets currently backed up nowhere) and **lookup** (no canonical place to find a credential). -This is a **central store**, not (yet) a deploy-time source of truth: host `.env` -files stay in place; the vault is the authoritative *registry + backup*. The -pull-at-deploy evolution is explicitly out of scope for v1 (see Out of scope). +**Scope (corrected 2026-08-11):** this serves the **CC sessions on THIS dev box +(nh3-dev)** — a per-box credential store + backup, NOT a fleet service. New dev +boxes get this same stack **copied and duplicated one at a time** (each box: its own +`bw` + a per-box `bootstrap.env`, backing up its own local secrets, hostname- +namespaced). **No daemon / no central broker** — the subprocess CLI is the final +shape. Local `.env`/`env.sh` files stay in place; the vault is the authoritative +*registry + backup*. Deploy-time source-of-truth (pull-at-deploy) stays out of scope. ## Architecture -- **Store:** a **dedicated Vaultwarden service account** (`secrets-broker@…`), its - personal vault *is* the registry. (An Organization + per-host Collections is the - textbook multi-user structure, but **rbw's write path into org collections is - weak** — see the rbw capability gate — so v1 uses the service account's own vault - with a folder/name taxonomy. Org migration is a future option if granular - human ACLs are ever needed.) +- **Store:** the operator-provisioned service account (`infra-ops@phasefinal.com`) + in the **`infra-ops` org** (id `d30c6b58…`), **Default collection** (id + `b829376a…`). Items land in that org collection so the operator's **primary + account** (shared into the org) sees them too; organised by **folder** (per box, + named for the hostname) + a `/` name convention. - **Client:** **`bw`** (official Bitwarden CLI, installed to `~/.local` on nh3-dev). Switched from `rbw` after rbw's `register` returned an undebuggable 400 against this Vaultwarden despite valid creds (a direct `client_credentials` grant + both prelogin @@ -40,10 +43,12 @@ pull-at-deploy evolution is explicitly out of scope for v1 (see Out of scope). (`login --apikey` via `BW_CLIENTID`/`BW_CLIENTSECRET` env, `unlock --passwordenv --raw` → `BW_SESSION`) and full write support — org collections + attachments included, which reopens the org-vs-personal-vault choice rbw had foreclosed. -- **Broker host + identity:** runs as the session user (`lkraven`) on **nh3-dev** - (this machine — where the live CC sessions + rbw live); rbw 1.15.0 is also present - on nh3-extdev. Fleet ssh + the trust boundary for the bootstrap secret. -- **Code home:** `eshpfi-management/services/secrets-broker/`. +- **Runs as** the session user (`lkraven`) on **nh3-dev**, invoked directly by CC + sessions on this box. No ssh, no daemon; each dev box runs its own copy against its + own local secrets. +- **Code home:** `eshpfi-management/services/secrets-broker/` (the `secret` CLI is + copied to each dev box; the per-box `bootstrap.env` lives in + `~/.config/secrets-broker/`, `0600`, never committed). ## Data model / taxonomy (lookup-first) @@ -51,13 +56,13 @@ pull-at-deploy evolution is explicitly out of scope for v1 (see Out of scope). folders are unavailable: `//` (e.g. `ana-docker/gitea/.env`), or `/` for non-host creds (`gitea/claude-bot-token`, `wireguard/irv-ml1`, `certs/phasefinal-wildcard`). -- **Folders** (if rbw supports `add --folder` — gate) group by class: `hosts/`, - `tokens/`, `certs/`, `ssh-keys/`, `db/`, `wireguard/`, `misc/`. -- **Item type by shape:** a credential pair → Bitwarden *login* (username/password/ - uri); an env file or freeform blob → *secure note* (whole file in the note body). -- **Binary secrets** (`*.pem`, `*.key`, `*.pfx`, `acme.json`, `client_secrets.json`) - → **base64 into a note field** (rbw has no attachments). Cap ~64 KB/item; larger - keys are flagged, not silently truncated. +- **Folders** group by class (one per box, named for the hostname); the item name + carries the real path so lookup works regardless. +- **Item type:** everything is a *secure note* (type 2) — the file/secret content in + the note body (arbitrary `KEY=VALUE` env content isn't a clean login shape). +- **Binary secrets** (`*.pem`, `*.key`, `*.pfx`) → **base64 into a hidden field** + (`content_b64`); portable and rbw-agnostic. bw could use attachments, but base64 + keeps a single fetch path. Cap ~64 KB/item. - Metadata fields on every item: `source_host`, `source_path`, `synced_at`, `sha256` (of the plaintext, for drift detection without decrypting to compare).