--- a/tools/bootstrap-recovery/isolated-keycape.py +++ b/tools/bootstrap-recovery/isolated-keycape.py @@ -322,6 +322,23 @@ def run_verifier(session, admission, payload): + # Select from the already-restored config before further private/runtime reads. + # This is not public preflight before archive decryption or its key prompt. + import yaml + cfg = yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml'],validate=True)) + if not isinstance(cfg,dict) or cfg.get('issuer') != admission['issuer']: + raise Refused() + consumer = admission['consumer'] + clients = cfg.get('clients') + if not isinstance(clients,list) or any(not isinstance(client,dict) for client in clients): + raise Refused() + matches = [client for client in clients if client.get('clientId') == consumer['client_id']] + if len(matches) != 1: + raise Refused() + client = matches[0] + if (client.get('secretRef') != 'env:'+consumer['secret_env'] or client.get('clientType') != 'confidential' + or 'client_credentials' not in client.get('grantTypes',[])): + raise Refused() issuer = session['issuer'] if {v['target'] for v in session.get('volumes',{}).values()}!={'/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls'}:raise Refused() from urllib.parse import urlsplit @@ -352,15 +369,7 @@ if durable!={k:v for k,v in files.items() if k.startswith('/var/lib/keycape-policy/')}: raise Refused() equal() - # Reading YAML is data parsing only; no archived executable is evaluated. - import yaml - cfg = yaml.safe_load(files['/etc/keycape/config.yaml']) - if cfg['issuer'] != admission['issuer']: - raise Refused() - consumer = admission['consumer'] - client = next(x for x in cfg['clients'] if x['clientId']==consumer['client_id']) - if (client['secretRef']!='env:'+consumer['secret_env'] or client['clientType']!='confidential' - or 'client_credentials' not in client['grantTypes'] or not env.get(consumer['secret_env'])): + if not env.get(consumer['secret_env']): raise Refused() from cryptography.hazmat.primitives import serialization key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) --- a/tests/test_isolated_keycape_recovery.py +++ b/tests/test_isolated_keycape_recovery.py @@ -53,6 +53,34 @@ for row in self.manifest['files']: if row['path'].endswith('/deployment/keycape.json'):row.update(bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest()) with self.assertRaises(a.Refused):self.payload() + + +class OriginalSelectorTests(unittest.TestCase): + """Already-restored public fixture config; no archive/key/native action.""" + def assert_refused_before_further_inputs(self,clients): + import yaml + cfg={'issuer':'https://issuer.example','clients':clients} + payload={'files':{'/etc/keycape/config.yaml':base64.b64encode(yaml.safe_dump(cfg).encode()).decode(), + '/etc/keycape/key.pem':'deliberately-not-base64'}} + admission={'issuer':'https://issuer.example','consumer':{'client_id':'existing','secret_env':'KEYCAPE_ORIGINAL_SECRET'}} + # Empty session/no env and an undecodable key also expose early private access. + from contextlib import ExitStack + with ExitStack() as stack: + blocked=[stack.enter_context(patch.object(a,name,side_effect=AssertionError('later input or native call'))) + for name in ('container_file','runtime_file','runtime_files','command','check_jwks','process')] + with self.assertRaises(a.Refused):a.run_verifier({},admission,payload) + for call in blocked:call.assert_not_called() + def client(self): + return {'clientId':'existing','clientType':'confidential','secretRef':'env:KEYCAPE_ORIGINAL_SECRET', + 'grantTypes':['client_credentials']} + def test_absent_original_selector_refused_before_further_inputs(self): + self.assert_refused_before_further_inputs([dict(self.client(),clientId='unrelated')]) + def test_identical_duplicate_selector_refused_before_further_inputs(self): + self.assert_refused_before_further_inputs([self.client(),self.client()]) + def test_conflicting_duplicate_selector_refused_before_further_inputs(self): + self.assert_refused_before_further_inputs([self.client(),dict(self.client(),secretRef='env:OTHER')]) + def test_empty_registry_refused_before_further_inputs(self): + self.assert_refused_before_further_inputs([]) class NativeTests(unittest.TestCase): def setUp(self): --- a/tests/isolated-keycape-native-fixture-manifest.json +++ b/tests/isolated-keycape-native-fixture-manifest.json @@ -7,7 +7,7 @@ "tests/run_isolated_keycape_native_fixture.py": "sha256:ecbadecc475622eaaf20a726110aebef5f4fe7af141110a98aca1640179b4a03", "tests/test_identity_bundle.py": "sha256:be5ce74097594cc7f7e77dbccd6cb53aa72f263cd1bb442ce5464038aed1116c", "tests/test_isolated_keycape_fixture_plan.py": "sha256:5945619f4c5957187763276cc900870a9f08f9475b63c240694688e0c2eca7dd", - "tests/test_isolated_keycape_recovery.py": "sha256:247e9a87f5be2ee9f20f7edbb4a9f612ab7cb0cd940392c3caeca8b046605f55", + "tests/test_isolated_keycape_recovery.py": "sha256:5f729e077db51dd6b17d00dc3eb71c0e652849f55fe68505a07befa663c91dea", "tests/test_isolated_keycape_session.py": "sha256:cbdf51efde26eff337e9303a2c431f80b333b8846d763eb62853f81eb3f13e2c", "tools/bootstrap-recovery/capture-identity.py": "sha256:47dca7c4dc96fa7eb9fb02c81fca9ca8dc5281aac918979eb7367148168ba705", "tools/bootstrap-recovery/check-privacyidea-database.py": "sha256:90f454c87998d7f5d625e3c6656c3418f8f56275dc1879a9d19ed605431b94a4", @@ -25,7 +25,7 @@ "tools/bootstrap-recovery/inventory-scaleway.py": "sha256:a219df205708fa4ed44b763a418585d63c820c25434bc0fd14e805eb5c2f79d3", "tools/bootstrap-recovery/inventory-storage-share.py": "sha256:673f9b17fb2f3fef6fcdb5c50a2bcca69e3df3157a9e5582a2d995c6b87640b5", "tools/bootstrap-recovery/isolated-keycape-session.py": "sha256:8fa38efe28167b59a6307f342b2ac8fcda78ecda60e6228f22017424d2343c22", - "tools/bootstrap-recovery/isolated-keycape.py": "sha256:f67627b7d1647d75187dd899874efe22383bc91de5d97dabb9801076668ab3c2", + "tools/bootstrap-recovery/isolated-keycape.py": "sha256:86f37add7221e4c73e3f9d878c09a3cc81061627cbf024ed6887d973d929b718", "tools/bootstrap-recovery/isolated-lldap.py": "sha256:35bfa8411186ad936a199bd450c7e18c2ad51059301a6db9650e6bd055ab1b81", "tools/bootstrap-recovery/isolated-privacyidea.py": "sha256:4df7196678aac1ad594f32abf45c68dc21a9d2760d9e8a536a215a4a3c5d277f", "tools/bootstrap-recovery/prepare-identity-coverage.py": "sha256:cd5f3067d28b4c7cc93ed08b8cf3869218bdecf7c54b5dffed22d697de71dbb1", @@ -81,7 +81,7 @@ "synthetic_source_root": "private /dev/shm/nk49-keycape-fixture-", "safe_result": "private /tmp/nk49-keycape-native-fixture-review-/result.json" }, - "scope": "candidate itself on complete synthetic format/integrity archive fixture; no native PI/LLDAP/Authelia proof, real custody, real factor or real client credential; successor binds sealed-descriptor custody preparation sources; synthetic fixture itself overrides custody with generated fixture key as before", + "scope": "candidate itself on complete synthetic format/integrity archive fixture; no native PI/LLDAP/Authelia proof, real custody, real factor or real client credential; successor binds sealed-descriptor custody preparation sources; synthetic fixture itself overrides custody with generated fixture key as before; unexecuted exact-one-selector adapter successor: no new native outcome inferred from earlier fixtures", "mutations": [ "conditional creation of exact fresh local containers and local-driver tmpfs volumes", "private synthetic file import to those volumes", --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -1130,6 +1130,24 @@ `recovery-owner-handoffs-r1` source review; independent ID/UUID/status/remote readback is recorded separately when performed. +2026-10-17 exact-selector source preparation: the adapter now requires exactly +one selected client in the already-restored archived configuration before further +runtime environment reads, signer-key parsing or native checker invocation. +Absent, duplicate (identical or conflicting) and empty selectors refuse early; +existing unique registration behavior is retained. This check cannot run before +private archive decryption or its key prompt. Current KeyCape Go configuration +also rejects duplicate IDs; this is an explicit adapter guard, not a claim that +the pinned native issuer admitted duplicates. Tests use public/synthetic config +and mocked native calls only. Changed adapter bytes require a fresh reviewed +source/dependency and admission binding; old actual fixture receipts are retained. +The exact eight-field current consumer selector independently passes its offline +AST-only preflight. TLS public provenance/private custody, archive-time selector +binding, current owner recovery assent and outage-compatible actual consumer +proof remain open under T35/T36 and the existing owner tasks. No actual archive, +key, Secret object, token exchange, OpenBao login, rotation or native action was +performed. Source evidence and proposed owner declaration/consumer plan: +Custodian docs/changes/netkingdom-target-2026-10-17/native-identity-r1/. + ## Verify actual SOPS compatibility and sealed-descriptor read/write custody ```task