feat(secrets-broker): nh3-dev backfill complete (25/25) + attachment + resilient run
Scope corrected to per-dev-box (CC sessions on this box), not a fleet service; each
box duplicates the stack and backs up its own local secrets, hostname-namespaced.
CLI:
- backfill is local-only (scan this box's ~/development/*/{env.sh,.env} + ~/.config
credentials; exclude bootstrap.env/examples/AIPA-Data archives).
- large files (>6000 B) route to a bw ATTACHMENT instead of the note field
(Vaultwarden caps notes at ~10000 encrypted chars); get/verify read it back.
- backfill catches per-item failures and continues (bw errors raise BwError,
main converts to a clean exit); idempotent upsert makes re-runs safe.
Backfilled all 25 nh3-dev secret files into the infra-ops org's Default collection
(folder = hostname), every one round-trip verified (2 large via attachment, 23 via
note). README added for duplicating the stack to new dev boxes. Contract scope +
data-model sections updated (bw, org/collection, per-box).
This commit is contained in:
@@ -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 (`<hostname>/…`) 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.
|
||||
+144
-93
@@ -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 `<host>/<stack>/<file>` 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
|
||||
`<host>/<path>` 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 <name> (--file P | --stdin) [--folder C] [--field k=v]...
|
||||
secret get <name> [--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 = "<binary — value in content_b64 field>"
|
||||
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 = "<binary — value in the content_b64 field>", 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 = "<binary>"
|
||||
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__":
|
||||
|
||||
@@ -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 `<host>/<path>` 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: `<host>/<stack>/<file>` (e.g. `ana-docker/gitea/.env`),
|
||||
or `<domain>/<name>` 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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user