diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 2e58e7c..c1a76bf 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -322,10 +322,10 @@ | task | NK-WP-0055-T02 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T03 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T04 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | -| task | NK-WP-0055-T05 | todo | — | workplans/NK-WP-0055-security-platform-production-readiness.md | +| task | NK-WP-0055-T05 | done | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T06 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T07 | todo | — | workplans/NK-WP-0055-security-platform-production-readiness.md | -| task | NK-WP-0055-T08 | todo | — | workplans/NK-WP-0055-security-platform-production-readiness.md | +| task | NK-WP-0055-T08 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T09 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T10 | done | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T11 | progress | — | workplans/NK-WP-0055-security-platform-production-readiness.md | diff --git a/tests/test_kingpath_offsite.py b/tests/test_kingpath_offsite.py index 726b3c7..44c69b1 100644 --- a/tests/test_kingpath_offsite.py +++ b/tests/test_kingpath_offsite.py @@ -1,6 +1,7 @@ """Break-glass bundle sealing and Storage Share publication on contained fixtures.""" from datetime import datetime,timezone import importlib.util +import hashlib import io import json import os @@ -177,3 +178,25 @@ def test_verify_refuses_tampered_ciphertext(tmp_path,key): try: with pytest.raises(k.Refused):k.verify(upload,'pw',fd,tmp_path/'verify.json',share) finally:os.close(fd) + + +def test_verify_binds_items_to_expected_digests(tmp_path,key): + share=Share();upload=uploaded(tmp_path,key,share);fd=keyfd(key[0]) + sha=hashlib.sha256(SSHKEY).hexdigest() + try:record=k.verify(upload,'pw',fd,tmp_path/'verify.json',share,expect={'tunnel-ssh-key':sha}) + finally:os.close(fd) + assert record['sha256_matched']=={'tunnel-ssh-key':sha} + + +@pytest.mark.parametrize('expect',[{'tunnel-ssh-key':'0'*64},{'not-uploaded':'0'*64}]) +def test_verify_refuses_a_digest_mismatch_or_missing_item(tmp_path,key,expect): + share=Share();upload=uploaded(tmp_path,key,share);fd=keyfd(key[0]) + try: + with pytest.raises(k.Refused):k.verify(upload,'pw',fd,tmp_path/'verify.json',share,expect=expect) + finally:os.close(fd) + assert not (tmp_path/'verify.json').exists() + + +@pytest.mark.parametrize('spec',['name','x=abc','Bad=' + '0'*64]) +def test_expected_refuses_malformed_pairs(spec): + with pytest.raises(k.Refused):k.expected([spec]) diff --git a/tools/bootstrap-recovery/kingpath-offsite.py b/tools/bootstrap-recovery/kingpath-offsite.py index ed7ad9f..6261933 100644 --- a/tools/bootstrap-recovery/kingpath-offsite.py +++ b/tools/bootstrap-recovery/kingpath-offsite.py @@ -176,7 +176,18 @@ def kind(name,data): return 'opaque' -def verify(upload,password,keyfd,receipt,client=None): +def expected(specs): + # NAME=SHA256 pairs the drill must match; digests are public, the content stays in memory. + result={} + for spec in specs or []: + name,sep,sha=spec.partition('=') + if not sep or not ITEM.fullmatch(name) or name in result or not re.fullmatch(r'[0-9a-f]{64}',sha):raise Refused('expect') + result[name]=sha + return result + + +def verify(upload,password,keyfd,receipt,client=None,expect=None): + expect=expect or {} if receipt.exists() or receipt.is_symlink():raise Refused('receipt_exists') if (upload.get('schema')!='netkingdom.kingpath-offsite.v1' or upload.get('status')!='readback_verified' or not re.fullmatch(r'net-kingdom-kingpath-\d{8}T\d{6}Z-[0-9a-f]{12}\.tar\.age',upload.get('artifact',''))):raise Refused('upload_receipt') @@ -195,10 +206,14 @@ def verify(upload,password,keyfd,receipt,client=None): if not member.isfile() or member.name in found:raise Refused('archive') found[member.name]=archive.extractfile(member).read() if {n:len(d) for n,d in found.items()}!=upload['items']:raise Refused('items_changed') + for name,sha in expect.items(): + if name not in found:raise Refused('expected_item_missing') + if hashlib.sha256(found[name]).hexdigest()!=sha:raise Refused('item_sha256_mismatch') record={'schema':'netkingdom.kingpath-verify.v1','status':'verified', 'observed_at':datetime.now(timezone.utc).isoformat(),'artifact':upload['artifact'],'etag':upload['etag'], 'ciphertext_sha256':upload['ciphertext_sha256'],'unrelated_key_refused':True,'custody_key_matches_recipient':True, 'items':{n:{'bytes':len(d),'shape':kind(n,d)} for n,d in sorted(found.items())}, + 'sha256_matched':{n:expect[n] for n in sorted(expect)}, 'plaintext_written_to_disk':False,'secret_values_exported':False,'openbao_contacted':False,'sso_contacted':False} fd=os.open(receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) with os.fdopen(fd,'w') as out:json.dump(record,out,indent=2,sort_keys=True);out.write('\n') @@ -210,8 +225,10 @@ def main(): mode=parser.add_mutually_exclusive_group(required=True) mode.add_argument('--item',action='append',metavar='NAME=PATH') mode.add_argument('--verify',type=Path,metavar='UPLOAD_RECEIPT') + parser.add_argument('--expect',action='append',metavar='NAME=SHA256',help='with --verify: item digest that must match') parser.add_argument('--receipt',type=Path,required=True) args=parser.parse_args() + if args.expect and not args.verify:parser.error('--expect needs --verify') if not sys.stdin.isatty(): print('refused: run in your own terminal',file=sys.stderr);return 2 if args.verify: @@ -219,11 +236,12 @@ def main(): if not keyfd.isdigit(): print('refused: run under sops-custody-unlock.sh --sealed-fd',file=sys.stderr);return 2 try: + expect=expected(args.expect) upload=json.loads(args.verify.expanduser().read_text()) with warnings.catch_warnings(): warnings.simplefilter('error',getpass.GetPassWarning) password=getpass.getpass('Storage Share Backup app password: ') - record=verify(upload,password,int(keyfd),args.receipt.expanduser()) + record=verify(upload,password,int(keyfd),args.receipt.expanduser(),expect=expect) except (Refused,getpass.GetPassWarning,ValueError,KeyError) as error: print(f'refused: {error}',file=sys.stderr);return 1 except (urllib.error.URLError,OSError): diff --git a/workplans/NK-WP-0055-security-platform-production-readiness.md b/workplans/NK-WP-0055-security-platform-production-readiness.md index 6efd544..cac90e6 100644 --- a/workplans/NK-WP-0055-security-platform-production-readiness.md +++ b/workplans/NK-WP-0055-security-platform-production-readiness.md @@ -161,9 +161,10 @@ state_hub_task_id: "4338365e-cdaa-5c62-880d-db3536f39fa8" ```task id: NK-WP-0055-T05 -status: todo +status: done priority: low assignee: claude-code +decision_id: "55dcb6aa-e081-4aec-b644-1f49a4ec96a8" state_hub_task_id: "9adbc2b8-3680-5d91-8a80-670f712bf9d9" ``` @@ -175,6 +176,11 @@ secret, verified 2026-10-27. `warden route`, and one attended diagnostic run passes. The alternative is an accepted decision that the diagnostic is not needed for production. +**Done 2026-10-28 by founder decision `55dcb6aa`:** not needed for production. Factor state is +proven by the isolated restore drill (NK-WP-0053, by-user MFA stage) and by real login; +`pi-admin` in the privacyIDEA UI is the attended live-repair fallback. `mfa_state.py` stays in the +repository but is not a production capability, and no credential lane is created for it. + ## Rotate or revoke the bootstrap-era and exposed credentials ```task @@ -221,9 +227,13 @@ Drill results land in the readiness ledger. ```task id: NK-WP-0055-T08 -status: todo +status: wait priority: low assignee: claude-code +needs_human: true +decision_id: "3291751f-4dad-4b19-bee9-379368962046" +blocked_on: founder-run +blocking_reason: "Founder seals both receipts to Storage Share with kingpath-offsite.py and runs --verify --expect against the recorded sha256 values." state_hub_task_id: "4533872f-ccfa-5538-96ed-a3bad0ea550e" ``` @@ -234,6 +244,32 @@ their public digests. **Done when:** an accepted custody location with a second copy holds them, still bound by their recorded hashes, or the founder accepts single-copy custody as an exception. +**2026-10-28 founder decision `3291751f`:** Storage Share, sealed to the `railiance-backup` age key +with `kingpath-offsite.py`, beside the break-glass copy. `--verify` gained `--expect NAME=SHA256` +(3 new tests, 24 total), so the read-back binds each item to its recorded digest without printing +content. Founder run, in your own terminal from `~/net-kingdom`: + +```bash +sha256sum # must show 16d72f7c…1651 and 4e048dee…579d +python3 tools/bootstrap-recovery/kingpath-offsite.py \ + --item nk49-t18-keycape-receipt= \ + --item rpf64-t07-openbao-receipt= \ + --receipt ~/nk-wp-0055-t08-upload.json +sso-mfa/bootstrap/sops-custody-unlock.sh --sealed-fd \ + --expected-recipient "$(python3 -c 'import json;print(json.load(open("tools/bootstrap-recovery/identity-backup-profile.json"))["public_recipient"])')" -- \ + python3 tools/bootstrap-recovery/kingpath-offsite.py --verify ~/nk-wp-0055-t08-upload.json \ + --expect nk49-t18-keycape-receipt= \ + --expect rpf64-t07-openbao-receipt= \ + --receipt ~/nk-wp-0055-t08-verify.json +``` + +The full digests are kept out of this file because the secret scanner flags them; they are in +`docs/evidence/2026-10-23-nk49-t18-keycape-attempt/verifier-output.json` and railiance-platform +`workplans/RPF-WP-0064-isolated-original-openbao-restore.md`. Both files must be mode 0400 or 0600. The sealed key is the `railiance-backup` key (crisis item 1), +not the custodian key. **Done when** the verify receipt shows `status: verified` with both names +under `sha256_matched`; the upload and verify receipts are value-free and can be committed under +`docs/evidence/`. + ## Founder production-scope decisions ```task