diff --git a/docs/evidence/2026-10-16-identity-outage-recovery/isolated-lldap-preparation.json b/docs/evidence/2026-10-16-identity-outage-recovery/isolated-lldap-preparation.json new file mode 100644 index 0000000..8014f16 --- /dev/null +++ b/docs/evidence/2026-10-16-identity-outage-recovery/isolated-lldap-preparation.json @@ -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 +} diff --git a/docs/evidence/2026-10-16-identity-outage-recovery/offline-service-images.json b/docs/evidence/2026-10-16-identity-outage-recovery/offline-service-images.json new file mode 100644 index 0000000..852c46a --- /dev/null +++ b/docs/evidence/2026-10-16-identity-outage-recovery/offline-service-images.json @@ -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 +} diff --git a/docs/identity-service-restore-packet.md b/docs/identity-service-restore-packet.md index 10104b2..2473c13 100644 --- a/docs/identity-service-restore-packet.md +++ b/docs/identity-service-restore-packet.md @@ -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 | diff --git a/registry/capabilities/capability.security.bootstrap-recovery.md b/registry/capabilities/capability.security.bootstrap-recovery.md index 5d9b6e0..7322768 100644 --- a/registry/capabilities/capability.security.bootstrap-recovery.md +++ b/registry/capabilities/capability.security.bootstrap-recovery.md @@ -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. diff --git a/tests/test_isolated_lldap_recovery.py b/tests/test_isolated_lldap_recovery.py new file mode 100644 index 0000000..bde7962 --- /dev/null +++ b/tests/test_isolated_lldap_recovery.py @@ -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() diff --git a/tools/bootstrap-recovery/README.md b/tools/bootstrap-recovery/README.md index f7b4656..255cefd 100644 --- a/tools/bootstrap-recovery/README.md +++ b/tools/bootstrap-recovery/README.md @@ -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 diff --git a/tools/bootstrap-recovery/isolated-lldap.py b/tools/bootstrap-recovery/isolated-lldap.py new file mode 100644 index 0000000..abe0865 --- /dev/null +++ b/tools/bootstrap-recovery/isolated-lldap.py @@ -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()) diff --git a/tools/bootstrap-recovery/run-isolated-lldap.sh b/tools/bootstrap-recovery/run-isolated-lldap.sh new file mode 100644 index 0000000..9cb05a5 --- /dev/null +++ b/tools/bootstrap-recovery/run-isolated-lldap.sh @@ -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.' diff --git a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md index 51af634..0a45822 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -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