Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
176 lines
8.4 KiB
Python
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())
|