diff --git a/docs/keycape-openbao-consumer-custody.md b/docs/keycape-openbao-consumer-custody.md new file mode 100644 index 0000000..89ea17b --- /dev/null +++ b/docs/keycape-openbao-consumer-custody.md @@ -0,0 +1,154 @@ +# KeyCape `secrets-engine-openbao` consumer custody packet + +Prepared 2026-10-06 by secrets-engine for KEY-WP-0037-T02 and +RPF-WP-0035-T02 (proposed CCR-2026-0033 custody child). Tracked in +SECRETS-WP-0012. **Source and documentation only.** No value was generated, +fetched or written; no host, cluster, OpenBao or KeyCape state was changed. +The founder's custody-boundary decision `632a8893-0d6a-4591-a50b-40bbdb798f32` +approves the two-copy boundary, not generation, fetch, delivery, registration +or deployment. Each live step below still needs its own approval. + +This packet names the **consumer copy only**: the secrets-engine-owned +mode-0600 file. The authoritative copy (platform KV +`platform/services/keycape/secrets-engine-openbao`, field `CLIENT_SECRET`) and +the issuer-side ESO copy (`sso` Secret `keycape-secrets-engine-openbao-client`, +key `client-secret`) are platform's and KeyCape's. Issuer ESO does not supply +this copy, and nothing here adds a client-side OpenBao reader. + +## What the code supports (determined from source) + +- secrets-engine is a CLI, not a daemon. The OpenBao JWT login runs inside + whichever process an operator starts with `--auth service-jwt` (or `auto` + once `SECRETS_ENGINE_OPENBAO_JWT_LOGIN` is set); it logs in for one command + and self-revokes (`engine_auth.login_service_jwt`). There is no service + account, unit file or container for the engine. +- The provider reads the secret from the path in + `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` (`config.py`), rereads it before + every exchange and sends it only as HTTP Basic (`service_auth.py`). +- As of this packet the read goes through `read_client_secret_file`, which + refuses, without exposing the value: a relative path; a missing file; any + symlink or non-canonical path component; a non-regular file; a file or + parent directory not owned by the effective user; any group/other mode bit on + the file (must be 0600) or its parent (must be 0700); a path inside a Git + worktree; empty content; content that is not exactly one token. Before this + change only the file mode, worktree and empty checks existed. The approval + profile shares the same provider and therefore the same checks. +- `secrets-engine service-auth check-secret-file [--path P] [--json]` runs those + checks and prints check names and pass/fail only — never the value, its + length or any hash. Exit 0 = accepted, 1 = refused. This is the bounded + validation interface RPF-WP-0035-T02 asked for. + +## Consumer coordinates + +| Coordinate | Value | Basis | +| --- | --- | --- | +| Host | Operator workstation (the WSL2 Linux host where every documented live secrets-engine OpenBao operation runs against `BAO_ADDR=https://bao.coulomb.social`) | Documented practice; **founder decision D1** | +| OS user / owner | `worsch`, file and parent `worsch:worsch` | The account that runs the CLI; **founder decision D2** | +| File | `/home/worsch/.secrets-engine/keycape/secrets-engine-openbao.client-secret` | Follows existing `~/.secrets-engine/bootstrap/` convention | +| File mode | `0600`, regular file, single token, no trailing data other than one newline | Enforced by code | +| Parent | `/home/worsch/.secrets-engine/keycape`, `0700`, owned by `worsch` | Enforced by code | +| Grandparent | `/home/worsch/.secrets-engine` exists today as `0755` with only `0700` children; not changed by this packet | Observed metadata only | +| Git | Outside every worktree (`/home/worsch` and `~/.secrets-engine` are not worktrees) | Enforced by code | +| Storage | Persistent ext4 home, not tmpfs | **Founder decision D3** | +| Config reference | `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE=` | `config.py` | +| Companion config | `SECRETS_ENGINE_KEYCAPE_TOKEN_URL=https://kc.coulomb.social/token`, `SECRETS_ENGINE_KEYCAPE_ISSUER=https://kc.coulomb.social`, `SECRETS_ENGINE_OPENBAO_JWT_LOGIN=/home/worsch/.secrets-engine/keycape/openbao-jwt-login.yaml` (non-secret `mount`/`role: secrets-engine-openbao`/`bound_issuer`, content published by platform) | `engine_auth.py`, platform design | + +One host and one file. A second host or user would be a third copy and needs +its own approval; the engine has no mechanism that distributes the file. + +## Delivery (platform custody → this file) + +Attended operator path, executed on the host above as `worsch`, inside the +platform-owned custody child of CCR-2026-0033: + +1. Preconditions: founder approval of the exact custody child; platform's + operator OpenBao session for the KV write; `umask 077`; shell tracing and + credential-bearing debug logs off. +2. The custody child generates one random value **in process memory** and + compare-before-writes it to the authoritative KV field. If that write fails, + no consumer file is written. +3. In the **same process**, it writes the consumer copy: create the parent + with `0700` if absent (refuse if it exists with other owner/mode); open a + new temp file in that directory with `O_CREAT|O_EXCL|O_WRONLY`, mode `0600`; + write, `fsync`, `rename` onto the final path; `fsync` the directory. The + value never appears in argv, environment, stdout/stderr, logs, Git or the hub. +4. It then runs `secrets-engine service-auth check-secret-file --json` and + records only that result plus non-secret IDs in the native receipt. + +No client-side read of OpenBao is used or required: the consumer copy is +written from the same in-memory value that creates the authoritative version. +If the founder rejects same-process delivery, the only alternative is a +separately admitted, single-path client-side reader (as RPF-WP-0035-T06 does +for the approval client); this packet does not request one. + +Ownership: platform owns generation, the authoritative write and the delivery +code path; secrets-engine owns these destination coordinates, the acceptance +check and the file's lifecycle on the host. + +## Detecting a failed delivery + +- `check-secret-file` exits 1 and names the failing check (missing, mode, + owner, parent, symlink, worktree, empty). The receipt must carry `ok: true`. +- At use time the same checks refuse the exchange with `ProvisioningError` + (exit 6); `service-jwt` never falls back to bootstrap, `BAO_TOKEN` or AppRole. +- A value that is well-formed but does not match the issuer is detectable only + by the first live exchange (KEY-WP-0037-T04): KeyCape answers HTTP 401 and + the engine reports `KeyCape token exchange failed with HTTP 401`. No hash or + fingerprint of the value is recorded to compare copies; that is deliberate. +- Crash between the KV write and the rename: the KV version exists and the + consumer file does not (or a stale temp file remains in the 0700 directory). + Recovery is a fresh rotation, not a KV read; the operator removes the exact + temp file after inspecting its name and metadata only. + +## Rotation and revocation + +| Event | Owner | Action | +| --- | --- | --- | +| Generation / authoritative version | railiance-platform | New KV version via the custody child | +| Issuer pickup | KeyCape / NetKingdom | ESO refresh plus KeyCape restart (secrets load at startup) | +| Consumer copy replacement | secrets-engine (file), executed in platform's attended child | Atomic rename as above, then `check-secret-file` | +| Disable on compromise | KeyCape / NetKingdom | Remove or disable the registration first | +| Retire versions | railiance-platform | Destroy/retire per CCR | +| Remove consumer copy | secrets-engine operator | See rollback below | + +KeyCape holds one secret per client, so rotation has a cutover window: hold +the new value in the attended process, replace the consumer file only after +the issuer reload is confirmed, and accept that exchanges fail closed (401) in +between. Already-issued JWTs live at most 15 minutes; OpenBao tokens from this +login are self-revoked per command and bounded by platform's TTL/use limits. +No instant JWT recall is claimed. + +Rotation cadence is **founder decision D4**. + +## Rollback and cleanup + +1. Unset `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` (and the JWT login + contract variable) in the operator environment. +2. Remove the exact file path (no recursive removal); `rmdir` the parent only + if it is empty. +3. Verify with `check-secret-file --path `: it must exit 1 on `exists`. +4. Record cleanup status only. Unlink on ext4 is not secure erasure and not + revocation; revocation is the KeyCape and platform steps above. + +## Founder / owner decisions (with recommendation) + +- **D1 Host.** Recommended: the operator workstation only. Alternative: + railiance01, which has no documented secrets-engine OpenBao operator use and + would need its own user and path decision. +- **D2 OS user.** Recommended: the operator account `worsch`, since the engine + runs only as that user's CLI. A dedicated system user only makes sense once + an unattended engine runtime exists, and no source supports one today. +- **D3 Persistence.** Recommended: persistent home storage. tmpfs would + require re-delivery after every reboot, and every re-delivery would need either + a rotation or an unadmitted client-side KV read. Open owner question: whether + any workstation backup or sync tool covers `~/.secrets-engine`. If one does, + exclude it there before delivery. +- **D4 Rotation cadence.** Recommended: event-driven (compromise, host or user + change, operator departure) plus a 90-day scheduled rotation run through the + same attended child. Platform's CCR sets the binding value. + +## Not established + +No registration, issued JWT, KV version, ESO Secret or consumer file exists +by implication of this packet. Code tests use synthetic values only and prove +file acceptance/refusal, not live delivery, issuer match or OpenBao login. diff --git a/docs/service-auth.md b/docs/service-auth.md index 261a5dd..f730d57 100644 --- a/docs/service-auth.md +++ b/docs/service-auth.md @@ -17,6 +17,13 @@ outside every Git worktree. It is sent with HTTP Basic authentication and never placed in a request body, command argument, evidence record, or object representation. +Since 2026-10-06 the file must also be a canonical regular file (no symlink +component) owned by the running user, in a parent directory owned by that +user with mode 0700, and hold exactly one token. +`secrets-engine service-auth check-secret-file [--path P] [--json]` reports +those checks without the value. Consumer host, path, delivery and lifecycle +coordinates: [keycape-openbao-consumer-custody.md](keycape-openbao-consumer-custody.md). + JWT payload parsing does not prove a signature. OpenBao must verify RS256 against the configured issuer before issuing a token. railiance-platform owns the exact OpenBao JWT auth mount/role, issuer keys, claim bindings, token diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index 88c4bc0..d491d5e 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -957,6 +957,43 @@ def cmd_evidence_classify(cfg: Config, args) -> int: # -- parser ---------------------------------------------------------------- +def cmd_service_auth_check_secret_file(cfg: Config, args) -> int: + """Value-free acceptance check of the KeyCape consumer secret file.""" + import json as _json + + from secrets_engine.service_auth import check_client_secret_file + + raw = args.path or ( + str(cfg.keycape_client_secret_file) if cfg.keycape_client_secret_file else "" + ) + if not raw: + print( + "error: no path; pass --path or set SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE", + file=sys.stderr, + ) + return 2 + checks = check_client_secret_file(Path(raw)) + ok = bool(checks) and all(c.ok for c in checks) + if args.json: + print( + _json.dumps( + { + "path": raw, + "ok": ok, + "checks": [ + {"name": c.name, "ok": c.ok, "detail": c.detail} for c in checks + ], + }, + sort_keys=True, + ) + ) + else: + for c in checks: + print(f"{'ok ' if c.ok else 'FAIL'} {c.name}{': ' + c.detail if c.detail else ''}") + print("accepted" if ok else "refused") + return 0 if ok else 1 + + def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="secrets-engine", description=__doc__.splitlines()[0]) p.add_argument("--version", action="version", version=f"secrets-engine {__version__}") @@ -1171,6 +1208,16 @@ def build_parser() -> argparse.ArgumentParser: cl.add_argument("--json", action="store_true") cl.set_defaults(func=cmd_evidence_classify) + sa = sub.add_parser("service-auth", help="KeyCape service-auth consumer checks") + sasub = sa.add_subparsers(dest="subcmd", required=True) + sacheck = sasub.add_parser( + "check-secret-file", + help="value-free check of the client-secret file (never reads it out)", + ) + sacheck.add_argument("--path", default="") + sacheck.add_argument("--json", action="store_true") + sacheck.set_defaults(func=cmd_service_auth_check_secret_file) + return p diff --git a/src/secrets_engine/service_auth.py b/src/secrets_engine/service_auth.py index 66abb9e..7306026 100644 --- a/src/secrets_engine/service_auth.py +++ b/src/secrets_engine/service_auth.py @@ -10,6 +10,8 @@ from __future__ import annotations import base64 import binascii import json +import os +import stat from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path @@ -18,8 +20,8 @@ from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen -from secrets_engine.errors import BackendError -from secrets_engine.openbao import read_strict_token_file +from secrets_engine.errors import BackendError, ProvisioningError +from secrets_engine.safe_paths import containing_git_worktree CLIENT_ID = "secrets-engine-openbao" SUBJECT = "service:secrets-engine" @@ -154,6 +156,84 @@ def preflight_service_jwt( Transport = Callable[..., Any] +@dataclass(frozen=True) +class SecretFileCheck: + """One value-free consumer-file check. Never carries file contents.""" + + name: str + ok: bool + detail: str = "" + + +def check_client_secret_file(path: Path) -> list[SecretFileCheck]: + """Inspect a confidential-client secret file without exposing its value. + + The consumer copy must be an absolute, canonical (no symlink component), + regular, non-empty file owned by the effective user with no group/other + permission bits, in a parent directory owned by the same user with no + group/other permission bits, outside every Git worktree. Stops at the first + structural failure; later checks are then not reported as passed. + """ + checks: list[SecretFileCheck] = [] + + def add(name: str, ok: bool, detail: str = "") -> bool: + checks.append(SecretFileCheck(name, ok, detail)) + return ok + + raw = Path(path) + if not add("absolute", raw.is_absolute(), "" if raw.is_absolute() else "path must be absolute"): + return checks + try: + canonical = raw.resolve(strict=True) + except (OSError, RuntimeError): + add("exists", False, "file not found or unresolvable") + return checks + add("exists", True) + if not add( + "canonical", + canonical == raw, + "" if canonical == raw else "path has a symlink or non-canonical component", + ): + return checks + st = os.lstat(raw) + if not add( + "regular_file", + stat.S_ISREG(st.st_mode), + "" if stat.S_ISREG(st.st_mode) else "not a regular file", + ): + return checks + euid = os.geteuid() + add("file_owner", st.st_uid == euid, "" if st.st_uid == euid else "not owned by the running user") + mode = stat.S_IMODE(st.st_mode) + add("file_mode", not mode & 0o077, "" if not mode & 0o077 else f"group/other-accessible mode {oct(mode)}; must be 0600") + pst = os.lstat(raw.parent) + add("parent_owner", pst.st_uid == euid, "" if pst.st_uid == euid else "parent not owned by the running user") + pmode = stat.S_IMODE(pst.st_mode) + add( + "parent_mode", + not pmode & 0o077, + "" if not pmode & 0o077 else f"group/other-accessible parent mode {oct(pmode)}; must be 0700", + ) + worktree = containing_git_worktree(raw) + add("outside_git_worktree", worktree is None, "" if worktree is None else "inside a Git worktree") + add("non_empty", st.st_size > 0, "" if st.st_size > 0 else "file is empty") + return checks + + +def read_client_secret_file(path: Path, *, purpose: str = "KeyCape client secret") -> str: + """Read the consumer secret only after every value-free check passes.""" + failed = [c for c in check_client_secret_file(path) if not c.ok] + if failed: + reasons = "; ".join(f"{c.name}: {c.detail}" for c in failed) + raise ProvisioningError(f"{purpose} file {path} refused ({reasons})") + value = Path(path).read_text(encoding="utf-8").strip() + if not value or any(ch.isspace() for ch in value): + raise ProvisioningError( + f"{purpose} file {path} refused (must hold exactly one non-empty token)" + ) + return value + + @dataclass(frozen=True) class KeyCapeServiceAuthProvider: config: KeyCapeServiceAuthConfig @@ -161,10 +241,7 @@ class KeyCapeServiceAuthProvider: def exchange(self, *, now: datetime | None = None) -> ServiceJWT: """Perform one client-credentials exchange; never retry or fall back.""" - secret = read_strict_token_file( - self.config.client_secret_file, - purpose="KeyCape client secret", - ) + secret = read_client_secret_file(self.config.client_secret_file) basic = base64.b64encode( f"{self.config.client_id}:{secret}".encode("utf-8") ).decode("ascii") diff --git a/tests/test_consumer_secret_file.py b/tests/test_consumer_secret_file.py new file mode 100644 index 0000000..62bc8cd --- /dev/null +++ b/tests/test_consumer_secret_file.py @@ -0,0 +1,113 @@ +"""Consumer custody file checks for secrets-engine-openbao (synthetic values only).""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from secrets_engine import cli +from secrets_engine.errors import ProvisioningError +from secrets_engine.service_auth import check_client_secret_file, read_client_secret_file + +SYNTHETIC = "synthetic-not-a-real-client-secret" + + +def _private_file(tmp_path: Path, value: str = SYNTHETIC) -> Path: + d = tmp_path / "keycape" + d.mkdir(mode=0o700) + d.chmod(0o700) + f = d / "secrets-engine-openbao.client-secret" + f.write_text(value + "\n", encoding="utf-8") + f.chmod(0o600) + return f + + +def _failed(path: Path) -> set[str]: + return {c.name for c in check_client_secret_file(path) if not c.ok} + + +def test_accepts_private_file_and_reads_value(tmp_path): + f = _private_file(tmp_path) + assert _failed(f) == set() + assert read_client_secret_file(f) == SYNTHETIC + + +def test_checks_never_carry_the_value(tmp_path): + f = _private_file(tmp_path) + f.chmod(0o644) + for c in check_client_secret_file(f): + assert SYNTHETIC not in c.detail + with pytest.raises(ProvisioningError) as exc: + read_client_secret_file(f) + assert SYNTHETIC not in str(exc.value) + + +@pytest.mark.parametrize("mode", [0o640, 0o604, 0o660]) +def test_rejects_group_or_other_file_mode(tmp_path, mode): + f = _private_file(tmp_path) + f.chmod(mode) + assert "file_mode" in _failed(f) + + +def test_rejects_shared_parent_directory(tmp_path): + f = _private_file(tmp_path) + f.parent.chmod(0o750) + assert "parent_mode" in _failed(f) + + +def test_rejects_symlink(tmp_path): + f = _private_file(tmp_path) + link = f.parent / "link" + link.symlink_to(f) + assert "canonical" in _failed(link) + + +def test_rejects_relative_missing_and_empty(tmp_path): + assert "absolute" in _failed(Path("relative.secret")) + assert "exists" in _failed(tmp_path / "absent") + f = _private_file(tmp_path, value="") + f.write_text("", encoding="utf-8") + assert "non_empty" in _failed(f) + + +def test_rejects_multi_token_content(tmp_path): + f = _private_file(tmp_path, value="two tokens") + with pytest.raises(ProvisioningError, match="exactly one"): + read_client_secret_file(f) + + +def test_rejects_file_inside_git_worktree(tmp_path): + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + (repo / ".git" / "HEAD").write_text("ref: refs/heads/main\n") + f = _private_file(repo) + assert "outside_git_worktree" in _failed(f) + + +@pytest.mark.skipif(os.geteuid() != 0, reason="ownership change needs root") +def test_rejects_foreign_owner(tmp_path): # pragma: no cover - root only + f = _private_file(tmp_path) + os.chown(f, 65534, -1) + assert "file_owner" in _failed(f) + + +def test_cli_check_reports_without_value(tmp_path, capsys): + f = _private_file(tmp_path) + rc = cli.main(["service-auth", "check-secret-file", "--path", str(f), "--json"]) + out = capsys.readouterr().out + assert rc == 0 + assert SYNTHETIC not in out + assert json.loads(out)["ok"] is True + f.chmod(0o640) + rc = cli.main(["service-auth", "check-secret-file", "--path", str(f)]) + out = capsys.readouterr().out + assert rc == 1 and "FAIL file_mode" in out and SYNTHETIC not in out + + +def test_cli_check_uses_configured_env_path(tmp_path, capsys, monkeypatch): + f = _private_file(tmp_path) + monkeypatch.setenv("SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE", str(f)) + assert cli.main(["service-auth", "check-secret-file"]) == 0 + assert "accepted" in capsys.readouterr().out diff --git a/workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md b/workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md new file mode 100644 index 0000000..fa17ce9 --- /dev/null +++ b/workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md @@ -0,0 +1,71 @@ +--- +id: SECRETS-WP-0012 +type: workplan +title: "KeyCape secrets-engine-openbao consumer custody acceptance" +domain: infotech +repo: secrets-engine +status: blocked +flavor: implementation +owner: claude-code +topic_slug: custodian +created: "2026-10-06" +updated: "2026-10-06" +related_workplans: + - SECRETS-WP-0008 +--- + +# SECRETS-WP-0012 - KeyCape secrets-engine-openbao consumer custody acceptance + +Demand: KEY-WP-0037-T02 and RPF-WP-0035-T02 (proposed CCR-2026-0033) need +secrets-engine's source acceptance of the consumer copy of the +`secrets-engine-openbao` client secret: host, owner, path, mode, delivery, +rotation/revocation ownership and a bounded validation interface. The founder +approved the two-copy custody boundary (decision +`632a8893-0d6a-4591-a50b-40bbdb798f32`), not generation, fetch, delivery, +registration or deployment. Live service-JWT adoption stays SECRETS-WP-0008-T06. + +Packet: [docs/keycape-openbao-consumer-custody.md](../docs/keycape-openbao-consumer-custody.md). + +## Consumer packet and strict file acceptance + +```task +id: SECRETS-WP-0012-T01 +status: done +priority: high +``` + +Done 2026-10-06. The packet names the coordinates and marks D1-D4 as founder +decisions with recommendations. `service_auth.read_client_secret_file` +now refuses symlinked, foreign-owned, non-regular, shared-parent and +multi-token files in addition to the earlier mode, worktree and empty checks. +`secrets-engine service-auth check-secret-file` is the value-free validation +interface. Synthetic tests are in `tests/test_consumer_secret_file.py`. Full +suite: 510 passed, 1 skipped (root-only owner test). + +## Founder decisions D1-D4 + +```task +id: SECRETS-WP-0012-T02 +status: wait +needs_human: true +priority: high +blocked_on: founder +blocking_reason: "Host (workstation), OS user (worsch), persistent storage and rotation cadence are recommendations awaiting founder acceptance; the custody child cannot be finalized without them." +``` + +## Attended consumer-copy delivery + +```task +id: SECRETS-WP-0012-T03 +status: wait +needs_human: true +depends_on: [SECRETS-WP-0012-T02] +priority: high +blocked_on: message-from:railiance-platform +blocking_reason: "Requires the founder-approved CCR-2026-0033 custody child, executed attended on the named host. Platform generates and writes both copies in one process. secrets-engine records only the check-secret-file result. No client-side OpenBao read." +``` + +Acceptance: receipt carries `check-secret-file --json` with `ok: true` and +no value, length or hash. A failed write leaves no consumer file and is +recovered by rotation, not by a KV read. First live exchange evidence belongs +to KEY-WP-0037-T04, and OpenBao login proof belongs to SECRETS-WP-0008-T06.