NK-WP-0055-T13: kingpath-offsite --verify restores and checks the break-glass copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 4515@bnt-lap001 Assistant-Session: 0bf96127-6e04-41be-bfda-d11717c3f354
This commit is contained in:
parent
fd64fc8478
commit
317d578193
3 changed files with 134 additions and 1 deletions
|
|
@ -125,3 +125,55 @@ def test_production_recipient_is_the_registered_backup_recipient():
|
|||
profile=json.loads((ROOT/'tools/bootstrap-recovery/identity-backup-profile.json').read_text())
|
||||
assert k.RECIPIENT==profile['public_recipient']
|
||||
assert k.ACCOUNT_BASE=='https://nx4069.your-storageshare.de/remote.php/dav/files/Backup/railiance-backups/net-kingdom-kingpath/'
|
||||
|
||||
|
||||
KUBECONFIG=b'apiVersion: v1\nclusters: [{name: c, cluster: {server: "https://127.0.0.1:1"}}]\nusers: [{name: u, user: {}}]\n'
|
||||
SSHKEY=b'-----BEGIN OPENSSH PRIVATE KEY-----\nsynthetic\n-----END OPENSSH PRIVATE KEY-----\n'
|
||||
|
||||
|
||||
def uploaded(tmp_path,key,share):
|
||||
items=[f'admin-kubeconfig={private(tmp_path,"kc",KUBECONFIG)}',f'tunnel-ssh-key={private(tmp_path,"ssh",SSHKEY)}']
|
||||
return k.run(items,'pw',tmp_path/'upload.json',share,key[1],NOW)
|
||||
|
||||
|
||||
def keyfd(path):return os.open(path,os.O_RDONLY)
|
||||
|
||||
|
||||
def test_verify_fetches_decrypts_in_memory_and_checks_shapes(tmp_path,key):
|
||||
share=Share();upload=uploaded(tmp_path,key,share);fd=keyfd(key[0])
|
||||
try:record=k.verify(upload,'pw',fd,tmp_path/'verify.json',share)
|
||||
finally:os.close(fd)
|
||||
assert record['status']=='verified' and record['unrelated_key_refused'] and record['custody_key_matches_recipient']
|
||||
assert record['items']=={'admin-kubeconfig':{'bytes':len(KUBECONFIG),'shape':'kubeconfig'},
|
||||
'tunnel-ssh-key':{'bytes':len(SSHKEY),'shape':'ssh-private-key'}}
|
||||
text=(tmp_path/'verify.json').read_text();assert 'synthetic' not in text and '127.0.0.1' not in text
|
||||
|
||||
|
||||
def test_verify_refuses_a_custody_key_for_another_recipient(tmp_path,key):
|
||||
share=Share();upload=uploaded(tmp_path,key,share)
|
||||
other=tmp_path/'other.key';subprocess.run(['age-keygen','-o',str(other)],check=True,capture_output=True)
|
||||
fd=keyfd(other)
|
||||
try:
|
||||
with pytest.raises(k.Refused):k.verify(upload,'pw',fd,tmp_path/'verify.json',share)
|
||||
finally:os.close(fd)
|
||||
assert [c[0] for c in share.calls].count('GET')==1 # only the upload readback; no fetch with the wrong key
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change',['etag','sha','items','artifact'])
|
||||
def test_verify_refuses_a_changed_record_or_object(tmp_path,key,change):
|
||||
share=Share();upload=uploaded(tmp_path,key,share);fd=keyfd(key[0])
|
||||
if change=='etag':upload['etag']='"other"'
|
||||
if change=='sha':upload['ciphertext_sha256']='0'*64
|
||||
if change=='items':upload['items']={'admin-kubeconfig':1}
|
||||
if change=='artifact':upload['artifact']='../escape.tar.age'
|
||||
try:
|
||||
with pytest.raises(k.Refused):k.verify(upload,'pw',fd,tmp_path/'verify.json',share)
|
||||
finally:os.close(fd)
|
||||
assert not (tmp_path/'verify.json').exists()
|
||||
|
||||
|
||||
def test_verify_refuses_tampered_ciphertext(tmp_path,key):
|
||||
share=Share();upload=uploaded(tmp_path,key,share);share.tamper=True;fd=keyfd(key[0])
|
||||
try:
|
||||
with pytest.raises(k.Refused):k.verify(upload,'pw',fd,tmp_path/'verify.json',share)
|
||||
finally:os.close(fd)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ NK-WP-0055-T13. The founder runs this in their own terminal. Named private files
|
|||
to the registered public backup recipient, uploaded under a new name to the
|
||||
`Backup` account and read back with a hash check. Plaintext never reaches disk;
|
||||
the receipt holds names, sizes and the ciphertext digest only.
|
||||
|
||||
`--verify UPLOAD_RECEIPT` is the drill: it fetches that artifact back from
|
||||
Storage Share, checks ETag, size and digest against the upload receipt, refuses
|
||||
an unrelated key, decrypts in memory with the custody key from
|
||||
`sops-custody-unlock.sh --sealed-fd` and checks that every item is present and
|
||||
intact. Nothing is decrypted to disk and no item value is printed.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
|
|
@ -144,13 +150,86 @@ def run(specs,password,receipt,client=None,recipient=RECIPIENT,now=None):
|
|||
return record
|
||||
|
||||
|
||||
def fetch(client,password,upload):
|
||||
url=ACCOUNT_BASE+upload['artifact'];h=auth(password);size=upload['ciphertext_bytes']
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:version=check(response,url,size)
|
||||
if version!=upload['etag']:raise Refused('etag_changed')
|
||||
get=dict(h);get['If-Match']=version
|
||||
with client.open(urllib.request.Request(url,headers=get),timeout=180) as response:
|
||||
if check(response,url,size)!=version:raise Refused('readback')
|
||||
data=response.read(size+1)
|
||||
if len(data)!=size or hashlib.sha256(data).hexdigest()!=upload['ciphertext_sha256']:raise Refused('readback')
|
||||
return data
|
||||
|
||||
|
||||
def kind(name,data):
|
||||
# Shape only: proves the item is usable without recording any of its content.
|
||||
if name.endswith('kubeconfig'):
|
||||
import yaml
|
||||
config=yaml.safe_load(data)
|
||||
if not isinstance(config,dict) or not config.get('clusters') or not config.get('users'):raise Refused('item_shape')
|
||||
return 'kubeconfig'
|
||||
if name.endswith('ssh-key'):
|
||||
first=data.split(b'\n',1)[0]
|
||||
if not re.fullmatch(rb'-----BEGIN (OPENSSH|RSA|EC) PRIVATE KEY-----',first):raise Refused('item_shape')
|
||||
return 'ssh-private-key'
|
||||
return 'opaque'
|
||||
|
||||
|
||||
def verify(upload,password,keyfd,receipt,client=None):
|
||||
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')
|
||||
derived=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,),capture_output=True,timeout=10)
|
||||
if derived.returncode or derived.stdout.decode().strip()!=upload['recipient']:raise Refused('wrong_custody_key')
|
||||
data=fetch(client or offsite.opener(),password,upload)
|
||||
with tempfile.TemporaryDirectory(prefix='.kingpath-verify-') as temporary:
|
||||
wrong=Path(temporary)/'unrelated.key'
|
||||
subprocess.run(['age-keygen','-o',str(wrong)],capture_output=True,check=True)
|
||||
if subprocess.run(['age','-d','-i',str(wrong)],input=data,capture_output=True).returncode==0:raise Refused('unrelated_key_accepted')
|
||||
done=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}'],input=data,pass_fds=(keyfd,),capture_output=True,timeout=60)
|
||||
if done.returncode:raise Refused('decrypt')
|
||||
found={}
|
||||
with tarfile.open(fileobj=io.BytesIO(done.stdout)) as archive:
|
||||
for member in archive:
|
||||
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')
|
||||
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())},
|
||||
'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')
|
||||
return record
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument('--item',action='append',default=[],metavar='NAME=PATH',required=True)
|
||||
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('--receipt',type=Path,required=True)
|
||||
args=parser.parse_args()
|
||||
if not sys.stdin.isatty():
|
||||
print('refused: run in your own terminal',file=sys.stderr);return 2
|
||||
if args.verify:
|
||||
keyfd=os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FD','')
|
||||
if not keyfd.isdigit():
|
||||
print('refused: run under sops-custody-unlock.sh --sealed-fd',file=sys.stderr);return 2
|
||||
try:
|
||||
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())
|
||||
except (Refused,getpass.GetPassWarning,ValueError,KeyError) as error:
|
||||
print(f'refused: {error}',file=sys.stderr);return 1
|
||||
except (urllib.error.URLError,OSError):
|
||||
print('refused: provider or local I/O failure',file=sys.stderr);return 1
|
||||
print(f'OK: {len(record["items"])} items fetched, decrypted in memory and intact; receipt {args.receipt}')
|
||||
return 0
|
||||
try:
|
||||
sources=items(args.item) # refuse unsafe input before asking for the credential
|
||||
with warnings.catch_warnings():
|
||||
|
|
|
|||
|
|
@ -318,6 +318,8 @@ root token against an isolated restored OpenBao (never production); use produces
|
|||
2026-10-27: railiance-platform wrote "Break-glass: OpenBao with SSO down" in `docs/openbao.md` (`8016f56`, pushed): access via the static-key tunnel and admin kubeconfig, unseal at hidden prompts, value-free held-token check, the `generate-root` ceremony through the API (server OTP, shares at hidden prompts, in-memory decode, nothing in argv/env/history), revoke-and-confirm, and a post-event review record. Drill: RPF-WP-0064-T10 on an isolated restore, including the held root-token check. **Finding:** OpenBao 2.5.4 disables unauthenticated `generate-root` by default and the production listener (`rapp-openbao/helm/openbao-values.yaml`) does not enable it, so the ceremony cannot run on production without an existing token. rapp-openbao must choose (a) an incident-time listener edit plus restart and re-unseal, or (b) a standing loopback-only listener reachable only through `kubectl port-forward`/`exec`.
|
||||
|
||||
2026-10-27 founder decision (hub decision `732b5c96`): option (b), a standing loopback-only OpenBao listener with `generate-root` enabled, reachable only through `kubectl port-forward`/`exec` with the admin kubeconfig. Owner rapp-openbao prepares the change for founder review and rollout.
|
||||
|
||||
2026-10-27: `kingpath-offsite.py --verify UPLOAD_RECEIPT` added for the drill (7 more contained tests, 18 total): run under `sops-custody-unlock.sh --sealed-fd`, it checks the custody key against the recorded recipient, fetches the artifact with ETag/size/digest checks, refuses an unrelated key, decrypts in memory and checks each item's size and shape (kubeconfig with clusters/users, SSH private key header), writing a value-free receipt. The OpenBao part of the drill is RPF-WP-0064-T10 (drill mode being built).
|
||||
## Policy import pipeline and platform/tenant package split
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue