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
197 lines
10 KiB
Python
197 lines
10 KiB
Python
"""Real isolated-image restoration with synthetic retained credentials only."""
|
|
import base64
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
import secrets
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tarfile
|
|
import time
|
|
import uuid
|
|
|
|
import pytest
|
|
from test_identity_bundle import stage
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
spec = importlib.util.spec_from_file_location('isolated', ROOT / 'tools/bootstrap-recovery/isolated-lldap.py')
|
|
m = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(m)
|
|
PASSWORD = 'synthetic-retained-password'
|
|
JWT = secrets.token_hex(32) # Disposable fixture only; never provider custody.
|
|
|
|
|
|
def resources(root):
|
|
def write(kind, value):
|
|
p = root / 'resources/sso' / kind
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
(p / ('lldap-secrets.json' if kind == 'secret' else 'lldap.json')).write_text(json.dumps(value))
|
|
write('secret', {'metadata': {'name': 'lldap-secrets', 'namespace': 'sso'}, 'data': {
|
|
'LLDAP_JWT_SECRET': base64.b64encode(JWT.encode()).decode(),
|
|
'LLDAP_LDAP_USER_PASS': base64.b64encode(PASSWORD.encode()).decode()}})
|
|
write('deployment', {'metadata': {'name': 'lldap', 'namespace': 'sso'}, 'spec': {'template': {'spec': {
|
|
'containers': [{'name': 'lldap', 'env': [{'name': 'LLDAP_LDAP_BASE_DN', 'value': 'dc=example,dc=test'}]}]}}}})
|
|
|
|
|
|
def test_configuration_never_generates_a_missing_original_key(tmp_path):
|
|
resources(tmp_path)
|
|
(tmp_path / 'data/lldap').mkdir(parents=True)
|
|
with pytest.raises(m.bundle.Refused):
|
|
m.configuration(tmp_path)
|
|
|
|
|
|
def test_configuration_preserves_original_seed_and_disables_reset_sending(tmp_path):
|
|
resources(tmp_path)
|
|
data = tmp_path / 'data/lldap'
|
|
data.mkdir(parents=True)
|
|
(data / 'lldap_config.toml').write_text('key_seed = "synthetic-original-seed"\n')
|
|
import tomllib
|
|
value, jwt, password, base = m.configuration(tmp_path)
|
|
config = tomllib.loads(value.decode())
|
|
assert config['key_seed'] == 'synthetic-original-seed'
|
|
assert not config['force_ldap_user_pass_reset']
|
|
assert not config['force_update_private_key']
|
|
assert not config['smtp_options']['enable_password_reset']
|
|
assert PASSWORD.encode() not in value and jwt not in value
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('configured', [None, 'ld-admin'])
|
|
def test_configured_admin_follows_the_archived_deployment(tmp_path, configured):
|
|
# NK-WP-0054-T05: before and after the bind split the restore takes the configured
|
|
# super-admin from the archived deployment, never a hard-coded name.
|
|
resources(tmp_path)
|
|
if configured:
|
|
path = tmp_path / 'resources/sso/deployment/lldap.json'
|
|
deployment = json.loads(path.read_text())
|
|
deployment['spec']['template']['spec']['containers'][0]['env'].append(
|
|
{'name': 'LLDAP_LDAP_USER_DN', 'value': configured})
|
|
path.write_text(json.dumps(deployment))
|
|
data = tmp_path / 'data/lldap'
|
|
data.mkdir(parents=True)
|
|
(data / 'lldap_config.toml').write_text('key_seed = "synthetic-original-seed"\n')
|
|
import tomllib
|
|
value, _, password, _ = m.configuration(tmp_path)
|
|
assert tomllib.loads(value.decode())['ldap_user_dn'] == (configured or 'admin')
|
|
assert password == PASSWORD.encode()
|
|
|
|
def test_tar_transport_refuses_path_escape():
|
|
with pytest.raises(m.bundle.Refused):
|
|
m.archive_files({'../escape': b'synthetic'})
|
|
|
|
|
|
@pytest.fixture
|
|
def retained_directory(tmp_path):
|
|
if os.environ.get('NK49_RUN_ISOLATED_LLDAP_TEST') != '1':
|
|
pytest.skip('explicit local disposable-image fixture opt-in')
|
|
m.preflight()
|
|
resources(tmp_path)
|
|
name = 'nk49-lldap-fixture-' + uuid.uuid4().hex
|
|
config = ('database_url = "sqlite:///data/users.db?mode=rwc"\n'
|
|
'ldap_base_dn = "dc=example,dc=test"\n'
|
|
'ldap_host = "127.0.0.1"\nhttp_host = "127.0.0.1"\n'
|
|
'key_seed = "synthetic-retained-key-seed"\n'
|
|
'jwt_secret = "' + JWT + '"\n'
|
|
'ldap_user_pass = "' + PASSWORD + '"\n')
|
|
try:
|
|
m.command(['docker', 'run', '--detach', '--pull=never', '--name', name,
|
|
'--network', 'none', '--read-only', '--cap-drop', 'ALL', '--user', '1000:1000',
|
|
'--tmpfs', '/data:rw,nosuid,nodev,size=67108864,uid=1000,gid=1000',
|
|
'--tmpfs', '/tmp:rw,nosuid,nodev,size=16777216,uid=1000,gid=1000',
|
|
'--entrypoint', '/bin/sh', m.IMAGE, '-c', 'sleep 1800'])
|
|
m.command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/data'],
|
|
input=m.archive_files({'lldap_config.toml': config.encode()}))
|
|
m.command(['docker', 'exec', '--detach', name, '/bin/sh', '-c',
|
|
'echo $$ > /tmp/server.pid; exec /app/lldap run --config-file /data/lldap_config.toml > /dev/null 2>&1'])
|
|
result = m.command(['docker', 'run', '--interactive', '--rm', '--pull=never', '--network', 'container:' + name,
|
|
'--read-only', '--entrypoint', 'python3', m.CLIENT, '-c', m.PROBE],
|
|
input=json.dumps({'user': 'admin', 'password': PASSWORD,
|
|
'base': 'dc=example,dc=test', 'wrong_password': 'invalid-fixture'}).encode())
|
|
assert json.loads(result)['status'] == 'verified', result.decode()
|
|
m.command(['docker', 'exec', name, '/bin/sh', '-c', 'kill -TERM "$(cat /tmp/server.pid)"'])
|
|
for _ in range(60):
|
|
check = subprocess.run(['docker', 'exec', name, '/bin/sh', '-c', 'kill -0 "$(cat /tmp/server.pid)"'],
|
|
capture_output=True, timeout=10)
|
|
if check.returncode:
|
|
break
|
|
time.sleep(.1)
|
|
else:
|
|
pytest.fail('fixture did not stop')
|
|
raw = m.command(['docker', 'exec', name, 'tar', '-cf', '-', '-C', '/data', '.'])
|
|
data = tmp_path / 'data/lldap'
|
|
data.mkdir(parents=True)
|
|
with tarfile.open(fileobj=io.BytesIO(raw)) as archive:
|
|
for entry in archive:
|
|
if entry.isdir():
|
|
continue
|
|
assert entry.isfile() and not entry.name.startswith('/') and '..' not in Path(entry.name).parts
|
|
target = data / entry.name.removeprefix('./')
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(archive.extractfile(entry).read())
|
|
yield tmp_path
|
|
finally:
|
|
subprocess.run(['docker', 'rm', '--force', '--volumes', name], capture_output=True, timeout=30)
|
|
|
|
|
|
def test_native_restored_directory_binds_existing_user_and_preserves_rows(retained_directory):
|
|
result = {}
|
|
m.restore_lldap(retained_directory, 'admin', PASSWORD, result)
|
|
assert result['status'] == 'verified'
|
|
assert result['existing_user_native_ldap_bind'] and result['wrong_password_refused']
|
|
assert result['directory_identity_rows_preserved'] and result['container_cleanup_verified']
|
|
assert result['only_loopback_interface_verified'] and result['external_routes_absent']
|
|
assert PASSWORD not in json.dumps(result)
|
|
|
|
|
|
def test_native_wrong_retained_password_fails_and_removes_containers(retained_directory):
|
|
result = {}
|
|
with pytest.raises(m.bundle.Refused):
|
|
m.restore_lldap(retained_directory, 'admin', 'wrong-retained-password', result)
|
|
assert result['container_cleanup_verified']
|
|
assert result['failed_stage'] == 'existing_user_bind'
|
|
assert not result.get('existing_user_native_ldap_bind')
|
|
|
|
|
|
def test_encrypted_bundle_to_native_directory_and_tmpfs_cleanup(retained_directory, stage, tmp_path, monkeypatch, capsys):
|
|
import shutil
|
|
import sys
|
|
# Replace the synthetic SQLite placeholder with a real retained directory,
|
|
# retaining the complete profile's resource identities and other key assets.
|
|
for source in (retained_directory / 'data/lldap').iterdir():
|
|
if source.is_file():
|
|
shutil.copyfile(source, stage / 'data/lldap' / source.name)
|
|
dep = stage / 'resources/sso/deployment/lldap.json'
|
|
value = json.loads(dep.read_text())
|
|
value['spec'] = json.loads((retained_directory / 'resources/sso/deployment/lldap.json').read_text())['spec']
|
|
dep.write_text(json.dumps(value))
|
|
secret = stage / 'resources/sso/secret/lldap-secrets.json'
|
|
value = json.loads(secret.read_text())
|
|
value['data'] = json.loads((retained_directory / 'resources/sso/secret/lldap-secrets.json').read_text())['data']
|
|
secret.write_text(json.dumps(value))
|
|
key = tmp_path / 'fixture-age.key'
|
|
m.command(['age-keygen', '-o', str(key)])
|
|
recipient = m.command(['age-keygen', '-y', str(key)]).decode().strip()
|
|
profile = m.bundle.profile()
|
|
profile['public_recipient'] = recipient
|
|
monkeypatch.setattr(m.bundle, 'profile', lambda: profile)
|
|
artifact = tmp_path / 'net-kingdom-identity-20261016T063159Z-0123456789ab.tar.age'
|
|
m.bundle.seal(stage, artifact, {'synthetic_native_fixture': True})
|
|
transfer = tmp_path / 'transfer.json'
|
|
transfer.write_text(json.dumps({'schema': 'netkingdom.identity-offsite.v1', 'status': 'readback_verified',
|
|
'provider': 'Storage Share', 'folder': 'Backup/railiance-backups/net-kingdom-identity',
|
|
'artifact': artifact.name, 'ciphertext_bytes': artifact.stat().st_size,
|
|
'ciphertext_sha256': m.offline.offsite.digest(artifact), 'etag': '"synthetic"',
|
|
'fetched_path': str(artifact), 'independent_owner_retrieval_verified': True}))
|
|
monkeypatch.setattr(m.verify.custody, 'private_key_fd', lambda: os.open(key, os.O_RDONLY))
|
|
monkeypatch.setattr(m.offline, 'private_prompt', lambda label: PASSWORD)
|
|
receipt = tmp_path / 'recovery.json'
|
|
monkeypatch.setattr(sys, 'argv', ['isolated-lldap', '--transfer-receipt', str(transfer),
|
|
'--receipt', str(receipt), '--user', 'admin'])
|
|
assert m.main() == 0
|
|
result = json.loads(receipt.read_text())
|
|
assert result['status'] == 'isolated_lldap_verified'
|
|
assert result['temporary_plaintext_cleanup_verified'] and result['container_cleanup_verified']
|
|
assert result['all_manifest_hashes_verified'] and result['directory_identity_rows_preserved']
|
|
assert not result['production_contacted'] and not result['secret_values_exported']
|
|
assert PASSWORD not in capsys.readouterr().out + receipt.read_text()
|