net-kingdom/tools/bootstrap-recovery/recovery-kit.py
tegwick fbdd62b7ed
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Fix private recovery prompts on nonseekable terminals
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
2026-10-16 19:56:06 +02:00

176 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""Build a secret-free offline recovery kit; optionally publish exact new files.
Publication creates only three fixed versioned objects beside identity backups,
never changes shares, credentials, existing files, retention or runtime jobs.
"""
import argparse
import base64
from datetime import datetime, timezone
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import sys
import urllib.error
import urllib.request
import zipfile
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location('offline', HERE / 'retrieve-identity-offline.py')
offline = importlib.util.module_from_spec(spec)
spec.loader.exec_module(offline)
offsite = offline.offsite
FILES = [
'docs/disaster-recovery-storage-share.md',
'docs/identity-service-restore-packet.md',
'docs/evidence/2026-10-16-identity-backup-live/actual-offsite.json',
'docs/evidence/2026-10-16-identity-outage-recovery/image-observation.json',
'tools/bootstrap-recovery/retrieve-identity-offline.py',
'tools/bootstrap-recovery/identity-offsite.py',
'tools/bootstrap-recovery/verify-identity.py',
'tools/bootstrap-recovery/identity-bundle.py',
'tools/bootstrap-recovery/check-privacyidea-database.py',
'tools/bootstrap-recovery/identity-backup-profile.json',
'sso-mfa/bootstrap/sops-custody-unlock.sh',
'sso-mfa/bootstrap/sops-private.py',
'keys/age.pub',
]
def build():
content = {name: (ROOT / name).read_bytes() for name in FILES}
if any(b'AGE-SECRET-KEY-1' in value for name, value in content.items()
if not name.endswith(('.py', '.sh', '.md'))):
raise offsite.Refused()
sums = ''.join(hashlib.sha256(value).hexdigest() + ' ' + name + '\n'
for name, value in content.items()).encode()
output = io.BytesIO()
with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
for name, value in {**content, 'SHA256SUMS': sums}.items():
entry = zipfile.ZipInfo(name, date_time=(2026, 10, 16, 0, 0, 0))
entry.external_attr = 0o100600 << 16
entry.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(entry, value)
kit = output.getvalue()
version = hashlib.sha256(kit).hexdigest()
stem = 'net-kingdom-recovery-' + version[:16]
return {
stem + '-START-HERE.md': content['docs/disaster-recovery-storage-share.md'],
stem + '.zip': kit,
stem + '.zip.sha256': (version + ' ' + stem + '.zip\n').encode(),
}
def publish(files, headers, client=None):
client = client or offsite.opener()
result = []
for name, raw in files.items():
url = offline.ACCOUNT_BASE + name
digest = hashlib.sha256(raw).hexdigest()
# A retry may accept an identical object after exact readback, but must
# never overwrite a conflicting object or retry an unknown PUT outcome.
present = False
try:
with client.open(urllib.request.Request(url, method='HEAD', headers=headers), timeout=30) as response:
version = offsite.etag(response, url, len(raw))
present = True
except urllib.error.HTTPError as error:
if error.code != 404:
raise
if not present:
h = dict(headers, **{'If-None-Match': '*', 'Content-Length': str(len(raw)),
'Content-Type': 'application/zip' if name.endswith('.zip') else 'text/plain; charset=utf-8'})
with client.open(urllib.request.Request(url, data=raw, method='PUT', headers=h), timeout=60) as response:
if response.status != 201 or response.url != url:
raise offsite.Refused()
with client.open(urllib.request.Request(url, method='HEAD', headers=headers), timeout=30) as response:
version = offsite.etag(response, url, len(raw))
h = dict(headers, **{'If-Match': version})
with client.open(urllib.request.Request(url, headers=h), timeout=60) as response:
if offsite.etag(response, url, len(raw)) != version:
raise offsite.Refused()
downloaded = response.read(len(raw) + 1)
if downloaded != raw:
raise offsite.Refused()
result.append({'name': name, 'bytes': len(raw), 'sha256': digest,
'etag': version, 'readback_verified': True})
return result
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--output-dir', required=True)
p.add_argument('--publish', action='store_true')
p.add_argument('--auth-source', choices=('prompt', 'attended-openbao'), default='prompt')
p.add_argument('--receipt', required=True)
p.add_argument('--offline-transfer-receipt', help='After prompted publication, retrieve the known recovered generation using the same independently held access')
a = p.parse_args()
os.umask(0o077)
fd = os.open(a.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
r = {'schema': 'netkingdom.recovery-kit.v1', 'status': 'failed',
'secret_values_exported': False, 'new_grants_created': False}
stage = 'option_validation'
try:
if a.offline_transfer_receipt and (not a.publish or a.auth_source != 'prompt'):
raise offsite.Refused()
stage = 'build_kit'
files = build()
stage = 'write_local_kit'
directory = Path(a.output_dir)
directory.mkdir(mode=0o700, exist_ok=False)
for name, raw in files.items():
(directory / name).write_bytes(raw)
r.update(status='built', files=[{'name': n, 'bytes': len(v), 'sha256': hashlib.sha256(v).hexdigest()}
for n, v in files.items()])
if a.publish:
if a.auth_source == 'prompt':
stage = 'private_provider_prompt'
secret = offline.private_prompt('Independently held Storage Share Backup app password: ')
else:
stage = 'attended_owner_custody'
spec = importlib.util.spec_from_file_location('inventory', HERE / 'inventory-storage-share.py')
owner = importlib.util.module_from_spec(spec)
spec.loader.exec_module(owner)
_, credentials = owner.caller_credentials()
secret = credentials['BACKUP_PASSWORD']
if not secret or any(c in secret for c in '\r\n\x00'):
raise offsite.Refused()
try:
headers = {'Authorization': 'Basic ' + base64.b64encode(('Backup:' + secret).encode()).decode(),
'Accept-Encoding': 'identity'}
stage = 'provider_publication'
r.update(status='published_readback_verified', files=publish(files, headers),
folder='Backup/railiance-backups/net-kingdom-identity',
provider='Storage Share', credential_source=a.auth_source)
if a.offline_transfer_receipt:
stage = 'offline_provider_download'
transfer_fd = os.open(a.offline_transfer_receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
transfer = {'status': 'failed', 'error': 'offline_retrieval_failed_private_diagnostics_suppressed'}
try:
record = json.loads((ROOT / FILES[2]).read_text())
transfer = offline.retrieve(record, secret, 'account')
finally:
with os.fdopen(transfer_fd, 'w') as out:
json.dump(transfer, out, indent=2)
out.write('\n')
r['offline_retrieval_verified'] = transfer['status'] == 'readback_verified'
finally:
secret = None
r['finished_at'] = datetime.now(timezone.utc).isoformat()
except (Exception, KeyboardInterrupt) as error:
r.update(status='failed', error='kit_operation_failed_private_diagnostics_suppressed',
failed_stage=stage, **offline.failure_metadata(error))
finally:
with os.fdopen(fd, 'w') as output:
json.dump(r, output, indent=2)
output.write('\n')
# Silent: safe to use in the existing captured attended-owner conduit.
return int(r['status'] == 'failed')
if __name__ == '__main__':
sys.exit(main())