Name the secrets-engine-openbao consumer custody coordinates.
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:
parent
be8fab0332
commit
a3897c2317
6 changed files with 475 additions and 6 deletions
154
docs/keycape-openbao-consumer-custody.md
Normal file
154
docs/keycape-openbao-consumer-custody.md
Normal 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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
113
tests/test_consumer_secret_file.py
Normal file
113
tests/test_consumer_secret_file.py
Normal 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
|
||||||
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue