Name the secrets-engine-openbao consumer custody coordinates.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Adds the consumer custody packet for KEY-WP-0037-T02 / RPF-WP-0035-T02,
strict value-free client-secret file checks with a check-secret-file CLI,
synthetic tests, and SECRETS-WP-0012 tracking founder decisions and the
attended delivery as needs_human wait tasks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 132360@bnt-lap001
Assistant-Session: 769b1afd-4330-4c86-96b2-e510e26db42b
This commit is contained in:
tegwick 2026-10-06 13:39:29 +02:00
parent be8fab0332
commit a3897c2317
6 changed files with 475 additions and 6 deletions

View file

@ -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=<file above>` | `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 <file>`: 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.

View file

@ -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 placed in a request body, command argument, evidence record, or object
representation. 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 JWT payload parsing does not prove a signature. OpenBao must verify RS256
against the configured issuer before issuing a token. railiance-platform owns against the configured issuer before issuing a token. railiance-platform owns
the exact OpenBao JWT auth mount/role, issuer keys, claim bindings, token the exact OpenBao JWT auth mount/role, issuer keys, claim bindings, token

View file

@ -957,6 +957,43 @@ def cmd_evidence_classify(cfg: Config, args) -> int:
# -- parser ---------------------------------------------------------------- # -- 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: def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="secrets-engine", description=__doc__.splitlines()[0]) p = argparse.ArgumentParser(prog="secrets-engine", description=__doc__.splitlines()[0])
p.add_argument("--version", action="version", version=f"secrets-engine {__version__}") 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.add_argument("--json", action="store_true")
cl.set_defaults(func=cmd_evidence_classify) 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 return p

View file

@ -10,6 +10,8 @@ from __future__ import annotations
import base64 import base64
import binascii import binascii
import json import json
import os
import stat
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -18,8 +20,8 @@ from urllib.error import HTTPError, URLError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from secrets_engine.errors import BackendError from secrets_engine.errors import BackendError, ProvisioningError
from secrets_engine.openbao import read_strict_token_file from secrets_engine.safe_paths import containing_git_worktree
CLIENT_ID = "secrets-engine-openbao" CLIENT_ID = "secrets-engine-openbao"
SUBJECT = "service:secrets-engine" SUBJECT = "service:secrets-engine"
@ -154,6 +156,84 @@ def preflight_service_jwt(
Transport = Callable[..., Any] 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) @dataclass(frozen=True)
class KeyCapeServiceAuthProvider: class KeyCapeServiceAuthProvider:
config: KeyCapeServiceAuthConfig config: KeyCapeServiceAuthConfig
@ -161,10 +241,7 @@ class KeyCapeServiceAuthProvider:
def exchange(self, *, now: datetime | None = None) -> ServiceJWT: def exchange(self, *, now: datetime | None = None) -> ServiceJWT:
"""Perform one client-credentials exchange; never retry or fall back.""" """Perform one client-credentials exchange; never retry or fall back."""
secret = read_strict_token_file( secret = read_client_secret_file(self.config.client_secret_file)
self.config.client_secret_file,
purpose="KeyCape client secret",
)
basic = base64.b64encode( basic = base64.b64encode(
f"{self.config.client_id}:{secret}".encode("utf-8") f"{self.config.client_id}:{secret}".encode("utf-8")
).decode("ascii") ).decode("ascii")

View file

@ -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

View file

@ -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.