net-kingdom/tests/test_identity_offline_recovery.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

244 lines
9.8 KiB
Python

"""Outage download boundaries, failure cleanup and independently usable kit."""
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import sys
import os
import pty
import select
import termios
import urllib.error
import zipfile
import pytest
ROOT = Path(__file__).resolve().parents[1]
def load(name, root=ROOT):
spec = importlib.util.spec_from_file_location(name, root / 'tools/bootstrap-recovery' / (name + '.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
m = load('retrieve-identity-offline')
k = load('recovery-kit')
def test_private_prompt_with_real_controlling_terminal_hides_input():
import fcntl
master, slave = pty.openpty()
def terminal_session():
os.setsid()
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
code = ("import importlib.util; "
f"s=importlib.util.spec_from_file_location('offline',{str(ROOT / 'tools/bootstrap-recovery/retrieve-identity-offline.py')!r}); "
"m=importlib.util.module_from_spec(s); s.loader.exec_module(m); "
"value=m.private_prompt('Private fixture prompt: '); "
"print('accepted' if value=='fixture-never-echo-me' else 'rejected')")
child = subprocess.Popen([sys.executable, '-c', code], stdin=slave, stdout=slave,
stderr=slave, preexec_fn=terminal_session)
os.close(slave)
captured = b''
try:
assert select.select([master], [], [], 10)[0], 'prompt timeout'
while b'Private fixture prompt: ' not in captured:
captured += os.read(master, 4096)
assert select.select([master], [], [], 10)[0] or b'Private fixture prompt: ' in captured
os.write(master, b'fixture-never-echo-me\n')
child.wait(timeout=10)
while select.select([master], [], [], 0)[0]:
try:
block = os.read(master, 4096)
except OSError:
break
if not block:
break
captured += block
assert child.returncode == 0 and b'accepted' in captured
assert b'fixture-never-echo-me' not in captured
finally:
if child.poll() is None:
child.kill()
child.wait()
os.close(master)
def test_private_prompt_refuses_redirected_input(monkeypatch):
monkeypatch.setattr(m.sys.stdin, 'isatty', lambda: False)
monkeypatch.setattr(m.getpass, 'getpass', lambda *args: pytest.fail('must not prompt'))
with pytest.raises(m.offsite.Refused):
m.private_prompt('Private: ')
def test_private_prompt_never_falls_back_to_echo(monkeypatch):
import warnings
monkeypatch.setattr(m.sys.stdin, 'isatty', lambda: True)
monkeypatch.setattr(m.sys.stderr, 'isatty', lambda: True)
def cannot_hide(*args):
warnings.warn('fixture cannot hide input', m.getpass.GetPassWarning)
pytest.fail('must stop before fallback reads input')
monkeypatch.setattr(m.getpass, 'getpass', cannot_hide)
with pytest.raises(m.getpass.GetPassWarning):
m.private_prompt('Private: ')
def test_http_failure_receipt_metadata_never_includes_private_response():
error = urllib.error.HTTPError('https://private.invalid/secret', 401,
'private response', {'Authorization': 'secret'}, None)
assert m.failure_metadata(error) == {'failure_kind': 'provider_http_error', 'http_status': 401}
@pytest.mark.parametrize('script', ['retrieve-identity-offline.py', 'recovery-kit.py'])
def test_cli_refuses_unattended_prompt_with_safe_stage_receipt(tmp_path, script):
receipt = tmp_path / 'receipt.json'
args = [sys.executable, str(ROOT / 'tools/bootstrap-recovery' / script),
'--receipt', str(receipt)]
if script == 'recovery-kit.py':
args += ['--output-dir', str(tmp_path / 'kit'), '--publish']
else:
args += ['--source-receipt', str(ROOT / 'docs/evidence/2026-10-16-identity-backup-live/actual-offsite.json')]
result = subprocess.run(args, input=b'', capture_output=True, timeout=10)
assert result.returncode == 1
record = json.loads(receipt.read_text())
assert record['failed_stage'] == 'private_provider_prompt'
assert record['failure_kind'] == 'scope_or_integrity_refusal'
assert 'Traceback' not in result.stderr.decode()
class Response(io.BytesIO):
def __init__(self, url, raw, etag='"original"', status=200):
super().__init__(raw)
self.url = url
self.status = status
self.headers = {'Content-Length': str(len(raw)), 'ETag': etag}
class Provider:
def __init__(self, body):
self.body = body
self.calls = []
self.tamper = False
self.etag = '"original"'
self.objects = {}
def open(self, request, timeout):
method = request.get_method()
self.calls.append((method, request.full_url, dict(request.headers)))
assert request.full_url.startswith((m.ACCOUNT_BASE, m.offsite.BASE))
if self.objects or method == 'PUT':
if method == 'PUT':
assert request.get_header('If-none-match') == '*'
assert request.full_url not in self.objects
self.objects[request.full_url] = request.data
return Response(request.full_url, b'', status=201)
if request.full_url not in self.objects:
raise urllib.error.HTTPError(request.full_url, 404, 'missing', {}, None)
return Response(request.full_url, self.objects[request.full_url])
raw = self.body if not self.tamper or method == 'HEAD' else b'X' * len(self.body)
return Response(request.full_url, raw, self.etag)
@pytest.fixture
def record():
return dict(schema='netkingdom.identity-offsite.v1', status='readback_verified',
provider='Storage Share', folder='Backup/railiance-backups/net-kingdom-identity',
artifact='net-kingdom-identity-20261016T063159Z-5882771aa2ce.tar.age',
ciphertext_bytes=32, ciphertext_sha256=hashlib.sha256(b'A' * 32).hexdigest(),
etag='"original"')
@pytest.mark.parametrize('mode', ['account', 'folder-share'])
def test_offline_success_uses_only_fixed_provider_reads(record, mode):
provider = Provider(b'A' * 32)
record['untrusted_extra'] = 'must-not-export'
result = m.retrieve(record, 'fixtureSecret1234', mode, provider)
try:
assert Path(result['fetched_path']).read_bytes() == b'A' * 32
assert not result['openbao_contacted'] and not result['sso_contacted']
assert {c[0] for c in provider.calls} == {'HEAD', 'GET'}
assert 'fixtureSecret1234' not in json.dumps(result)
assert 'must-not-export' not in json.dumps(result)
assert provider.calls[1][2]['If-match'] == record['etag']
finally:
path = Path(result['fetched_path'])
path.unlink()
path.parent.rmdir()
@pytest.mark.parametrize('field,value', [
('artifact', '../another.tar.age'), ('folder', 'other'),
('ciphertext_sha256', 'x' * 64), ('ciphertext_bytes', True),
('provider', 'other'), ('etag', 'W/"weak"'),
])
def test_unreviewed_scope_refuses_before_contact(record, field, value):
record[field] = value
provider = Provider(b'A' * 32)
with pytest.raises(m.offsite.Refused):
m.retrieve(record, 'fixtureSecret1234', 'account', provider)
assert not provider.calls
def test_digest_failure_removes_incomplete_ciphertext(record, monkeypatch, tmp_path):
directory = tmp_path / 'download'
directory.mkdir()
monkeypatch.setattr(m.tempfile, 'mkdtemp', lambda **kwargs: str(directory))
provider = Provider(b'A' * 32)
provider.tamper = True
with pytest.raises(m.offsite.Refused):
m.retrieve(record, 'fixtureSecret1234', 'account', provider)
assert not directory.exists()
def test_changed_etag_refuses_before_download(record):
provider = Provider(b'A' * 32)
provider.etag = '"changed"'
with pytest.raises(m.offsite.Refused):
m.retrieve(record, 'fixtureSecret1234', 'account', provider)
assert len(provider.calls) == 1
def test_kit_contains_all_offline_imports_and_checksums(tmp_path):
files = k.build()
archive = next(value for name, value in files.items() if name.endswith('.zip'))
with zipfile.ZipFile(io.BytesIO(archive)) as kit:
kit.extractall(tmp_path)
for line in kit.read('SHA256SUMS').decode().splitlines():
digest, name = line.split(' ', 1)
assert hashlib.sha256((tmp_path / name).read_bytes()).hexdigest() == digest
for script in ('verify-identity.py', 'retrieve-identity-offline.py'):
result = subprocess.run([sys.executable, str(tmp_path / 'tools/bootstrap-recovery' / script), '--help'],
capture_output=True)
assert result.returncode == 0, result.stderr
assert k.build() == files
def test_kit_publication_conditional_and_exact_readback():
provider = Provider(b'')
# Ensure initial HEAD returns 404 instead of the download-fixture response.
provider.objects['unrelated'] = b'preserved'
files = k.build()
rows = k.publish(files, {'Authorization': 'fixture'}, provider)
assert len(rows) == 3 and all(row['readback_verified'] for row in rows)
assert len([c for c in provider.calls if c[0] == 'PUT']) == 3
k.publish(files, {'Authorization': 'fixture'}, provider)
assert len([c for c in provider.calls if c[0] == 'PUT']) == 3
assert provider.objects['unrelated'] == b'preserved'
def test_kit_conflicting_existing_object_never_overwritten():
provider = Provider(b'')
files = k.build()
first = next(iter(files))
provider.objects[m.ACCOUNT_BASE + first] = b'X' * len(files[first])
with pytest.raises(k.offsite.Refused):
k.publish(files, {'Authorization': 'fixture'}, provider)
assert not any(c[0] == 'PUT' for c in provider.calls)