NK-WP-0049-T33: independent attempt receipt verifier, retained location and ACME reissue step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 696317@bnt-lap001 Assistant-Session: bab9e511-0915-45b3-926a-2fab552edff6
This commit is contained in:
parent
710dc99585
commit
4c4c7c4569
4 changed files with 290 additions and 11 deletions
|
|
@ -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: <tmpdir>/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 <tmpdir>/recovery.json \
|
||||
--transfer-receipt <verified transfer receipt> \
|
||||
--source-root <clean checkout>/tools/bootstrap-recovery \
|
||||
--not-after <admission window end, ISO 8601 with offset>
|
||||
```
|
||||
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/<date>-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`.
|
||||
|
|
|
|||
112
tests/test_keycape_attempt_receipt_verifier.py
Normal file
112
tests/test_keycape_attempt_receipt_verifier.py
Normal file
|
|
@ -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()
|
||||
129
tools/bootstrap-recovery/verify-keycape-attempt-receipt.py
Normal file
129
tools/bootstrap-recovery/verify-keycape-attempt-receipt.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue