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