diff --git a/scripts/credential.py b/scripts/credential.py index 0886349..7c94d29 100755 --- a/scripts/credential.py +++ b/scripts/credential.py @@ -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) diff --git a/scripts/keycape_client_secret_generate.py b/scripts/keycape_client_secret_generate.py new file mode 100644 index 0000000..29a71b9 --- /dev/null +++ b/scripts/keycape_client_secret_generate.py @@ -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-ref forgejo-netkingdom-oidc@sha256: --receipt + +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()) diff --git a/tests/test_keycape_client_secret_generate.py b/tests/test_keycape_client_secret_generate.py new file mode 100644 index 0000000..b9a51f0 --- /dev/null +++ b/tests/test_keycape_client_secret_generate.py @@ -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() diff --git a/workplans/RPF-WP-0054-keycape-client-secret-generate-lane.md b/workplans/RPF-WP-0054-keycape-client-secret-generate-lane.md index 227d892..3ea022d 100644 --- a/workplans/RPF-WP-0054-keycape-client-secret-generate-lane.md +++ b/workplans/RPF-WP-0054-keycape-client-secret-generate-lane.md @@ -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 `@sha256:` 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 +--case-file --case-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