NK-WP-0055-T05 done by decision; T08 sealed Storage Share copy with digest-bound verify
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:
parent
666160a390
commit
417d88d80e
4 changed files with 83 additions and 6 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue