NK-WP-0055-T05 done by decision; T08 sealed Storage Share copy with digest-bound verify
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 308701@bnt-lap001
Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
This commit is contained in:
tegwick 2026-10-28 12:37:46 +01:00
parent 666160a390
commit 417d88d80e
4 changed files with 83 additions and 6 deletions

View file

@ -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 |

View file

@ -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])

View file

@ -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):

View file

@ -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 <keycape-t18-receipt> <openbao-restore-receipt> # must show 16d72f7c…1651 and 4e048dee…579d
python3 tools/bootstrap-recovery/kingpath-offsite.py \
--item nk49-t18-keycape-receipt=<keycape-t18-receipt> \
--item rpf64-t07-openbao-receipt=<openbao-restore-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=<full sha256 16d72f7c…1651> \
--expect rpf64-t07-openbao-receipt=<full sha256 4e048dee…579d> \
--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