net-kingdom/tests/test_isolated_authelia_recovery.py
tegwick 006dc23fdf
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
NK-WP-0049-T41: isolated Authelia restore stage, fixture-tested
isolated-authelia.py runs restored LLDAP as the network-none anchor with
Authelia joining its namespace. Checks the original config, storage key,
issuer key, KeyCape's client secret and existing-user first factor, with
wrong-password and wrong-client-secret refusal. Confinement rewrites: LDAP
to loopback, file notifier, NTP check off, healthcheck file off. Imports
isolated-lldap.py unedited; the T38-pinned KeyCape sources are untouched.
Native fixture tests on the pinned images pass (9). T17 now awaits the
founder-attended run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 328925@bnt-lap001
Assistant-Session: 14b795fd-c632-4c01-8d28-e7e490c7b0ed
2026-10-24 17:14:52 +02:00

242 lines
13 KiB
Python

"""Isolated Authelia restoration with synthetic retained credentials only."""
import base64
import importlib.util
import json
import os
from pathlib import Path
import secrets
import subprocess
import uuid
import pytest
import yaml
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from test_identity_bundle import stage # noqa: F401 (fixture reuse)
from test_isolated_lldap_recovery import PASSWORD, retained_directory # noqa: F401 (fixture reuse)
import test_isolated_lldap_recovery as lldap_fixture
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('isolated_authelia', ROOT / 'tools/bootstrap-recovery/isolated-authelia.py')
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
MANIFESTS = ROOT / 'sso-mfa/k8s/authelia'
CLIENT_SECRET = 'synthetic-keycape-client-' + secrets.token_hex(16) # Disposable fixture only.
def secret_values():
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return {'jwt_secret': secrets.token_hex(32), 'session_secret': secrets.token_hex(32),
'storage_encryption_key': secrets.token_hex(32), 'ldap_password': PASSWORD,
'oidc_hmac_secret': secrets.token_hex(32),
'oidc_issuer_private_key': key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption()).decode(),
'keycape_client_secret_hash': 'placeholder-replaced-in-native-fixture'}
def resources(root, values):
"""Write the repository's own Authelia manifests as archived resources."""
def write(kind, name, value):
p = root / 'resources/sso' / kind
p.mkdir(parents=True, exist_ok=True)
value = {**value, 'metadata': {**value.get('metadata', {}), 'name': name, 'namespace': 'sso'}}
(p / (name + '.json')).write_text(json.dumps(value))
deployment = next(d for d in yaml.safe_load_all((MANIFESTS / 'deployment.yaml').read_text())
if d and d['kind'] == 'Deployment')
configmap = yaml.safe_load((MANIFESTS / 'configmap.yaml').read_text())
config = yaml.safe_load(configmap['data']['configuration.yml'])
ldap = config['authentication_backend']['ldap']
ldap.update(base_dn='dc=example,dc=test', user='uid=admin,ou=people,dc=example,dc=test')
configmap['data']['configuration.yml'] = yaml.safe_dump(config, sort_keys=False)
write('deployment', 'authelia', deployment)
write('configmap', 'authelia-config', configmap)
write('secret', 'authelia-secrets', {'data': {k: base64.b64encode(v.encode()).decode() for k, v in values.items()}})
write('secret', 'authelia-smtp', {'data': {'password': base64.b64encode(b'synthetic-smtp-canary').decode()}})
keycape = {'authelia': {'clientId': 'keycape', 'clientSecret': CLIENT_SECRET,
'browserBaseURL': 'https://auth.coulomb.social'}}
write('secret', 'keycape-config', {'data': {'config.yaml': base64.b64encode(yaml.safe_dump(keycape).encode()).decode()}})
store = root / 'data/authelia/db.sqlite3'
store.parent.mkdir(parents=True, exist_ok=True)
if not store.exists():
store.write_bytes(b'')
def test_configuration_keeps_originals_and_confines_ldap_and_notifier(tmp_path):
values = secret_values()
resources(tmp_path, values)
runtime = m.configuration(tmp_path)
config = yaml.safe_load(runtime['config'])
assert config['authentication_backend']['ldap']['url'] == m.LDAP_URL
assert config['notifier'] == {'filesystem': {'filename': '/var/authelia/data/' + m.NOTIFICATIONS}}
assert config['ntp']['disable_startup_check'] is True
assert config['server']['disable_healthcheck'] is True
assert config['identity_providers']['oidc']['clients'][0]['secret'].startswith('{{ secret ')
assert runtime['secrets']['/run/secrets/authelia/storage_encryption_key'] == values['storage_encryption_key'].encode()
assert not any('smtp' in name.lower() for name in runtime['env'])
assert all(path.startswith('/run/secrets/authelia/') for name, path in runtime['env'].items()
if name.endswith('_FILE'))
assert runtime['host'] == 'auth.coulomb.social' and runtime['client_secret'] == CLIENT_SECRET
for value in values.values():
assert value.encode() not in runtime['config']
def test_configuration_refuses_missing_secret_file(tmp_path):
values = secret_values()
del values['storage_encryption_key']
resources(tmp_path, values)
with pytest.raises(m.bundle.Refused):
m.configuration(tmp_path)
def test_configuration_refuses_env_from_unknown_source(tmp_path):
resources(tmp_path, secret_values())
path = tmp_path / 'resources/sso/deployment/authelia.json'
value = json.loads(path.read_text())
value['spec']['template']['spec']['containers'][0]['env'].append(
{'name': 'AUTHELIA_EXTRA', 'valueFrom': {'secretKeyRef': {'name': 'other', 'key': 'x'}}})
path.write_text(json.dumps(value))
with pytest.raises(m.bundle.Refused):
m.configuration(tmp_path)
def test_configuration_refuses_unknown_keycape_client(tmp_path):
resources(tmp_path, secret_values())
path = tmp_path / 'resources/sso/secret/keycape-config.json'
keycape = {'authelia': {'clientId': 'not-registered', 'clientSecret': CLIENT_SECRET,
'browserBaseURL': 'https://auth.coulomb.social'}}
path.write_text(json.dumps({'metadata': {'name': 'keycape-config', 'namespace': 'sso'}, 'data': {'config.yaml': base64.b64encode(yaml.safe_dump(keycape).encode()).decode()}}))
with pytest.raises(m.bundle.Refused):
m.configuration(tmp_path)
def test_public_jwk_thumbprint_is_rfc7638():
pem = secret_values()['oidc_issuer_private_key'].encode()
jwk, thumbprint = m.public_jwk(pem)
assert set(jwk) == {'e', 'kty', 'n'} and len(thumbprint) == 43
@pytest.fixture
def retained_authelia(retained_directory): # noqa: F811
if os.environ.get('NK49_RUN_ISOLATED_AUTHELIA_TEST') != '1':
pytest.skip('explicit local disposable-image fixture opt-in')
m.preflight()
values = secret_values()
digest = m.command(['docker', 'run', '--rm', '--pull=never', '--network', 'none', '--entrypoint', '/app/authelia',
m.IMAGE, 'crypto', 'hash', 'generate', 'pbkdf2', '--variant', 'sha512',
'--password', CLIENT_SECRET]).decode()
values['keycape_client_secret_hash'] = digest.split('Digest: ', 1)[1].strip()
resources(retained_directory, values)
runtime = m.configuration(retained_directory)
(retained_directory / 'data/authelia/db.sqlite3').unlink()
name = 'nk49-authelia-fixture-' + uuid.uuid4().hex
try:
m.command(['docker', 'run', '--detach', '--pull=never', '--name', name, '--network', 'none',
'--read-only', '--cap-drop', 'ALL', '--user', '1000:1000',
*m.tmpfs('/var/authelia/data', 67108864), *m.tmpfs('/run/secrets/authelia', 1048576),
*m.tmpfs('/config', 1048576), *[x for k, v in runtime['env'].items() for x in ('--env', k + '=' + v)],
'--entrypoint', '/bin/sh', m.IMAGE, '-c', 'sleep 600'])
m.command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/secrets/authelia'],
input=m.archive_files({k.rsplit('/', 1)[1]: v for k, v in runtime['secrets'].items()}))
m.command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/config'],
input=m.archive_files({'configuration.yml': runtime['config']}))
m.command(['docker', 'exec', name, '/app/authelia', 'storage', 'migrate', 'up',
'--config', '/config/configuration.yml'])
store = m.command(['docker', 'exec', name, 'cat', '/var/authelia/data/db.sqlite3'])
(retained_directory / 'data/authelia/db.sqlite3').write_bytes(store)
yield retained_directory, values
finally:
subprocess.run(['docker', 'rm', '--force', '--volumes', name], capture_output=True, timeout=30)
def test_native_restored_authelia_passes_all_checks(retained_authelia):
root, values = retained_authelia
result = {}
m.restore_authelia(root, 'admin', PASSWORD, result)
assert result['status'] == 'verified', result
for key in ('original_configuration_validated', 'storage_encryption_key_verified', 'issuer_key_matches_archive',
'existing_user_first_factor', 'wrong_password_refused', 'archived_client_secret_accepted',
'wrong_client_secret_refused', 'only_loopback_interface_verified', 'container_cleanup_verified',
'container_isolation_verified_before_private_import', 'notifications_confined_to_tmpfs_file'):
assert result[key] is True, key
assert result['grant_issued'] is False
text = json.dumps(result)
assert PASSWORD not in text and CLIENT_SECRET not in text
assert not any(v in text for v in values.values())
def test_native_wrong_password_fails_and_removes_containers(retained_authelia):
root, _ = retained_authelia
result = {}
with pytest.raises(m.bundle.Refused):
m.restore_authelia(root, 'admin', 'wrong-retained-password', result)
assert result['failed_stage'] == 'existing_user_first_factor'
assert result['container_cleanup_verified']
def test_native_wrong_storage_key_is_refused(retained_authelia):
root, _ = retained_authelia
path = root / 'resources/sso/secret/authelia-secrets.json'
value = json.loads(path.read_text())
value['data']['storage_encryption_key'] = base64.b64encode(secrets.token_hex(32).encode()).decode()
path.write_text(json.dumps(value))
result = {}
with pytest.raises(m.bundle.Refused):
m.restore_authelia(root, 'admin', PASSWORD, result)
assert result['failed_stage'] == 'storage_decryption'
assert result['container_cleanup_verified']
assert lldap_fixture.m.IMAGE == m.lldap.IMAGE
def test_encrypted_bundle_to_native_authelia_and_tmpfs_cleanup(retained_authelia, stage, tmp_path, monkeypatch, capsys):
import shutil
import sys
root, values = retained_authelia
# Replace the synthetic placeholders with the retained LLDAP/Authelia state,
# keeping the complete profile's resource identities and other key assets.
for component in ('lldap', 'authelia'):
for source in (root / 'data' / component).iterdir():
if source.is_file():
shutil.copyfile(source, stage / 'data' / component / source.name)
for kind, name in (('deployment', 'lldap'), ('secret', 'lldap-secrets'), ('deployment', 'authelia'),
('configmap', 'authelia-config'), ('secret', 'authelia-secrets'), ('secret', 'authelia-smtp')):
target = stage / 'resources/sso' / kind / (name + '.json')
value = json.loads(target.read_text())
retained = json.loads((root / 'resources/sso' / kind / (name + '.json')).read_text())
value.update({k: v for k, v in retained.items() if k != 'metadata'})
target.write_text(json.dumps(value))
target = stage / 'resources/sso/secret/keycape-config.json'
value = json.loads(target.read_text())
value['data']['config.yaml'] = json.loads(
(root / 'resources/sso/secret/keycape-config.json').read_text())['data']['config.yaml']
target.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-authelia', '--transfer-receipt', str(transfer),
'--receipt', str(receipt), '--user', 'admin'])
assert m.main() == 0, receipt.read_text()
result = json.loads(receipt.read_text())
assert result['status'] == 'isolated_authelia_verified'
assert result['temporary_plaintext_cleanup_verified'] and result['container_cleanup_verified']
assert result['all_manifest_hashes_verified'] and result['issuer_key_matches_archive']
assert not result['production_contacted'] and not result['secret_values_exported']
text = capsys.readouterr().out + receipt.read_text()
assert PASSWORD not in text and CLIENT_SECRET not in text
assert not any(v in text for v in values.values())