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:
parent
31f483966c
commit
d5484bb90a
9 changed files with 815 additions and 7 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
177
tests/test_isolated_lldap_recovery.py
Normal file
177
tests/test_isolated_lldap_recovery.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
343
tools/bootstrap-recovery/isolated-lldap.py
Normal file
343
tools/bootstrap-recovery/isolated-lldap.py
Normal 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())
|
||||
37
tools/bootstrap-recovery/run-isolated-lldap.sh
Normal file
37
tools/bootstrap-recovery/run-isolated-lldap.sh
Normal 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.'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue