From d35c0f9d4b1c12684cfc334cd876ba0e96a13b88 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 17 Oct 2026 22:55:51 +0200 Subject: [PATCH] Bind mint broker validation to exact reviewed sources and track successor obligations Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1468b-63fa-7710-85dc-8f2e16b3ebfa --- credential-grants/catalog.yaml | 4 +- scripts/keycape_client_secret_generate.py | 89 ++++++++++++- tests/test_credential_grants.py | 14 ++ tests/test_keycape_client_secret_generate.py | 120 +++++++++++++++++- ...-WP-0060-browser-case-platform-bindings.md | 118 +++++++++++++++++ 5 files changed, 337 insertions(+), 8 deletions(-) create mode 100644 workplans/RPF-WP-0060-browser-case-platform-bindings.md diff --git a/credential-grants/catalog.yaml b/credential-grants/catalog.yaml index 690f116..afb17dd 100644 --- a/credential-grants/catalog.yaml +++ b/credential-grants/catalog.yaml @@ -137,9 +137,9 @@ grants: requires_human_above: 5m actors: allowed_types: - - approved-agent - human-operator - required_subject_binding: keycape-or-kubernetes-service-account + required_subject_binding: auth/netkingdom/role/credential-broker-keycape-client-secret-generate + # CCR-2026-0043: human net-kingdom-admins membership and /assurance/level=aal2. authorization: flex_auth_required: false flex_auth_mode: optional-preflight diff --git a/scripts/keycape_client_secret_generate.py b/scripts/keycape_client_secret_generate.py index 207bf7d..96bdedc 100644 --- a/scripts/keycape_client_secret_generate.py +++ b/scripts/keycape_client_secret_generate.py @@ -16,7 +16,7 @@ Steps (every one fails closed): 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.1``; status ``materialized``; client_type + an exactly source-pinned acknowledged browser revision; 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 @@ -46,6 +46,7 @@ import re import secrets import subprocess import sys +import tempfile import urllib.request import yaml @@ -57,7 +58,39 @@ 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.1' +# Owner configuration, never a caller-supplied root or a version search/fallback. +# The sibling source checkout must contain these exact reviewed bytes. +NETKINGDOM_ROOT = Path(__file__).resolve().parents[2] / 'net-kingdom' +ACKNOWLEDGED_TYPES = { + 'oidc-browser-relying-party@1.1': ( + 'security-case-types/oidc-browser-relying-party/type.yaml', + 'f705c64e4875e9b8e18a912fb51c135e77898926d6a451f5cb74ab3c0cbe4d1c'), +} +# Same authenticated producer and parameter projection as ops-warden case_inputs +# at b593c879, restricted to this broker's currently admitted 1.1. Source-only +# acknowledgment (including 1.2) never expands broker admission. A future 1.3 +# entry requires real acknowledgment and a separately reviewed admission binding. +# Neither source status nor caller-supplied YAML expands this set. +SOURCE_PINS = { + 'tools/security-case-types/security_case_validator.py': + '1bcc62e9bc374c711363295549f3cd5bb0b32952671ed4b08fddc7f74742959b', + 'tools/security-scenario-composer/security_scenario_composer.py': + '9f0cc69930aa8ee2cc3fb92236e6a99728df17b8c0fe36e1250241c38fc01c4f', + 'tools/playbook-capability-contract/playbook_contract_validator.py': + '2e1e0c809d4bc9d64c2d974cc23fecd1c5636785154c2554edd9191852fd926d', +} +PARAMETER_PINS = { + '1.1': '9ec2d6113bf70dbd35cfc247412363ff187e792bc82e24159a6f78e090aa3e4c', +} +_VALIDATE = """ +import json, sys +from pathlib import Path +sys.path.insert(0, str(Path(sys.argv[1]) / 'tools/security-case-types')) +import security_case_validator as v +case, typ = json.load(sys.stdin) +issues = v.validate_type(typ) + v.validate_case(case, typ, require_implemented=False) +print('valid' if not v.has_errors(issues) else 'invalid') +""" # oidc-browser-relying-party@1.1 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}$') @@ -95,6 +128,53 @@ def read_json(result, code): raise Exit(code, 'unparseable_bao_output') from None +def authenticated_source(relative, expected): + path = NETKINGDOM_ROOT / relative + # Refuse symlinks at every selected component, including the configured root. + if any(part.is_symlink() for part in (path, *path.parents)): + raise ValueError + raw = path.read_bytes() + if hashlib.sha256(raw).hexdigest() != expected: + raise ValueError + return raw + + +def check_acknowledged_case(doc): + # Authenticates the selected TYPE/producer bytes and validates case schema. + # The caller's self-hashed case reference is not authenticated case admission + # or owner acceptance. Those require independent Warden/native evidence; + # this source-only hardening does not complete the real admission binding. + binding = ACKNOWLEDGED_TYPES.get(doc['spec'].get('type')) + if binding is None: + raise Exit(2, 'case_type_unacknowledged') + try: + typ = yaml.safe_load(authenticated_source(*binding)) + spec = typ['spec'] + version = str(spec['version']) + fingerprint = hashlib.sha256(json.dumps( + spec['parameters'], sort_keys=True, separators=(',', ':')).encode()).hexdigest() + if (spec['status'] not in ('acknowledged', 'implemented') + or f"{spec['id']}@{version}" != doc['spec']['type'] + or PARAMETER_PINS.get(version) != fingerprint): + raise ValueError + with tempfile.TemporaryDirectory(prefix='platform-case-validator-') as directory: + for relative, expected in SOURCE_PINS.items(): + raw = authenticated_source(relative, expected) + dest = Path(directory) / relative + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(raw) + result = subprocess.run( + [sys.executable, '-I', '-c', _VALIDATE, directory], + input=json.dumps([doc, typ]), text=True, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + env={}, timeout=10, check=True) + if result.stdout != 'valid\n': + raise ValueError + except (OSError, ValueError, TypeError, KeyError, yaml.YAMLError, + subprocess.SubprocessError): + raise Exit(2, 'case_source_validation_failed') 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') @@ -112,14 +192,15 @@ def check_case(args, receipt): 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 not isinstance(spec.get('type'), str): + raise Exit(2, 'case_type_unacknowledged') 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') + check_acknowledged_case(doc) receipt['case_verified'] = True diff --git a/tests/test_credential_grants.py b/tests/test_credential_grants.py index fc81816..7bd1364 100644 --- a/tests/test_credential_grants.py +++ b/tests/test_credential_grants.py @@ -25,6 +25,20 @@ class CredentialGrantBindTests(unittest.TestCase): grants.validate_grant(grant, index, catalog, errors) self.assertEqual(errors, []) + def test_keycape_generator_actor_matches_applied_issuer_binding(self) -> None: + catalog = grants.yaml.safe_load( + (REPO_DIR / 'credential-grants/catalog.yaml').read_text()) + grant = next(g for g in catalog['grants'] if g['id'] == 'keycape/client-secret-generate') + ccr = grants.yaml.safe_load((REPO_DIR / 'credential-change-requests' / + 'CCR-2026-0043-keycape-client-secret-generate-issuer-binding.yaml').read_text()) + role = grants.yaml.safe_load((REPO_DIR / ccr['openbao']['auth']['role_file']).read_text()) + auth = ccr['openbao']['auth'] + self.assertEqual(grant['actors']['allowed_types'], ['human-operator']) + self.assertEqual(grant['actors']['required_subject_binding'], + f"auth/{auth['mount']}/role/{auth['role']}") + self.assertEqual(role['bound_claims'], + {'groups': ['net-kingdom-admins'], '/assurance/level': 'aal2'}) + def test_postgres_grants_bind_rapp(self) -> None: catalog = grants.yaml.safe_load( (REPO_DIR / "credential-grants/catalog.yaml").read_text() diff --git a/tests/test_keycape_client_secret_generate.py b/tests/test_keycape_client_secret_generate.py index 428424a..bd34ffd 100644 --- a/tests/test_keycape_client_secret_generate.py +++ b/tests/test_keycape_client_secret_generate.py @@ -6,6 +6,11 @@ token each call used: KV calls must use the minted grant token, never the caller's session. """ import hashlib +import importlib.util +import shutil +from unittest import mock + +import yaml import json import os from pathlib import Path @@ -75,7 +80,9 @@ class Generate(unittest.TestCase): 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')} + empty_home = self.tmp / 'empty-home' + empty_home.mkdir(exist_ok=True) + env = {'PATH': os.defpath, 'HOME': str(empty_home)} 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', @@ -149,15 +156,124 @@ class Generate(unittest.TestCase): def test_only_exact_acknowledged_type_version_is_admitted(self): for type_ref in ('oidc-browser-relying-party@1.0', 'oidc-browser-relying-party@1.2', + 'oidc-browser-relying-party@1.3', 'oidc-browser-relying-party@99.0', 'confidential-service-client@1.1'): with self.subTest(type_ref=type_ref): self.write_case(CASE.replace('oidc-browser-relying-party@1.1', type_ref)) self.state['calls'] = [] code, receipt = self.run_cli() - self.assertEqual((code, receipt['outcome']), (2, 'case_type_mismatch')) + self.assertEqual((code, receipt['outcome']), (2, 'case_type_unacknowledged')) self.assertEqual(self.state['calls'], []) + def test_source_acknowledgment_alone_does_not_admit_12(self): + self.write_case(CASE.replace('oidc-browser-relying-party@1.1', + 'oidc-browser-relying-party@1.2') + + ' consumer_namespace: forgejo\n') + code, receipt = self.run_cli() + self.assertEqual((code, receipt['outcome']), (2, 'case_type_unacknowledged')) + self.assertEqual(self.state['calls'], []) + + def test_caller_environment_cannot_select_type_or_source_root(self): + code, receipt = self.run_cli(env_extra={ + 'NETKINGDOM_ROOT': '/nonexistent', + 'WARDEN_NETKINGDOM_ROOT': '/nonexistent', + 'PYTHONPATH': '/nonexistent', + }) + self.assertEqual((code, receipt['outcome']), (0, 'generated')) + + def test_acknowledged_11_still_requires_its_exact_parameters(self): + self.write_case(CASE + ' unexpected_target: forgejo\n') + code, receipt = self.run_cli() + self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed')) + self.assertEqual(self.state['calls'], []) + + def test_future_revision_requires_explicit_owner_binding_and_acknowledgment(self): + # Synthetic owner configuration exercises the extension mechanism only. + # This is not the actual proposed 1.3 type, acknowledgment or parameter pin. + spec = importlib.util.spec_from_file_location( + 'future_fixture_generator', ROOT / 'scripts/keycape_client_secret_generate.py') + broker = importlib.util.module_from_spec(spec) + spec.loader.exec_module(broker) + source = self.tmp / 'synthetic-owner-root' + source.mkdir() + relative, digest = broker.ACKNOWLEDGED_TYPES['oidc-browser-relying-party@1.1'] + for rel in (relative, *broker.SOURCE_PINS): + dest = source / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(broker.NETKINGDOM_ROOT / rel, dest) + typ = source / relative + original = typ.read_bytes() + doc = yaml.safe_load(CASE) + doc['spec']['type'] = 'oidc-browser-relying-party@1.3' + typ.write_bytes(original.replace(b'version: "1.1"', b'version: "1.3"')) + # YAML numeric spelling is owner-authored, never an inferred revision. + type_doc = yaml.safe_load(original) + type_doc['spec']['version'] = '1.3' + typ.write_text(yaml.safe_dump(type_doc)) + binding = {doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())} + with mock.patch.object(broker, 'NETKINGDOM_ROOT', source): + with self.assertRaises(broker.Exit): + broker.check_acknowledged_case(doc) # no explicit binding + with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, binding): + with self.assertRaises(broker.Exit): + broker.check_acknowledged_case(doc) # no parameter admission + with mock.patch.dict(broker.PARAMETER_PINS, {'1.3': broker.PARAMETER_PINS['1.1']}): + broker.check_acknowledged_case(doc) + type_doc['spec']['status'] = 'proposed' + typ.write_text(yaml.safe_dump(type_doc)) + proposed = {doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())} + with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, proposed): + with self.assertRaises(broker.Exit) as caught: + broker.check_acknowledged_case(doc) + self.assertEqual(caught.exception.reason, 'case_source_validation_failed') + + def test_wrong_sources_proposed_unknown_versions_and_symlinks_fail_closed(self): + spec = importlib.util.spec_from_file_location( + 'candidate_generator', ROOT / 'scripts/keycape_client_secret_generate.py') + broker = importlib.util.module_from_spec(spec) + spec.loader.exec_module(broker) + source = self.tmp / 'net-kingdom' + source.mkdir() + relative, digest = broker.ACKNOWLEDGED_TYPES['oidc-browser-relying-party@1.1'] + for rel in (relative, *broker.SOURCE_PINS): + dest = source / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(broker.NETKINGDOM_ROOT / rel, dest) + doc = yaml.safe_load(CASE) + with mock.patch.object(broker, 'NETKINGDOM_ROOT', source): + broker.check_acknowledged_case(doc) + producer = source / next(iter(broker.SOURCE_PINS)) + raw = producer.read_bytes() + producer.write_bytes(raw + b'\n# drift\n') + with self.assertRaises(broker.Exit) as caught: + broker.check_acknowledged_case(doc) + self.assertEqual(caught.exception.reason, 'case_source_validation_failed') + producer.write_bytes(raw) + typ = source / relative + raw = typ.read_bytes() + typ.write_bytes(raw.replace(b'status: acknowledged', b'status: proposed')) + # Even a hypothetical owner pin update cannot admit proposed status. + with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, { + doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())}): + with self.assertRaises(broker.Exit) as caught: + broker.check_acknowledged_case(doc) + self.assertEqual(caught.exception.reason, 'case_source_validation_failed') + typ.write_bytes(raw + b'\n# source drift\n') + with self.assertRaises(broker.Exit) as caught: + broker.check_acknowledged_case(doc) + self.assertEqual(caught.exception.reason, 'case_source_validation_failed') + typ.unlink() + typ.symlink_to(broker.__file__) + with self.assertRaises(broker.Exit) as caught: + broker.check_acknowledged_case(doc) + self.assertEqual(caught.exception.reason, 'case_source_validation_failed') + for ref in ('oidc-browser-relying-party@1.3', 'oidc-browser-relying-party@99.0'): + doc['spec']['type'] = ref + with self.assertRaises(broker.Exit) as caught: + broker.check_acknowledged_case(doc) + self.assertEqual(caught.exception.reason, 'case_type_unacknowledged') + def test_exact_accepted_case_still_requires_materialization(self): self.write_case(CASE.replace('status: materialized', 'status: accepted')) code, receipt = self.run_cli() diff --git a/workplans/RPF-WP-0060-browser-case-platform-bindings.md b/workplans/RPF-WP-0060-browser-case-platform-bindings.md new file mode 100644 index 0000000..6332064 --- /dev/null +++ b/workplans/RPF-WP-0060-browser-case-platform-bindings.md @@ -0,0 +1,118 @@ +--- +id: RPF-WP-0060 +type: workplan +title: "Implement Platform bindings for the browser SecurityCase successor" +domain: financials +repo: railiance-platform +status: active +owner: codex +topic_slug: netkingdom +created: "2026-10-17" +updated: "2026-10-17" +flavor: implementation +origin: cross-repo +origin_ref: NK-WP-0048-T16 +state_hub_workstream_id: "43110f82-ef9f-5a46-a305-f0e287b7b226" +--- + +Demand: Platform's source review of the proposed browser SecurityCase 1.3 +requires four concrete owner obligations before owner-descriptor acceptance. +The review labels `RPF-1.3-T1` through `T4` were not live work records; this +workplan makes them ordinary owner tasks without changing RPF-WP-0054's +completed first-generation lane or implementing its residual rotation work. + +Review: `the-custodian/docs/changes/netkingdom-target-2026-10-17/nk48-1.3-owner-review/owner-review-railiance-platform.json`. +The 1.3 type remains proposed. This workplan grants no native access, case +acceptance, owner acknowledgment, catalog activation or policy change. + +## T01 — Bind the broker to reviewed acknowledged type sources + +```task +id: RPF-WP-0060-T01 +status: progress +priority: high +state_hub_task_id: "afec2053-1995-506d-a112-cda1de552c1c" +``` + +Implements review obligation RPF-1.3-T1. The broker's explicit source map admits +only its existing 1.1 revision. Exact type and producer bytes, parameter +fingerprint, acknowledgment state and case schema are checked before any Bao +call. Source-only 1.2 and proposed 1.3 remain refused. The fixed installation +layout is `railiance-platform` and `net-kingdom` as real sibling directories; +missing, symlinked or drifted selected sources refuse rather than fall back. + +2026-10-17: source hardening and catalog regressions independently reviewed in +`the-custodian/docs/changes/netkingdom-target-2026-10-17/platform-broker-review-r1/`. +The exact case reference binds caller-supplied bytes; schema validation does +not authenticate case acceptance. Existing Warden admission/native evidence +must establish that independently. This task remains open for the real binding +and explicit 1.3 source/parameter pins after genuine acknowledgment and review. +No speculative successor pin or acknowledgment is added. + +Done when the actual admitted-case binding is verified, the genuinely +acknowledged successor is explicitly selected, and refusal tests cover unknown, +unacknowledged, wrong-case and drifted-source inputs before native effects. + +## T02 — Correct the documented mint caller and issuer binding + +```task +id: RPF-WP-0060-T02 +status: done +priority: high +state_hub_task_id: "1a764c10-3a41-5eff-af96-c9e33a6e2c8b" +``` + +Implements review obligation RPF-1.3-T2. `credential-grants/catalog.yaml` now +names human-operator only and the exact existing issuer role +`auth/netkingdom/role/credential-broker-keycape-client-secret-generate`. +Regression checks bind this statement to CCR-2026-0043's role source, +net-kingdom-admins membership and aal2 claim. No OpenBao role, policy, grant, +credential or live configuration changes. + +## T03 — Implement mint, guarded destruction and authenticated evidence + +```task +id: RPF-WP-0060-T03 +status: todo +priority: high +state_hub_task_id: "b5d73ec6-c593-50a9-8367-f899edec6f5f" +``` + +Implements review obligation RPF-1.3-T3. Bind the actual generator to the closed +`railiance-platform.custody-mint.v1` projection and authenticated publication. +Capture grant accessor and revocation evidence and revoke the issuer session. +Destruction uses attended platform-admin authority and compares the original +CAS-0 version-1 creation time, current version and absence of other versions +before metadata deletion. No standing family-wide delete grant is introduced. +Unavailable authority, uncertain effects or failed revocation remain UNCERTAIN +for owner reconciliation. Verify with the frozen adapter, then obtain the +required native authority and actual execution receipts. + +## T04 — Implement and admit the delivery lane and capability + +```task +id: RPF-WP-0060-T04 +status: todo +priority: high +state_hub_task_id: "84f8b0a4-8450-5623-8608-dc277978383b" +``` + +Implements review obligation RPF-1.3-T4. Reuse the fixed proposed issuer Store +contract; validate the preexisting Store and only create the case-derived +ExternalSecret in `sso`. Refuse conflicts; report NO_CHANGE only for matching +preexisting configuration; establish current Ready status before success. +Return the closed metadata-only `netkingdom.deliver-secret.v1` receipt and use +UID and resourceVersion preconditions to remove only authenticated case-created +resources. Never read Secret values or create the Store through this lane. + +Bind real human net-kingdom-admins/aal2 admission, per-attempt session and +revocation, durable pre-intent/recovery journal and authenticated owner evidence. +The separate command-mechanics candidate is preparation, not this capability; +its production factory refuses. Review freshness/defaulting/transport failure +cases before integrating it. Obtain ops-warden's catalog routing acceptance for +`platform-secret-delivery` through an authorized handoff before recording owner +acceptance. No routing handoff or native action is implied by this work record. + +The parent stays active until these obligations and their required owner +acceptance are proved. Source tests do not complete native acceptance or +KEY-WP-0039's negative/expiry/cleanup exercise.