Prepare native isolated directory recovery and offline service images

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
This commit is contained in:
tegwick 2026-10-16 20:33:57 +02:00
parent 31f483966c
commit d5484bb90a
9 changed files with 815 additions and 7 deletions

View file

@ -0,0 +1,57 @@
{
"schema": "netkingdom.isolated-lldap-preparation.v1",
"date": "2026-10-16",
"status": "native_synthetic_verified_owner_run_pending",
"sources": [
{
"path": "tools/bootstrap-recovery/isolated-lldap.py",
"sha256": "35bfa8411186ad936a199bd450c7e18c2ad51059301a6db9650e6bd055ab1b81"
},
{
"path": "tools/bootstrap-recovery/run-isolated-lldap.sh",
"sha256": "f0b9cecf92482b0af10a6e304c66677dc4390b6a4560826700a659723af5e2fc"
},
{
"path": "tests/test_isolated_lldap_recovery.py",
"sha256": "729791f7271da617969255e782351f134b807500394f3522db6ae00d4bd4dc77"
},
{
"path": "docs/identity-service-restore-packet.md",
"sha256": "2c30f87cbcd836d93177503932f82824a3bd9a0a6683aec07987d3e740353d44"
}
],
"target": {
"engine_id": "74f93783-6cef-43a8-a3c4-f62586cd3bc1",
"engine_name": "bnt-lap001",
"linux_local_unix_endpoint_only": true
},
"tests": {
"focused_command": "NK49_RUN_ISOLATED_LLDAP_TEST=1 python3 -m pytest -q tests/test_isolated_lldap_recovery.py",
"focused_passed": 6,
"failed": 0,
"broader_run_passed": 49,
"broader_run_skipped": 1,
"skip_reason": "separately opted-in PostgreSQL fixture; actual PostgreSQL archive recovery already accepted in T23",
"native_existing_user_and_invalid_credential_result": true,
"native_encrypted_bundle_to_directory_restore": true,
"original_key_missing_refused": true,
"network_none_and_before_import_isolation_guard_verified": true,
"directory_data_preserved_and_container_tmpfs_cleanup_verified": true,
"fixture_signing_secret_generated_per_run": true
},
"credential_route": {
"catalog_id": "net-kingdom-lldap-bind-credential",
"owner": "railiance-platform",
"resolvable_fetch_lane": false,
"use": "owner boundary discovery only; no service credential fetched; actual existing-user input remains private with operator"
},
"knowledge": {
"version": "0.2.6",
"digest": "8d1d6a9d4d07fec7b619618de7dfe2be2254c1790f85f4198c7fe19db9751c02",
"status": "withheld; current local security companion, task card and owner route reviewed directly; orientation only"
},
"actual_backup_native_lldap_authentication_proven": false,
"actual_private_key_or_user_password_received_by_agent": false,
"production_contacted": false,
"other_service_authentication_or_mfa_or_audit_proven": false
}

View file

@ -0,0 +1,51 @@
{
"schema": "netkingdom.identity-recovery-offline-images.v1",
"status": "saved_loaded_inspected",
"observed_at": "2026-10-16T18:19:11.824598+00:00",
"target": {
"engine_id": "74f93783-6cef-43a8-a3c4-f62586cd3bc1",
"engine_name": "bnt-lap001",
"images": [
{
"reference": "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d",
"image_id": "sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d"
},
{
"reference": "ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473",
"image_id": "sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473"
}
],
"production_contacted": false,
"network_mode": "none",
"host_ports_published": false
},
"archive_path": "/tmp/nk49-service-image-cache-vxokyfoa/identity-images.tar",
"archive_bytes": 347521536,
"archive_sha256": "e61124c5b4473bf49671e254ad83a8829a046c72e8d46582dad6f6594fecd434",
"images": [
{
"reference": "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d",
"image_id": "sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d"
},
{
"reference": "ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473",
"image_id": "sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473"
},
{
"reference": "postgres@sha256:ff23cdce56cac62ada6f66013e1a50864c0abbe79d132d40b6e05bd80f378a70",
"image_id": "sha256:ff23cdce56cac62ada6f66013e1a50864c0abbe79d132d40b6e05bd80f378a70"
},
{
"reference": "docker.io/authelia/authelia@sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54",
"image_id": "sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54"
},
{
"reference": "forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010",
"image_id": "sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010"
}
],
"production_registry_required_for_local_drill": false,
"offhost_image_escrow_proven": false,
"source_archive_image_lineage_proven": false,
"service_custody_or_age_key_supplied_to_image_cache": false
}

View file

@ -1,6 +1,7 @@
# Isolated identity service restore packet
NK-WP-0049-T15. Prepared 2026-10-16; **draft, not admitted or executed**.
NK-WP-0049-T15. Prepared 2026-10-16. **LLDAP stage ready for the private
operator run; remaining service stages are draft and unexecuted.**
Accountable preparer: codex. Execution/acceptance: Railiance Platform and the
LLDAP, Authelia, KeyCape and privacyIDEA service owners.
@ -15,7 +16,15 @@ needs equivalent provenance and archive checks before replacing this input.
## Targets and image admission still required
No execution target has been admitted. Select a disposable host/context and
The bounded LLDAP stage below selects the local Linux Docker engine
`bnt-lap001`, ID `74f93783-6cef-43a8-a3c4-f62586cd3bc1`; the executor refuses a
different engine or a remote Docker endpoint. All five exact image references
have been saved, loaded and inspected on that workstation; receipt:
`docs/evidence/2026-10-16-identity-outage-recovery/offline-service-images.json`.
This establishes local image availability without production registry access,
not an off-host image escrow or exact historical mutable-tag provenance.
No broader execution target has been admitted. Select a disposable host/context and
record its immutable identity, operator, private endpoints, maximum lifetime,
resource limits and exact owned cleanup inventory. Never select the production
context or reuse live PVCs. Use fresh private storage; no production host mounts,
@ -66,6 +75,51 @@ export literal environment values, Secret payloads or arbitrary configs.
plaintext/key files. Confirm absence and production resources unchanged;
retain sanitized receipts, original ciphertext and independently held keys.
## Ready owner step: isolated LLDAP recovery
Execute in your own terminal, using an existing account and the password valid
at the backup's capture time. No password belongs in the arguments:
```bash
bash tools/bootstrap-recovery/run-isolated-lldap.sh EXISTING_USER \
/tmp/nk49-offline-handoff-1X660a5t/transfer.json
```
Replace `EXISTING_USER` with the selected existing username. The command asks
privately for the independently held `railiance-backup` age key and then the
account password. It rechecks ciphertext size/hash, recipient and every archive
file before importing anything. Decrypted staging is in a private Linux tmpfs;
the key helper removes its temporary file at exit.
The executor creates only uniquely named `nk49-lldap-*` containers on the fixed
local engine. LLDAP has `--network none`, no published ports, read-only root,
all capabilities dropped, no added privileges, user1000, 256 MiB memory/one CPU
and private tmpfs for data/config. The client shares only that loopback network
namespace and verifies there is no external interface or route. Container
isolation is inspected before private import. No Kubernetes/OpenBao/SSO API,
production directory endpoint or provider grant is used. Containers have a
30-minute holding lifetime and are removed on exit; plaintext and containers
are checked for cleanup. A forced host/process termination can still require
owner reconciliation of the named resources.
The original server key/seed, JWT secret and directory data are preserved.
Admin-password reset, private-key replacement, SMTP and LDAPS listeners are
disabled. The private account password travels only through memory/stdin; it is
not placed in Docker arguments, environment values, logs or evidence. A native
LDAP bind must pass, and an explicit LDAP invalid-credentials result must reject
the wrong password. After a graceful stop, users/groups/memberships and available
attribute tables are compared with the original snapshot. Only equality and
aggregate counts leave the process.
The actual-image tests use disposable synthetic accounts, a real encrypted
bundle and the same restore executor. They cover positive and wrong-credential
paths, isolation, original-key refusal, directory preservation and cleanup.
They are not the real-backup proof: T16 remains waiting for the owner's private
run and safe receipt. Return only the outcome and evidence directory.
This step does not restore Authelia, KeyCape or privacyIDEA, and does not prove
current production password validity, MFA or audit continuity.
## Acceptance matrix
| Task / owner lane | Positive check | Negative check | Safe evidence |

View file

@ -73,6 +73,8 @@ availability:
- tools/bootstrap-recovery/retrieve-identity-offline.py
- tools/bootstrap-recovery/recovery-kit.py
- tools/bootstrap-recovery/run-offline-recovery.sh
- tools/bootstrap-recovery/isolated-lldap.py
- tools/bootstrap-recovery/run-isolated-lldap.sh
- docs/disaster-recovery-storage-share.md
- docs/identity-service-restore-packet.md
- docs/storage-share-recovery-publication.md
@ -103,6 +105,7 @@ evidence:
- tests/test_identity_offsite.py
- tests/test_identity_backup_preparation.py
- tests/test_identity_offline_recovery.py
- tests/test_isolated_lldap_recovery.py
consumer_guidance:
recommended_for:
- Operator recovers identity, custody or data with independently held recovery material
@ -158,3 +161,10 @@ and independent outage-path archive recovery passed on 2026-10-16; T23/T26 are
done. See [the published location and trusted kit digest](../../docs/storage-share-recovery-publication.md).
The [isolated service packet](../../docs/identity-service-restore-packet.md)
records the remaining service-owner admission and native tests.
The local LLDAP stage now has a fixed-target executor with no external network,
ports or host mounts, Linux-tmpfs plaintext, original directory/key preservation,
native existing-user/wrong-password checks and cleanup. Six actual-image tests
with synthetic credentials passed, including an encrypted-bundle restore. All
five pinned service images were saved/reloaded locally. T28/T29 record that
preparation; actual private real-backup LLDAP acceptance remains T16.

View file

@ -0,0 +1,177 @@
"""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
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()

View file

@ -18,6 +18,16 @@ Actual owner publication/readback and outage-path archive recovery passed on
Service restore admission remains in the
[isolated restore packet](../../docs/identity-service-restore-packet.md).
The first native-service stage is `run-isolated-lldap.sh USER TRANSFER_RECEIPT`.
It preflights the exact local Docker engine/images before asking privately for
the age key and backup-era existing-user password. `isolated-lldap.py` validates
the encrypted archive, restores original directory/key material only into
network-none/read-only/tmpfs containers, proves native LDAP bind and explicit
invalid-credential denial, compares durable identity rows and removes its owned
containers/plaintext. It uses no live Kubernetes/OpenBao endpoint or provider
grant. Actual-image synthetic tests pass; a real owner run is still required
for T16. See the packet for the exact target, resource limits and private command.
`verify-bundle.py` validates a digest-bound age-encrypted `secrets/` tar archive
using an explicit private temporary key and registered `keys/age.pub`. It checks
an exact required-file list, unrelated wrong-key refusal, safe isolated file

View file

@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""Owner-run native LLDAP restore from the verified identity bundle.
Private inputs remain in the owner process/pipe. Decrypted staging uses Linux
tmpfs. Containers have no network, ports, host mounts or production API access.
"""
import argparse
import base64
from datetime import datetime, timezone
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path, PurePosixPath
import re
import signal
import sqlite3
import subprocess
import tarfile
import tempfile
import time
import tomllib
import uuid
HERE = Path(__file__).resolve().parent
def load(name):
spec = importlib.util.spec_from_file_location(name, HERE / (name + '.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
verify = load('verify-identity')
offline = load('retrieve-identity-offline')
bundle = verify.bundle
IMAGE = 'docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d'
CLIENT = 'ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473'
ENGINE_ID = '74f93783-6cef-43a8-a3c4-f62586cd3bc1'
ENGINE_NAME = 'bnt-lap001'
def command(args, **kwargs):
result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
timeout=kwargs.pop('timeout', 90), **kwargs)
if result.returncode:
raise bundle.Refused()
return result.stdout
def preflight():
contexts = json.loads(command(['docker', 'context', 'inspect']))
endpoints = [contexts[0]['Endpoints']['docker']['Host']]
if os.environ.get('DOCKER_HOST'):
endpoints.append(os.environ['DOCKER_HOST'])
if not all(url.startswith('unix://') for url in endpoints):
raise bundle.Refused()
facts = json.loads(command(['docker', 'info', '--format',
'{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}']))
if facts['ID'] != ENGINE_ID or facts['Name'] != ENGINE_NAME or facts['OSType'] != 'linux':
raise bundle.Refused()
# /dev/shm must actually be tmpfs, not a similarly named disk directory.
mounts = Path('/proc/mounts').read_text().splitlines()
if not any(line.split()[1:3] == ['/dev/shm', 'tmpfs'] for line in mounts):
raise bundle.Refused()
images = []
for image in (IMAGE, CLIENT):
image_id = command(['docker', 'image', 'inspect', '--format', '{{.Id}}', image]).decode().strip()
images.append({'reference': image, 'image_id': image_id})
return {'engine_id': facts['ID'], 'engine_name': facts['Name'], 'images': images,
'production_contacted': False, 'network_mode': 'none', 'host_ports_published': False}
def snapshot(path):
# Include all fields of durable identity tables, including stored password
# hashes. Only counts and the equality boolean leave this process.
result = {}
with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as db:
if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
raise bundle.Refused()
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
for name in ('users', 'groups', 'memberships', 'user_attributes', 'group_attributes',
'user_attribute_values', 'group_attribute_values',
'user_attribute_schema', 'group_attribute_schema'):
if name in tables:
rows = db.execute('SELECT * FROM "' + name + '"').fetchall()
encoded = sorted(repr(row) for row in rows)
result[name] = {'count': len(rows), 'digest': hashlib.sha256('\n'.join(encoded).encode()).digest()}
if not {'users', 'groups', 'memberships'} <= result.keys():
raise bundle.Refused()
return result
def configuration(root):
data = root / 'data/lldap'
existing = data / 'lldap_config.toml'
config = tomllib.loads(existing.read_text()) if existing.is_file() else {}
deployment = bundle.resource(root, 'sso', 'deployment', 'lldap')
main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'lldap')
env = {e['name']: e['value'] for e in main.get('env', []) if 'value' in e}
secret = bundle.resource(root, 'sso', 'secret', 'lldap-secrets')['data']
jwt = base64.b64decode(secret['LLDAP_JWT_SECRET'], validate=True)
password = base64.b64decode(secret['LLDAP_LDAP_USER_PASS'], validate=True)
if not jwt or not password or b'\n' in jwt or b'\n' in password:
raise bundle.Refused()
base = env.get('LLDAP_LDAP_BASE_DN', config.get('ldap_base_dn'))
user = env.get('LLDAP_LDAP_USER_DN', config.get('ldap_user_dn', 'admin'))
if not isinstance(base, str) or not base or not isinstance(user, str):
raise bundle.Refused()
chosen = {'database_url': 'sqlite:///data/users.db?mode=rwc',
'ldap_base_dn': base, 'ldap_user_dn': user,
'ldap_host': '127.0.0.1', 'http_host': '127.0.0.1',
'ldap_port': 3890, 'http_port': 17170,
'force_ldap_user_pass_reset': False, 'force_update_private_key': False}
seed = env.get('LLDAP_KEY_SEED', config.get('key_seed'))
if seed:
if not isinstance(seed, str):
raise bundle.Refused()
chosen['key_seed'] = seed
else:
value = env.get('LLDAP_KEY_FILE', config.get('key_file', '/data/private_key'))
key = PurePosixPath(value)
if not key.is_absolute():
key = PurePosixPath('/data') / key
if key.parent != PurePosixPath('/data') or not (data / key.name).is_file():
raise bundle.Refused()
chosen['key_file'] = str(key)
# JSON strings are valid TOML basic strings for these service inputs.
raw = '\n'.join(k + ' = ' + json.dumps(v) for k, v in chosen.items())
raw += '\n[smtp_options]\nenable_password_reset = false\n[ldaps_options]\nenabled = false\n'
return raw.encode(), jwt, password, base
def archive_files(files):
out = io.BytesIO()
with tarfile.open(fileobj=out, mode='w') as archive:
for name, value in files.items():
if PurePosixPath(name).is_absolute() or '..' in PurePosixPath(name).parts:
raise bundle.Refused()
entry = tarfile.TarInfo(name)
entry.size = len(value)
entry.mode = 0o600
entry.uid = entry.gid = 1000
archive.addfile(entry, io.BytesIO(value))
return out.getvalue()
PROBE = r'''
import contextlib,io,json,logging,socket,sys,time
logging.disable(logging.CRITICAL)
result={'status':'failed'}
stage='network_isolation'
try:
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
from pathlib import Path
assert {p.name for p in Path('/sys/class/net').iterdir()}=={'lo'}
assert len(Path('/proc/net/route').read_text().splitlines())==1
stage='native_client_import'
from ldap3 import Server,Connection,NONE
from ldap3.utils.dn import escape_rdn
values=json.load(sys.stdin)
stage='directory_startup'
for _ in range(60):
try:
with socket.create_connection(('127.0.0.1',3890),timeout=1):pass
break
except OSError:time.sleep(.25)
else:raise RuntimeError()
dn='uid='+escape_rdn(values['user'])+',ou=people,'+values['base']
server=Server('127.0.0.1',port=3890,get_info=NONE,connect_timeout=3)
stage='existing_user_bind'
connection=Connection(server,user=dn,password=values['password'],auto_bind=True,raise_exceptions=True)
assert connection.bound
connection.unbind()
stage='wrong_password_bind'
wrong=Connection(server,user=dn,password=values['wrong_password'],auto_bind=False,raise_exceptions=False)
refused=not wrong.bind() and wrong.result.get('result')==49
wrong.unbind()
assert refused
result={'status':'verified','existing_user_native_ldap_bind':True,'wrong_password_refused':True,
'only_loopback_interface_verified':True,'external_routes_absent':True}
except Exception as error:result={'status':'failed','failed_stage':stage,'failure_type':type(error).__name__}
print(json.dumps(result))
'''
def restore_lldap(root, user, password, receipt):
before = snapshot(root / 'data/lldap/users.db')
config, jwt, admin_password, base = configuration(root)
name = 'nk49-lldap-' + uuid.uuid4().hex
client_name = name + '-client'
try:
command(['docker', 'run', '--detach', '--pull=never', '--name', name,
'--network', 'none', '--read-only', '--cap-drop', 'ALL',
'--security-opt', 'no-new-privileges', '--user', '1000:1000',
'--memory', '256m', '--cpus', '1', '--pids-limit', '128',
'--tmpfs', '/data:rw,nosuid,nodev,noexec,size=268435456,uid=1000,gid=1000,mode=0700',
'--tmpfs', '/run/recovery:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700',
'--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000',
'--env', 'LLDAP_JWT_SECRET_FILE=/run/recovery/jwt',
'--env', 'LLDAP_LDAP_USER_PASS_FILE=/run/recovery/admin-password',
'--entrypoint', '/bin/sh', IMAGE, '-c', 'sleep 1800'])
guard = json.loads(command(['docker', 'container', 'inspect', name, '--format',
'{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"user":{{json .Config.User}}}']))
host = guard['host']
if (host['NetworkMode'] != 'none' or not host['ReadonlyRootfs'] or host['Privileged']
or host.get('Binds') or host.get('PortBindings') or 'ALL' not in host.get('CapDrop', [])
or 'no-new-privileges' not in host.get('SecurityOpt', [])
or guard['user'] != '1000:1000'
or any(mount['Type'] != 'tmpfs' for mount in guard['mounts'])):
raise bundle.Refused()
receipt['container_isolation_verified_before_private_import'] = True
payload = {p.relative_to(root / 'data/lldap').as_posix(): p.read_bytes()
for p in (root / 'data/lldap').rglob('*') if p.is_file()}
command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/data'], input=archive_files(payload))
command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/recovery'],
input=archive_files({'lldap.toml': config, 'jwt': jwt, 'admin-password': admin_password}))
# No credential values in Docker configuration, environment or argv.
command(['docker', 'exec', '--detach', name, '/bin/sh', '-c',
'echo $$ > /run/recovery/server.pid; exec /app/lldap run --config-file /run/recovery/lldap.toml > /dev/null 2>&1'])
raw = command(['docker', 'run', '--interactive', '--rm', '--name', client_name, '--pull=never', '--network', 'container:' + name,
'--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
'--memory', '256m', '--cpus', '1', '--pids-limit', '64',
'--entrypoint', 'python3', CLIENT, '-c', PROBE],
input=json.dumps({'user': user, 'password': password, 'base': base,
'wrong_password': 'nk49-wrong-' + uuid.uuid4().hex}).encode())
result = json.loads(raw)
receipt.update(result)
if result.get('status') != 'verified':
raise bundle.Refused()
command(['docker', 'exec', name, '/bin/sh', '-c', 'kill -TERM "$(cat /run/recovery/server.pid)"'])
for _ in range(60):
running = subprocess.run(['docker', 'exec', name, '/bin/sh', '-c',
'kill -0 "$(cat /run/recovery/server.pid)"'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
if running.returncode:
break
time.sleep(.1)
else:
raise bundle.Refused()
raw = command(['docker', 'exec', name, '/bin/sh', '-c',
'test ! -s /data/users.db-wal && cat /data/users.db'])
after_path = root / 'post-restore-users.db'
after_path.write_bytes(raw)
if snapshot(after_path) != before:
raise bundle.Refused()
receipt.update(directory_identity_rows_preserved=True,
directory_comparison_scope=sorted(before),
directory_table_counts={k: v['count'] for k, v in before.items()})
finally:
# A failed create can still have created a container (e.g. lost reply).
for owned in (client_name, name):
found = subprocess.run(['docker', 'container', 'inspect', owned], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=15)
if found.returncode == 0:
command(['docker', 'rm', '--force', '--volumes', owned])
absent = subprocess.run(['docker', 'container', 'inspect', owned], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=15)
command(['docker', 'info', '--format', '{{.ID}}'])
if absent.returncode == 0:
raise bundle.Refused()
receipt['container_cleanup_verified'] = True
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--transfer-receipt', required=True)
parser.add_argument('--receipt', required=True)
parser.add_argument('--user', required=True)
args = parser.parse_args()
os.umask(0o077)
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
result = {'schema': 'netkingdom.isolated-lldap-recovery.v1', 'status': 'failed',
'production_contacted': False, 'secret_values_exported': False,
'container_cleanup_verified': False, 'temporary_plaintext_cleanup_verified': False,
'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
keyfd = None
work = None
stage = 'target_preflight'
try:
result['target'] = preflight()
stage = 'input_validation'
if not re.fullmatch(r'[A-Za-z0-9_.-]{1,128}', args.user):
raise bundle.Refused()
transfer = json.loads(Path(args.transfer_receipt).read_text())
offline.validate(transfer)
if transfer.get('independent_owner_retrieval_verified') is not True:
raise bundle.Refused()
path = Path(transfer['fetched_path'])
bundle.private(path, max_bytes=bundle.LIMIT)
if path.stat().st_size != transfer['ciphertext_bytes'] or offline.offsite.digest(path) != transfer['ciphertext_sha256']:
raise bundle.Refused()
keyfd = verify.custody.private_key_fd()
derived = command(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], pass_fds=(keyfd,)).strip()
if derived != bundle.profile()['public_recipient'].encode():
raise bundle.Refused()
stage = 'private_existing_password_prompt'
password = offline.private_prompt('Existing account password for the isolated LLDAP check: ')
with tempfile.TemporaryDirectory(prefix='nk49-service-recovery-', dir='/dev/shm') as temporary:
work = Path(temporary)
archive = work / 'archive.tar'
stage = 'archive_decryption'
with archive.open('xb') as output:
proc = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}', str(path)],
stdout=output, stderr=subprocess.DEVNULL, pass_fds=(keyfd,), timeout=180)
if proc.returncode or archive.stat().st_size > bundle.LIMIT + 4 * 1024 * 1024:
raise bundle.Refused()
restored = work / 'restored'
restored.mkdir(mode=0o700)
stage = 'archive_validation'
manifest, _ = bundle.restore(archive, restored)
result.update(ciphertext_sha256=transfer['ciphertext_sha256'],
manifest_file_count=len(manifest['files']), all_manifest_hashes_verified=True)
stage = 'native_directory_check'
restore_lldap(restored, args.user, password, result)
password = None
if work.exists():
raise bundle.Refused()
result.update(status='isolated_lldap_verified', temporary_plaintext_cleanup_verified=True)
except (Exception, KeyboardInterrupt):
result.update(status='failed', error='isolated_lldap_check_failed_private_diagnostics_suppressed')
result.setdefault('failed_stage', stage)
finally:
if keyfd is not None:
os.close(keyfd)
if work is not None and not work.exists():
result['temporary_plaintext_cleanup_verified'] = True
result['finished_at'] = datetime.now(timezone.utc).isoformat()
with os.fdopen(fd, 'w') as output:
json.dump(result, output, indent=2)
output.write('\n')
success = result['status'] == 'isolated_lldap_verified'
print('OK: restored LLDAP existing-user bind, wrong-password refusal, directory preservation and cleanup passed.'
if success else 'ERROR: isolated LLDAP recovery refused; review the safe receipt. No production change.')
return 0 if success else 1
if __name__ == '__main__':
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
signal.signal(sig, offline.interrupted)
raise SystemExit(main())

View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Owner-run existing-user check. No private key/password belongs in arguments.
set +x
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd -- "$ROOT"
umask 077
[[ $# == 2 ]] || { printf '%s\n' 'Usage: run-isolated-lldap.sh USER VERIFIED_TRANSFER_RECEIPT'; exit 1; }
[[ "$1" =~ ^[A-Za-z0-9_.-]{1,128}$ ]] || { printf '%s\n' 'ERROR: invalid username.'; exit 1; }
[[ -f "$2" && ! -L "$2" ]] || { printf '%s\n' 'ERROR: provide the existing verified transfer receipt.'; exit 1; }
[[ -t 0 && -t 1 && -t 2 ]] || { printf '%s\n' 'ERROR: run in your own interactive terminal.'; exit 1; }
for tool in python3 age age-keygen openssl docker; do
command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; }
done
python3 - <<'PY'
import importlib.util
from pathlib import Path
p=Path('tools/bootstrap-recovery/isolated-lldap.py')
spec=importlib.util.spec_from_file_location('isolated',p)
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
try:
m.preflight()
except Exception:
raise SystemExit('ERROR: exact local Docker target, pinned images or tmpfs preflight failed; no key requested.')
print('OK: exact bnt-lap001 recovery engine, pinned images and tmpfs available.')
PY
NK49_LLDAP_RUN="$(mktemp -d /tmp/nk49-isolated-lldap-XXXXXXXX)"
printf 'Safe evidence directory: %s\n' "$NK49_LLDAP_RUN"
printf '%s\n' 'This restores only LLDAP into disposable containers with no external network, ports or host mounts.'
printf '%s\n' 'Enter the railiance-backup age key, then the existing account password that was valid at backup capture.'
printf '%s\n' 'The check rejects wrong passwords and preserves original users, groups and memberships. It changes no production service.'
bash sso-mfa/bootstrap/sops-custody-unlock.sh \
--expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \
--prompt -- python3 tools/bootstrap-recovery/isolated-lldap.py \
--user "$1" --transfer-receipt "$2" --receipt "$NK49_LLDAP_RUN/recovery.json"
printf 'Safe receipt: %s/recovery.json\n' "$NK49_LLDAP_RUN"
printf '%s\n' 'Return only the outcome and evidence directory. Authelia/KeyCape/MFA/audit acceptance remains separate.'

View file

@ -28,7 +28,7 @@ These are retrospective task records of evidenced work, not additional delivery
or newly granted authority. The earlier dated narrative remains in Git at
`cc2dd01`; its evidence references are carried into the tasks below.
**Current position:** 16 tasks done; 2 in progress; 1 ready to start (`todo`);
**Current position:** 18 tasks done; 2 in progress; 1 ready to start (`todo`);
8 waiting on explicit prerequisites. These are task counts, not an effort-based
completion percentage. Assignees are also written in task descriptions because
the current Forge projector does not populate the dedicated Hub assignee field. The outage-retrieval tool and restore-packet draft are now prepared;
@ -390,7 +390,7 @@ id: NK-WP-0049-T15
status: progress
priority: high
assignee: codex
depends_on: [NK-WP-0049-T08, NK-WP-0049-T13, NK-WP-0049-T27]
depends_on: [NK-WP-0049-T08, NK-WP-0049-T13, NK-WP-0049-T27, NK-WP-0049-T28, NK-WP-0049-T29]
state_hub_task_id: "1d1eb903-a4b0-5bc4-a421-0b842d7f68ae"
```
@ -408,6 +408,12 @@ matching images independently, obtain private existing-user/MFA test inputs and
review version-specific native commands with service owners. Incorporate T02's
ledger before submitting current-caller acceptance.
2026-10-16 checkpoint: T28 completed the exact local image save/load/inspect
and T29 prepared and natively tested the bounded LLDAP stage. Its actual owner
run is T16 and can proceed independently of the remaining multi-service packet.
The other service adapters, matching archive/runtime lineage and private
existing-user/MFA inputs are still required; no blanket T15 completion is claimed.
**Done when:** service owners accept the exact targets/procedure, existing-user
and MFA test identities/input custody are available without posting secrets,
outbound notification/provider writes are disabled, and every required new
@ -422,8 +428,8 @@ id: NK-WP-0049-T16
status: wait
priority: high
assignee: codex
depends_on: [NK-WP-0049-T15]
blocking_reason: "Await the accepted isolated restore packet and independently supplied existing-user test credentials; no production restore is admitted by task refinement."
depends_on: [NK-WP-0049-T13, NK-WP-0049-T29]
blocking_reason: "Await the private owner run of the prepared local-only LLDAP restore with the existing backup-era user password and independent age key; executor, target, images and native synthetic checks are ready in T29."
state_hub_task_id: "b64d4d6a-e851-58d1-9929-f03343147de0"
```
@ -439,6 +445,17 @@ provider writes/outbound effects stay confined; cleanup is verified. Native
fixtures from T05 and SQLite integrity from T13 do not satisfy this current-data
acceptance. Record only safe counts, identities and outcomes.
2026-10-16 refinement: the concrete LLDAP stage is prepared independently in
T29, on the fixed local recovery engine with no external network/ports/host
mounts. It no longer waits for the entire multi-service T15 packet. All-image
local save/load evidence is T28. **Unblock:** run
`bash tools/bootstrap-recovery/run-isolated-lldap.sh EXISTING_USER /tmp/nk49-offline-handoff-1X660a5t/transfer.json`
in the owner's private terminal. Supply only the age key and existing backup-era
password at hidden prompts; return the safe receipt/outcome. No production
credential grant or directory write is part of this check. Existing-user input
availability remains unconfirmed. T15 still owns the other service lanes.
## Prove restored Authelia storage and OIDC continuity
```task
@ -750,6 +767,58 @@ archive/runtime image-lineage matching and independent image availability,
private existing-user/MFA inputs and owner-reviewed native commands remain T15;
no service restore, production change or owner acceptance is claimed.
## Stage and verify the five exact service images for local offline recovery
```task
id: NK-WP-0049-T28
status: done
priority: high
assignee: codex
depends_on: [NK-WP-0049-T27]
```
**Accountable worker:** codex.
Completed 2026-10-16. All five pinned references (PostgreSQL, LLDAP, Authelia,
KeyCape, privacyIDEA) were inspected on the fixed local Linux Docker engine
`bnt-lap001`, ID `74f93783-6cef-43a8-a3c4-f62586cd3bc1`. Missing LLDAP and
privacyIDEA images were pulled only by their observed immutable digests. Saved a
private 347,521,536-byte image archive, loaded it and re-inspected every exact
reference. Evidence and archive SHA256/location:
`docs/evidence/2026-10-16-identity-outage-recovery/offline-service-images.json`.
The local drill no longer requires the production registry. This is not off-host
image escrow or proof of historical mutable-tag/backup lineage; those limits
remain in T15. No service custody or age key was supplied to image caching.
## Prepare and verify the bounded native LLDAP recovery executor
```task
id: NK-WP-0049-T29
status: done
priority: high
assignee: codex
depends_on: [NK-WP-0049-T13, NK-WP-0049-T27, NK-WP-0049-T28]
```
**Accountable worker:** codex; operator retains actual private recovery inputs.
Completed 2026-10-16. `isolated-lldap.py` and `run-isolated-lldap.sh` provide the
exact owner-run stage in `docs/identity-service-restore-packet.md`: fixed local
engine/images, no remote Docker endpoint, original key/seed/JWT/directory data,
Linux-tmpfs decryption, memory/stdin-only private inputs, network-none/read-only
containers, isolation inspection before import, actual existing-user bind,
explicit invalid-credential denial, stable directory rows and cleanup checks.
Missing original server keys are refused; reset/replacement/notification paths
are disabled. No live Kubernetes/OpenBao/SSO path or provider grant is used.
Evidence: `tests/test_isolated_lldap_recovery.py` exercises the actual pinned
image, disposable retained-account directory and a full synthetic encrypted
bundle through the same native executor. The private real-bundle run and its
acceptance remain T16; fixture success is not actual user/MFA/audit recovery.
See `isolated-lldap-preparation.json` in the October16 outage evidence directory
for exact test/source/target provenance and limitations.
## Accept remaining recovery and caller evidence and close the G08 handoff
```task
@ -769,7 +838,7 @@ Owner boundary: NetKingdom, Platform and affected service owners, with Custodian
G08 receiving the final evidence. The completed archive/job milestones remain
accepted independently of this final gate.
**Done when:** T02 and T15–T27 have their stated completed or explicitly accepted
**Done when:** T02 and T15–T29 have their stated completed or explicitly accepted
handoff outcomes; service authentication/token/audit proof and current/legacy
caller dispositions are accepted; the source workplan and projected tasks agree;
and G08 has an exact evidence/handoff record. Record actionable residuals as live