RPF-WP-0054-T03: credential.py generate for keycape-client-secret-generate
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Silent generate-in-custody: verifies the SecurityCase file (digest, type,
materialized, confidential, client_id) per ops-warden 23985370, mints the 5m
create-only grant token from the caller's session, proves absence, writes once
with CAS 0, verifies by metadata only and revokes the token. Agents, root and
callers without the issuer policy are refused. Secret length awaits key-cape.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 469992@bnt-lap001
Assistant-Session: e4b8e420-df0a-441d-83ae-7f19130594d9
This commit is contained in:
codex 2026-10-09 20:12:44 +02:00
parent 7d498c8547
commit c921aa0aed
4 changed files with 453 additions and 2 deletions

View file

@ -1177,6 +1177,12 @@ def build_parser() -> argparse.ArgumentParser:
def main() -> int:
guard_verbose_env()
if sys.argv[1:2] == ["generate"]:
# RPF-WP-0054: generate-in-custody runs on the caller's own session and
# must stay silent, so it bypasses the lease runner and issuer prompt.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from keycape_client_secret_generate import main as generate_main
return generate_main(sys.argv[2:])
parser = build_parser()
args = parser.parse_args()
args.lease_dir = resolve_repo_path(args.lease_dir)

View file

@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""RPF-WP-0054-T03: generate a KeyCape confidential-client secret inside custody.
Entry point for the oidc-browser-relying-party ``mint-secret`` step (lane
``keycape-client-secret-generate``). Normally reached as
``credential.py generate ...``; ops-warden runs it as a child in the caller's
own environment and holds no token.
credential.py generate --grant keycape/client-secret-generate \\
--client-id forgejo --case-file <case.yaml> \\
--case-ref forgejo-netkingdom-oidc@sha256:<hex64> --receipt <file>
Steps (every one fails closed):
1. Refuse agents (``WARDEN_AGENT_ID``) and root. The caller's session must hold
the broker issuer policy for this grant.
2. Re-derive the case reference from ``--case-file`` (sha256 over its exact
bytes) and require: it equals ``--case-ref``; type
``oidc-browser-relying-party@1.0``; status ``materialized``; client_type
``confidential``; ``parameters.client_id`` equals ``--client-id``, which
matches the type's pattern and is not a reserved entry.
3. Mint a 5-minute grant token through token role
``keycape-client-secret-generate``. All KV calls below use only that token.
4. Prove absence by metadata (404). An existing entry is never touched.
5. Generate the value in this process and write it with CAS 0 through the
``bao`` child's stdin.
6. Verify by metadata only: current version 1, live. The data path is never read.
7. Revoke the grant token (revoke-self).
The process prints nothing. The receipt (mode 0600, refused if it exists) and
the optional State Hub event hold the path, version, case reference, booleans and
an outcome code only.
Exit codes: 0 generated (dry-run: would generate); 1 contained internal error;
2 precondition refused; 3 entry already exists (never overwritten, including a
lost CAS race); 4 token or write failure; 5 verification failure.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import sys
import urllib.request
import yaml
GRANT = 'keycape/client-secret-generate'
TOKEN_ROLE = 'keycape-client-secret-generate'
GRANT_POLICY = 'keycape-client-secret-generate'
ISSUER_POLICY = 'credential-broker-keycape-client-secret-generate-issuer'
PREFIX = 'workloads/keycape'
RESERVED = {'secrets-engine-openbao'}
FIELD = 'CLIENT_SECRET'
CASE_TYPE = 'oidc-browser-relying-party@1.0'
# oidc-browser-relying-party@1.0 parameter client_id.
CLIENT_ID_RE = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
CASE_REF_RE = re.compile(r'^[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64}$')
# Proposed 48 random bytes -> 64 URL-safe characters; key-cape confirmation
# pending (RPF-WP-0054-T01).
SECRET_BYTES = 48
TOKEN_TTL = '5m'
SCHEMA = 'platform.keycape-client-secret-generate.v1'
class Exit(Exception):
def __init__(self, code: int, reason: str):
super().__init__(reason)
self.code = code
self.reason = reason
def bao(args, token=None, payload=None):
env = dict(os.environ)
if token is not None:
env['BAO_TOKEN'] = env['VAULT_TOKEN'] = token
return subprocess.run(['bao', *args], env=env,
input=None if payload is None else json.dumps(payload).encode(),
capture_output=True, timeout=60)
def absent(result):
return result.returncode != 0 and (b'404' in result.stderr or b'No value found' in result.stderr)
def read_json(result, code):
try:
return json.loads(result.stdout)
except ValueError:
raise Exit(code, 'unparseable_bao_output') from None
def check_case(args, receipt):
if not CLIENT_ID_RE.match(args.client_id) or args.client_id in RESERVED:
raise Exit(2, 'client_id_invalid_or_reserved')
if not CASE_REF_RE.match(args.case_ref):
raise Exit(2, 'case_ref_malformed')
try:
raw = Path(args.case_file).read_bytes()
doc = yaml.safe_load(raw)
except (OSError, yaml.YAMLError):
raise Exit(2, 'case_file_unreadable') from None
spec = (doc or {}).get('spec') if isinstance(doc, dict) else None
if not isinstance(spec, dict):
raise Exit(2, 'case_file_shape')
params = spec.get('parameters') or {}
derived = f"{spec.get('id')}@sha256:{hashlib.sha256(raw).hexdigest()}"
if derived != args.case_ref:
raise Exit(2, 'case_ref_mismatch')
if spec.get('type') != CASE_TYPE:
raise Exit(2, 'case_type_mismatch')
if spec.get('status') != 'materialized':
raise Exit(2, 'case_not_materialized')
if params.get('client_type') != 'confidential':
raise Exit(2, 'case_client_not_confidential')
if params.get('client_id') != args.client_id:
raise Exit(2, 'case_client_id_mismatch')
receipt['case_verified'] = True
def run(args, receipt, state):
if args.grant != GRANT:
raise Exit(2, 'unknown_grant')
if os.environ.get('WARDEN_AGENT_ID'):
raise Exit(2, 'agent_callers_refused')
check_case(args, receipt)
ident = bao(['token', 'lookup', '-format=json'])
if ident.returncode != 0:
raise Exit(2, 'caller_session_missing')
policies = (read_json(ident, 2).get('data') or {}).get('policies') or []
if 'root' in policies:
raise Exit(2, 'root_refused')
if ISSUER_POLICY not in policies:
raise Exit(2, 'caller_lacks_issuer_policy')
created = bao(['token', 'create', f'-role={TOKEN_ROLE}', f'-policy={GRANT_POLICY}',
f'-ttl={TOKEN_TTL}', '-format=json'])
if created.returncode != 0:
raise Exit(4, 'grant_token_create_failed')
try:
state['token'] = read_json(created, 4)['auth']['client_token']
except (KeyError, TypeError):
raise Exit(4, 'grant_token_create_failed') from None
token = state['token']
meta_path = f'platform/metadata/{PREFIX}/{args.client_id}'
meta = bao(['read', '-format=json', meta_path], token=token)
if meta.returncode == 0:
raise Exit(3, 'already_present')
if not absent(meta):
raise Exit(2, 'absence_not_proven')
if args.dry_run:
return 'dry_run_would_generate'
value = secrets.token_urlsafe(SECRET_BYTES)
written = bao(['write', '-format=json', f'platform/data/{PREFIX}/{args.client_id}', '-'],
token=token, payload={'options': {'cas': 0}, 'data': {FIELD: value}})
del value
if written.returncode != 0:
if bao(['read', '-format=json', meta_path], token=token).returncode == 0:
raise Exit(3, 'already_present_lost_race')
raise Exit(4, 'write_failed')
meta = bao(['read', '-format=json', meta_path], token=token)
if meta.returncode != 0:
raise Exit(5, 'verify_metadata_unreadable')
data = read_json(meta, 5).get('data') or {}
version = (data.get('versions') or {}).get('1') or {}
if data.get('current_version') != 1 or version.get('deletion_time') or version.get('destroyed'):
raise Exit(5, 'verify_version_mismatch')
receipt['kv_version'] = 1
return 'generated'
def record_state_hub(url, receipt):
body = {
'summary': (f"keycape-client-secret-generate {receipt['outcome']}: {receipt['path']} "
f"v{receipt.get('kv_version', '-')} case {receipt['case_ref']}"),
'event_type': 'credential_change_evidence',
'author': 'railiance-platform-credential-broker',
}
request = urllib.request.Request(url.rstrip('/') + '/progress/', data=json.dumps(body).encode(),
headers={'Content-Type': 'application/json'}, method='POST')
with urllib.request.urlopen(request, timeout=10) as response:
return 200 <= response.status < 300
def main(argv=None):
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 1)
os.dup2(devnull, 2)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--grant', required=True)
parser.add_argument('--client-id', required=True)
parser.add_argument('--case-ref', required=True)
parser.add_argument('--case-file', required=True)
parser.add_argument('--receipt', type=Path)
parser.add_argument('--dry-run', action='store_true')
parser.add_argument('--record-state-hub', action='store_true')
parser.add_argument('--state-hub-url', default=os.environ.get('STATE_HUB_URL', 'http://127.0.0.1:8000'))
try:
args = parser.parse_args(argv)
except SystemExit:
return 2
receipt = {'schema': SCHEMA, 'mode': 'dry-run' if args.dry_run else 'generate',
'grant': args.grant, 'client_id': args.client_id, 'case_ref': args.case_ref,
'path': f'platform/{PREFIX}/{args.client_id}', 'value_reads': 0,
'credential_values_emitted': False}
fd = None
if args.receipt is not None:
try:
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
except OSError:
return 2
state = {}
try:
receipt['outcome'] = run(args, receipt, state)
code = 0
except Exit as exc:
receipt['outcome'] = exc.reason
code = exc.code
except Exception:
receipt['outcome'] = 'contained_internal_error'
code = 1
if state.get('token'):
receipt['grant_token_revoked'] = bao(['token', 'revoke', '-self'], token=state['token']).returncode == 0
receipt['exit_code'] = code
if args.record_state_hub:
try:
receipt['state_hub_recorded'] = record_state_hub(args.state_hub_url, receipt)
except Exception:
receipt['state_hub_recorded'] = False
if fd is not None:
os.write(fd, (json.dumps(receipt, indent=2, sort_keys=True) + '\n').encode())
os.close(fd)
return code
if __name__ == '__main__':
sys.exit(main())

View file

@ -0,0 +1,177 @@
"""Offline tests for credential.py generate (RPF-WP-0054-T03) against a fake ``bao``.
Runs ``credential.py generate`` as a real subprocess, so the silence contract and
the delegation from credential.py are tested end to end. The fake records which
token each call used: KV calls must use the minted grant token, never the
caller's session.
"""
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import textwrap
import unittest
ROOT = Path(__file__).resolve().parents[1]
CLI = ROOT / 'scripts/credential.py'
GRANT_TOKEN = 'hvs.grant-' + 'G7h8' * 6
FAKE_BAO = textwrap.dedent(f'''\
#!/usr/bin/env python3
import json, os, sys
sf = os.environ['FAKE_BAO_STATE']
st = json.load(open(sf))
a = sys.argv[1:]
tok = os.environ.get('BAO_TOKEN', 'caller-session')
st['calls'].append({{'args': a, 'token': tok}})
def save(): json.dump(st, open(sf, 'w'))
def out(obj): save(); print(json.dumps(obj)); sys.exit(0)
def missing(): save(); sys.stderr.write('No value found at x\\n'); sys.exit(2)
if a[:2] == ['token', 'lookup']: out({{'data': {{'policies': st['caller_policies']}}}})
if a[:2] == ['token', 'create']:
if st.get('fail_create'): save(); sys.exit(2)
out({{'auth': {{'client_token': '{GRANT_TOKEN}', 'accessor': 'acc'}}}})
if a[:2] == ['token', 'revoke']: st['revoked'] = tok; save(); sys.exit(0)
if a[0] == 'read':
rest = a[-1].split('/', 2)[2]
if rest not in st['kv']: missing()
n = len(st['kv'][rest])
out({{'data': {{'current_version': n, 'versions': {{str(i + 1): {{}} for i in range(n)}}}}}})
if a[0] == 'write':
rest = a[-2].split('/', 2)[2]
body = json.loads(sys.stdin.read())
st['written'] = body
if st.get('race'): st['kv'][rest] = [{{'x': 1}}]; save(); sys.exit(2)
if st.get('fail_write'): save(); sys.exit(2)
e = st['kv'].setdefault(rest, [])
if body['options']['cas'] != len(e): save(); sys.exit(2)
e.append(body['data'])
out({{'data': {{'version': len(e)}}}})
save(); sys.exit(9)
''')
CASE = textwrap.dedent('''\
apiVersion: netkingdom.io/security-case/v0.1
kind: SecurityCase
spec:
id: forgejo-netkingdom-oidc
type: oidc-browser-relying-party@1.0
status: materialized
parameters:
client_id: forgejo
client_type: confidential
''')
class Generate(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
(self.tmp / 'bin').mkdir()
(self.tmp / 'bin/bao').write_text(FAKE_BAO)
(self.tmp / 'bin/bao').chmod(0o755)
self.state = {'calls': [], 'kv': {},
'caller_policies': ['default', 'credential-broker-keycape-client-secret-generate-issuer']}
self.write_case(CASE)
def write_case(self, text):
self.case = self.tmp / 'case.yaml'
self.case.write_text(text)
self.ref = 'forgejo-netkingdom-oidc@sha256:' + hashlib.sha256(text.encode()).hexdigest()
def run_cli(self, *extra, client_id='forgejo', ref=None, env_extra=None):
(self.tmp / 'state.json').write_text(json.dumps(self.state))
receipt = self.tmp / f'r{len(list(self.tmp.glob("r*.json")))}.json'
env = {k: v for k, v in os.environ.items() if k not in ('BAO_TOKEN', 'VAULT_TOKEN', 'WARDEN_AGENT_ID')}
env.update(PATH=f'{self.tmp / "bin"}:{env["PATH"]}', FAKE_BAO_STATE=str(self.tmp / 'state.json'),
**(env_extra or {}))
proc = subprocess.run([sys.executable, str(CLI), 'generate', '--grant', 'keycape/client-secret-generate',
'--client-id', client_id, '--case-file', str(self.case),
'--case-ref', ref or self.ref, '--receipt', str(receipt), *extra],
env=env, capture_output=True, text=True)
self.assertEqual((proc.stdout, proc.stderr), ('', ''))
self.state = json.loads((self.tmp / 'state.json').read_text())
text = receipt.read_text()
self.assertNotIn(GRANT_TOKEN, text)
written = self.state.get('written')
if written:
self.assertNotIn(written['data']['CLIENT_SECRET'], text)
return proc.returncode, json.loads(text)
def kv_calls(self):
return [c for c in self.state['calls'] if c['args'][0] in ('read', 'write')]
def test_generates_once_with_grant_token_and_revokes_it(self):
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (0, 'generated'))
self.assertEqual(receipt['kv_version'], 1)
self.assertTrue(receipt['grant_token_revoked'])
self.assertEqual(self.state['revoked'], GRANT_TOKEN)
self.assertTrue(all(c['token'] == GRANT_TOKEN for c in self.kv_calls()))
self.assertFalse(any(c['args'][0] == 'read' and '/data/' in c['args'][-1] for c in self.kv_calls()))
value = self.state['written']['data']['CLIENT_SECRET']
self.assertEqual(len(value), 64)
self.assertEqual(self.state['written']['options'], {'cas': 0})
def test_existing_entry_is_never_touched(self):
self.state['kv']['workloads/keycape/forgejo'] = [{'CLIENT_SECRET': 'old'}]
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (3, 'already_present'))
self.assertNotIn('written', self.state)
self.assertTrue(receipt['grant_token_revoked'])
def test_lost_cas_race_reports_already_present(self):
self.state['race'] = True
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (3, 'already_present_lost_race'))
def test_dry_run_writes_nothing(self):
code, receipt = self.run_cli('--dry-run')
self.assertEqual((code, receipt['outcome']), (0, 'dry_run_would_generate'))
self.assertNotIn('written', self.state)
def test_case_checks_refuse_before_any_bao_call(self):
cases = [
('case_ref_mismatch', {'ref': 'forgejo-netkingdom-oidc@sha256:' + '0' * 64}),
('case_client_id_mismatch', {'client_id': 'gitea'}),
('client_id_invalid_or_reserved', {'client_id': 'secrets-engine-openbao'}),
('client_id_invalid_or_reserved', {'client_id': '../forgejo'}),
('case_ref_malformed', {'ref': 'forgejo'}),
]
for outcome, kwargs in cases:
with self.subTest(outcome=outcome, **kwargs):
self.state['calls'] = []
code, receipt = self.run_cli(**kwargs)
self.assertEqual((code, receipt['outcome']), (2, outcome))
self.assertEqual(self.state['calls'], [])
def test_unmaterialized_or_public_case_refused(self):
for text, outcome in ((CASE.replace('materialized', 'accepted'), 'case_not_materialized'),
(CASE.replace('confidential', 'public'), 'case_client_not_confidential')):
with self.subTest(outcome=outcome):
self.write_case(text)
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, outcome))
def test_agents_root_and_unbound_callers_refused(self):
code, receipt = self.run_cli(env_extra={'WARDEN_AGENT_ID': 'codex'})
self.assertEqual((code, receipt['outcome']), (2, 'agent_callers_refused'))
self.state['caller_policies'] = ['root']
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'root_refused'))
self.state['caller_policies'] = ['default']
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'caller_lacks_issuer_policy'))
self.assertFalse(any(c['args'][:2] == ['token', 'create'] for c in self.state['calls']))
def test_write_failure_is_reported_and_token_revoked(self):
self.state['fail_write'] = True
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (4, 'write_failed'))
self.assertTrue(receipt['grant_token_revoked'])
if __name__ == '__main__':
unittest.main()

View file

@ -45,7 +45,7 @@ case's `client-secret-path` artifacts and are unchanged here.
```task
id: RPF-WP-0054-T01
status: todo
status: progress
priority: high
state_hub_task_id: "38f92db0-317e-5b2b-8bbd-b4af3272cc21"
```
@ -61,6 +61,18 @@ goes into the receipt and into OpenBao `custom_metadata` (non-secret).
**Done when:** both replies are recorded here, with no open question about
format, field or caller.
2026-10-09 ops-warden (`23985370`): the caller is the human operator who runs
`warden case run`. Warden runs `credential.py generate` as a child process in
the caller's environment and holds no token or grant, so the caller's own OIDC
session is the acting identity. Warden refuses the step under `WARDEN_AGENT_ID`.
The case reference is `<case_id>@sha256:<hex64>` over the case file's exact
bytes (`warden case plan` prints it). The platform must also take `--case-file`,
re-derive the digest, and check that status is `materialized` and that
`parameters.client_id` equals `--client-id`. The client_id pattern is taken from
the case type, `^[a-z0-9][a-z0-9-]{1,62}$` (authoritative; it replaces the
proposal above). Still open: key-cape confirmation of length and alphabet,
asked 2026-10-09.
## T02 — Decide the grant shape
```task
@ -103,7 +115,7 @@ built.
```task
id: RPF-WP-0054-T03
status: todo
status: progress
priority: high
depends_on: [RPF-WP-0054-T01, RPF-WP-0054-T02]
state_hub_task_id: "3e159e92-f5ee-5a92-b887-e06daf7dfbdb"
@ -133,6 +145,16 @@ reserved path, bad client_id, missing case-ref, write failure, verify mismatch,
silence contract, and an assertion that no data-path read occurs. No secret
text appears in any receipt.
2026-10-09 progress: `scripts/keycape_client_secret_generate.py`, reached as
`credential.py generate --grant keycape/client-secret-generate --client-id <id>
--case-file <case.yaml> --case-ref <ref> [--receipt f] [--dry-run]
[--record-state-hub]`. It refuses agents, root, and callers without the issuer
policy. It verifies the case file as ops-warden specified, mints the 5m grant
token, and makes every KV call with that token only. It proves absence, writes
once with CAS 0, verifies by metadata (version 1), revokes the token, and prints
nothing. There are 8 offline tests. Remaining before done: key-cape's format
confirmation (`SECRET_BYTES` is the proposed 48 bytes, 64 URL-safe characters).
## T04 — Grant, policy and token role source plus CCR
```task