diff --git a/docs/bootstrap-recovery-ceremony.md b/docs/bootstrap-recovery-ceremony.md index 43a1095..6abbee5 100644 --- a/docs/bootstrap-recovery-ceremony.md +++ b/docs/bootstrap-recovery-ceremony.md @@ -568,19 +568,44 @@ hidden key prompt. deny `openbao:admin`). A pass shows founder authority for this one attempt. It is not native acceptance and not single use. The NK49 execution keeps single-use consumption and cleanup. -3. **TLS/trust input per T35 disposition** (founder decision - `9f9d0d88-837a-433c-8a11-d30b633b0073`). Under reissue, record the public chain - with railiance-cluster `tools/public-tls-metadata.py` at attempt time and compare - it to the admission. Under original recovery, use only the custody path that - RCLUSTER-WP-0018-T03/T04 delivers. No self-signed substitute is allowed. The TLS - key stays in the owner's own terminal. +3. **TLS/trust input: ACME reissue** (founder decision + `9f9d0d88-837a-433c-8a11-d30b633b0073`, resolved 2026-10-21 = A). The + `kc.coulomb.social` TLS identity is reissued through `letsencrypt-prod` ACME + HTTP-01 and chains to ISRG Root X1 (trust sha256 `22b557a2…43d1`). The original + key is not required, and no self-signed substitute is allowed. + **Before the real attempt**, the owner (railiance-cluster) rehearses the same + issuance against Let's Encrypt staging. This protects the duplicate-certificate + limit of 5 per week. At attempt time, record the public chain with + railiance-cluster `tools/public-tls-metadata.py` and check SAN + `kc.coulomb.social` plus the ISRG Root X1 chain. Abort if HTTP-01 cannot + complete or the chain differs. The TLS key stays in the owner's own terminal. 4. **Existing six-argument launcher** `tools/bootstrap-recovery/run-isolated-keycape.sh` with the reviewed admission digest. Its T34 source closure and abort/cleanup behaviour are unchanged. The private manifest is derived and the archive registration is compared to the selector inside custody only. -Still open before T33 can close: T35 owner metadata (RCLUSTER-WP-0018-T02) and -the founder disposition; the execution deadline, safe receipt location and -independent receipt verifier fixed in the packet; the anchor installed on the -attempt host. Public observations are in +5. **Safe receipt and independent verification.** The launcher prints + `Safe receipt: /recovery.json`. Before anyone copies it, verify it + from a separate shell against a clean checkout at the reviewed commit: + ```sh + python3 -I tools/bootstrap-recovery/verify-keycape-attempt-receipt.py \ + --receipt /recovery.json \ + --transfer-receipt \ + --source-root /tools/bootstrap-recovery \ + --not-after + ``` + The admission window, at most 24 h and signed by the founder, is the execution + deadline. Retain the receipt only on exit 0 (`verified`), and keep it at + `docs/evidence/-nk49-t18-keycape-attempt/recovery.json` together with the + verifier output. On exit 1, keep it out of Git and report the findings. Do not + repair it by hand. The verifier refuses secret-shaped content, open cleanup + obligations, daemon uncertainty, any claim of consumer reconnect or human MFA, + a ciphertext that differs from the transfer receipt, and source that differs + from the checkout. A pass is not consumer continuity (T18), retained-factor MFA + (T19) or owner acceptance. + +Still open before T33 can close (2026-10-23): the anchor installed on the attempt +host (SECRETS-WP-0015-T04, founder- and root-owned) and the owner's Let's Encrypt +staging rehearsal from step 3. T35, T36 and T39 are done. The deadline, receipt +location and verifier are fixed above. Public observations are in `docs/evidence/2026-10-21-nk49-t33-readiness/readiness-observation.json`. diff --git a/tests/test_keycape_attempt_receipt_verifier.py b/tests/test_keycape_attempt_receipt_verifier.py new file mode 100644 index 0000000..230d40b --- /dev/null +++ b/tests/test_keycape_attempt_receipt_verifier.py @@ -0,0 +1,112 @@ +"""Synthetic tests for the independent KeyCape attempt receipt verifier; no cluster, no values.""" +import copy +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +SOURCE = Path(__file__).parents[1] / 'tools/bootstrap-recovery/verify-keycape-attempt-receipt.py' +spec = importlib.util.spec_from_file_location('verify_receipt', SOURCE) +v = importlib.util.module_from_spec(spec) +spec.loader.exec_module(v) + +CIPHERTEXT = 'c' * 64 + + +class Verifier(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + hashes = {} + for name in v.SOURCE_FILES: + (self.root / name).write_bytes(f'synthetic {name}\n'.encode()) + hashes[name] = hashlib.sha256((self.root / name).read_bytes()).hexdigest() + done = {'status': 'absence_verified'} + self.receipt = { + 'schema': v.SCHEMA, 'status': v.PASSED, 'provision_phase': 'complete', + **{k: True for k in v.REQUIRED_TRUE}, **{k: False for k in v.REQUIRED_FALSE}, + **{k: [dict(done)] for k in v.OBLIGATIONS}, 'plaintext_cleanup_obligation': dict(done), + 'ciphertext_sha256': CIPHERTEXT, 'public_signing_spki_sha256': 'a' * 64, + 'source_files_sha256': hashes, 'session_source_sha256': hashes['isolated-keycape-session.py'], + 'finished_at': '2026-10-24T10:00:00+00:00'} + self.transfer = {'ciphertext_sha256': CIPHERTEXT} + + def tearDown(self): + self.tmp.cleanup() + + def check(self, receipt=None, transfer=None, not_after=None): + raw = json.dumps(self.receipt if receipt is None else receipt).encode() + return v.verify(raw, self.transfer if transfer is None else transfer, self.root, not_after=not_after) + + def mutated(self, **changes): + r = copy.deepcopy(self.receipt) + r.update(changes) + return r + + def test_complete_receipt_verifies(self): + self.assertEqual(self.check(), []) + + def test_refuses_failed_or_partial_outcomes(self): + cases = [self.mutated(status='failed'), self.mutated(status='bounded_cleanup_uncertain'), + self.mutated(provision_phase='issuer_launch'), self.mutated(schema='other'), + self.mutated(error='isolated_keycape_failed_private_diagnostics_suppressed')] + cases += [self.mutated(**{k: False}) for k in v.REQUIRED_TRUE] + missing = copy.deepcopy(self.receipt) + del missing['denied_scopes_refused'] + cases.append(missing) + for receipt in cases: + with self.subTest(receipt={k: receipt.get(k) for k in ('status', 'provision_phase')}): + self.assertTrue(self.check(receipt)) + + def test_refuses_overclaims_and_uncertainty(self): + for key in v.REQUIRED_FALSE: + with self.subTest(key=key): + self.assertIn(f'{key} is not false', self.check(self.mutated(**{key: True}))) + + def test_refuses_open_cleanup_obligations(self): + open_row = [{'status': 'registered_before_plaintext'}] + for key in v.OBLIGATIONS: + with self.subTest(key=key): + self.assertTrue(self.check(self.mutated(**{key: open_row}))) + self.assertTrue(self.check(self.mutated(plaintext_cleanup_obligation={'status': 'pending'}))) + + def test_binds_ciphertext_to_transfer_receipt(self): + self.assertIn('ciphertext_sha256 differs from the transfer receipt', + self.check(transfer={'ciphertext_sha256': 'd' * 64})) + self.assertIn('transfer receipt lacks ciphertext_sha256', self.check(transfer={})) + + def test_recomputes_source_from_checkout(self): + (self.root / 'isolated-keycape.py').write_bytes(b'changed\n') + self.assertIn('source differs from checkout: isolated-keycape.py', self.check()) + + def test_session_source_must_match(self): + self.assertIn('session_source_sha256 differs', self.check(self.mutated(session_source_sha256='0' * 64))) + + def test_admission_window(self): + early = datetime(2026, 10, 24, 9, 0, tzinfo=timezone.utc) + late = datetime(2026, 10, 24, 11, 0, tzinfo=timezone.utc) + self.assertIn('attempt finished after the admission window', self.check(not_after=early)) + self.assertEqual(self.check(not_after=late), []) + + def test_secret_shaped_content_is_refused(self): + for value in ('-----BEGIN PRIVATE KEY-----', 'AGE-SECRET-KEY-1QQQQ', + 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzZXJ2aWNlIn0.sig', 'A' * 200): + with self.subTest(value=value[:12]): + findings = self.check(self.mutated(note=value)) + self.assertIn('secret-shaped content present; do not retain or publish this receipt', findings) + + def test_cli_exit_codes(self): + receipt, transfer = self.root / 'recovery.json', self.root / 'transfer.json' + receipt.write_text(json.dumps(self.receipt)) + transfer.write_text(json.dumps(self.transfer)) + args = ['--receipt', str(receipt), '--transfer-receipt', str(transfer), '--source-root', str(self.root)] + self.assertEqual(v.main(args), 0) + receipt.write_text(json.dumps(self.mutated(status='failed'))) + self.assertEqual(v.main(args), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/bootstrap-recovery/verify-keycape-attempt-receipt.py b/tools/bootstrap-recovery/verify-keycape-attempt-receipt.py new file mode 100644 index 0000000..9bede3b --- /dev/null +++ b/tools/bootstrap-recovery/verify-keycape-attempt-receipt.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Independent verifier for an isolated KeyCape attempt receipt (NK-WP-0049-T33/T18). + +Reads the safe receipt written by run-isolated-keycape.sh, the public transfer +receipt and a checkout of the reviewed source. It imports nothing from the +session code: source hashes are recomputed from the checkout, the ciphertext +binding comes from the transfer receipt, and the receipt itself is scanned for +secret-shaped content before anyone retains or publishes it. A pass means the +receipt is internally complete and value-safe. It is not consumer continuity +(T18), retained-factor MFA (T19) or owner acceptance. +""" +import argparse +import hashlib +import json +import re +import sys +from datetime import datetime +from pathlib import Path + +SCHEMA = 'netkingdom.isolated-keycape-recovery.v1' +PASSED = 'isolated_keycape_native_registration_checked' +SOURCE_FILES = ('isolated-keycape.py', 'isolated-keycape-session.py', 'run-isolated-keycape.sh', + 'isolated-lldap.py', 'identity-bundle.py', 'verify-identity.py', + 'retrieve-identity-offline.py', 'identity-offsite.py') +REQUIRED_TRUE = ('all_manifest_hashes_verified', 'native_service_registration_verified', + 'wrong_unrelated_secret_explicitly_refused', 'denied_scopes_refused', + 'original_config_registry_policy_receipts_equal', + 'original_https_issuer_and_trust_preserved', 'original_public_signing_key_in_live_jwks', + 'temporary_plaintext_cleanup_verified', 'container_cleanup_verified', + 'bounded_cleanup_reaper_started') +REQUIRED_FALSE = ('production_contacted', 'secret_values_exported', 'in_flight_daemon_request_uncertain', + 'downstream_consumer_reconnect_proven', 'human_mfa_proven') +OBLIGATIONS = ('container_cleanup_obligations', 'tmpfs_volume_cleanup_obligations', + 'public_image_probe_cleanup_obligations') +SECRET_SHAPES = (re.compile(r'-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)'), + re.compile(r'AGE-SECRET-KEY-1'), + re.compile(r'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.'), + re.compile(r'[A-Za-z0-9+/]{120,}={0,2}')) +SHA256 = re.compile(r'^[a-f0-9]{64}$') + + +def sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def verify(raw, transfer, source_root, *, not_after=None): + """Return a list of findings; empty means the receipt verifies.""" + findings = [] + text = raw.decode('utf-8', errors='replace') + for shape in SECRET_SHAPES: + if shape.search(text): + findings.append('secret-shaped content present; do not retain or publish this receipt') + break + try: + receipt = json.loads(raw) + except ValueError: + return findings + ['receipt is not JSON'] + if not isinstance(receipt, dict): + return findings + ['receipt is not an object'] + if receipt.get('schema') != SCHEMA: + findings.append('schema differs') + if receipt.get('status') != PASSED: + findings.append(f"status is {receipt.get('status')!r}, not {PASSED}") + findings += [f'{k} is not true' for k in REQUIRED_TRUE if receipt.get(k) is not True] + findings += [f'{k} is not false' for k in REQUIRED_FALSE if receipt.get(k) is not False] + for key in OBLIGATIONS: + rows = receipt.get(key, []) + if not isinstance(rows, list) or any(not isinstance(r, dict) or r.get('status') != 'absence_verified' + for r in rows): + findings.append(f'{key} not all absence_verified') + plaintext = receipt.get('plaintext_cleanup_obligation') + if not isinstance(plaintext, dict) or plaintext.get('status') != 'absence_verified': + findings.append('plaintext_cleanup_obligation not absence_verified') + if receipt.get('provision_phase') != 'complete': + findings.append('provision_phase is not complete') + if 'error' in receipt: + findings.append('receipt records an error') + expected_ct = transfer.get('ciphertext_sha256') if isinstance(transfer, dict) else None + if not isinstance(expected_ct, str) or not SHA256.match(expected_ct): + findings.append('transfer receipt lacks ciphertext_sha256') + elif receipt.get('ciphertext_sha256') != expected_ct: + findings.append('ciphertext_sha256 differs from the transfer receipt') + spki = receipt.get('public_signing_spki_sha256') + if not isinstance(spki, str) or not SHA256.match(spki): + findings.append('public_signing_spki_sha256 missing') + recorded = receipt.get('source_files_sha256') + if not isinstance(recorded, dict) or set(recorded) != set(SOURCE_FILES): + findings.append('source_files_sha256 does not cover the reviewed source set') + else: + for name in SOURCE_FILES: + path = source_root / name + if not path.is_file() or sha256(path) != recorded[name]: + findings.append(f'source differs from checkout: {name}') + if receipt.get('session_source_sha256') != recorded.get('isolated-keycape-session.py'): + findings.append('session_source_sha256 differs') + finished = receipt.get('finished_at') + try: + finished_at = datetime.fromisoformat(finished) + except (TypeError, ValueError): + findings.append('finished_at missing or malformed') + else: + if not_after is not None and finished_at > not_after: + findings.append('attempt finished after the admission window') + return findings + + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--receipt', required=True, type=Path) + p.add_argument('--transfer-receipt', required=True, type=Path) + p.add_argument('--source-root', required=True, type=Path, + help='tools/bootstrap-recovery of a checkout at the reviewed commit') + p.add_argument('--not-after', help='admission window end, ISO 8601 with offset') + args = p.parse_args(argv) + not_after = datetime.fromisoformat(args.not_after) if args.not_after else None + if not_after is not None and not_after.tzinfo is None: + p.error('--not-after needs a UTC offset') + findings = verify(args.receipt.read_bytes(), json.loads(args.transfer_receipt.read_text()), + args.source_root, not_after=not_after) + print(json.dumps({'schema': 'netkingdom.keycape-attempt-receipt-verification.v1', + 'receipt_sha256': sha256(args.receipt), + 'verdict': 'verified' if not findings else 'refused', 'findings': findings, + 'claims_not_made': ['consumer continuity (T18)', 'retained-factor MFA (T19)', + 'owner acceptance']}, indent=2)) + return 0 if not findings else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md index 6004792..ba381d0 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -1013,7 +1013,7 @@ status: wait priority: high assignee: codex depends_on: [NK-WP-0049-T32, NK-WP-0049-T34, NK-WP-0049-T35, NK-WP-0049-T36, NK-WP-0049-T39] -blocking_reason: "T35/T36/T39 done (2026-10-23; TLS = ACME letsencrypt-prod reissue). Still waiting on: attempt-host admission anchor install (SECRETS-WP-0015-T04, founder/root-owned); an LE staging rehearsal of the kc.coulomb.social reissue before the real attempt; fixed deadline/receipt location/verifier." +blocking_reason: "Packet complete in source 2026-10-23 (ceremony steps 1-5: admission, selector preflight, ACME reissue TLS, launcher, verify-keycape-attempt-receipt.py + retained location; deadline = signed admission window). Waiting only on: attempt-host admission anchor install (SECRETS-WP-0015-T04, founder/root-owned) and railiance-cluster LE staging rehearsal of the kc.coulomb.social reissue." state_hub_task_id: "56c8d469-7a10-5d50-a30e-95cbf3cd4756" ``` @@ -1039,6 +1039,19 @@ execution, original consumer continuity and acceptance. T19 retains retained-fac MFA. This task neither authorizes a production consumer redirect nor creates a new client grant. +2026-10-23: packet completed in source. `docs/bootstrap-recovery-ceremony.md` +step 3 now carries the decided ACME reissue disposition, with abort conditions +and the required Let's Encrypt staging rehearsal. A new step 5 fixes the +deadline (the founder-signed admission window, at most 24 h), the retained +receipt location, and an independent verifier, +`tools/bootstrap-recovery/verify-keycape-attempt-receipt.py`, with 10 synthetic +tests. The verifier imports no session code. It recomputes source hashes from a +clean checkout, binds the ciphertext to the transfer receipt, and requires every +native check, cleanup obligation and absence of uncertainty. It refuses +consumer/MFA over-claims and secret-shaped content. Two external prerequisites +remain: the anchor install (SECRETS-WP-0015-T04) and the staging rehearsal +(railiance-cluster). + ## Deliver attended custody, metadata preflight and private manifest preparation source ```task