From bdf71998e9b56b327213c1dd085ee1c0e4270fb7 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 1 Nov 2026 23:05:08 +0100 Subject: [PATCH] docs: preserve reviewed NetKingdom session artifacts --- .../r6-publication-backup/brief.index-record | 1 + .../r6-publication-backup/brief.worktree | 85 + .../r6-source-commit.json | 5 + .../r6-workplan-hashes.json | 5 + .../r6-workplan-postimage.md | 1015 ++++++++++++ .../r6-workplan-preimage.md | 956 +++++++++++ .../r6-workplan.patch | 79 + .../r7-final-record-commit.json | 5 + .../r7-publication-backup/brief.index | 100 ++ .../r7-publication-backup/brief.index-record | 1 + .../r7-publication-backup/brief.worktree | 85 + .../r7-source-commit.json | 5 + .../r7-source-sync.exit.json | 1 + .../r7-source-sync.stderr.txt | 1 + .../r7-source-sync.stdout.json | 375 +++++ .../revisions/r1/isolated-keycape.py | 337 ++++ .../revisions/r2/README.md | 144 ++ .../revisions/r2/candidate.patch | 1004 ++++++++++++ .../tests/test_isolated_keycape_recovery.py | 218 +++ .../r2/tests/test_isolated_keycape_session.py | 62 + .../isolated-keycape-session.py | 278 ++++ .../bootstrap-recovery/isolated-keycape.py | 391 +++++ .../run-isolated-keycape.sh | 40 + .../revisions/r3/README.md | 19 + .../revisions/r3/candidate.patch | 1216 ++++++++++++++ .../sso-mfa/bootstrap/sops-private.py | 210 +++ .../tests/test_isolated_keycape_recovery.py | 223 +++ .../tests/test_isolated_keycape_session.py | 138 ++ .../check-privacyidea-database.py | 156 ++ .../identity-backup-profile.json | 158 ++ .../bootstrap-recovery/identity-bundle.py | 211 +++ .../bootstrap-recovery/identity-offsite.py | 124 ++ .../isolated-keycape-session.py | 381 +++++ .../bootstrap-recovery/isolated-keycape.py | 419 +++++ .../bootstrap-recovery/isolated-lldap.py | 343 ++++ .../retrieve-identity-offline.py | 164 ++ .../bootstrap-recovery/retrieve-identity.py | 71 + .../run-isolated-keycape.sh | 40 + .../bootstrap-recovery/verify-identity.py | 90 + .../revisions/r4/LIFECYCLE-R4.md | 81 + .../revisions/r4/README.md | 168 ++ .../revisions/r4/candidate.patch | 1449 +++++++++++++++++ .../sso-mfa/bootstrap/sops-private.py | 210 +++ .../tests/test_isolated_keycape_recovery.py | 223 +++ 44 files changed, 11287 insertions(+) create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-publication-backup/brief.index-record create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-publication-backup/brief.worktree create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-source-commit.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-hashes.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-postimage.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-preimage.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan.patch create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-final-record-commit.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.index create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.index-record create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.worktree create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-commit.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.exit.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.stderr.txt create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.stdout.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r1/isolated-keycape.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/README.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/candidate.patch create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tests/test_isolated_keycape_recovery.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tests/test_isolated_keycape_session.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/isolated-keycape-session.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/isolated-keycape.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/run-isolated-keycape.sh create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/README.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/candidate.patch create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/sso-mfa/bootstrap/sops-private.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tests/test_isolated_keycape_recovery.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tests/test_isolated_keycape_session.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/check-privacyidea-database.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/identity-backup-profile.json create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/identity-bundle.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/identity-offsite.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/isolated-keycape-session.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/isolated-keycape.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/isolated-lldap.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/retrieve-identity-offline.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/retrieve-identity.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/run-isolated-keycape.sh create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/verify-identity.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/LIFECYCLE-R4.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/README.md create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/candidate.patch create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/sso-mfa/bootstrap/sops-private.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tests/test_isolated_keycape_recovery.py diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-publication-backup/brief.index-record b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-publication-backup/brief.index-record new file mode 100644 index 00000000..f072587f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-publication-backup/brief.index-record @@ -0,0 +1 @@ +100644 ea9ccd0b2bd2cc1ec89723b35c44c53f0d79ba98 0 .custodian-brief.md diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-publication-backup/brief.worktree b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-publication-backup/brief.worktree new file mode 100644 index 00000000..18bd5dff --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-publication-backup/brief.worktree @@ -0,0 +1,85 @@ + +# Custodian Brief — net-kingdom + +**Domain:** infotech +**Last synced:** 2026-10-16 19:19 UTC +**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* + +## Active Workstreams + +### Publish the NetKingdom emission-cadence security profile +Progress: 5/6 done | workplan_id: `04685f94-1991-5e62-80d2-5669913e99fc` + +**Open tasks:** +- ► Bind and hand off the published contract `e3fbc8b8` + +### Register Playwright human and agent consumers through governed identity delivery +Progress: 1/3 done | workplan_id: `06ba98d5-609e-5aaa-9bf7-4c4f4460e377` + +**Open tasks:** +- ! Verify issuer and real consumer acceptance `5a3aaf57` + *(wait: Await owner-scoped registry/custody activation, native cluster authentication and individual transport, then real human and agent acceptance under CUST-WP-0080-T05.)* +- ► Deliver custody integration and guarded registration activation `a9dadd07` + +### Remove bootstrap secret output and verify separated recovery custody +Progress: 22/31 done | workplan_id: `17d315f0-829e-57c7-85cc-3a7914152804` + +**Open tasks:** +- ! Accept remaining recovery and caller evidence and close the G08 handoff `d68cd4d3` + *(wait: Await the separately tracked native service proofs, current/legacy caller dispositions and residual-scope decisions; archive recovery, backup activation, independent outage retrieval and guide publication are complete.)* +- ! Resolve historical credential and configuration-preserving rotation dispositions `3ea6bf00` + *(wait: Await the caller/consumer ledger, current replacement-lane acceptance and explicit credential-owner dispositions; existing exposure-rotation deferral is unchanged.)* +- ! Verify current caller and provider admission at their actual boundaries `77aa42be` + *(wait: Await the current-caller ledger and exact per-caller owner admission/test inputs; the backup credential grants do not admit unrelated callers.)* +- ! Prove retained privacyIDEA token decryption and existing-token MFA `6f8af1a7` + *(wait: LLDAP T16, token-material T31 and audit T20 passed; await the exact isolated MFA adapter and owner-selected retained-token/factor inputs.)* +- ! Prove restored KeyCape issuer, policy and consumer continuity `141dd773` + *(wait: LLDAP recovery T16 passed; await the exact isolated KeyCape adapter/procedure and retained-token MFA proof for the selected flow.)* +- ! Prove restored Authelia storage and OIDC continuity `b47f9975` + *(wait: LLDAP recovery T16 passed; await the exact isolated Authelia adapter/procedure and private authentication-flow inputs from T15.)* +- ► Map current callers, owners and native acceptance gaps `479d6970` +- … and 2 more open tasks + +### Establish SecurityCaseTypes so that known security cases are light +Progress: 7/9 done | workplan_id: `e31f3f9d-0748-5137-b0a3-7b3ca46a66eb` + +**Open tasks:** +- ! T05 — Acknowledge the type through informed-decision `d9b26bcf` + *(wait: Type1.2 architecture has a confirmed source-only founder acknowledgment; canonical1.1 remains unchanged/default and the1.2 reference case stays proposed. Needs explicit version selection/reference-source integration without normal admission bypass, closed owner adapters plus global/native receipt verifier (WARDEN-WP-0044-T03), independently verified six-object structural materialization and CCR42 claims/created-only provenance, exact broker/case/source reconciliation, then separately authorized native mint/delivery and attended registry/consumer verification. Source acknowledgment, a bundle or local tests do not prove implemented/materialized/live status.)* +- ! T06 — First KnownSecurityCase: Forgejo `c3c9c3ae` + *(wait: The next types follow once the first one is proven end to end.)* + +### Define an execution-attribution receipt for Railiance runs +Progress: 1/2 done | workplan_id: `e2533f3a-aa43-59b3-bff3-8e64b6149487` + +**Open tasks:** +- ! Agree the evidence holder and schema with audit-core and Railiance `963120c1` + *(wait: Audit-core accepted evidence custody and published field/admission requirements in AUDIT-WP-0014-T01. Still needs Railiance owner confirmation of exact emitter and wire binding before canonical schema/validator implementation, followed by actual emission/readback and attribution/clock/unknown-case acceptance.)* + +### Enterprise Federation & SAML — Expanded-Mode Keycloak Identity Broker +Progress: 1/9 done | workplan_id: `1075448f-d533-5f9e-94b7-c3adfe151a07` + +**Open tasks:** +- ! Admit the database consumer `2fe6f100` + *(wait: ADR-0009 adoption trigger absent; then railiance-platform/rapp-postgres must admit the database consumer and return backup/restore evidence.)* +- ! Package the broker deployment `32d1411b` + *(wait: ADR-0009 adoption trigger and named package/runtime owner absent; requires T02 database admission and governed OpenBao/ESO role plus immutable issuer assignment.)* +- ! Integrate an upstream identity provider `6697a994` + *(wait: Named tenant/upstream IdP owner/federation need absent; requires admitted broker and owner-reviewed tenant-scoped claim mappings plus actual login evidence.)* +- ! Define federated assurance `d845380d` + *(wait: Adoption trigger absent; requires per-IdP assurance trust mapping and reviewed privileged step-up delivery from issuer/package and access-engine owners.)* +- ! Verify IAM conformance and coexistence `9cb7fd93` + *(wait: Requires admitted live broker, canonical tenant issuer selection and dedicated test-client conformance/coexistence evidence; adoption trigger absent.)* +- ! Enforce tenant and platform boundaries `38998c68` + *(wait: Requires tenant-engine tier/issuer ownership and access-engine boundary/freshness enforcement evidence after adoption trigger and broker admission.)* +- ! Prove recovery and audit delivery `e5f44ddc` + *(wait: Requires admitted database/broker, named recovery and audit owners, off-host restore and observed audit/break-glass receipts; adoption trigger absent.)* +- … and 1 more open tasks + +--- +## MCP Orientation (when available) + +If the state-hub MCP server is reachable, call: +`get_domain_summary("infotech")` +This provides richer cross-domain context. +If the MCP call fails, use this file as your orientation source. diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-source-commit.json b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-source-commit.json new file mode 100644 index 00000000..6146803d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-source-commit.json @@ -0,0 +1,5 @@ +{ + "exit_code": 1, + "stdout": "", + "stderr": "{\"blocked_blob_ids\": [\"feb4df39d0e3e0c3bffd976c31c3b30196651863\"], \"scanned_blobs\": 9, \"scanner_sha256\": \"88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\", \"scanner_version\": \"8.30.1\", \"scope\": \"changed-staged-blobs\", \"status\": \"blocked\"}\n" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-hashes.json b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-hashes.json new file mode 100644 index 00000000..1bf4d6be --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-hashes.json @@ -0,0 +1,5 @@ +{ + "path": "workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md", + "before_sha256": "8b6e12a53b0d5e88d71bb1d3e11bedd0ee3a66b30a9ea2b86e6ac1ff38539baf", + "after_sha256": "29c10e410449ebf1f4b0fcc804177895cb91b47c35adb33fbe9246265ad57cbc" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-postimage.md b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-postimage.md new file mode 100644 index 00000000..4bed790d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-postimage.md @@ -0,0 +1,1015 @@ +--- +id: NK-WP-0049 +type: workplan +title: "Remove bootstrap secret output and verify separated recovery custody" +domain: infotech +repo: net-kingdom +status: active +owner: codex +topic_slug: netkingdom +created: "2026-10-12" +updated: "2026-10-16" +flavor: implementation +depends_on: [] +state_hub_workstream_id: "17d315f0-829e-57c7-85cc-3a7914152804" +--- + +Native owner work for CUST-WP-0080 G08. This workplan remains active until the +remaining caller, service-recovery and custody acceptance gates are satisfied. +NK-WP-0045 covers the non-secret root-grant record. Founder-deferred exposure +rotation remains in CUST-WP-0073-T05; this plan does not silently reopen it. + +**Replanned 2026-10-16 at the operator's request:** substantial completed work is +now represented by completed tasks, and remaining work has separate deliverables, +dependencies and acceptance criteria. T01–T03 retain their historical IDs and Hub +UUIDs. Completed parts of the old T02/T03 umbrellas are extracted into T04–T14; +T02 now covers the current-caller ledger and T03 retains final owner acceptance. +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:** 22 tasks done; 2 in progress; 1 ready to start (`todo`); +6 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; +independent outage retrieval and Storage Share publication are accepted; +service admission remains an explicit gate. + +**Next actions:** finish the read-only caller ledger (T02); admit the drafted +isolated restore packet (T15) and record residual-scope handoffs (T24). +Independent outage retrieval (T23) and guide publication (T26) are done. After owner admission, execute the individual native service and +caller checks. A task's owner is accountable for the result; another repository's +approval or credential grant still requires its own owner path. + +**Closure:** T03 assembles owner acceptance after its named dependencies pass. +Passing archive recovery does not prove existing-user authentication, retained +MFA-token decryption, historical audit signatures or current caller admission. +The verified backup job stays active while those separate tasks are completed. + +## Remove secret-bearing output and ambient root discovery + +```task +id: NK-WP-0049-T01 +status: done +priority: high +assignee: codex +state_hub_task_id: "d7ca8d5f-c5ff-5cba-bd25-038cb53ffe4e" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +2026-10-12: regression reproduced emergency/reprint root export, generation +prefix disclosure, permissive file modes, dry-run side effects and unattended +entry. Replaced emergency export with a non-secret guide (legacy secret-input +arguments refused), suppressed generation prefixes/tracing and set private +permissions. Bootstrap requires the registered public recipient and an attended +terminal; never generates/discovers a permanent private key. Encrypted resume +requires an explicit matching temporary custody key before cluster activity. +Dry-run is a non-mutating plan. The live cluster/apply path was not executed. + +A fresh uniquely named encrypted ops bundle follows apply; old delivery flags +cannot skip current acknowledgment. New acknowledgment state binds its SHA256 +and timestamp. Declining stops before cleanup/completion. This is an operator +statement, not an independent restore receipt. Historical fields remain unchanged. +Make/help/status/console and current credential guidance reflect that distinction. +Evidence and exact source manifest: Custodian +`docs/evidence/2026-10-12-netkingdom-security/bootstrap-handoff.json`. + +## Harden custody, SOPS and rotation source entry points + +```task +id: NK-WP-0049-T04 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T01] +state_hub_task_id: "04c4e8be-5bd8-5d5c-99b1-79d8ae758462" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-12–13. Public-recipient-only encryption, explicit temporary +custody, private no-overwrite output, sanitized diagnostics, stdin-only secret +transport and failed-operation cleanup replace ambient-key and output paths. +SOPS read/edit/data-key rotation preserve recipient/MAC/source-version guards; +component rotations preserve unrelated fields and retain recovery candidates. +This records source and disposable-native proof, not live rotation or revocation. + +Evidence: Custodian `docs/evidence/2026-10-12-netkingdom-security/` receipts +`custody-entrypoints.json`, `sops-make-entrypoints.json`, `rotation-output.json`, +and `admin-bootstrap.json`; October13 `sops-write-callers.json`. + +## Repair component, diagnostic, LDAP and resolver callers + +```task +id: NK-WP-0049-T05 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T04] +state_hub_task_id: "e8e38f42-609e-5c07-a8e6-e73c8eb4ae95" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-13–14. Repaired KeyCape configuration and component Secret +publication, original privacyIDEA key export/restore transport, scoped MFA +counts, LDAP group maintenance and configuration-preserving resolver cutover. +Retired unsafe personal-token mint/refresh and direct LDAP lifecycle shortcuts. +Disposable native PI/LLDAP tests and source regressions passed; estate provider +admission, historical credential validity and service authentication remain T21/T22. + +Evidence: Custodian October13 `keycape-caller-reconciliation.json`, +`service-secret-callers.json`, `privacyidea-key-custody.json`, +`mfa-state-diagnostic.json`, `realm-bootstrap-repair.json`; October14 +`legacy-lldap-retirement.json`, `platform-group-boundary.json`, +`lldap-group-maintenance.json`, `resolver-cutover.json` under the corresponding +`docs/evidence/YYYY-MM-DD-netkingdom-security/` directories. + +## Inventory source callers and retire remaining unsafe delivery paths + +```task +id: NK-WP-0049-T06 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T04, NK-WP-0049-T05] +state_hub_task_id: "a5f50891-c5b2-513b-8670-e1915ee52015" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-15. Enumerated all 87 current non-test shell/Python callers +in the declared bootstrap/k8s scope plus Make/console/delivery adapters. Repaired +streamed encrypted bundle publication and fail-closed bootstrap/lab paths; +retired unsafe delivery copiers, root prompts, registry patchers and stale +rollback recipes. The attended inventory records source dispositions and their +native-proof limits. T02 separately maps these to current live users/owners. + +Evidence: [caller inventory](../docs/evidence/2026-10-15-bootstrap-recovery/caller-source-inventory.json), +[attended procedures](../docs/attended-procedure-inventory.md) and the October15 +recovery evidence directory. No live production bootstrap or lab-init acceptance +is claimed. + +## Discover off-host coverage and recover the existing PI database + +```task +id: NK-WP-0049-T07 +status: done +priority: high +assignee: codex +state_hub_task_id: "2d7302a2-319a-5633-bf58-d51ccd25ebf6" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-15–16. Bounded Storage Share/Scaleway inventories identified +existing database coverage and the missing full identity bundle. The operator +independently matched the backup recipient, then recovered the exact existing +PI dump: correct-key decryption, wrong-key refusal, isolated native PostgreSQL +restore/readback and cleanup passed (5 tokens, 2 resolvers, 2 realms). + +Evidence: October15 recovery `offsite-discovery-summary.json`, +`privacyidea-transfer-r1.json`, `privacyidea-database-drill-r1.json`, +`database-drill-review.json`. This database-only proof led to T08–T14. + +## Implement the full identity capture and archive recovery toolchain + +```task +id: NK-WP-0049-T08 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T06, NK-WP-0049-T07] +state_hub_task_id: "317ca173-c784-5329-9810-5e3d300604b8" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Defined exact LLDAP/Authelia/KeyCape/privacyIDEA assets, +Deployment/PVC/reference bindings, original keys and snapshot consistency limits. +Implemented native SQLite/PG capture, streamed age sealing, strict data-bearing +manifest checks, provider readback, independent retrieval, bounded retention, +root runtime and attended archive verifier. All 56 focused checks passed, +including actual synthetic encrypted/native PostgreSQL recovery. + +Evidence: [profile](../tools/bootstrap-recovery/identity-backup-profile.json), +[implementation review](../docs/evidence/2026-10-16-identity-backup-implementation/review.json), +and the live evidence's tested ESO-default/host-binding corrections. Production +archive recovery is separately evidenced in T13. + +## Admit and verify the two scoped backup credential deliveries + +```task +id: NK-WP-0049-T09 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T08] +state_hub_task_id: "4a47eb2a-7e42-5ae7-9df1-f5ede746eca1" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Platform CCR-2026-0048/0049 are verified. The operator +approved folder-only provider-normalized publisher21/retention25 rights, including +re-sharing, in addition to the original rollout. Coding-agent denials precede KV +publication. Exact 900-second single-policy readers, wrong-SA/namespace/audience +rejection, metadata/sibling/old-escrow/token-create denials, ESO equality and +reader/session revocation passed. Operator passwords and age private keys are +not job credentials. + +Evidence: live `publisher-setup-verified.json`, `retention-setup-verified.json`, +`publisher-native-delivery-verified.json`, `retention-native-delivery-verified.json`; +approval decisions `40bfae40-6108-4e51-8613-073339f5345f` and +`5edd5203-fae7-461b-8775-5d22b7873971`; Platform CCR files. + +## Apply capture isolation and install the guarded node runtime + +```task +id: NK-WP-0049-T10 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T08, NK-WP-0049-T09] +state_hub_task_id: "0519444c-d559-5455-aa27-8c5dc8b832b0" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Applied exact UID/resource-version/spec-guarded DNS +selector exclusions, preserving ordinary service matches. Capture refuses any +remaining helper allow policy. Installed immutable runtime sources bound to the +actual hostname, machine identity and cluster UID. Disabled-unit replacement +checked previous contents and retained rollback bytes. Health unit validation +passed. Activation remained gated until T13. + +Evidence: live `isolation-applied.json`, `capture-ready-plan.json`, +`installed-runtime-packet-r2.json`, `upgrade-disabled-runtime.py`. Actual complete +hardened-job execution is recorded in T14. + +## Connect recovery to Railiance bootstrap and reuse discovery + +```task +id: NK-WP-0049-T11 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T08] +state_hub_task_id: "d69af493-4026-56a8-a526-c267d90dce18" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Added the Railiance bootstrap `identity-recovery` +dispatcher and independent-custody → isolated verification → layer/service-owner +handoff. Updated `capability.security.bootstrap-recovery`, linked bootstrap +sequencing and composed reuse-surface federation. Keyword discovery for identity +backup, restore, privacyIDEA and disaster recovery was verified. The operator +actually used this handoff in T13. No unattended production importer is claimed. + +Evidence: [ceremony](../docs/bootstrap-recovery-ceremony.md), owning capability +entry, `railiance-bootstrap/docs/identity-recovery.md`, bootstrap commit `d1dcfde` +and reuse-surface federation commit `662782e`. + +## Create and verify the first full off-host identity bundle + +```task +id: NK-WP-0049-T12 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T09, NK-WP-0049-T10] +state_hub_task_id: "decb415a-8012-521c-9dbe-d73c6ff0bbfb" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Captured original service keys/current data plus the +linked native PI database dump, sealed and published a unique Storage Share +artifact, then downloaded it and verified size/digest before publishing its index. +Helper and plaintext cleanup passed. Artifact: +`net-kingdom-identity-20261016T063159Z-5882771aa2ce.tar.age` (109738344 bytes). + +Evidence: live `actual-capture.json`, `actual-offsite.json`; ciphertext SHA256 +`7117b3fe1e4840c86b301c94c85e6b16709e5626bb0884bd0580bde630e30e30`. +No age private key was needed by the capture job. + +## Independently retrieve and recover the complete actual archive + +```task +id: NK-WP-0049-T13 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T11, NK-WP-0049-T12] +state_hub_task_id: "602d4921-cfac-5e5b-9993-4fbf67195c7d" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Independently downloaded the exact first generation +through attended Backup-account custody, rehashed it locally and revoked the +session. The operator used the independently held age key through the bootstrap +handoff. Correct/wrong-key cases, all 36 manifest files/hashes, original RSA pairs, +SQLite integrity and isolated native PG16.14 restore/readback passed; plaintext +and containers were removed. No key or restored secret values reached the agent. + +Evidence: live `independent-owner-transfer.json`, `actual-full-archive-recovery.json`, +`actual-full-recovery-review.json`. Existing-user/MFA authentication and old audit +signatures are deliberately separate T16–T20 outcomes. + +## Activate and prove the complete recurring backup and retention job + +```task +id: NK-WP-0049-T14 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T10, NK-WP-0049-T13] +state_hub_task_id: "eaf0708f-b3b7-5531-8172-95edf30c3a19" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16 after T13. Both daily backup/retention and hourly health +timers are enabled/active. The actual hardened systemd service passed retention +before/after a second capture/upload/download (Result=success, ExecMainStatus=0). +Both verified generations are retained, the independently recovered point is +pinned, and nothing was deleted. Helpers/plaintext were cleaned; failure markers +and reporting outbox are empty. Health check succeeded. + +Evidence: live `activation-result.json`, `activation-review.json`, +`recurring-job-review.json`. Cadence: daily 03:45 UTC plus up to 5 minutes jitter, +hourly health, 25-hour freshness bound. Second-provider/host-loss gaps go to T24; +this completed task is not a full-estate recovery claim. + +## Map current callers, owners and native acceptance gaps + +```task +id: NK-WP-0049-T02 +status: progress +priority: high +assignee: codex +depends_on: [NK-WP-0049-T06] +state_hub_task_id: "479d6970-6516-50f5-9436-bb5b56d58aad" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +The source inventory and known legacy objects exist; a complete current-use +ledger is not yet accepted. Next action is read-only reconciliation of the 93 +entries in the latest October15 source inventory (the earlier snapshot had 87) against active Make/bootstrap/operator routes and owner evidence. + +**Done when:** each entry is identified as current, retired or owner-deferred, +with its owner, credential route, current consumer(s), native evidence or exact +missing proof, and next responsible task. Explicitly cover the historical +`sso/keycape-pi-token` and `mfa/privacyidea-trigger-admin` objects, lab init, +factor diagnostic delivery, LDAP group/provider boundary, resolver cutover and +root-output/isolation boundaries. Secret presence is not credential validity. +Send current native checks to T21 and historical revocation/rotation decisions to +T22. Do not reopen completed source repairs without a new finding. + +## Prepare the isolated full-service restore and acceptance packet + +```task +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, NK-WP-0049-T28, NK-WP-0049-T29, NK-WP-0049-T30] +state_hub_task_id: "1d1eb903-a4b0-5bc4-a421-0b842d7f68ae" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +**Prepared draft scope moved to T27.** See +`docs/identity-service-restore-packet.md`: restore order, containment, safe evidence +and per-service positive/negative checks are drafted. Read-only current runtime +image digests were observed for all four services; correspondence to the selected +archive and independent image availability are not yet proved. + +**Next action:** admit a named disposable target/private route, verify and stage +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 +credential or production effect has its own owner decision. The verifier deletes +its plaintext; it is not a production importer. This task prepares authority and +inputs for T16–T20, not an automatic production restore. + +## Prove restored LLDAP directory and existing-user authentication + +```task +id: NK-WP-0049-T16 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13, NK-WP-0049-T29] +state_hub_task_id: "b64d4d6a-e851-58d1-9929-f03343147de0" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: NetKingdom directory/User Engine owners. Restore the captured +original directory/configuration in the admitted isolated environment. + +**Done when:** user, group and membership identities are preserved; an independently +supplied existing-user credential authenticates; a wrong credential is refused; +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. + + +Completed 2026-10-16 at 18:37 UTC. The operator privately supplied the +independent age key and an existing backup-era account credential to the exact +local executor. All 36 archive hashes and the known ciphertext/source/target +bindings matched. Native existing-user LDAP bind passed; explicit invalid +credentials were rejected. The original seven users, 17 groups, 13 memberships +and the available attribute/schema rows were preserved across service startup, +authentication and graceful shutdown. The container had only loopback/no +external routes; pre-import isolation and container/tmpfs cleanup passed. +No production endpoint was contacted or secret exported to the agent. + +Evidence: `actual-isolated-lldap-recovery.json` and +`actual-isolated-lldap-review.json` in +`docs/evidence/2026-10-16-identity-outage-recovery/`. This closes the actual +LLDAP stage independently of the remaining T15 packet. It does not prove +Authelia/KeyCape authentication, retained-token MFA or audit continuity. T17, +T18 and T19 retain their remaining adapter/input prerequisites. + +## Prove restored Authelia storage and OIDC continuity + +```task +id: NK-WP-0049-T17 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T15, NK-WP-0049-T16] +blocking_reason: "LLDAP recovery T16 passed; await the exact isolated Authelia adapter/procedure and private authentication-flow inputs from T15." +state_hub_task_id: "b47f9975-d6d7-5bb8-93ce-886deb1dfa70" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: SSO/Authelia and directory owners. Restore the original storage, +encryption/session/signing material and configuration in isolation. + +**Done when:** native storage decryption succeeds, issuer/signing identity and +client configuration are preserved, and an isolated authentication flow has +positive/negative results using the restored directory. Disable outbound +notifications and verify cleanup. Archive integrity/RSA parsing alone are not +native Authelia acceptance. + +## Prove retained privacyIDEA token decryption and existing-token MFA + +```task +id: NK-WP-0049-T19 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T15, NK-WP-0049-T16, NK-WP-0049-T31] +blocking_reason: "LLDAP T16, token-material T31 and audit T20 passed; await the exact isolated MFA adapter and owner-selected retained-token/factor inputs." +state_hub_task_id: "6f8af1a7-2a3f-5fa8-8202-83103ebd55b2" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: MFA/privacyIDEA and directory owners. Use the exact linked dump +and original enckey from the independently recovered generation. + +**Done when:** retained token material decrypts inside the isolated process, +a selected existing token completes a native MFA check, a wrong key/code is +refused, and cleanup is verified. Obtain any token/user input through attended +private custody; never emit token plaintext, seeds or credentials. Native database +row counts and disposable synthetic-token fixtures do not prove this outcome. + + +2026-10-16 refinement: native token-material readback is separately tracked in +T31 using the prepared T30 executor. T19 retains actual existing-token MFA and +wrong-code/wrong-key authentication refusal. Internal decryption or padding +success does not authenticate a user. No MFA code is needed for T20/T31; the +selected user's private factor input and exact isolated MFA adapter remain T19. + +## Prove historical and new privacyIDEA audit-signature continuity + +```task +id: NK-WP-0049-T20 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13, NK-WP-0049-T30] +state_hub_task_id: "375fa8f9-b8a5-5048-97a7-ec141aa7c45d" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: MFA/privacyIDEA audit owner. Use the original recovered audit +pair and linked database in the admitted isolated target. + +**Done when:** existing pre-backup audit signatures are verified natively, a new +isolated audit entry is signed and verified with the preserved identity, a wrong +key fails, and cleanup is recorded. Report any historical verification failure +for explicit owner disposition; do not regenerate keys to hide it. Matching RSA +pairs from T13 are a prerequisite, not audit-history verification. + + +Completed 2026-10-16 at 19:10 UTC using the actual independently retrieved +bundle and original audit identity on the fixed isolated local engine. All +279,209 stored rows were signed and verified; zero unsigned, invalid or legacy +signatures. A new isolated entry verified, and tampering/unrelated-public-key +checks were refused. Container/plaintext cleanup passed. Receipt and reviewed +source/ciphertext/target bindings: `actual-isolated-privacyidea-recovery.json` +and `actual-isolated-privacyidea-review.json` in +`docs/evidence/2026-10-16-identity-outage-recovery/`. +Stored-row continuity is accepted; event completeness and existing-token MFA +are separate and are not claimed. + +## Prove restored KeyCape issuer, policy and consumer continuity + +```task +id: NK-WP-0049-T18 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T15, NK-WP-0049-T16, NK-WP-0049-T19, NK-WP-0049-T32, NK-WP-0049-T33] +blocking_reason: "Source and synthetic KeyCape lifecycle proof are delivered in T32; await exact owner-held recovery attempt preparation T33, selected consumer acceptance and retained-token MFA for the selected flow." +state_hub_task_id: "141dd773-c1fa-56c1-a852-bbe2372e5125" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: KeyCape and the selected current consumer owners. Restore the +original signing/configuration, registry, authentication policies and receipts. + +**Done when:** issuer/JWKS signing identity, registry/client configuration and +policy/receipt continuity are preserved; selected current consumers pass native +positive/negative authentication using the restored dependencies; and isolation +and cleanup hold. Confirm the actual dependency graph in T15. Do not route a +production consumer to the isolated issuer or mint new client grants implicitly. + +## Verify current caller and provider admission at their actual boundaries + +```task +id: NK-WP-0049-T21 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T02] +blocking_reason: "Await the current-caller ledger and exact per-caller owner admission/test inputs; the backup credential grants do not admit unrelated callers." +state_hub_task_id: "77aa42be-7a39-5d93-8042-03d78b4094b4" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: each current caller's service/provider and credential owner. +Use T02's ledger to select the exact current procedures and required owner lanes. + +**Done when:** each current caller has scoped delivery and native positive/negative +acceptance or an explicit accepted retirement/disposition. Include dedicated +factor-diagnostic admission (do not borrow KeyCape's reader), deployed reserved +LDAP group denial, resolver/cutover input provenance and functional proof, and +bootstrap/root-output/isolation callers. Fixtures, an existing tty and historical +bootstrap receipts are not current authority. Propose exact write/cutover actions +before execution; record deferred actions separately rather than faking a pass. + +## Resolve historical credential and configuration-preserving rotation dispositions + +```task +id: NK-WP-0049-T22 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T02, NK-WP-0049-T21] +blocking_reason: "Await the caller/consumer ledger, current replacement-lane acceptance and explicit credential-owner dispositions; existing exposure-rotation deferral is unchanged." +state_hub_task_id: "3ea6bf00-a87f-58ea-bdce-328c13a03a55" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: Platform/custody and affected consumer owners. Review historical +personal-admin/trigger credential copies and unsafe predecessor paths against +known current consumers and rollback custody. + +**Done when:** every affected credential has an owner-accepted disposition: +verified revocation/replacement, continued bounded use with acceptance, or an +explicit linked deferral. Signing/client-secret changes require preservation and +native multi-consumer proof if actually approved. CUST-WP-0073-T05's founder +rotation deferral and decision `1591daa1-6e85-4dfd-aadd-5a9fe1ef47fa` remain in force; +record their exact applicability rather than automatically rotating or treating +source retirement as provider revocation. + +## Provide retrieval that works when OpenBao and SSO are unavailable + +```task +id: NK-WP-0049-T23 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13, NK-WP-0049-T25] +state_hub_task_id: "1ec537ee-dfee-5576-be36-a81bdba417d1" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16 at 18:02 UTC. The operator used independently held Backup +account access in the private prompt. The actual protected 109,738,344-byte +ciphertext was downloaded with unchanged ETag/size/SHA256, with no OpenBao/SSO +contact or provider writes by the retrieval path. The human-held age key matched +the registered recipient; wrong-key refusal, all 36 manifest hashes, SQLite +integrity, original key pairs and native isolated PostgreSQL 16.14 restore passed. +The database retained five token, two resolver and two realm rows. Disposable +container and temporary plaintext cleanup passed. The agent received no secret. + +Evidence: `actual-offline-transfer.json`, `actual-offline-recovery.json` and +`actual-offline-review.json` under +`docs/evidence/2026-10-16-identity-outage-recovery/`. The review rechecked the +local ciphertext digest and verifier source. This proves the independent path +without requiring a deliberate production outage. Service authentication, +retained-token decryption and historical audit verification remain T16–T20. +T26 separately records accepted guide publication. + +## Record owner disposition for second-provider and external monitoring gaps + +```task +id: NK-WP-0049-T24 +status: todo +priority: medium +assignee: codex +depends_on: [NK-WP-0049-T14] +state_hub_task_id: "474f5a78-a643-59c6-bcf9-fc4a908dfcb0" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +**Next action:** record the current single-provider and host-local-health limits, +then obtain explicit owner scope decisions for a second encrypted provider copy, +external host-loss/freshness detection and recurring recovery-drill cadence. + +**Done when:** each gap has either an accepted bounded residual risk or a linked +live intake/workplan with owner and trigger. Existing daily RPO/25-hour health +settings remain the approved baseline. Scaleway full-identity publication and +an external watchdog are not implemented; do not add their grants/jobs implicitly. +This task triages the new insights instead of silently expanding NK49's required +implementation to an entire disaster-recovery programme. + +## Deliver and verify the offline retrieval adapter and recovery tools kit + +```task +id: NK-WP-0049-T25 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13] +state_hub_task_id: "1c5ea7a1-edf7-5072-b36b-8be7f827957a" +``` + +**Accountable worker:** codex. + +Completed 2026-10-16. `retrieve-identity-offline.py` uses privately entered +independent Backup-account access or an exact read-capable identity-folder share. +It performs only fixed-provider HEAD/GET, binds the reviewed generation by strong +ETag/length/SHA256, refuses redirects/proxies/unreviewed scope and removes failed +downloads. No OpenBao/SSO, age key or standing agent credential is required. +`recovery-kit.py` builds a reproducible allowlisted ZIP containing the tools, +public profile, known-good ciphertext receipt and recovery guides, with file +checksums. Its publication path creates only three versioned guide/ZIP/checksum +objects and verifies readback without overwriting existing conflicting objects. +`run-offline-recovery.sh` provides the owner's two-hidden-prompt handoff. + +Evidence: `tests/test_identity_offline_recovery.py` and the bounded test/build +receipt in `docs/evidence/2026-10-16-identity-outage-recovery/`. The focused run +passed 65 tests including real disposable age/PostgreSQL fixtures. ZIP-contained +entry points import without sibling repositories. This is source/fixture proof, +not actual independent-provider retrieval (T23) or publication (T26). + +2026-10-16 correction: the first owner handoff stopped before its credential +prompt because Python text `r+` requires a seekable stream. Reproduced against a +real controlling PTY; replaced both prompts with the shared hidden-input helper, +refusing redirected input and echo fallback. Added real-terminal and both-CLI +regressions plus closed stage/HTTP-code diagnostics (no raw responses). The +focused rerun passed 36 tests. Evidence: `terminal-prompt-fix.json` in the outage +evidence directory. This source correction does not complete T23/T26. + +## Publish and verify the disaster recovery guide on Storage Share + +```task +id: NK-WP-0049-T26 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T25, NK-WP-0049-T27] +state_hub_task_id: "94d054fe-e5d8-5ddf-8e48-80714123d184" +``` + +**Accountable worker:** codex; operator holds provider access privately. + +Requested by the operator on 2026-10-16; decision +`fc3ef8db-0a33-41d5-a0ba-d13169c5f884` records the bounded scope. Guide source: +`docs/disaster-recovery-storage-share.md`. Document scope, independently held +provider/key requirements, exact known-good fallback, copy/download verification, +archive-check commands, service restore order, recurring schedule and remaining +coverage limits. Include the offline tools so failed Forgejo does not prevent +access to the recovery scripts; retain an independent copy and trusted digest. + +Completed 2026-10-16 at 18:01 UTC through the private independent-provider +owner handoff. All three objects were conditionally created and read back with +matching strong ETag, length and content; the recorded digests match a fresh +reproducible local build. No existing object or provider permission was changed. +The failed first attempt and terminal correction remain documented in T25. + +**Published location:** Storage Share account Backup, +`railiance-backups/net-kingdom-identity/`: + +- `net-kingdom-recovery-57d1940947aa25ff-START-HERE.md` +- `net-kingdom-recovery-57d1940947aa25ff.zip` +- `net-kingdom-recovery-57d1940947aa25ff.zip.sha256` + +**Trusted ZIP SHA256:** +`57d1940947aa25ff83d0f25ddf30090eb40595cccaab64225de882a2fd59ea2f`. +Evidence: `actual-offline-publication.json` and `actual-offline-review.json` in +`docs/evidence/2026-10-16-identity-outage-recovery/`. The published guide is the +exact pre-drill revision; this acceptance record records the later successful +drill without implying an unperformed republication. Keep an independent copy +and trusted digest outside the failed service/provider dependency. + +## Draft the isolated service restore packet and observe candidate images + +```task +id: NK-WP-0049-T27 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T08, NK-WP-0049-T13] +state_hub_task_id: "e273e803-bee7-5d90-bc51-ecc08c0c8eff" +``` + +**Accountable worker:** codex. + +Completed 2026-10-16. Draft in `docs/identity-service-restore-packet.md` names the +known recovered input, restore order, original key/data ownership, default-deny +containment, blocked production/provider/notification effects, cleanup and five +native acceptance lanes with positive/negative checks. Read-only metadata from +the pinned primary cluster resolved all four current service image digests, +including the separately observed privacyIDEA main container and LLDAP's mutable +source tag. Evidence: `docs/evidence/2026-10-16-identity-outage-recovery/image-observation.json`. + +This task extracts completed preparation from T15. Named target admission, +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] +state_hub_task_id: "4829b091-76d2-5b10-bdfc-032800190c0a" +``` + +**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] +state_hub_task_id: "2f8c73b0-1cdd-5b22-99b6-017ccde4454b" +``` + +**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. + +## Prepare the bounded native privacyIDEA token-material and audit executor + +```task +id: NK-WP-0049-T30 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13, NK-WP-0049-T28] +state_hub_task_id: "6e4a43db-9be2-5662-bdbf-1595fce03052" +``` + +**Accountable worker:** codex; operator retains private recovery inputs. + +Completed 2026-10-16. `isolated-privacyidea.py` and its owner launcher use the +fixed local Docker target and exact cached PostgreSQL/privacyIDEA3.12.2 images. +The linked dump, original enckey/audit pair and required secret fields are +restored only into a loopback-only, nonroot, read-only, capability-free, +no-host-mount/port, tmpfs-backed environment with inspected pre-import isolation. +Decrypted host staging is tmpfs; normal cleanup removes all private data and +owned containers, with a bounded holding lifetime if the parent is interrupted. + +The native provider decrypts token-key material internally, checks every stored +audit row, adds one isolated signed entry and checks wrong-public-key/tampering +negatives. Original policy for legacy signature verification is retained only +from literal archived configuration; archived Python is never executed. Invalid +or unsigned history remains reported, not repaired or sampled away. Native +material readback is not existing-token MFA or proof of event completeness. + +Evidence: `tests/test_isolated_privacyidea_recovery.py` and +`isolated-privacyidea-preparation.json` in the October16 outage evidence folder. +Synthetic pinned-image tests exercise valid/tampered/unsigned history, malformed +keys, encrypted archive to native restore, isolation and cleanup. Real original +material/audit acceptance remains T31/T20; actual MFA remains T19. + +## Verify retained privacyIDEA token material inside the isolated provider + +```task +id: NK-WP-0049-T31 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13, NK-WP-0049-T30] +state_hub_task_id: "26383841-8076-5c53-bbd1-9db64e32544a" +``` + +**Accountable worker:** codex; operator supplies only the independently held age +key in the hidden helper. This task extracts actual material-readback scope from +T19, preserving T19's existing-token MFA acceptance gate and historical identity. + +Completed 2026-10-16 at 19:10 UTC in the same actual run as T20. All five +retained token materials were decrypted internally with the original enckey; +an unrelated key could not reproduce any of the five. All 36 manifest hashes, +exact executor/ciphertext/target bindings, isolation and cleanup passed. Safe +receipts are the `actual-isolated-privacyidea-{recovery,review}.json` files +referenced under T20. No retained-factor authentication is claimed. +**Done when:** exact ciphertext/source/target/manifest bindings pass; the native +provider reads every nonempty encrypted token-key field using the original +enckey without exporting material; an unrelated key cannot reproduce that +material; aggregate counts and container/plaintext cleanup are recorded. + +Only bounded internal readback is accepted here. Padding/readback success does +not prove the recovered secret authenticates a retained factor; actual positive +MFA and wrong-key/wrong-code authentication refusal remain T19. No token seed, +plaintext, serial or user record belongs in the receipt, Git, State Hub or chat. + +## Deliver isolated KeyCape restore source and synthetic lifecycle proof + +```task +id: NK-WP-0049-T32 +status: done +priority: high +assignee: codex +``` + +Completed 2026-10-16. This extracts bounded source/fixture delivery from T18; +T18 retains actual original archive, issuer and selected consumer acceptance. +The adapter reuses the existing transfer/archive/custody contracts, binds the +fixed local daemon and cached images, restores private state only to temporary +memory, verifies original issuer/TLS/signing/configuration/policy bytes and runs +the original native service-client checker with wrong-secret/excess-scope denial. +Durable pre-import obligations and exact parent pidfd/session quiescence precede +loss/deadline cleanup. Daemon response uncertainty remains uncertainty after +resource absence; it never authorizes recovery success or replay. + +Frozen R6 fixes a demonstrated image-declared anonymous-volume incompatibility +using bounded helper-only tmpfs, preserving anonymous disk-volume refusal. Safe +phase markers carry no private diagnostics. Independently passed 72 focused +checks, then actual pinned-image synthetic positive, hard-parent-loss, deadline +and nonzero-after-accept cases. Independent fixed-daemon inventories and host +mount-namespace checks prove all exact resources and plaintext absent. Reaper +outcomes are retained; initial sandbox receipt lookup failure is preserved and +superseded by device-bound host verification. R1–R5 failures remain retained. + +Evidence: Custodian docs/changes/netkingdom-target-2026-10-16/ +identity-restore-adapter-next/revisions/r6/, actual-fixture-r6-all.json and +coordinator-r6-all-review.json. Exact source/dependency hashes and candidate +manifest bind the proof. This uses generated test keys only; it proves neither +actual owner-held recovery, human MFA, downstream reconnect nor native authority. + +## Prepare the exact owner-held KeyCape recovery attempt + +```task +id: NK-WP-0049-T33 +status: todo +priority: high +assignee: codex +depends_on: [NK-WP-0049-T32] +``` + +**Accountable worker:** NetKingdom codex with KeyCape and selected consumer owners. +Prepare the exact archive/manifest, original issuer/TLS/trust and executable +bindings, selected current registration and dependency graph using public metadata. +Use the existing independently held archive-key and owner-controlled TLS custody +paths; never put secret values in the packet. Validate the attended launcher and +its full dependency closure, execution deadline, recovery obligations and safe +receipt path before asking for any owner-held input. Bind approved source revision +and rollback/abort limits through the existing owner process. + +**Done when:** reviewed executable command and metadata-only admission packet +are ready for the existing attended custody path; prerequisites and independent +receipt verification are explicit. T18 still owns actual execution, original +consumer continuity and acceptance. T19 retains retained-factor MFA. This task +neither authorizes a production consumer redirect nor creates a new client grant. + +## Accept remaining recovery and caller evidence and close the G08 handoff + +```task +id: NK-WP-0049-T03 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T02, NK-WP-0049-T16, NK-WP-0049-T17, NK-WP-0049-T18, NK-WP-0049-T19, NK-WP-0049-T20, NK-WP-0049-T21, NK-WP-0049-T22, NK-WP-0049-T23, NK-WP-0049-T24, NK-WP-0049-T26] +blocking_reason: "Await the separately tracked native service proofs, current/legacy caller dispositions and residual-scope decisions; archive recovery, backup activation, independent outage retrieval and guide publication are complete." +state_hub_task_id: "d68cd4d3-1ba3-530e-88f9-26ebb8dd6eac" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +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–T31 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 +work records before workplan closure. Preserve historical IDs and receipts; do +not call a deferred rotation or an untested full-estate restore complete. + +## Evidence locations and refinement history + +“Live” above means `docs/evidence/2026-10-16-identity-backup-live/` in NetKingdom. +Implementation preparation is in the October16 implementation directory; earlier +backup discovery/database recovery is in `docs/evidence/2026-10-15-bootstrap-recovery/`. +The source-repair receipts explicitly labelled Custodian belong to that owning +repository. Use the [recovery ceremony](../docs/bootstrap-recovery-ceremony.md) +for exact procedure and approval history. No secret values belong in task evidence. + +Refinement rule: substantial work and newly understood remaining scope must be +visible in task blocks at a checkpoint, not only in progress prose. This replan +preserves T01–T03 identity and original evidence while making the completed +milestones and each remaining acceptance gate separately manageable. + +Managed identifier assignment also consumed a preexisting staged generated brief; +the follow-up commit restores its prior published baseline while preserving the +operator's working brief and original staged version. This is a projection-tool +side effect, not new recovery evidence. diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-preimage.md b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-preimage.md new file mode 100644 index 00000000..fb558b37 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan-preimage.md @@ -0,0 +1,956 @@ +--- +id: NK-WP-0049 +type: workplan +title: "Remove bootstrap secret output and verify separated recovery custody" +domain: infotech +repo: net-kingdom +status: active +owner: codex +topic_slug: netkingdom +created: "2026-10-12" +updated: "2026-10-16" +flavor: implementation +depends_on: [] +state_hub_workstream_id: "17d315f0-829e-57c7-85cc-3a7914152804" +--- + +Native owner work for CUST-WP-0080 G08. This workplan remains active until the +remaining caller, service-recovery and custody acceptance gates are satisfied. +NK-WP-0045 covers the non-secret root-grant record. Founder-deferred exposure +rotation remains in CUST-WP-0073-T05; this plan does not silently reopen it. + +**Replanned 2026-10-16 at the operator's request:** substantial completed work is +now represented by completed tasks, and remaining work has separate deliverables, +dependencies and acceptance criteria. T01–T03 retain their historical IDs and Hub +UUIDs. Completed parts of the old T02/T03 umbrellas are extracted into T04–T14; +T02 now covers the current-caller ledger and T03 retains final owner acceptance. +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:** 22 tasks done; 2 in progress; 1 ready to start (`todo`); +6 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; +independent outage retrieval and Storage Share publication are accepted; +service admission remains an explicit gate. + +**Next actions:** finish the read-only caller ledger (T02); admit the drafted +isolated restore packet (T15) and record residual-scope handoffs (T24). +Independent outage retrieval (T23) and guide publication (T26) are done. After owner admission, execute the individual native service and +caller checks. A task's owner is accountable for the result; another repository's +approval or credential grant still requires its own owner path. + +**Closure:** T03 assembles owner acceptance after its named dependencies pass. +Passing archive recovery does not prove existing-user authentication, retained +MFA-token decryption, historical audit signatures or current caller admission. +The verified backup job stays active while those separate tasks are completed. + +## Remove secret-bearing output and ambient root discovery + +```task +id: NK-WP-0049-T01 +status: done +priority: high +assignee: codex +state_hub_task_id: "d7ca8d5f-c5ff-5cba-bd25-038cb53ffe4e" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +2026-10-12: regression reproduced emergency/reprint root export, generation +prefix disclosure, permissive file modes, dry-run side effects and unattended +entry. Replaced emergency export with a non-secret guide (legacy secret-input +arguments refused), suppressed generation prefixes/tracing and set private +permissions. Bootstrap requires the registered public recipient and an attended +terminal; never generates/discovers a permanent private key. Encrypted resume +requires an explicit matching temporary custody key before cluster activity. +Dry-run is a non-mutating plan. The live cluster/apply path was not executed. + +A fresh uniquely named encrypted ops bundle follows apply; old delivery flags +cannot skip current acknowledgment. New acknowledgment state binds its SHA256 +and timestamp. Declining stops before cleanup/completion. This is an operator +statement, not an independent restore receipt. Historical fields remain unchanged. +Make/help/status/console and current credential guidance reflect that distinction. +Evidence and exact source manifest: Custodian +`docs/evidence/2026-10-12-netkingdom-security/bootstrap-handoff.json`. + +## Harden custody, SOPS and rotation source entry points + +```task +id: NK-WP-0049-T04 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T01] +state_hub_task_id: "04c4e8be-5bd8-5d5c-99b1-79d8ae758462" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-12–13. Public-recipient-only encryption, explicit temporary +custody, private no-overwrite output, sanitized diagnostics, stdin-only secret +transport and failed-operation cleanup replace ambient-key and output paths. +SOPS read/edit/data-key rotation preserve recipient/MAC/source-version guards; +component rotations preserve unrelated fields and retain recovery candidates. +This records source and disposable-native proof, not live rotation or revocation. + +Evidence: Custodian `docs/evidence/2026-10-12-netkingdom-security/` receipts +`custody-entrypoints.json`, `sops-make-entrypoints.json`, `rotation-output.json`, +and `admin-bootstrap.json`; October13 `sops-write-callers.json`. + +## Repair component, diagnostic, LDAP and resolver callers + +```task +id: NK-WP-0049-T05 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T04] +state_hub_task_id: "e8e38f42-609e-5c07-a8e6-e73c8eb4ae95" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-13–14. Repaired KeyCape configuration and component Secret +publication, original privacyIDEA key export/restore transport, scoped MFA +counts, LDAP group maintenance and configuration-preserving resolver cutover. +Retired unsafe personal-token mint/refresh and direct LDAP lifecycle shortcuts. +Disposable native PI/LLDAP tests and source regressions passed; estate provider +admission, historical credential validity and service authentication remain T21/T22. + +Evidence: Custodian October13 `keycape-caller-reconciliation.json`, +`service-secret-callers.json`, `privacyidea-key-custody.json`, +`mfa-state-diagnostic.json`, `realm-bootstrap-repair.json`; October14 +`legacy-lldap-retirement.json`, `platform-group-boundary.json`, +`lldap-group-maintenance.json`, `resolver-cutover.json` under the corresponding +`docs/evidence/YYYY-MM-DD-netkingdom-security/` directories. + +## Inventory source callers and retire remaining unsafe delivery paths + +```task +id: NK-WP-0049-T06 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T04, NK-WP-0049-T05] +state_hub_task_id: "a5f50891-c5b2-513b-8670-e1915ee52015" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-15. Enumerated all 87 current non-test shell/Python callers +in the declared bootstrap/k8s scope plus Make/console/delivery adapters. Repaired +streamed encrypted bundle publication and fail-closed bootstrap/lab paths; +retired unsafe delivery copiers, root prompts, registry patchers and stale +rollback recipes. The attended inventory records source dispositions and their +native-proof limits. T02 separately maps these to current live users/owners. + +Evidence: [caller inventory](../docs/evidence/2026-10-15-bootstrap-recovery/caller-source-inventory.json), +[attended procedures](../docs/attended-procedure-inventory.md) and the October15 +recovery evidence directory. No live production bootstrap or lab-init acceptance +is claimed. + +## Discover off-host coverage and recover the existing PI database + +```task +id: NK-WP-0049-T07 +status: done +priority: high +assignee: codex +state_hub_task_id: "2d7302a2-319a-5633-bf58-d51ccd25ebf6" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-15–16. Bounded Storage Share/Scaleway inventories identified +existing database coverage and the missing full identity bundle. The operator +independently matched the backup recipient, then recovered the exact existing +PI dump: correct-key decryption, wrong-key refusal, isolated native PostgreSQL +restore/readback and cleanup passed (5 tokens, 2 resolvers, 2 realms). + +Evidence: October15 recovery `offsite-discovery-summary.json`, +`privacyidea-transfer-r1.json`, `privacyidea-database-drill-r1.json`, +`database-drill-review.json`. This database-only proof led to T08–T14. + +## Implement the full identity capture and archive recovery toolchain + +```task +id: NK-WP-0049-T08 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T06, NK-WP-0049-T07] +state_hub_task_id: "317ca173-c784-5329-9810-5e3d300604b8" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Defined exact LLDAP/Authelia/KeyCape/privacyIDEA assets, +Deployment/PVC/reference bindings, original keys and snapshot consistency limits. +Implemented native SQLite/PG capture, streamed age sealing, strict data-bearing +manifest checks, provider readback, independent retrieval, bounded retention, +root runtime and attended archive verifier. All 56 focused checks passed, +including actual synthetic encrypted/native PostgreSQL recovery. + +Evidence: [profile](../tools/bootstrap-recovery/identity-backup-profile.json), +[implementation review](../docs/evidence/2026-10-16-identity-backup-implementation/review.json), +and the live evidence's tested ESO-default/host-binding corrections. Production +archive recovery is separately evidenced in T13. + +## Admit and verify the two scoped backup credential deliveries + +```task +id: NK-WP-0049-T09 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T08] +state_hub_task_id: "4a47eb2a-7e42-5ae7-9df1-f5ede746eca1" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Platform CCR-2026-0048/0049 are verified. The operator +approved folder-only provider-normalized publisher21/retention25 rights, including +re-sharing, in addition to the original rollout. Coding-agent denials precede KV +publication. Exact 900-second single-policy readers, wrong-SA/namespace/audience +rejection, metadata/sibling/old-escrow/token-create denials, ESO equality and +reader/session revocation passed. Operator passwords and age private keys are +not job credentials. + +Evidence: live `publisher-setup-verified.json`, `retention-setup-verified.json`, +`publisher-native-delivery-verified.json`, `retention-native-delivery-verified.json`; +approval decisions `40bfae40-6108-4e51-8613-073339f5345f` and +`5edd5203-fae7-461b-8775-5d22b7873971`; Platform CCR files. + +## Apply capture isolation and install the guarded node runtime + +```task +id: NK-WP-0049-T10 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T08, NK-WP-0049-T09] +state_hub_task_id: "0519444c-d559-5455-aa27-8c5dc8b832b0" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Applied exact UID/resource-version/spec-guarded DNS +selector exclusions, preserving ordinary service matches. Capture refuses any +remaining helper allow policy. Installed immutable runtime sources bound to the +actual hostname, machine identity and cluster UID. Disabled-unit replacement +checked previous contents and retained rollback bytes. Health unit validation +passed. Activation remained gated until T13. + +Evidence: live `isolation-applied.json`, `capture-ready-plan.json`, +`installed-runtime-packet-r2.json`, `upgrade-disabled-runtime.py`. Actual complete +hardened-job execution is recorded in T14. + +## Connect recovery to Railiance bootstrap and reuse discovery + +```task +id: NK-WP-0049-T11 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T08] +state_hub_task_id: "d69af493-4026-56a8-a526-c267d90dce18" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Added the Railiance bootstrap `identity-recovery` +dispatcher and independent-custody → isolated verification → layer/service-owner +handoff. Updated `capability.security.bootstrap-recovery`, linked bootstrap +sequencing and composed reuse-surface federation. Keyword discovery for identity +backup, restore, privacyIDEA and disaster recovery was verified. The operator +actually used this handoff in T13. No unattended production importer is claimed. + +Evidence: [ceremony](../docs/bootstrap-recovery-ceremony.md), owning capability +entry, `railiance-bootstrap/docs/identity-recovery.md`, bootstrap commit `d1dcfde` +and reuse-surface federation commit `662782e`. + +## Create and verify the first full off-host identity bundle + +```task +id: NK-WP-0049-T12 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T09, NK-WP-0049-T10] +state_hub_task_id: "decb415a-8012-521c-9dbe-d73c6ff0bbfb" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Captured original service keys/current data plus the +linked native PI database dump, sealed and published a unique Storage Share +artifact, then downloaded it and verified size/digest before publishing its index. +Helper and plaintext cleanup passed. Artifact: +`net-kingdom-identity-20261016T063159Z-5882771aa2ce.tar.age` (109738344 bytes). + +Evidence: live `actual-capture.json`, `actual-offsite.json`; ciphertext SHA256 +`7117b3fe1e4840c86b301c94c85e6b16709e5626bb0884bd0580bde630e30e30`. +No age private key was needed by the capture job. + +## Independently retrieve and recover the complete actual archive + +```task +id: NK-WP-0049-T13 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T11, NK-WP-0049-T12] +state_hub_task_id: "602d4921-cfac-5e5b-9993-4fbf67195c7d" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16. Independently downloaded the exact first generation +through attended Backup-account custody, rehashed it locally and revoked the +session. The operator used the independently held age key through the bootstrap +handoff. Correct/wrong-key cases, all 36 manifest files/hashes, original RSA pairs, +SQLite integrity and isolated native PG16.14 restore/readback passed; plaintext +and containers were removed. No key or restored secret values reached the agent. + +Evidence: live `independent-owner-transfer.json`, `actual-full-archive-recovery.json`, +`actual-full-recovery-review.json`. Existing-user/MFA authentication and old audit +signatures are deliberately separate T16–T20 outcomes. + +## Activate and prove the complete recurring backup and retention job + +```task +id: NK-WP-0049-T14 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T10, NK-WP-0049-T13] +state_hub_task_id: "eaf0708f-b3b7-5531-8172-95edf30c3a19" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16 after T13. Both daily backup/retention and hourly health +timers are enabled/active. The actual hardened systemd service passed retention +before/after a second capture/upload/download (Result=success, ExecMainStatus=0). +Both verified generations are retained, the independently recovered point is +pinned, and nothing was deleted. Helpers/plaintext were cleaned; failure markers +and reporting outbox are empty. Health check succeeded. + +Evidence: live `activation-result.json`, `activation-review.json`, +`recurring-job-review.json`. Cadence: daily 03:45 UTC plus up to 5 minutes jitter, +hourly health, 25-hour freshness bound. Second-provider/host-loss gaps go to T24; +this completed task is not a full-estate recovery claim. + +## Map current callers, owners and native acceptance gaps + +```task +id: NK-WP-0049-T02 +status: progress +priority: high +assignee: codex +depends_on: [NK-WP-0049-T06] +state_hub_task_id: "479d6970-6516-50f5-9436-bb5b56d58aad" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +The source inventory and known legacy objects exist; a complete current-use +ledger is not yet accepted. Next action is read-only reconciliation of the 93 +entries in the latest October15 source inventory (the earlier snapshot had 87) against active Make/bootstrap/operator routes and owner evidence. + +**Done when:** each entry is identified as current, retired or owner-deferred, +with its owner, credential route, current consumer(s), native evidence or exact +missing proof, and next responsible task. Explicitly cover the historical +`sso/keycape-pi-token` and `mfa/privacyidea-trigger-admin` objects, lab init, +factor diagnostic delivery, LDAP group/provider boundary, resolver cutover and +root-output/isolation boundaries. Secret presence is not credential validity. +Send current native checks to T21 and historical revocation/rotation decisions to +T22. Do not reopen completed source repairs without a new finding. + +## Prepare the isolated full-service restore and acceptance packet + +```task +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, NK-WP-0049-T28, NK-WP-0049-T29, NK-WP-0049-T30] +state_hub_task_id: "1d1eb903-a4b0-5bc4-a421-0b842d7f68ae" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +**Prepared draft scope moved to T27.** See +`docs/identity-service-restore-packet.md`: restore order, containment, safe evidence +and per-service positive/negative checks are drafted. Read-only current runtime +image digests were observed for all four services; correspondence to the selected +archive and independent image availability are not yet proved. + +**Next action:** admit a named disposable target/private route, verify and stage +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 +credential or production effect has its own owner decision. The verifier deletes +its plaintext; it is not a production importer. This task prepares authority and +inputs for T16–T20, not an automatic production restore. + +## Prove restored LLDAP directory and existing-user authentication + +```task +id: NK-WP-0049-T16 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13, NK-WP-0049-T29] +state_hub_task_id: "b64d4d6a-e851-58d1-9929-f03343147de0" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: NetKingdom directory/User Engine owners. Restore the captured +original directory/configuration in the admitted isolated environment. + +**Done when:** user, group and membership identities are preserved; an independently +supplied existing-user credential authenticates; a wrong credential is refused; +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. + + +Completed 2026-10-16 at 18:37 UTC. The operator privately supplied the +independent age key and an existing backup-era account credential to the exact +local executor. All 36 archive hashes and the known ciphertext/source/target +bindings matched. Native existing-user LDAP bind passed; explicit invalid +credentials were rejected. The original seven users, 17 groups, 13 memberships +and the available attribute/schema rows were preserved across service startup, +authentication and graceful shutdown. The container had only loopback/no +external routes; pre-import isolation and container/tmpfs cleanup passed. +No production endpoint was contacted or secret exported to the agent. + +Evidence: `actual-isolated-lldap-recovery.json` and +`actual-isolated-lldap-review.json` in +`docs/evidence/2026-10-16-identity-outage-recovery/`. This closes the actual +LLDAP stage independently of the remaining T15 packet. It does not prove +Authelia/KeyCape authentication, retained-token MFA or audit continuity. T17, +T18 and T19 retain their remaining adapter/input prerequisites. + +## Prove restored Authelia storage and OIDC continuity + +```task +id: NK-WP-0049-T17 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T15, NK-WP-0049-T16] +blocking_reason: "LLDAP recovery T16 passed; await the exact isolated Authelia adapter/procedure and private authentication-flow inputs from T15." +state_hub_task_id: "b47f9975-d6d7-5bb8-93ce-886deb1dfa70" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: SSO/Authelia and directory owners. Restore the original storage, +encryption/session/signing material and configuration in isolation. + +**Done when:** native storage decryption succeeds, issuer/signing identity and +client configuration are preserved, and an isolated authentication flow has +positive/negative results using the restored directory. Disable outbound +notifications and verify cleanup. Archive integrity/RSA parsing alone are not +native Authelia acceptance. + +## Prove retained privacyIDEA token decryption and existing-token MFA + +```task +id: NK-WP-0049-T19 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T15, NK-WP-0049-T16, NK-WP-0049-T31] +blocking_reason: "LLDAP T16, token-material T31 and audit T20 passed; await the exact isolated MFA adapter and owner-selected retained-token/factor inputs." +state_hub_task_id: "6f8af1a7-2a3f-5fa8-8202-83103ebd55b2" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: MFA/privacyIDEA and directory owners. Use the exact linked dump +and original enckey from the independently recovered generation. + +**Done when:** retained token material decrypts inside the isolated process, +a selected existing token completes a native MFA check, a wrong key/code is +refused, and cleanup is verified. Obtain any token/user input through attended +private custody; never emit token plaintext, seeds or credentials. Native database +row counts and disposable synthetic-token fixtures do not prove this outcome. + + +2026-10-16 refinement: native token-material readback is separately tracked in +T31 using the prepared T30 executor. T19 retains actual existing-token MFA and +wrong-code/wrong-key authentication refusal. Internal decryption or padding +success does not authenticate a user. No MFA code is needed for T20/T31; the +selected user's private factor input and exact isolated MFA adapter remain T19. + +## Prove historical and new privacyIDEA audit-signature continuity + +```task +id: NK-WP-0049-T20 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13, NK-WP-0049-T30] +state_hub_task_id: "375fa8f9-b8a5-5048-97a7-ec141aa7c45d" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: MFA/privacyIDEA audit owner. Use the original recovered audit +pair and linked database in the admitted isolated target. + +**Done when:** existing pre-backup audit signatures are verified natively, a new +isolated audit entry is signed and verified with the preserved identity, a wrong +key fails, and cleanup is recorded. Report any historical verification failure +for explicit owner disposition; do not regenerate keys to hide it. Matching RSA +pairs from T13 are a prerequisite, not audit-history verification. + + +Completed 2026-10-16 at 19:10 UTC using the actual independently retrieved +bundle and original audit identity on the fixed isolated local engine. All +279,209 stored rows were signed and verified; zero unsigned, invalid or legacy +signatures. A new isolated entry verified, and tampering/unrelated-public-key +checks were refused. Container/plaintext cleanup passed. Receipt and reviewed +source/ciphertext/target bindings: `actual-isolated-privacyidea-recovery.json` +and `actual-isolated-privacyidea-review.json` in +`docs/evidence/2026-10-16-identity-outage-recovery/`. +Stored-row continuity is accepted; event completeness and existing-token MFA +are separate and are not claimed. + +## Prove restored KeyCape issuer, policy and consumer continuity + +```task +id: NK-WP-0049-T18 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T15, NK-WP-0049-T16, NK-WP-0049-T19] +blocking_reason: "LLDAP recovery T16 passed; await the exact isolated KeyCape adapter/procedure and retained-token MFA proof for the selected flow." +state_hub_task_id: "141dd773-c1fa-56c1-a852-bbe2372e5125" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: KeyCape and the selected current consumer owners. Restore the +original signing/configuration, registry, authentication policies and receipts. + +**Done when:** issuer/JWKS signing identity, registry/client configuration and +policy/receipt continuity are preserved; selected current consumers pass native +positive/negative authentication using the restored dependencies; and isolation +and cleanup hold. Confirm the actual dependency graph in T15. Do not route a +production consumer to the isolated issuer or mint new client grants implicitly. + +## Verify current caller and provider admission at their actual boundaries + +```task +id: NK-WP-0049-T21 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T02] +blocking_reason: "Await the current-caller ledger and exact per-caller owner admission/test inputs; the backup credential grants do not admit unrelated callers." +state_hub_task_id: "77aa42be-7a39-5d93-8042-03d78b4094b4" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: each current caller's service/provider and credential owner. +Use T02's ledger to select the exact current procedures and required owner lanes. + +**Done when:** each current caller has scoped delivery and native positive/negative +acceptance or an explicit accepted retirement/disposition. Include dedicated +factor-diagnostic admission (do not borrow KeyCape's reader), deployed reserved +LDAP group denial, resolver/cutover input provenance and functional proof, and +bootstrap/root-output/isolation callers. Fixtures, an existing tty and historical +bootstrap receipts are not current authority. Propose exact write/cutover actions +before execution; record deferred actions separately rather than faking a pass. + +## Resolve historical credential and configuration-preserving rotation dispositions + +```task +id: NK-WP-0049-T22 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T02, NK-WP-0049-T21] +blocking_reason: "Await the caller/consumer ledger, current replacement-lane acceptance and explicit credential-owner dispositions; existing exposure-rotation deferral is unchanged." +state_hub_task_id: "3ea6bf00-a87f-58ea-bdce-328c13a03a55" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Owner boundary: Platform/custody and affected consumer owners. Review historical +personal-admin/trigger credential copies and unsafe predecessor paths against +known current consumers and rollback custody. + +**Done when:** every affected credential has an owner-accepted disposition: +verified revocation/replacement, continued bounded use with acceptance, or an +explicit linked deferral. Signing/client-secret changes require preservation and +native multi-consumer proof if actually approved. CUST-WP-0073-T05's founder +rotation deferral and decision `1591daa1-6e85-4dfd-aadd-5a9fe1ef47fa` remain in force; +record their exact applicability rather than automatically rotating or treating +source retirement as provider revocation. + +## Provide retrieval that works when OpenBao and SSO are unavailable + +```task +id: NK-WP-0049-T23 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13, NK-WP-0049-T25] +state_hub_task_id: "1ec537ee-dfee-5576-be36-a81bdba417d1" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +Completed 2026-10-16 at 18:02 UTC. The operator used independently held Backup +account access in the private prompt. The actual protected 109,738,344-byte +ciphertext was downloaded with unchanged ETag/size/SHA256, with no OpenBao/SSO +contact or provider writes by the retrieval path. The human-held age key matched +the registered recipient; wrong-key refusal, all 36 manifest hashes, SQLite +integrity, original key pairs and native isolated PostgreSQL 16.14 restore passed. +The database retained five token, two resolver and two realm rows. Disposable +container and temporary plaintext cleanup passed. The agent received no secret. + +Evidence: `actual-offline-transfer.json`, `actual-offline-recovery.json` and +`actual-offline-review.json` under +`docs/evidence/2026-10-16-identity-outage-recovery/`. The review rechecked the +local ciphertext digest and verifier source. This proves the independent path +without requiring a deliberate production outage. Service authentication, +retained-token decryption and historical audit verification remain T16–T20. +T26 separately records accepted guide publication. + +## Record owner disposition for second-provider and external monitoring gaps + +```task +id: NK-WP-0049-T24 +status: todo +priority: medium +assignee: codex +depends_on: [NK-WP-0049-T14] +state_hub_task_id: "474f5a78-a643-59c6-bcf9-fc4a908dfcb0" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +**Next action:** record the current single-provider and host-local-health limits, +then obtain explicit owner scope decisions for a second encrypted provider copy, +external host-loss/freshness detection and recurring recovery-drill cadence. + +**Done when:** each gap has either an accepted bounded residual risk or a linked +live intake/workplan with owner and trigger. Existing daily RPO/25-hour health +settings remain the approved baseline. Scaleway full-identity publication and +an external watchdog are not implemented; do not add their grants/jobs implicitly. +This task triages the new insights instead of silently expanding NK49's required +implementation to an entire disaster-recovery programme. + +## Deliver and verify the offline retrieval adapter and recovery tools kit + +```task +id: NK-WP-0049-T25 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13] +state_hub_task_id: "1c5ea7a1-edf7-5072-b36b-8be7f827957a" +``` + +**Accountable worker:** codex. + +Completed 2026-10-16. `retrieve-identity-offline.py` uses privately entered +independent Backup-account access or an exact read-capable identity-folder share. +It performs only fixed-provider HEAD/GET, binds the reviewed generation by strong +ETag/length/SHA256, refuses redirects/proxies/unreviewed scope and removes failed +downloads. No OpenBao/SSO, age key or standing agent credential is required. +`recovery-kit.py` builds a reproducible allowlisted ZIP containing the tools, +public profile, known-good ciphertext receipt and recovery guides, with file +checksums. Its publication path creates only three versioned guide/ZIP/checksum +objects and verifies readback without overwriting existing conflicting objects. +`run-offline-recovery.sh` provides the owner's two-hidden-prompt handoff. + +Evidence: `tests/test_identity_offline_recovery.py` and the bounded test/build +receipt in `docs/evidence/2026-10-16-identity-outage-recovery/`. The focused run +passed 65 tests including real disposable age/PostgreSQL fixtures. ZIP-contained +entry points import without sibling repositories. This is source/fixture proof, +not actual independent-provider retrieval (T23) or publication (T26). + +2026-10-16 correction: the first owner handoff stopped before its credential +prompt because Python text `r+` requires a seekable stream. Reproduced against a +real controlling PTY; replaced both prompts with the shared hidden-input helper, +refusing redirected input and echo fallback. Added real-terminal and both-CLI +regressions plus closed stage/HTTP-code diagnostics (no raw responses). The +focused rerun passed 36 tests. Evidence: `terminal-prompt-fix.json` in the outage +evidence directory. This source correction does not complete T23/T26. + +## Publish and verify the disaster recovery guide on Storage Share + +```task +id: NK-WP-0049-T26 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T25, NK-WP-0049-T27] +state_hub_task_id: "94d054fe-e5d8-5ddf-8e48-80714123d184" +``` + +**Accountable worker:** codex; operator holds provider access privately. + +Requested by the operator on 2026-10-16; decision +`fc3ef8db-0a33-41d5-a0ba-d13169c5f884` records the bounded scope. Guide source: +`docs/disaster-recovery-storage-share.md`. Document scope, independently held +provider/key requirements, exact known-good fallback, copy/download verification, +archive-check commands, service restore order, recurring schedule and remaining +coverage limits. Include the offline tools so failed Forgejo does not prevent +access to the recovery scripts; retain an independent copy and trusted digest. + +Completed 2026-10-16 at 18:01 UTC through the private independent-provider +owner handoff. All three objects were conditionally created and read back with +matching strong ETag, length and content; the recorded digests match a fresh +reproducible local build. No existing object or provider permission was changed. +The failed first attempt and terminal correction remain documented in T25. + +**Published location:** Storage Share account Backup, +`railiance-backups/net-kingdom-identity/`: + +- `net-kingdom-recovery-57d1940947aa25ff-START-HERE.md` +- `net-kingdom-recovery-57d1940947aa25ff.zip` +- `net-kingdom-recovery-57d1940947aa25ff.zip.sha256` + +**Trusted ZIP SHA256:** +`57d1940947aa25ff83d0f25ddf30090eb40595cccaab64225de882a2fd59ea2f`. +Evidence: `actual-offline-publication.json` and `actual-offline-review.json` in +`docs/evidence/2026-10-16-identity-outage-recovery/`. The published guide is the +exact pre-drill revision; this acceptance record records the later successful +drill without implying an unperformed republication. Keep an independent copy +and trusted digest outside the failed service/provider dependency. + +## Draft the isolated service restore packet and observe candidate images + +```task +id: NK-WP-0049-T27 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T08, NK-WP-0049-T13] +state_hub_task_id: "e273e803-bee7-5d90-bc51-ecc08c0c8eff" +``` + +**Accountable worker:** codex. + +Completed 2026-10-16. Draft in `docs/identity-service-restore-packet.md` names the +known recovered input, restore order, original key/data ownership, default-deny +containment, blocked production/provider/notification effects, cleanup and five +native acceptance lanes with positive/negative checks. Read-only metadata from +the pinned primary cluster resolved all four current service image digests, +including the separately observed privacyIDEA main container and LLDAP's mutable +source tag. Evidence: `docs/evidence/2026-10-16-identity-outage-recovery/image-observation.json`. + +This task extracts completed preparation from T15. Named target admission, +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] +state_hub_task_id: "4829b091-76d2-5b10-bdfc-032800190c0a" +``` + +**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] +state_hub_task_id: "2f8c73b0-1cdd-5b22-99b6-017ccde4454b" +``` + +**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. + +## Prepare the bounded native privacyIDEA token-material and audit executor + +```task +id: NK-WP-0049-T30 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13, NK-WP-0049-T28] +state_hub_task_id: "6e4a43db-9be2-5662-bdbf-1595fce03052" +``` + +**Accountable worker:** codex; operator retains private recovery inputs. + +Completed 2026-10-16. `isolated-privacyidea.py` and its owner launcher use the +fixed local Docker target and exact cached PostgreSQL/privacyIDEA3.12.2 images. +The linked dump, original enckey/audit pair and required secret fields are +restored only into a loopback-only, nonroot, read-only, capability-free, +no-host-mount/port, tmpfs-backed environment with inspected pre-import isolation. +Decrypted host staging is tmpfs; normal cleanup removes all private data and +owned containers, with a bounded holding lifetime if the parent is interrupted. + +The native provider decrypts token-key material internally, checks every stored +audit row, adds one isolated signed entry and checks wrong-public-key/tampering +negatives. Original policy for legacy signature verification is retained only +from literal archived configuration; archived Python is never executed. Invalid +or unsigned history remains reported, not repaired or sampled away. Native +material readback is not existing-token MFA or proof of event completeness. + +Evidence: `tests/test_isolated_privacyidea_recovery.py` and +`isolated-privacyidea-preparation.json` in the October16 outage evidence folder. +Synthetic pinned-image tests exercise valid/tampered/unsigned history, malformed +keys, encrypted archive to native restore, isolation and cleanup. Real original +material/audit acceptance remains T31/T20; actual MFA remains T19. + +## Verify retained privacyIDEA token material inside the isolated provider + +```task +id: NK-WP-0049-T31 +status: done +priority: high +assignee: codex +depends_on: [NK-WP-0049-T13, NK-WP-0049-T30] +state_hub_task_id: "26383841-8076-5c53-bbd1-9db64e32544a" +``` + +**Accountable worker:** codex; operator supplies only the independently held age +key in the hidden helper. This task extracts actual material-readback scope from +T19, preserving T19's existing-token MFA acceptance gate and historical identity. + +Completed 2026-10-16 at 19:10 UTC in the same actual run as T20. All five +retained token materials were decrypted internally with the original enckey; +an unrelated key could not reproduce any of the five. All 36 manifest hashes, +exact executor/ciphertext/target bindings, isolation and cleanup passed. Safe +receipts are the `actual-isolated-privacyidea-{recovery,review}.json` files +referenced under T20. No retained-factor authentication is claimed. +**Done when:** exact ciphertext/source/target/manifest bindings pass; the native +provider reads every nonempty encrypted token-key field using the original +enckey without exporting material; an unrelated key cannot reproduce that +material; aggregate counts and container/plaintext cleanup are recorded. + +Only bounded internal readback is accepted here. Padding/readback success does +not prove the recovered secret authenticates a retained factor; actual positive +MFA and wrong-key/wrong-code authentication refusal remain T19. No token seed, +plaintext, serial or user record belongs in the receipt, Git, State Hub or chat. + +## Accept remaining recovery and caller evidence and close the G08 handoff + +```task +id: NK-WP-0049-T03 +status: wait +priority: high +assignee: codex +depends_on: [NK-WP-0049-T02, NK-WP-0049-T16, NK-WP-0049-T17, NK-WP-0049-T18, NK-WP-0049-T19, NK-WP-0049-T20, NK-WP-0049-T21, NK-WP-0049-T22, NK-WP-0049-T23, NK-WP-0049-T24, NK-WP-0049-T26] +blocking_reason: "Await the separately tracked native service proofs, current/legacy caller dispositions and residual-scope decisions; archive recovery, backup activation, independent outage retrieval and guide publication are complete." +state_hub_task_id: "d68cd4d3-1ba3-530e-88f9-26ebb8dd6eac" +``` + +**Accountable worker:** codex. Service/credential-owner acceptance remains with +the named owner boundary. + +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–T31 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 +work records before workplan closure. Preserve historical IDs and receipts; do +not call a deferred rotation or an untested full-estate restore complete. + +## Evidence locations and refinement history + +“Live” above means `docs/evidence/2026-10-16-identity-backup-live/` in NetKingdom. +Implementation preparation is in the October16 implementation directory; earlier +backup discovery/database recovery is in `docs/evidence/2026-10-15-bootstrap-recovery/`. +The source-repair receipts explicitly labelled Custodian belong to that owning +repository. Use the [recovery ceremony](../docs/bootstrap-recovery-ceremony.md) +for exact procedure and approval history. No secret values belong in task evidence. + +Refinement rule: substantial work and newly understood remaining scope must be +visible in task blocks at a checkpoint, not only in progress prose. This replan +preserves T01–T03 identity and original evidence while making the completed +milestones and each remaining acceptance gate separately manageable. + +Managed identifier assignment also consumed a preexisting staged generated brief; +the follow-up commit restores its prior published baseline while preserving the +operator's working brief and original staged version. This is a projection-tool +side effect, not new recovery evidence. diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan.patch b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan.patch new file mode 100644 index 00000000..55f69e78 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r6-workplan.patch @@ -0,0 +1,79 @@ +--- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md ++++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +@@ -559,8 +559,8 @@ + status: wait + priority: high + assignee: codex +-depends_on: [NK-WP-0049-T15, NK-WP-0049-T16, NK-WP-0049-T19] +-blocking_reason: "LLDAP recovery T16 passed; await the exact isolated KeyCape adapter/procedure and retained-token MFA proof for the selected flow." ++depends_on: [NK-WP-0049-T15, NK-WP-0049-T16, NK-WP-0049-T19, NK-WP-0049-T32, NK-WP-0049-T33] ++blocking_reason: "Source and synthetic KeyCape lifecycle proof are delivered in T32; await exact owner-held recovery attempt preparation T33, selected consumer acceptance and retained-token MFA for the selected flow." + state_hub_task_id: "141dd773-c1fa-56c1-a852-bbe2372e5125" + ``` + +@@ -910,6 +910,65 @@ + MFA and wrong-key/wrong-code authentication refusal remain T19. No token seed, + plaintext, serial or user record belongs in the receipt, Git, State Hub or chat. + ++## Deliver isolated KeyCape restore source and synthetic lifecycle proof ++ ++```task ++id: NK-WP-0049-T32 ++status: done ++priority: high ++assignee: codex ++``` ++ ++Completed 2026-10-16. This extracts bounded source/fixture delivery from T18; ++T18 retains actual original archive, issuer and selected consumer acceptance. ++The adapter reuses the existing transfer/archive/custody contracts, binds the ++fixed local daemon and cached images, restores private state only to temporary ++memory, verifies original issuer/TLS/signing/configuration/policy bytes and runs ++the original native service-client checker with wrong-secret/excess-scope denial. ++Durable pre-import obligations and exact parent pidfd/session quiescence precede ++loss/deadline cleanup. Daemon response uncertainty remains uncertainty after ++resource absence; it never authorizes recovery success or replay. ++ ++Frozen R6 fixes a demonstrated image-declared anonymous-volume incompatibility ++using bounded helper-only tmpfs, preserving anonymous disk-volume refusal. Safe ++phase markers carry no private diagnostics. Independently passed 72 focused ++checks, then actual pinned-image synthetic positive, hard-parent-loss, deadline ++and nonzero-after-accept cases. Independent fixed-daemon inventories and host ++mount-namespace checks prove all exact resources and plaintext absent. Reaper ++outcomes are retained; initial sandbox receipt lookup failure is preserved and ++superseded by device-bound host verification. R1–R5 failures remain retained. ++ ++Evidence: Custodian docs/changes/netkingdom-target-2026-10-16/ ++identity-restore-adapter-next/revisions/r6/, actual-fixture-r6-all.json and ++coordinator-r6-all-review.json. Exact source/dependency hashes and candidate ++manifest bind the proof. This uses generated test keys only; it proves neither ++actual owner-held recovery, human MFA, downstream reconnect nor native authority. ++ ++## Prepare the exact owner-held KeyCape recovery attempt ++ ++```task ++id: NK-WP-0049-T33 ++status: todo ++priority: high ++assignee: codex ++depends_on: [NK-WP-0049-T32] ++``` ++ ++**Accountable worker:** NetKingdom codex with KeyCape and selected consumer owners. ++Prepare the exact archive/manifest, original issuer/TLS/trust and executable ++bindings, selected current registration and dependency graph using public metadata. ++Use the existing independently held archive-key and owner-controlled TLS custody ++paths; never put secret values in the packet. Validate the attended launcher and ++its full dependency closure, execution deadline, recovery obligations and safe ++receipt path before asking for any owner-held input. Bind approved source revision ++and rollback/abort limits through the existing owner process. ++ ++**Done when:** reviewed executable command and metadata-only admission packet ++are ready for the existing attended custody path; prerequisites and independent ++receipt verification are explicit. T18 still owns actual execution, original ++consumer continuity and acceptance. T19 retains retained-factor MFA. This task ++neither authorizes a production consumer redirect nor creates a new client grant. ++ + ## Accept remaining recovery and caller evidence and close the G08 handoff + + ```task diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-final-record-commit.json b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-final-record-commit.json new file mode 100644 index 00000000..0f37535e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-final-record-commit.json @@ -0,0 +1,5 @@ +{ + "exit_code": 0, + "stdout": "[main 878abc9] docs(recovery): retain isolated source publication and task bindings\n 1 file changed, 13 insertions(+)\n", + "stderr": "{\"blocked_blob_ids\": [], \"scanned_blobs\": 1, \"scanner_sha256\": \"88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\", \"scanner_version\": \"8.30.1\", \"scope\": \"changed-staged-blobs\", \"status\": \"clean\"}\n" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.index b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.index new file mode 100644 index 00000000..ea9ccd0b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.index @@ -0,0 +1,100 @@ + +# Custodian Brief — net-kingdom + +**Domain:** infotech +**Last synced:** 2026-10-15 17:24 UTC +**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* + +## Active Workstreams + +### Implement deterministic posture and evidence feedback +Progress: 4/5 done | workplan_id: `9d7b04f9-3803-5613-b7a5-8bd606c77f5a` + +**Open tasks:** +- ! Obtain audit-core freshness adoption `7d2029d2` + *(wait: audit-core tenancy.yaml must publish structured authoritative owner, observation/expiry and replacement-action metadata for WH-ENG-20260822-AUDIT-E2-03. Re-evaluate with explicit current as-of; later recovery prose does not renew E2.)* + +### Publish the NetKingdom emission-cadence security profile +Progress: 4/5 done | workplan_id: `04685f94-1991-5e62-80d2-5669913e99fc` + +**Open tasks:** +- ! Bind and hand off the published contract `e3fbc8b8` + *(wait: Approval Engine and Qonto owner declarations now pass the published schema and supplied-inventory profile; local-identity incompatibility is retained. Requires actual sender cadence mappings, lifecycle delivery/readback/reconciliation and Kings Guard operational feed evidence.)* + +### Reconcile reef placement and security-zone canon dependencies +Progress: 3/6 done | workplan_id: `965ad365-6b81-50a1-a2a3-2d0c1fcce0b4` + +**Open tasks:** +- ! T02 — Extend provider declarations to reefs `5b35b646` + *(wait: Requires repo-manager acceptance of the reef provider carrier/schema, then railiance-infra authored ceilings, conditions and evidence in the authoritative reef declaration.)* +- ! T03 — Reconcile reef ceilings mechanically `e3f0bb0f` + *(wait: Mechanical join of reef ceilings to consumer V declarations waits on T02.)* +- ! T06 — Resolve the `DataClassification` mismatch `bf8b3e1d` + *(wait: Requires InfoTechCanon ruling on public disclosure classification versus synthetic provenance, then ops-warden authoritative dataclass_floor mapping and tests; no public-to-synthetic alias.)* + +### Remove bootstrap secret output and verify separated recovery custody +Progress: 1/3 done | workplan_id: `17d315f0-829e-57c7-85cc-3a7914152804` + +**Open tasks:** +- ! Verify owner-approved recovery and current native callers `d68cd4d3` + *(wait: Source caller reconciliation and exact owner-scoped isolated recovery ceremony are required; no live bootstrap or root retrieval is authorized by this source receipt.)* +- ► Reconcile other legacy custody callers and root-output boundaries `479d6970` + +### Enterprise Federation & SAML — Expanded-Mode Keycloak Identity Broker +Progress: 1/9 done | workplan_id: `1075448f-d533-5f9e-94b7-c3adfe151a07` + +**Open tasks:** +- ! Admit the database consumer `2fe6f100` + *(wait: ADR-0009 adoption trigger absent; then railiance-platform/rapp-postgres must admit the database consumer and return backup/restore evidence.)* +- ! Package the broker deployment `32d1411b` + *(wait: ADR-0009 adoption trigger and named package/runtime owner absent; requires T02 database admission and governed OpenBao/ESO role plus immutable issuer assignment.)* +- ! Integrate an upstream identity provider `6697a994` + *(wait: Named tenant/upstream IdP owner/federation need absent; requires admitted broker and owner-reviewed tenant-scoped claim mappings plus actual login evidence.)* +- ! Define federated assurance `d845380d` + *(wait: Adoption trigger absent; requires per-IdP assurance trust mapping and reviewed privileged step-up delivery from issuer/package and access-engine owners.)* +- ! Verify IAM conformance and coexistence `9cb7fd93` + *(wait: Requires admitted live broker, canonical tenant issuer selection and dedicated test-client conformance/coexistence evidence; adoption trigger absent.)* +- ! Enforce tenant and platform boundaries `38998c68` + *(wait: Requires tenant-engine tier/issuer ownership and access-engine boundary/freshness enforcement evidence after adoption trigger and broker admission.)* +- ! Prove recovery and audit delivery `e5f44ddc` + *(wait: Requires admitted database/broker, named recovery and audit owners, off-host restore and observed audit/break-glass receipts; adoption trigger absent.)* +- … and 1 more open tasks + +### Establish SecurityCaseTypes so that known security cases are light +Progress: 7/9 done | workplan_id: `e31f3f9d-0748-5137-b0a3-7b3ca46a66eb` + +**Open tasks:** +- ! T05 — Acknowledge the type through informed-decision `d9b26bcf` + *(wait: Type1.2 architecture has a confirmed source-only founder acknowledgment; canonical1.1 remains unchanged/default and the1.2 reference case stays proposed. Needs explicit version selection/reference-source integration without normal admission bypass, closed owner adapters plus global/native receipt verifier (WARDEN-WP-0044-T03), independently verified six-object structural materialization and CCR42 claims/created-only provenance, exact broker/case/source reconciliation, then separately authorized native mint/delivery and attended registry/consumer verification. Source acknowledgment, a bundle or local tests do not prove implemented/materialized/live status.)* +- ! T06 — First KnownSecurityCase: Forgejo `c3c9c3ae` + *(wait: The next types follow once the first one is proven end to end.)* + +### Register Playwright human and agent consumers through governed identity delivery +Progress: 1/3 done | workplan_id: `06ba98d5-609e-5aaa-9bf7-4c4f4460e377` + +**Open tasks:** +- ! Verify issuer and real consumer acceptance `5a3aaf57` + *(wait: Await owner-scoped registry/custody activation, native cluster authentication and individual transport, then real human and agent acceptance under CUST-WP-0080-T05.)* +- ► Deliver custody integration and guarded registration activation `a9dadd07` + +### Define an execution-attribution receipt for Railiance runs +Progress: 1/2 done | workplan_id: `e2533f3a-aa43-59b3-bff3-8e64b6149487` + +**Open tasks:** +- ! Agree the evidence holder and schema with audit-core and Railiance `963120c1` + *(wait: Audit-core accepted evidence custody and published field/admission requirements in AUDIT-WP-0014-T01. Still needs Railiance owner confirmation of exact emitter and wire binding before canonical schema/validator implementation, followed by actual emission/readback and attribution/clock/unknown-case acceptance.)* + +### Cut over NetKingdom identity to railiance01 and retire CoulombCore +Progress: 7/8 done | workplan_id: `d76ddccc-00c8-548a-b141-2cd660fa38da` + +**Open tasks:** +- ! T08 - Final deletion and closure `42a3b4c0` + *(wait: Founder 2026-10-11: coulombcore is retired and the machine reused (D4 batch approved). Order: host-side inventory (containers, on-disk manifests; no kube API, certs expired 2026-08-31), then stop k3s for good and wipe for reuse. Executes after Sol's passing railiance01 restore/restart drill receipt. Never rotate certs or restart k3s there first.)* + +--- +## MCP Orientation (when available) + +If the state-hub MCP server is reachable, call: +`get_domain_summary("infotech")` +This provides richer cross-domain context. +If the MCP call fails, use this file as your orientation source. diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.index-record b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.index-record new file mode 100644 index 00000000..f072587f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.index-record @@ -0,0 +1 @@ +100644 ea9ccd0b2bd2cc1ec89723b35c44c53f0d79ba98 0 .custodian-brief.md diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.worktree b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.worktree new file mode 100644 index 00000000..18bd5dff --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-publication-backup/brief.worktree @@ -0,0 +1,85 @@ + +# Custodian Brief — net-kingdom + +**Domain:** infotech +**Last synced:** 2026-10-16 19:19 UTC +**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* + +## Active Workstreams + +### Publish the NetKingdom emission-cadence security profile +Progress: 5/6 done | workplan_id: `04685f94-1991-5e62-80d2-5669913e99fc` + +**Open tasks:** +- ► Bind and hand off the published contract `e3fbc8b8` + +### Register Playwright human and agent consumers through governed identity delivery +Progress: 1/3 done | workplan_id: `06ba98d5-609e-5aaa-9bf7-4c4f4460e377` + +**Open tasks:** +- ! Verify issuer and real consumer acceptance `5a3aaf57` + *(wait: Await owner-scoped registry/custody activation, native cluster authentication and individual transport, then real human and agent acceptance under CUST-WP-0080-T05.)* +- ► Deliver custody integration and guarded registration activation `a9dadd07` + +### Remove bootstrap secret output and verify separated recovery custody +Progress: 22/31 done | workplan_id: `17d315f0-829e-57c7-85cc-3a7914152804` + +**Open tasks:** +- ! Accept remaining recovery and caller evidence and close the G08 handoff `d68cd4d3` + *(wait: Await the separately tracked native service proofs, current/legacy caller dispositions and residual-scope decisions; archive recovery, backup activation, independent outage retrieval and guide publication are complete.)* +- ! Resolve historical credential and configuration-preserving rotation dispositions `3ea6bf00` + *(wait: Await the caller/consumer ledger, current replacement-lane acceptance and explicit credential-owner dispositions; existing exposure-rotation deferral is unchanged.)* +- ! Verify current caller and provider admission at their actual boundaries `77aa42be` + *(wait: Await the current-caller ledger and exact per-caller owner admission/test inputs; the backup credential grants do not admit unrelated callers.)* +- ! Prove retained privacyIDEA token decryption and existing-token MFA `6f8af1a7` + *(wait: LLDAP T16, token-material T31 and audit T20 passed; await the exact isolated MFA adapter and owner-selected retained-token/factor inputs.)* +- ! Prove restored KeyCape issuer, policy and consumer continuity `141dd773` + *(wait: LLDAP recovery T16 passed; await the exact isolated KeyCape adapter/procedure and retained-token MFA proof for the selected flow.)* +- ! Prove restored Authelia storage and OIDC continuity `b47f9975` + *(wait: LLDAP recovery T16 passed; await the exact isolated Authelia adapter/procedure and private authentication-flow inputs from T15.)* +- ► Map current callers, owners and native acceptance gaps `479d6970` +- … and 2 more open tasks + +### Establish SecurityCaseTypes so that known security cases are light +Progress: 7/9 done | workplan_id: `e31f3f9d-0748-5137-b0a3-7b3ca46a66eb` + +**Open tasks:** +- ! T05 — Acknowledge the type through informed-decision `d9b26bcf` + *(wait: Type1.2 architecture has a confirmed source-only founder acknowledgment; canonical1.1 remains unchanged/default and the1.2 reference case stays proposed. Needs explicit version selection/reference-source integration without normal admission bypass, closed owner adapters plus global/native receipt verifier (WARDEN-WP-0044-T03), independently verified six-object structural materialization and CCR42 claims/created-only provenance, exact broker/case/source reconciliation, then separately authorized native mint/delivery and attended registry/consumer verification. Source acknowledgment, a bundle or local tests do not prove implemented/materialized/live status.)* +- ! T06 — First KnownSecurityCase: Forgejo `c3c9c3ae` + *(wait: The next types follow once the first one is proven end to end.)* + +### Define an execution-attribution receipt for Railiance runs +Progress: 1/2 done | workplan_id: `e2533f3a-aa43-59b3-bff3-8e64b6149487` + +**Open tasks:** +- ! Agree the evidence holder and schema with audit-core and Railiance `963120c1` + *(wait: Audit-core accepted evidence custody and published field/admission requirements in AUDIT-WP-0014-T01. Still needs Railiance owner confirmation of exact emitter and wire binding before canonical schema/validator implementation, followed by actual emission/readback and attribution/clock/unknown-case acceptance.)* + +### Enterprise Federation & SAML — Expanded-Mode Keycloak Identity Broker +Progress: 1/9 done | workplan_id: `1075448f-d533-5f9e-94b7-c3adfe151a07` + +**Open tasks:** +- ! Admit the database consumer `2fe6f100` + *(wait: ADR-0009 adoption trigger absent; then railiance-platform/rapp-postgres must admit the database consumer and return backup/restore evidence.)* +- ! Package the broker deployment `32d1411b` + *(wait: ADR-0009 adoption trigger and named package/runtime owner absent; requires T02 database admission and governed OpenBao/ESO role plus immutable issuer assignment.)* +- ! Integrate an upstream identity provider `6697a994` + *(wait: Named tenant/upstream IdP owner/federation need absent; requires admitted broker and owner-reviewed tenant-scoped claim mappings plus actual login evidence.)* +- ! Define federated assurance `d845380d` + *(wait: Adoption trigger absent; requires per-IdP assurance trust mapping and reviewed privileged step-up delivery from issuer/package and access-engine owners.)* +- ! Verify IAM conformance and coexistence `9cb7fd93` + *(wait: Requires admitted live broker, canonical tenant issuer selection and dedicated test-client conformance/coexistence evidence; adoption trigger absent.)* +- ! Enforce tenant and platform boundaries `38998c68` + *(wait: Requires tenant-engine tier/issuer ownership and access-engine boundary/freshness enforcement evidence after adoption trigger and broker admission.)* +- ! Prove recovery and audit delivery `e5f44ddc` + *(wait: Requires admitted database/broker, named recovery and audit owners, off-host restore and observed audit/break-glass receipts; adoption trigger absent.)* +- … and 1 more open tasks + +--- +## MCP Orientation (when available) + +If the state-hub MCP server is reachable, call: +`get_domain_summary("infotech")` +This provides richer cross-domain context. +If the MCP call fails, use this file as your orientation source. diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-commit.json b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-commit.json new file mode 100644 index 00000000..042a6ac9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-commit.json @@ -0,0 +1,5 @@ +{ + "exit_code": 0, + "stdout": "[main 5fcd595] feat(recovery): add isolated KeyCape restore and loss cleanup proof\n 9 files changed, 1985 insertions(+), 2 deletions(-)\n create mode 100644 tests/isolated-keycape-native-fixture-manifest.json\n create mode 100644 tests/run_isolated_keycape_native_fixture.py\n create mode 100644 tests/test_isolated_keycape_fixture_plan.py\n create mode 100644 tests/test_isolated_keycape_recovery.py\n create mode 100644 tests/test_isolated_keycape_session.py\n create mode 100644 tools/bootstrap-recovery/isolated-keycape-session.py\n create mode 100644 tools/bootstrap-recovery/isolated-keycape.py\n create mode 100644 tools/bootstrap-recovery/run-isolated-keycape.sh\n", + "stderr": "{\"blocked_blob_ids\": [], \"scanned_blobs\": 9, \"scanner_sha256\": \"88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\", \"scanner_version\": \"8.30.1\", \"scope\": \"changed-staged-blobs\", \"status\": \"clean\"}\n" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.exit.json b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.exit.json new file mode 100644 index 00000000..43623d14 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.exit.json @@ -0,0 +1 @@ +{"exit_code": 0} diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.stderr.txt b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.stderr.txt new file mode 100644 index 00000000..3345563f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.stderr.txt @@ -0,0 +1 @@ +WARN: git commit failed for writeback: Generated-only commits rejected: staged diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.stdout.json b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.stdout.json new file mode 100644 index 00000000..889aad74 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/r7-source-sync.stdout.json @@ -0,0 +1,375 @@ +{ + "repo_slug": "net-kingdom", + "repo_path": "/home/worsch/net-kingdom", + "source_revision": "5fcd5952add1c0167116094298a1d5feaa60cf30", + "source_revision_reason": null, + "issues": [ + { + "severity": "WARN", + "check_id": "C-24", + "message": "Repo classification advisory: capability_tag 'security' is not a recommended family tag (allowed, check for synonym)", + "file_path": "", + "db_id": "", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-35", + "message": "Repo Manager warning: workplans/: repository uses more than one workplan prefix: ['NET-WP', 'NK-WP']", + "file_path": "workplans/", + "db_id": "", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-33", + "message": "WORK-RECORDS.md is missing or stale \u2014 regenerate via fix-consistency (CUST-WP-0061-T04 generated work-record index)", + "file_path": "WORK-RECORDS.md", + "db_id": "", + "file_value": "", + "db_value": "", + "fixable": true + }, + { + "severity": "WARN", + "check_id": "C-37", + "message": "Residual workplan 'nk-wp-0011' has no origin/origin_ref provenance (not invented)", + "file_path": "workplans/NK-WP-0011-enterprise-federation-saml.md", + "db_id": "1075448f-d533-5f9e-94b7-c3adfe151a07", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'USER-WP-0020' is not linked to State Hub", + "file_path": "workplans/NK-WP-0022-railiance01-identity-cutover-and-coulombcore-retirement.md", + "db_id": "", + "file_value": "USER-WP-0020", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'KEY-WP-0004' is not linked to State Hub", + "file_path": "workplans/NK-WP-0022-railiance01-identity-cutover-and-coulombcore-retirement.md", + "db_id": "", + "file_value": "KEY-WP-0004", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'USER-WP-0020' is not linked to State Hub", + "file_path": "workplans/NK-WP-0023-user-engine-portal-platform-integration.md", + "db_id": "", + "file_value": "USER-WP-0020", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'KEY-WP-0004' is not linked to State Hub", + "file_path": "workplans/NK-WP-0023-user-engine-portal-platform-integration.md", + "db_id": "", + "file_value": "KEY-WP-0004", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'USER-WP-0021' is not linked to State Hub", + "file_path": "workplans/NK-WP-0024-user-engine-portal-integration-expansion.md", + "db_id": "", + "file_value": "USER-WP-0021", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'FLEX-WP-0009' is not linked to State Hub", + "file_path": "workplans/NK-WP-0024-user-engine-portal-integration-expansion.md", + "db_id": "", + "file_value": "FLEX-WP-0009", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'AUDIT-WP-0003' is not linked to State Hub", + "file_path": "workplans/NK-WP-0024-user-engine-portal-integration-expansion.md", + "db_id": "", + "file_value": "AUDIT-WP-0003", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'EMAIL-WP-0004' is not linked to State Hub", + "file_path": "workplans/NK-WP-0024-user-engine-portal-integration-expansion.md", + "db_id": "", + "file_value": "EMAIL-WP-0004", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'USER-WP-0022' is not linked to State Hub", + "file_path": "workplans/NK-WP-0025-public-self-registration-and-application-jit.md", + "db_id": "", + "file_value": "USER-WP-0022", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'KEY-WP-0008' is not linked to State Hub", + "file_path": "workplans/NK-WP-0025-public-self-registration-and-application-jit.md", + "db_id": "", + "file_value": "KEY-WP-0008", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'CSOC-WP-0003' is not linked to State Hub", + "file_path": "workplans/NK-WP-0025-public-self-registration-and-application-jit.md", + "db_id": "", + "file_value": "CSOC-WP-0003", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-37", + "message": "Residual workplan 'nk-wp-0027' has no origin/origin_ref provenance (not invented)", + "file_path": "workplans/NK-WP-0027-reef-placement-reconciliation.md", + "db_id": "965ad365-6b81-50a1-a2a3-2d0c1fcce0b4", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-37", + "message": "Residual workplan 'nk-wp-0031' has no origin/origin_ref provenance (not invented)", + "file_path": "workplans/NK-WP-0031-deterministic-posture-feedback.md", + "db_id": "9d7b04f9-3803-5613-b7a5-8bd606c77f5a", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-37", + "message": "Residual workplan 'nk-wp-0040' has no origin/origin_ref provenance (not invented)", + "file_path": "workplans/NK-WP-0040-execution-attribution-receipt.md", + "db_id": "e2533f3a-aa43-59b3-bff3-8e64b6149487", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Task dependency target 'NK-WP-0049-T32' of task 'NK-WP-0049-T18' is not linked to State Hub", + "file_path": "workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md#NK-WP-0049-T18", + "db_id": "", + "file_value": "NK-WP-0049-T32", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Task dependency target 'NK-WP-0049-T33' of task 'NK-WP-0049-T18' is not linked to State Hub", + "file_path": "workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md#NK-WP-0049-T18", + "db_id": "", + "file_value": "NK-WP-0049-T33", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-41", + "message": "Task wait-qualifier drift 'NK-WP-0049-T18': file={'needs_human': False, 'blocking_reason': 'Source and synthetic KeyCape lifecycle proof are delivered in T32; await exact owner-held recovery attempt preparation T33, selected consumer acceptance and retained-token MFA for the selected flow.', 'decision_id': None, 'blocked_on': None, 'wait_until': None} db={'needs_human': False, 'blocking_reason': 'LLDAP recovery T16 passed; await the exact isolated KeyCape adapter/procedure and retained-token MFA proof for the selected flow.', 'decision_id': None, 'blocked_on': None, 'wait_until': None} (file wins)", + "file_path": "workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md#NK-WP-0049-T18", + "db_id": "141dd773-c1fa-56c1-a852-bbe2372e5125", + "file_value": "{\"blocked_on\": null, \"blocking_reason\": \"Source and synthetic KeyCape lifecycle proof are delivered in T32; await exact owner-held recovery attempt preparation T33, selected consumer acceptance and retained-token MFA for the selected flow.\", \"decision_id\": null, \"needs_human\": false, \"wait_until\": null}", + "db_value": "{\"blocked_on\": null, \"blocking_reason\": \"LLDAP recovery T16 passed; await the exact isolated KeyCape adapter/procedure and retained-token MFA proof for the selected flow.\", \"decision_id\": null, \"needs_human\": false, \"wait_until\": null}", + "fixable": true + }, + { + "severity": "WARN", + "check_id": "C-11", + "message": "Task 'NK-WP-0049-T32' has no state_hub_task_id", + "file_path": "workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md#NK-WP-0049-T32", + "db_id": "", + "file_value": "", + "db_value": "", + "fixable": true + }, + { + "severity": "WARN", + "check_id": "C-11", + "message": "Task 'NK-WP-0049-T33' has no state_hub_task_id", + "file_path": "workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md#NK-WP-0049-T33", + "db_id": "", + "file_value": "", + "db_value": "", + "fixable": true + }, + { + "severity": "WARN", + "check_id": "C-20", + "message": "Workplan dependency target 'RAIL-PL-WP-0002' is not linked to State Hub", + "file_path": "workplans/archived/260702-NET-WP-0017-it-security-readiness-for-user-onboarding.md", + "db_id": "", + "file_value": "RAIL-PL-WP-0002", + "db_value": "", + "fixable": false + }, + { + "severity": "INFO", + "check_id": "C-08", + "message": "Retired DB workstream 'nk-wp-0035@retired-20260908' (id=064c5e8b\u2026, status=archived) has no backing workplan file \u2014 projection tombstone, not live work", + "file_path": "", + "db_id": "064c5e8b-7852-4d7f-a5fa-e26e406ab8e0", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "INFO", + "check_id": "C-08", + "message": "Retired DB workstream 'adhoc-2026-08-14@retired-20260827' (id=a5097121\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work", + "file_path": "", + "db_id": "a5097121-1461-59f8-81fa-e6ecef527337", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "INFO", + "check_id": "C-08", + "message": "Retired DB workstream 'adhoc-2026-08-23@retired-20260827' (id=e25ba5fa\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work", + "file_path": "", + "db_id": "e25ba5fa-eaae-5039-8d93-a69c6d837fc4", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "INFO", + "check_id": "C-08", + "message": "Retired DB workstream 'net-kingdom-adhoc-2026-06-14@retired-20260827' (id=c6f3d6bf\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work", + "file_path": "", + "db_id": "c6f3d6bf-4916-490d-96ce-092d2776d7e7", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "INFO", + "check_id": "C-08", + "message": "Retired DB workstream 'net-kingdom-adhoc-2026-07-02@retired-20260827' (id=67c1c7ac\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work", + "file_path": "", + "db_id": "67c1c7ac-d8b1-41dc-a81a-4e43f1afd068", + "file_value": "", + "db_value": "", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-26", + "message": "Workplan id 'NET-WP-0020' uses prefix 'NET-WP' but repo canonical prefix is 'NK-WP' \u2014 new plans must conform", + "file_path": "workplans/NET-WP-0020-openbao-unseal-custody-and-ssh-automation.md", + "db_id": "", + "file_value": "NET-WP-0020", + "db_value": "NK-WP", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-26", + "message": "Workplan id 'NET-WP-0015' uses prefix 'NET-WP' but repo canonical prefix is 'NK-WP' \u2014 new plans must conform", + "file_path": "workplans/archived/260603-NET-WP-0015-platform-root-custody-and-openbao-identity-bootstrap.md", + "db_id": "", + "file_value": "NET-WP-0015", + "db_value": "NK-WP", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-26", + "message": "Workplan id 'NET-WP-0016' uses prefix 'NET-WP' but repo canonical prefix is 'NK-WP' \u2014 new plans must conform", + "file_path": "workplans/archived/260603-NET-WP-0016-guided-security-bootstrap-experience.md", + "db_id": "", + "file_value": "NET-WP-0016", + "db_value": "NK-WP", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-26", + "message": "Workplan id 'NET-WP-0018' uses prefix 'NET-WP' but repo canonical prefix is 'NK-WP' \u2014 new plans must conform", + "file_path": "workplans/archived/260604-NET-WP-0018-bootstrap-automation-and-rebuild-readiness.md", + "db_id": "", + "file_value": "NET-WP-0018", + "db_value": "NK-WP", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-26", + "message": "Workplan id 'NET-WP-0017' uses prefix 'NET-WP' but repo canonical prefix is 'NK-WP' \u2014 new plans must conform", + "file_path": "workplans/archived/260702-NET-WP-0017-it-security-readiness-for-user-onboarding.md", + "db_id": "", + "file_value": "NET-WP-0017", + "db_value": "NK-WP", + "fixable": false + }, + { + "severity": "WARN", + "check_id": "C-26", + "message": "Workplan id 'NET-WP-0019' uses prefix 'NET-WP' but repo canonical prefix is 'NK-WP' \u2014 new plans must conform", + "file_path": "workplans/archived/260702-NET-WP-0019-t06-adjacent-user-lifecycle-dry-run-polish.md", + "db_id": "", + "file_value": "NET-WP-0019", + "db_value": "NK-WP", + "fixable": false + } + ], + "fixes_applied": [ + "bindings: synced 64/64", + "C-17 cleared: pushed 1 backlogged commit(s)", + "C-33 fixed: regenerated WORK-RECORDS.md", + "C-41 fixed: task 141dd773\u2026 wait qualifiers synced", + "C-11 fixed: task 'NK-WP-0049-T32' \u2192 cd78dbb7\u2026", + "C-11 fixed: task 'NK-WP-0049-T33' \u2192 56c8d469\u2026", + "brief: .custodian-brief.md updated", + "push: pushed" + ], + "summary": { + "fail": 0, + "automation_error": 0, + "warn": 30, + "info": 5 + }, + "result": "warn" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r1/isolated-keycape.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r1/isolated-keycape.py new file mode 100644 index 00000000..c8b6ab66 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r1/isolated-keycape.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""T18 native adapter for an owner-admitted, already restored isolated session. + +Called after identity-bundle.restore, never with an unvalidated directory. This +module does not provision TLS, grant credentials, or claim downstream acceptance. +The existing decrypt/tmpfs wrapper remains responsible for archive cleanup. +""" +import base64 +import hashlib +import importlib.util +import json +import io +import secrets +import tarfile +import re +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent +IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010' +LABEL = 'net-kingdom.recovery-run' + + +class Refused(Exception): + pass + + +def load_bundle(): + spec = importlib.util.spec_from_file_location('identity_bundle', HERE / 'identity-bundle.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def private_payload(root, manifest, expected_manifest_sha256): + """Bind the complete validated archive before selecting KeyCape material. + + Private hashes never enter the public receipt. The digest is supplied by the + existing private archive admission, not inferred from an untrusted manifest. + """ + encoded = json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode() + if hashlib.sha256(encoded).hexdigest() != expected_manifest_sha256: + raise Refused() + entries = manifest['files'] + if len(entries) != len({e['path'] for e in entries}): + raise Refused() + values = {} + bundle = load_bundle() + for entry in entries: + if not bundle.allowed(entry['path'], manifest['profile']): + raise Refused() + path = root / entry['path'] + bundle.private(path) + value = path.read_bytes() + if len(value) != entry['bytes'] or hashlib.sha256(value).hexdigest() != entry['sha256']: + raise Refused() + values[entry['path']] = value + deployment = json.loads(values['resources/sso/deployment/keycape.json']) + main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'keycape') + if main['image'] != IMAGE: + # Mutable-tag/historical mismatches need a separate explicit owner admission. + raise Refused() + secret = json.loads(values['resources/sso/secret/keycape-config.json'])['data'] + files = {'/etc/keycape/' + name: base64.b64decode(secret[name], validate=True) + for name in ('config.yaml', 'key.pem')} + for path, value in values.items(): + if path.startswith('data/keycape/'): + relative = path.removeprefix('data/keycape/') + files['/var/lib/keycape-policy/' + relative] = value + if '/var/lib/keycape-policy/policy.json' not in files: + raise Refused() + # Resolve original delivered environment, including all referenced secrets. + env = {} + for item in main.get('env', []): + if item['name'] in env: + raise Refused() + if 'value' in item: + env[item['name']] = item['value'] + else: + ref = item['valueFrom']['secretKeyRef'] + source = json.loads(values['resources/sso/secret/' + ref['name'] + '.json'])['data'] + env[item['name']] = base64.b64decode(source[ref['key']], validate=True).decode() + if env.get('KEYCAPE_CONFIG') != '/etc/keycape/config.yaml' or env.get('KEYCAPE_POLICY_PATH') != '/var/lib/keycape-policy/policy.json': + raise Refused() + return {'files': {p: base64.b64encode(v).decode() for p, v in files.items()}, 'env': env} + + +def command(argv, **kwargs): + maximum = kwargs.pop('max_output', 32768) + result = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=kwargs.pop('timeout', 180), **kwargs) + if result.returncode or len(result.stdout) > maximum: + raise Refused() + return result.stdout + + +def inspect_session(session): + """No credential environment is read by Docker inspect or returned to caller.""" + if not re.fullmatch(r'[a-f0-9]{32}', session['run_id']): + raise Refused() + names = session['containers'] + if not names or len(names) > 12 or len(set(names)) != len(names): + raise Refused() + for name in names: + if not re.fullmatch(r'nk49-kc-' + session['run_id'] + r'-[a-z0-9-]{1,24}', name): + raise Refused() + if session['worker'] not in names or session['issuer'] not in names: + raise Refused() + import os + contexts=json.loads(command(['docker','context','inspect'])) + endpoints=[contexts[0]['Endpoints']['docker']['Host'],os.environ.get('DOCKER_HOST','unix:///var/run/docker.sock')] + if not all(x.startswith('unix://') for x in endpoints):raise Refused() + info = json.loads(command(['docker', 'info', '--format', '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}'])) + if info != {'ID': '74f93783-6cef-43a8-a3c4-f62586cd3bc1', 'Name': 'bnt-lap001', 'OSType': 'linux'}: + raise Refused() + # The worker is the anchor; all dependencies/TLS/issuer share its network-none namespace. + anchor = command(['docker', 'container', 'inspect', session['worker'], '--format', '{{.Id}}']).decode().strip() + for name in names: + obj = json.loads(command(['docker', 'container', 'inspect', name, '--format', + '{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"labels":{{json .Config.Labels}},"image":{{json .Config.Image}},"user":{{json .Config.User}}}'])) + host = obj['host'] + if (obj['labels'].get(LABEL) != session['run_id'] or not obj['user'] or obj['user'].split(':')[0] in ('0', 'root') + or not host['ReadonlyRootfs'] or host['Privileged'] or host.get('Binds') or host.get('PortBindings') + or host.get('PidMode') or host.get('IpcMode') == 'host' + or host.get('UTSMode') or host.get('UsernsMode') == 'host' or host.get('CgroupnsMode') == 'host' + or host.get('CapAdd') or host.get('Devices') or host.get('DeviceRequests') or host.get('VolumesFrom') + or host.get('CapDrop') != ['ALL'] + or host.get('SecurityOpt') not in (['no-new-privileges'], ['no-new-privileges:true']) + or any(m['Type'] != 'tmpfs' for m in obj['mounts']) + or host['NetworkMode'] != ('none' if name == session['worker'] else 'container:' + anchor) + or obj['image'] != session['images'][name] or '@sha256:' not in obj['image']): + raise Refused() + if session['images'][session['issuer']] != IMAGE: + raise Refused() + + +def container_file(container, path): + # Docker streams one tar member into memory; never materialize private files. + raw = command(['docker', 'cp', container + ':' + path, '-'], max_output=16*1024*1024) + with tarfile.open(fileobj=io.BytesIO(raw), mode='r:') as archive: + members = archive.getmembers() + if len(members) != 1 or not members[0].isfile() or members[0].size > 8*1024*1024: + raise Refused() + return archive.extractfile(members[0]).read() + + +JWKS_PROBE = r""" +import base64,ipaddress,json,socket,ssl,sys,urllib.request +from urllib.parse import urlsplit +p=json.load(sys.stdin);issuer=p['issuer'];origin=urlsplit(issuer) +assert origin.scheme=='https' +assert all(ipaddress.ip_address(x[4][0]).is_loopback for x in socket.getaddrinfo(origin.hostname,origin.port or 443,type=socket.SOCK_STREAM)) +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):return None +opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect(), + urllib.request.HTTPSHandler(context=ssl.create_default_context(cadata=p['trust']))) +def get(url): + u=urlsplit(url) + assert (u.scheme,u.hostname,u.port or 443)==(origin.scheme,origin.hostname,origin.port or 443) + assert not u.username and not u.password and not u.fragment + with opener.open(url,timeout=10) as response: + raw=response.read(1024*1024+1);assert len(raw)<=1024*1024 + return json.loads(raw) +d=get(issuer.rstrip('/')+'/.well-known/openid-configuration');assert d['issuer']==issuer +keys=get(d['jwks_uri'])['keys'] +def integer(value):return int.from_bytes(base64.urlsafe_b64decode(value+'='*(-len(value)%4)),'big') +assert any(k.get('kty')=='RSA' and integer(k['n'])==int(p['n']) and integer(k['e'])==int(p['e']) for k in keys) +print('{"original_public_signing_key_in_live_jwks":true}') +""" + + +def check_jwks(session, admission, public_numbers): + trust=container_file(session['issuer'],admission['trust_path']).decode() + request={'issuer':admission['issuer'],'trust':trust,'n':str(public_numbers.n),'e':str(public_numbers.e)} + result=json.loads(command(['docker','exec','-i',session['worker'],'python3','-c',JWKS_PROBE],input=json.dumps(request).encode())) + if result != {'original_public_signing_key_in_live_jwks': True}:raise Refused() + + +def run_verifier(session, admission, payload): + issuer = session['issuer'] + from urllib.parse import urlsplit + import ipaddress + address = urlsplit(admission['issuer']) + if (address.scheme != 'https' or not address.hostname or address.username or address.password + or address.query or address.fragment): + raise Refused() + hosts = container_file(issuer, '/etc/hosts').decode().splitlines() + matched = [row.split('#')[0].split() for row in hosts] + matched = [row for row in matched if len(row)>1 and address.hostname in row[1:]] + if not matched or any(not ipaddress.ip_address(row[0]).is_loopback for row in matched): + raise Refused() + if hashlib.sha256(container_file(issuer, '/keycape')).hexdigest() != admission['verifier_sha256']: + raise Refused() + if hashlib.sha256(container_file(issuer, admission['trust_path'])).hexdigest() != admission['trust_sha256']: + raise Refused() + # Capture private environment only in owner process memory, never receipt. + environment = json.loads(command(['docker','container','inspect',issuer,'--format','{{json .Config.Env}}'])) + env = dict(item.split('=',1) for item in environment) + if env.get('SSL_CERT_FILE') != admission['trust_path'] or any(k.lower().endswith('_proxy') and v for k,v in env.items()): + raise Refused() + for name,value in payload['env'].items(): + if env.get(name) != value: + raise Refused() + files = {k:base64.b64decode(v,validate=True) for k,v in payload['files'].items()} + def equal(): + for path,value in files.items(): + if container_file(issuer,path) != value: + raise Refused() + # Full durable state tree comparison also excludes extra runtime receipts. + raw = command(['docker','cp',issuer+':/var/lib/keycape-policy/.','-'],max_output=32*1024*1024) + found = {} + with tarfile.open(fileobj=io.BytesIO(raw),mode='r:') as archive: + for member in archive: + if member.isdir():continue + name = member.name.removeprefix('./') + if not member.isfile() or name in found or member.size>8*1024*1024: + raise Refused() + found[name]=archive.extractfile(member).read() + if found != {k.removeprefix('/var/lib/keycape-policy/'):v for k,v in files.items() if k.startswith('/var/lib/keycape-policy/')}: + raise Refused() + equal() + # Reading YAML is data parsing only; no archived executable is evaluated. + import yaml + cfg = yaml.safe_load(files['/etc/keycape/config.yaml']) + if cfg['issuer'] != admission['issuer']: + raise Refused() + consumer = admission['consumer'] + client = next(x for x in cfg['clients'] if x['clientId']==consumer['client_id']) + if (client['secretRef']!='env:'+consumer['secret_env'] or client['clientType']!='confidential' + or 'client_credentials' not in client['grantTypes'] or not env.get(consumer['secret_env'])): + raise Refused() + wrong = secrets.token_urlsafe(48) + if wrong == env[consumer['secret_env']]:raise Refused() + args = ['docker','exec','--env','NK49_UNRELATED_NEGATIVE_SECRET='+wrong,issuer,'/keycape','verify-client', + '-issuer',admission['issuer'],'-previous-secret-env','NK49_UNRELATED_NEGATIVE_SECRET'] + flags = {'client_id':'client-id','secret_env':'secret-env','audience':'audience','scope':'scope', + 'subject':'expect-subject','tenant':'expect-tenant','roles':'expect-roles','deny_scope':'deny-scope'} + for name,flag in flags.items():args+=['-'+flag,consumer[name]] + # Native command reports on stderr. Do not return or print its raw response. + result = subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=150) + raw = (result.stdout+result.stderr).decode() + required = ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings', + 'principal_type is service','sub matches the registration','tenant matches the registration','roles present', + 'granted scope carries no excess grant','predecessor secret refused'] + required += ['excess scope refused: '+scope for scope in consumer['deny_scope'].split()] + if result.returncode or any('PASS '+name+'\n' not in raw for name in required) or 'FAIL ' in raw: + raise Refused() + equal() + from cryptography.hazmat.primitives import serialization + key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) + check_jwks(session,admission,key.public_key().public_numbers()) + public=key.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) + return {'native_service_registration_verified':True,'wrong_unrelated_secret_explicitly_refused':True, + 'denied_scopes_refused':True,'original_config_registry_policy_receipts_equal':True, + 'public_signing_spki_sha256':hashlib.sha256(public).hexdigest(), + 'original_https_issuer_and_trust_preserved':True,'original_public_signing_key_in_live_jwks':True} + + +def cleanup(session, receipt=None): + """Attempt every owned cleanup even when one obligation fails. + + Preserve exact safe container names and outcomes so the owner can recover an + uncertain/partial cleanup. An inspect/daemon failure never means absence. + """ + obligations = [] + failed = False + for name in reversed(session['containers']): + row = {'container': name, 'status': 'unverified'} + obligations.append(row) + try: + label = command(['docker', 'container', 'inspect', name, '--format', + '{{index .Config.Labels "' + LABEL + '"}}']).decode().strip() + if label != session['run_id']: + row['status'] = 'ownership_mismatch_not_removed' + failed = True + continue + # A failed remove is followed by a successful explicit inventory; + # interrupted remove may actually have completed. + try: + command(['docker', 'rm', '--force', '--volumes', name]) + except Exception: + pass + remaining = command(['docker','container','ls','--all','--filter', + 'name=^/' + name + '$','--format','{{.Names}}']).decode().splitlines() + if remaining: + row['status'] = 'still_present' + failed = True + else: + row['status'] = 'absence_verified' + except Exception: + failed = True + if receipt is not None: + receipt['container_cleanup_obligations'] = obligations + if failed: + raise Refused() + + + +def native_checks(root, manifest, admission, receipt): + """Owner wrapper provides a reviewed session after existing restore pipeline. + + Admission must bind ciphertext, canonical private manifest digest, executable + and trust bytes, exact image/target list, original issuer and selected consumer. + None of these admission fields grant authority or carry a client secret. + """ + session = admission['session'] + # Validate ownership/containment before permitting either exec or cleanup. + inspect_session(session) + receipt.update(status='failed', source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + ciphertext_sha256=admission['ciphertext_sha256'], production_contacted=False, + human_mfa_proven=False, downstream_consumer_reconnect_proven=False, + container_cleanup_verified=False) + try: + for field in ('ciphertext_sha256', 'manifest_sha256', 'verifier_sha256', 'trust_sha256'): + if not re.fullmatch('[a-f0-9]{64}', admission[field]): + raise Refused() + for field in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope'): + if not isinstance(admission['consumer'].get(field), str) or not admission['consumer'][field].strip(): + raise Refused() + payload = private_payload(root, manifest, admission['manifest_sha256']) + result = run_verifier(session, admission, payload) + required = {'native_service_registration_verified','wrong_unrelated_secret_explicitly_refused', + 'denied_scopes_refused','original_config_registry_policy_receipts_equal', + 'original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks'} + if any(result.get(k) is not True for k in required) or not re.fullmatch('[a-f0-9]{64}', result.get('public_signing_spki_sha256','')): + raise Refused() + # Closed receipt allowlist: never copy arbitrary worker output. + receipt.update({k: True for k in required}) + receipt['public_signing_spki_sha256'] = result['public_signing_spki_sha256'] + receipt['status'] = 'isolated_keycape_native_registration_checked' + finally: + try: + cleanup(session, receipt) + receipt['container_cleanup_verified'] = True + except Exception: + receipt['status'] = 'failed' + raise diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/README.md b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/README.md new file mode 100644 index 00000000..e2b028fd --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/README.md @@ -0,0 +1,144 @@ +# T18 KeyCape recovery adapter candidate, revision 2 + +**Pending root review and native fixture proof. T18 remains open.** Source and +39 passing synthetic tests are prepared in `/tmp/nk49-keycape-adapter-next` and +copied here. No canonical owner file, credential, actual recovery, Docker run, +production route or secret-bearing archive was touched. `candidate.patch` adds +five files to NetKingdom; exact digests/check provenance are in `preparation.json`. + +Files: + +- `isolated-keycape.py`: archive/source/containment/native acceptance checks. +- `isolated-keycape-session.py`: existing-pipeline decryption, private import, + isolated original-image session, HTTPS transport, checks and cleanup. +- `run-isolated-keycape.sh`: attended owner launcher through the existing hidden + custody helper, with target preflight before the backup-key prompt. +- Two focused test files: check-stage negatives and session/cleanup ordering. + +The wrapper uses existing `isolated-lldap`, offline transfer validation, +`verify-identity.custody` and `identity-bundle.restore` APIs. It checks the exact +independently retrieved ciphertext length/digest, custody recipient, complete +archive manifest and original immutable archived KeyCape image. Decryption and +archive restore remain on `/dev/shm`. The private manifest digest comes from +owner admission; it is never emitted publicly. Archive commands/args/envFrom and +non-KeyCape environment overrides are refused. Runtime environment permits only +original KeyCape deliveries, PATH/HOME/HOSTNAME and the exact admitted +SSL_CERT_FILE; duplicate or additional security environment (including +SSL_CERT_DIR/GODEBUG/PYTHONOPTIMIZE/proxy overrides) is refused. Original config/complete static +registry, signing key, policy/receipt tree, factor delivery and original delivered +environment are preserved. Secrets reach Docker by environment **names** only, +with values in the owner subprocess environment, never argv or printed output. + +The original KeyCape image is distroless. Docker's documented +[cp corner cases](https://docs.docker.com/reference/cli/docker/container/cp/#corner-cases) +exclude tmpfs and user-created mounts. Consequently the candidate shares exactly +four Docker **local-driver tmpfs** volumes between the existing pinned Python +helper and original KeyCape issuer. Driver, scope, exact options, owner label, +source, target and read/write flags are inspected before private import. There +are no host directory mounts, disk volumes, new image, JWT verifier or execution +service. The helper's private import and comparison occur inside those exact +mounts; daemon `cp` is used only for public `/keycape` executable readback from +the original image root filesystem. This volume extension still needs native +pinned-image fixture verification before approval. + +Both containers use the fixed local Docker engine, no network interfaces beyond +loopback, readonly root, nonroot UID, no capabilities/devices/privilege/host +namespaces or unadmitted security options. Peers share the exact network-none +anchor. Only namespace-local unprivileged-port sysctl `0` is admitted so the +existing nonroot Python helper can present original HTTPS on loopback443 and +forward to the original KeyCape process on loopback8080. The issuer uses its exact +original image default `/keycape` launch: Entrypoint/Cmd and actual Path/Args are +inspected; the original file/environment bytes and executable digest are bound. +Production dependency addresses remain unreachable. No readiness, LDAP, +Authelia, MFA, tenant-engine or notification endpoint is imitated. A selected +service registration can be checked without claiming `/readyz` passed. + +The owner supplies an existing trusted issuer certificate/private key and trust +bundle through private regular files. No certificate, CA, signing key, client +registration or credential is generated/reissued. Original HTTPS hostname, trust +and same-origin discovery/JWKS are retained, without redirect/proxy/insecure +bypass. The public JWKS probe requires exactly the original owner's single RSA +signing key with `use:sig`, `alg:RS256`, `kid:key-1`, matching the original key's +public numbers. It checks before and after the native exchange. Every security +condition uses explicit refusal, including when Python optimization is enabled. +This probe only compares public discovery/JWKS data; JWT/exchange/claim/scope and +wrong-secret verification exclusively use shipped `/keycape verify-client`. + +The negative check supplies a generated unrelated random secret through the +existing `-previous-secret-env` refusal mechanism. The bounded receipt says +**wrong unrelated secret refused**, never rotation. An absent exact PASS, +nonzero exit, TLS/discovery error, malformed output or any FAIL refuses evidence. +All durable private files and the full receipt tree are compared before/after; +extra receipt files refuse continuity. Public receipts expose only booleans, +public signing SPKI fingerprint, ciphertext/source hashes and exact safe cleanup +obligations. No config, env value, private digest, credential, JWT, response body, +subject or client ID is copied into receipts. + +Normal/failure cleanup attempts every safely owned container and tmpfs volume, +records exact verified/uncertain obligations and refuses success if any removal +is uncertain. The existing outer tmpfs staging is checked absent. A scoped +1800-second cleanup child contains only public exact run identifiers, no custody +FD or credential environment, and survives loss of the parent process. Normal +verified cleanup cancels it. This is a bounded owner-session recovery mechanism, +not a standing daemon; interruption behavior and Docker tmpfs lifecycle still +need native fixture proof. A refused preflight/import records uncertainty rather +than inventing absence. + +## Exact owner inputs and invocation, after approval/native fixture proof + +The launcher takes six paths/public arguments: + +``` +bash tools/bootstrap-recovery/run-isolated-keycape.sh \ + VERIFIED_TRANSFER_RECEIPT PRIVATE_ADMISSION ADMISSION_SHA256 \ + PRIVATE_EXISTING_TLS_CERT PRIVATE_EXISTING_TLS_KEY PRIVATE_ORIGINAL_TRUST +``` + +Do not run this candidate until root review and synthetic pinned-image runtime +proof admit the exact source. It prompts only for the independently held age key +using the existing custody helper. Current client credentials come only from the +original encrypted archive's already delivered registration/environment; no key, +password, factor or TLS private input is passed to the agent. + +The hash-bound owner admission JSON has `ciphertext_sha256`, `manifest_sha256`, +`verifier_sha256` (original image `/keycape` binary), `trust_sha256`, +`tls_cert_sha256`, original `issuer`, and `consumer` containing existing exact +`client_id`, `secret_env`, `audience`, `scope`, `subject`, `tenant`, `roles`, +`deny_scope`. Private `manifest_sha256` is SHA256 of +`json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode()` from the +admitted original archive. The wrapper checks it against the validated manifest, +not an arbitrary directory. Trust path/session names are derived internally; +operator input cannot select a remote Docker engine or arbitrary container set. +The selected original issuer configuration must use HTTPS443 and local service +port8080, as the admitted owner release does. Other configurations fail closed +and need explicit source review, rather than silent rewrite. + +## Remaining proof and retained review findings + +The 39 tests use synthetic data and mocked Docker/native outcomes. They exercise +archive integrity/symlink/lineage drift, optimizer-safe JWKS wrong-key/extra-key/ +wrong-kid/cross-origin/DNS negatives, default launch binding, capabilities/devices/ +namespace/security overrides, transport-vs-wrong-secret distinction, private +import ordering, secret-free argv, shared-tmpfs planning, and partial/uncertain +cleanup. They do **not** prove the actual pinned image supports the complete +session or that the selected current credential/factor works. + +Next: root review; native disposable pinned-image fixture with original hostname +and a synthetic trusted TLS pair; positive/negative service behavior and hard +interruption cleanup; then an admitted owner-run actual recovery. Actual T18 +still needs selected downstream-consumer reconnect and owner acceptance. Human/ +browser flow uses the existing `keycape login`; retained-factor MFA stays T19. +Both remain explicitly unproven in the receipt. + +R1 source/receipt are retained under `revisions/r1/`. Root's findings were added +as regression tests: added caps/devices/security exceptions; cleanup failure +incorrectly preserving success or abandoning later obligations; Python assert +optimization; extra JWKS signer ambiguity; same-image alternate launch; and +unsupported private tmpfs daemon-copy assumptions. Revision2 addresses these in +source. No root approval or native acceptance is fabricated. + +Knowledge orientation: emergent-security0.2.6, digest +`8d1d6a9d4d07fec7b619618de7dfe2254c1790f85f4198c7fe19db9751c02`, +withheld/changed-source status; current owner AGENTS/companion/recovery contract +and verifier source were reviewed. Supervised assignment; no autopilot or +privileged execution claimed. diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/candidate.patch b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/candidate.patch new file mode 100644 index 00000000..832f67cb --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/candidate.patch @@ -0,0 +1,1004 @@ +--- /dev/null ++++ b/tools/bootstrap-recovery/isolated-keycape.py +@@ -0,0 +1,391 @@ ++#!/usr/bin/env python3 ++"""T18 native adapter for an owner-admitted, already restored isolated session. ++ ++Called after identity-bundle.restore, never with an unvalidated directory. This ++module does not provision TLS, grant credentials, or claim downstream acceptance. ++The existing decrypt/tmpfs wrapper remains responsible for archive cleanup. ++""" ++import base64 ++import hashlib ++import importlib.util ++import json ++import io ++import secrets ++import tarfile ++import re ++import subprocess ++from pathlib import Path ++ ++HERE = Path(__file__).resolve().parent ++IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010' ++LABEL = 'net-kingdom.recovery-run' ++ ++ ++class Refused(Exception): ++ pass ++ ++ ++def load_bundle(): ++ spec = importlib.util.spec_from_file_location('identity_bundle', HERE / 'identity-bundle.py') ++ module = importlib.util.module_from_spec(spec) ++ spec.loader.exec_module(module) ++ return module ++ ++ ++def private_payload(root, manifest, expected_manifest_sha256): ++ """Bind the complete validated archive before selecting KeyCape material. ++ ++ Private hashes never enter the public receipt. The digest is supplied by the ++ existing private archive admission, not inferred from an untrusted manifest. ++ """ ++ encoded = json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode() ++ if hashlib.sha256(encoded).hexdigest() != expected_manifest_sha256: ++ raise Refused() ++ entries = manifest['files'] ++ if len(entries) != len({e['path'] for e in entries}): ++ raise Refused() ++ values = {} ++ bundle = load_bundle() ++ for entry in entries: ++ if not bundle.allowed(entry['path'], manifest['profile']): ++ raise Refused() ++ path = root / entry['path'] ++ bundle.private(path) ++ value = path.read_bytes() ++ if len(value) != entry['bytes'] or hashlib.sha256(value).hexdigest() != entry['sha256']: ++ raise Refused() ++ values[entry['path']] = value ++ deployment = json.loads(values['resources/sso/deployment/keycape.json']) ++ main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'keycape') ++ if main.get('command') or main.get('args') or main.get('envFrom'): ++ raise Refused() ++ if main['image'] != IMAGE: ++ # Mutable-tag/historical mismatches need a separate explicit owner admission. ++ raise Refused() ++ secret = json.loads(values['resources/sso/secret/keycape-config.json'])['data'] ++ files = {'/etc/keycape/' + name: base64.b64decode(secret[name], validate=True) ++ for name in ('config.yaml', 'key.pem')} ++ for path, value in values.items(): ++ if path.startswith('data/keycape/'): ++ relative = path.removeprefix('data/keycape/') ++ files['/var/lib/keycape-policy/' + relative] = value ++ if '/var/lib/keycape-policy/policy.json' not in files: ++ raise Refused() ++ if 'resources/sso/secret/keycape-factor-read.json' in values: ++ factor=json.loads(values['resources/sso/secret/keycape-factor-read.json'])['data'] ++ files['/etc/keycape-factor/admin-token']=base64.b64decode(factor['admin-token'],validate=True) ++ # Resolve original delivered environment, including all referenced secrets. ++ env = {} ++ for item in main.get('env', []): ++ if not re.fullmatch(r'KEYCAPE_[A-Z0-9_]+',item['name']):raise Refused() ++ if item['name'] in env: ++ raise Refused() ++ if 'value' in item: ++ env[item['name']] = item['value'] ++ else: ++ ref = item['valueFrom']['secretKeyRef'] ++ source = json.loads(values['resources/sso/secret/' + ref['name'] + '.json'])['data'] ++ env[item['name']] = base64.b64decode(source[ref['key']], validate=True).decode() ++ if env.get('KEYCAPE_CONFIG') != '/etc/keycape/config.yaml' or env.get('KEYCAPE_POLICY_PATH') != '/var/lib/keycape-policy/policy.json': ++ raise Refused() ++ return {'files': {p: base64.b64encode(v).decode() for p, v in files.items()}, 'env': env} ++ ++ ++def command(argv, **kwargs): ++ maximum = kwargs.pop('max_output', 32768) ++ result = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ++ timeout=kwargs.pop('timeout', 180), **kwargs) ++ if result.returncode or len(result.stdout) > maximum: ++ raise Refused() ++ return result.stdout ++ ++ ++def inspect_session(session, worker_only=False): ++ """No credential environment is read by Docker inspect or returned to caller.""" ++ if not re.fullmatch(r'[a-f0-9]{32}', session['run_id']): ++ raise Refused() ++ names = session['containers'] ++ if not names or len(names) > 12 or len(set(names)) != len(names): ++ raise Refused() ++ for name in names: ++ if not re.fullmatch(r'nk49-kc-' + session['run_id'] + r'-[a-z0-9-]{1,24}', name): ++ raise Refused() ++ if session['worker'] not in names or session['issuer'] not in names: ++ raise Refused() ++ import os ++ contexts=json.loads(command(['docker','context','inspect'])) ++ endpoints=[contexts[0]['Endpoints']['docker']['Host'],os.environ.get('DOCKER_HOST','unix:///var/run/docker.sock')] ++ if not all(x.startswith('unix://') for x in endpoints):raise Refused() ++ info = json.loads(command(['docker', 'info', '--format', '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}'])) ++ if info != {'ID': '74f93783-6cef-43a8-a3c4-f62586cd3bc1', 'Name': 'bnt-lap001', 'OSType': 'linux'}: ++ raise Refused() ++ # The worker is the anchor; all dependencies/TLS/issuer share its network-none namespace. ++ anchor = command(['docker', 'container', 'inspect', session['worker'], '--format', '{{.Id}}']).decode().strip() ++ for name,spec in session.get('volumes',{}).items(): ++ volume=json.loads(command(['docker','volume','inspect',name])) ++ expected={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} ++ if len(volume)!=1 or volume[0]['Driver']!='local' or volume[0].get('Scope')!='local' or volume[0].get('Options')!=expected or spec['options']!=expected or volume[0].get('Labels',{}).get(LABEL)!=session['run_id']:raise Refused() ++ for name in names: ++ obj = json.loads(command(['docker', 'container', 'inspect', name, '--format', ++ '{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"labels":{{json .Config.Labels}},"image":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}}}'])) ++ host = obj['host'] ++ mounts=obj['mounts'] ++ destinations=[m['Destination'] for m in mounts] ++ if len(destinations)!=len(set(destinations)):raise Refused() ++ for volume,expected_spec in session.get('volumes',{}).items(): ++ matches=[m for m in mounts if m['Destination']==expected_spec['target']] ++ if len(matches)!=1 or matches[0]['Type']!='volume' or matches[0]['Name']!=volume or matches[0]['RW']!=(name==session['worker']):raise Refused() ++ if (obj['labels'].get(LABEL) != session['run_id'] or not obj['user'] or obj['user'].split(':')[0] in ('0', 'root') ++ or not host['ReadonlyRootfs'] or host['Privileged'] or host.get('Binds') or host.get('PortBindings') ++ or host.get('PidMode') or host.get('IpcMode') == 'host' ++ or host.get('UTSMode') or host.get('UsernsMode') == 'host' or host.get('CgroupnsMode') == 'host' ++ or host.get('Sysctls',{}) not in ({},{'net.ipv4.ip_unprivileged_port_start':'0'} if name==session['worker'] else {}) ++ or host.get('CapAdd') or host.get('Devices') or host.get('DeviceRequests') or host.get('VolumesFrom') ++ or host.get('CapDrop') != ['ALL'] ++ or host.get('SecurityOpt') not in (['no-new-privileges'], ['no-new-privileges:true']) ++ or any(m['Type']!='tmpfs' and not (m['Type']=='volume' and m['Name'] in session.get('volumes',{}) and m['Destination']==session['volumes'][m['Name']]['target']) for m in obj['mounts']) ++ or host['NetworkMode'] != ('none' if name == session['worker'] else 'container:' + anchor) ++ or obj['image'] != session['images'][name] or '@sha256:' not in obj['image']): ++ raise Refused() ++ if worker_only:return ++ if session['images'][session['issuer']] != IMAGE: ++ raise Refused() ++ # Known admitted archive has no command/args override; image default /keycape ++ # must be the actual issuer executable, not an alternate same-image command. ++ launch=json.loads(command(['docker','container','inspect',session['issuer'],'--format', ++ '{"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"path":{{json .Path}},"args":{{json .Args}}}'])) ++ if launch != {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}: ++ raise Refused() ++ ++ ++def container_file(container, path): ++ # Docker streams one tar member into memory; never materialize private files. ++ raw = command(['docker', 'cp', container + ':' + path, '-'], max_output=128*1024*1024) ++ with tarfile.open(fileobj=io.BytesIO(raw), mode='r:') as archive: ++ members = archive.getmembers() ++ if len(members) != 1 or not members[0].isfile() or members[0].size > 96*1024*1024: ++ raise Refused() ++ return archive.extractfile(members[0]).read() ++ ++ ++JWKS_PROBE = r""" ++import base64,ipaddress,json,socket,ssl,sys,urllib.request ++from urllib.parse import urlsplit ++p=json.load(sys.stdin);issuer=p['issuer'];origin=urlsplit(issuer) ++def require(condition): ++ if not condition:raise ValueError('public_jwks_binding_refused') ++require(origin.scheme=='https') ++resolved=socket.getaddrinfo(origin.hostname,origin.port or 443,type=socket.SOCK_STREAM) ++require(bool(resolved) and all(ipaddress.ip_address(x[4][0]).is_loopback for x in resolved)) ++class NoRedirect(urllib.request.HTTPRedirectHandler): ++ def redirect_request(self,*args,**kwargs):return None ++opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect(), ++ urllib.request.HTTPSHandler(context=ssl.create_default_context(cadata=p['trust']))) ++def get(url): ++ u=urlsplit(url) ++ require((u.scheme,u.hostname,u.port or 443)==(origin.scheme,origin.hostname,origin.port or 443)) ++ require(not u.username and not u.password and not u.fragment) ++ with opener.open(url,timeout=10) as response: ++ raw=response.read(1024*1024+1);require(len(raw)<=1024*1024) ++ return json.loads(raw) ++d=get(issuer.rstrip('/')+'/.well-known/openid-configuration');require(d['issuer']==issuer) ++keys=get(d['jwks_uri'])['keys'] ++def integer(value):return int.from_bytes(base64.urlsafe_b64decode(value+'='*(-len(value)%4)),'big') ++# The exact admitted owner release constructs one key, kid key-1. Reject every ++# extra key, including irrelevant keys: a native successful exchange can then ++# only have used the original signer through the existing verifier. ++require(isinstance(keys,list) and len(keys)==1) ++k=keys[0] ++require(k.get('kty')=='RSA' and k.get('kid')=='key-1' and k.get('use')=='sig' and k.get('alg')=='RS256') ++require(integer(k['n'])==int(p['n']) and integer(k['e'])==int(p['e'])) ++print('{"original_public_signing_key_in_live_jwks":true}') ++""" ++ ++ ++def check_jwks(session, admission, public_numbers): ++ trust=runtime_file(session,admission['trust_path']).decode() ++ request={'issuer':admission['issuer'],'trust':trust,'n':str(public_numbers.n),'e':str(public_numbers.e)} ++ result=json.loads(command(['docker','exec','-i',session['worker'],'python3','-c',JWKS_PROBE],input=json.dumps(request).encode())) ++ if result != {'original_public_signing_key_in_live_jwks': True}:raise Refused() ++ ++ ++RUNTIME_FILES = r""" ++import base64,json,sys ++from pathlib import Path ++p=json.load(sys.stdin) ++roots=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') ++result={} ++paths=[] ++if p.get('tree'): ++ paths=list(Path('/var/lib/keycape-policy').rglob('*')) ++else:paths=[Path(x) for x in p['paths']] ++for f in paths: ++ if f.is_dir():continue ++ if not str(f).startswith(roots) or not f.is_file() or any(x.is_symlink() for x in (f,*f.parents)) or f.stat().st_size>8*1024*1024:raise ValueError() ++ result[str(f)]=base64.b64encode(f.read_bytes()).decode() ++ if len(result)>4096:raise ValueError() ++print(json.dumps(result)) ++""" ++ ++ ++def runtime_files(session, paths=(), tree=False): ++ raw=command(['docker','exec','-i',session['worker'],'python3','-c',RUNTIME_FILES], ++ input=json.dumps({'paths':list(paths),'tree':tree}).encode(),max_output=48*1024*1024) ++ return {k:base64.b64decode(v,validate=True) for k,v in json.loads(raw).items()} ++ ++ ++def runtime_file(session,path): ++ return runtime_files(session,[path])[path] ++ ++ ++def run_verifier(session, admission, payload): ++ issuer = session['issuer'] ++ if {v['target'] for v in session.get('volumes',{}).values()}!={'/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls'}:raise Refused() ++ from urllib.parse import urlsplit ++ import ipaddress ++ address = urlsplit(admission['issuer']) ++ if (address.scheme != 'https' or not address.hostname or address.username or address.password ++ or address.query or address.fragment): ++ raise Refused() ++ if hashlib.sha256(container_file(issuer, '/keycape')).hexdigest() != admission['verifier_sha256']: ++ raise Refused() ++ if hashlib.sha256(runtime_file(session, admission['trust_path'])).hexdigest() != admission['trust_sha256']: ++ raise Refused() ++ # Capture private environment only in owner process memory, never receipt. ++ environment = json.loads(command(['docker','container','inspect',issuer,'--format','{{json .Config.Env}}'])) ++ env = dict(item.split('=',1) for item in environment) ++ if len(env)!=len(environment):raise Refused() ++ if set(env)-set(payload['env'])-{'PATH','HOME','HOSTNAME','SSL_CERT_FILE'}:raise Refused() ++ if env.get('SSL_CERT_FILE') != admission['trust_path'] or any(k.lower().endswith('_proxy') and v for k,v in env.items()): ++ raise Refused() ++ if {k:v for k,v in env.items() if k.startswith('KEYCAPE_')}!={k:v for k,v in payload['env'].items() if k.startswith('KEYCAPE_')}:raise Refused() ++ for name,value in payload['env'].items(): ++ if env.get(name) != value: ++ raise Refused() ++ files = {k:base64.b64decode(v,validate=True) for k,v in payload['files'].items()} ++ def equal(): ++ if runtime_files(session,files)!=files:raise Refused() ++ durable=runtime_files(session,tree=True) ++ if durable!={k:v for k,v in files.items() if k.startswith('/var/lib/keycape-policy/')}: ++ raise Refused() ++ equal() ++ # Reading YAML is data parsing only; no archived executable is evaluated. ++ import yaml ++ cfg = yaml.safe_load(files['/etc/keycape/config.yaml']) ++ if cfg['issuer'] != admission['issuer']: ++ raise Refused() ++ consumer = admission['consumer'] ++ client = next(x for x in cfg['clients'] if x['clientId']==consumer['client_id']) ++ if (client['secretRef']!='env:'+consumer['secret_env'] or client['clientType']!='confidential' ++ or 'client_credentials' not in client['grantTypes'] or not env.get(consumer['secret_env'])): ++ raise Refused() ++ from cryptography.hazmat.primitives import serialization ++ key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) ++ check_jwks(session,admission,key.public_key().public_numbers()) ++ wrong = secrets.token_urlsafe(48) ++ if wrong == env[consumer['secret_env']]:raise Refused() ++ args = ['docker','exec','--env','NK49_UNRELATED_NEGATIVE_SECRET='+wrong,issuer,'/keycape','verify-client', ++ '-issuer',admission['issuer'],'-previous-secret-env','NK49_UNRELATED_NEGATIVE_SECRET'] ++ flags = {'client_id':'client-id','secret_env':'secret-env','audience':'audience','scope':'scope', ++ 'subject':'expect-subject','tenant':'expect-tenant','roles':'expect-roles','deny_scope':'deny-scope'} ++ for name,flag in flags.items():args+=['-'+flag,consumer[name]] ++ # Native command reports on stderr. Do not return or print its raw response. ++ result = subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=150) ++ raw = (result.stdout+result.stderr).decode() ++ required = ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings', ++ 'principal_type is service','sub matches the registration','tenant matches the registration','roles present', ++ 'granted scope carries no excess grant','predecessor secret refused'] ++ required += ['excess scope refused: '+scope for scope in consumer['deny_scope'].split()] ++ if result.returncode or any('PASS '+name+'\n' not in raw for name in required) or 'FAIL ' in raw: ++ raise Refused() ++ equal() ++ from cryptography.hazmat.primitives import serialization ++ key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) ++ check_jwks(session,admission,key.public_key().public_numbers()) ++ public=key.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) ++ return {'native_service_registration_verified':True,'wrong_unrelated_secret_explicitly_refused':True, ++ 'denied_scopes_refused':True,'original_config_registry_policy_receipts_equal':True, ++ 'public_signing_spki_sha256':hashlib.sha256(public).hexdigest(), ++ 'original_https_issuer_and_trust_preserved':True,'original_public_signing_key_in_live_jwks':True} ++ ++ ++def cleanup(session, receipt=None): ++ """Attempt every owned cleanup even when one obligation fails. ++ ++ Preserve exact safe container names and outcomes so the owner can recover an ++ uncertain/partial cleanup. An inspect/daemon failure never means absence. ++ """ ++ obligations = [] ++ failed = False ++ for name in reversed(session['containers']): ++ row = {'container': name, 'status': 'unverified'} ++ obligations.append(row) ++ try: ++ label = command(['docker', 'container', 'inspect', name, '--format', ++ '{{index .Config.Labels "' + LABEL + '"}}']).decode().strip() ++ if label != session['run_id']: ++ row['status'] = 'ownership_mismatch_not_removed' ++ failed = True ++ continue ++ # A failed remove is followed by a successful explicit inventory; ++ # interrupted remove may actually have completed. ++ try: ++ command(['docker', 'rm', '--force', '--volumes', name]) ++ except Exception: ++ pass ++ remaining = command(['docker','container','ls','--all','--filter', ++ 'name=^/' + name + '$','--format','{{.Names}}']).decode().splitlines() ++ if remaining: ++ row['status'] = 'still_present' ++ failed = True ++ else: ++ row['status'] = 'absence_verified' ++ except Exception: ++ failed = True ++ if receipt is not None: ++ receipt['container_cleanup_obligations'] = obligations ++ if failed: ++ raise Refused() ++ ++ ++ ++def native_checks(root, manifest, admission, receipt, cleanup_session=True): ++ """Owner wrapper provides a reviewed session after existing restore pipeline. ++ ++ Admission must bind ciphertext, canonical private manifest digest, executable ++ and trust bytes, exact image/target list, original issuer and selected consumer. ++ None of these admission fields grant authority or carry a client secret. ++ """ ++ session = admission['session'] ++ # Validate ownership/containment before permitting either exec or cleanup. ++ inspect_session(session) ++ receipt.update(status='failed', source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), ++ ciphertext_sha256=admission['ciphertext_sha256'], production_contacted=False, ++ human_mfa_proven=False, downstream_consumer_reconnect_proven=False, ++ container_cleanup_verified=False) ++ try: ++ for field in ('ciphertext_sha256', 'manifest_sha256', 'verifier_sha256', 'trust_sha256'): ++ if not re.fullmatch('[a-f0-9]{64}', admission[field]): ++ raise Refused() ++ for field in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope'): ++ if not isinstance(admission['consumer'].get(field), str) or not admission['consumer'][field].strip(): ++ raise Refused() ++ payload = private_payload(root, manifest, admission['manifest_sha256']) ++ result = run_verifier(session, admission, payload) ++ required = {'native_service_registration_verified','wrong_unrelated_secret_explicitly_refused', ++ 'denied_scopes_refused','original_config_registry_policy_receipts_equal', ++ 'original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks'} ++ if any(result.get(k) is not True for k in required) or not re.fullmatch('[a-f0-9]{64}', result.get('public_signing_spki_sha256','')): ++ raise Refused() ++ # Closed receipt allowlist: never copy arbitrary worker output. ++ receipt.update({k: True for k in required}) ++ receipt['public_signing_spki_sha256'] = result['public_signing_spki_sha256'] ++ receipt['status'] = 'isolated_keycape_native_registration_checked' ++ finally: ++ try: ++ if cleanup_session: ++ cleanup(session, receipt) ++ receipt['container_cleanup_verified'] = True ++ except Exception: ++ receipt['status'] = 'failed' ++ raise +--- /dev/null ++++ b/tools/bootstrap-recovery/isolated-keycape-session.py +@@ -0,0 +1,278 @@ ++#!/usr/bin/env python3 ++"""Owner-run T18 session assembly using the existing archive/containment pipeline. ++ ++Candidate only: native pinned-image tests and review are required before use. ++Shared Docker local-driver tmpfs is required because Docker cp does not support ++private tmpfs/mount paths, and the original KeyCape image is distroless. ++""" ++import argparse ++import base64 ++from datetime import datetime, timezone ++import hashlib ++import importlib.util ++import json ++import os ++from pathlib import Path ++import signal ++import subprocess ++import sys ++import tempfile ++import time ++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 ++ ++adapter=load('isolated-keycape') ++local=load('isolated-lldap') ++bundle=local.bundle ++WORKER=local.CLIENT ++RUNROOTS=('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls') ++VOLUME_OPTIONS={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} ++ ++IMPORT=r''' ++import base64,json,os,sys ++from pathlib import Path ++os.umask(0o077) ++p=json.load(sys.stdin) ++allowed=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') ++for name,encoded in p.items(): ++ f=Path(name) ++ if not name.startswith(allowed) or '..' in f.parts or any(x.is_symlink() for x in (f,*f.parents)):raise ValueError() ++ f.parent.mkdir(mode=0o700,parents=True,exist_ok=True) ++ fd=os.open(f,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) ++ with os.fdopen(fd,'wb') as out:out.write(base64.b64decode(encoded,validate=True)) ++''' ++ ++# Small transport inside the already pinned helper; it has only loopback routes. ++# No token handling, auth bypass, dependency imitation, reset or notifier path. ++TLS=r''' ++import select,socket,socketserver,ssl,threading ++context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ++context.minimum_version=ssl.TLSVersion.TLSv1_2 ++context.load_cert_chain('/recovery-tls/server.pem','/recovery-tls/server-key.pem') ++class Handler(socketserver.BaseRequestHandler): ++ def handle(self): ++ try: ++ with context.wrap_socket(self.request,server_side=True) as incoming: ++ with socket.create_connection(('127.0.0.1',8080),timeout=10) as outgoing: ++ incoming.settimeout(15);outgoing.settimeout(15) ++ while True: ++ ready,_,_=select.select([incoming,outgoing],[],[],15) ++ if not ready:return ++ for source in ready: ++ data=source.recv(65536) ++ if not data:return ++ (outgoing if source is incoming else incoming).sendall(data) ++ except Exception:pass ++class Server(socketserver.ThreadingTCPServer): ++ allow_reuse_address=True ++ daemon_threads=True ++with Server(('127.0.0.1',443),Handler) as server:server.serve_forever() ++''' ++ ++ ++def run(argv,**kwargs):return adapter.command(argv,**kwargs) ++ ++ ++def plan_session(): ++ run_id=uuid.uuid4().hex ++ stem='nk49-kc-'+run_id ++ worker=stem+'-transport';issuer=stem+'-issuer' ++ volumes={stem+'-v'+str(i):{'target':root,'options':dict(VOLUME_OPTIONS)} for i,root in enumerate(RUNROOTS)} ++ return {'run_id':run_id,'containers':[worker,issuer], 'worker':worker,'issuer':issuer, ++ 'images':{worker:WORKER,issuer:adapter.IMAGE},'volumes':volumes} ++ ++ ++def verify_volume(name,spec,run_id): ++ obj=json.loads(run(['docker','volume','inspect',name])) ++ if (len(obj)!=1 or obj[0]['Name']!=name or obj[0]['Driver']!='local' or obj[0].get('Scope')!='local' ++ or obj[0].get('Options')!=VOLUME_OPTIONS or spec['options']!=VOLUME_OPTIONS ++ or obj[0].get('Labels',{}).get(adapter.LABEL)!=run_id):raise adapter.Refused() ++ ++ ++def base_args(session,name,network): ++ return ['docker','run','--detach','--pull=never','--name',name,'--network',network, ++ '--label',adapter.LABEL+'='+session['run_id'],'--read-only','--cap-drop','ALL', ++ '--security-opt','no-new-privileges','--user','1000:1000','--memory','512m','--cpus','1', ++ '--pids-limit','64','--log-driver','none','--restart','no', ++ '--tmpfs','/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700'] ++ ++ ++def mounts(session,readonly=False): ++ args=[] ++ for name,spec in session['volumes'].items(): ++ args+=['--mount','type=volume,source='+name+',target='+spec['target']+(',readonly' if readonly else '')] ++ return args ++ ++ ++def provision(session,payload,tls,issuer): ++ """Inspect containment and driver options before the first private import.""" ++ from urllib.parse import urlsplit ++ address=urlsplit(issuer) ++ if address.scheme!='https' or not address.hostname or address.port not in (None,443) or address.path not in ('','/'): ++ raise adapter.Refused() ++ for name,spec in session['volumes'].items(): ++ run(['docker','volume','create','--driver','local','--label',adapter.LABEL+'='+session['run_id'], ++ '--opt','type=tmpfs','--opt','device=tmpfs','--opt','o='+VOLUME_OPTIONS['o'],name]) ++ verify_volume(name,spec,session['run_id']) ++ args=base_args(session,session['worker'],'none')+mounts(session) ++ args+=['--sysctl','net.ipv4.ip_unprivileged_port_start=0','--add-host',address.hostname+':127.0.0.1', ++ '--entrypoint','python3',WORKER,'-c','import time;time.sleep(1800)'] ++ run(args) ++ # Check helper and every private filesystem before touching the ciphertext's ++ # private contents. Issuer is created only after originals are present. ++ adapter.inspect_session({**session,'containers':[session['worker']],'issuer':session['worker']},worker_only=True) ++ files={**payload['files'],**{k:base64.b64encode(v).decode() for k,v in tls.items()}} ++ run(['docker','exec','-i',session['worker'],'python3','-c',IMPORT],input=json.dumps(files).encode()) ++ anchor=run(['docker','container','inspect',session['worker'],'--format','{{.Id}}']).decode().strip() ++ args=base_args(session,session['issuer'],'container:'+anchor)+mounts(session,readonly=True) ++ # CLI argv carries environment names only. Original credentials stay in the ++ # owner subprocess environment and Docker's private container configuration. ++ env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':'unix:///var/run/docker.sock',**payload['env'], ++ 'SSL_CERT_FILE':'/recovery-tls/trust.pem'} ++ for name in (*payload['env'],'SSL_CERT_FILE'):args+=['--env',name] ++ args+=[adapter.IMAGE] ++ run(args,env=env) ++ adapter.inspect_session(session) ++ run(['docker','exec','--detach',session['worker'],'python3','-c',TLS]) ++ ++ ++def cleanup_all(session,receipt): ++ failed=False ++ try:adapter.cleanup(session,receipt) ++ except Exception:failed=True ++ rows=[] ++ for name,spec in session['volumes'].items(): ++ row={'volume':name,'status':'unverified'};rows.append(row) ++ try: ++ verify_volume(name,spec,session['run_id']) ++ run(['docker','volume','rm',name]) ++ remaining=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines() ++ if remaining:row['status']='still_present';failed=True ++ else:row['status']='absence_verified' ++ except Exception:failed=True ++ receipt['tmpfs_volume_cleanup_obligations']=rows ++ receipt['container_cleanup_verified']=not failed ++ if failed:raise adapter.Refused() ++ ++def start_reaper(session): ++ # Scoped cleanup child contains only public exact run identifiers, no custody ++ # descriptors or secret environment. It survives loss of the parent owner ++ # process; normal successful cleanup cancels it. This is not a standing job. ++ code="""import importlib.util,json,sys,time ++spec=importlib.util.spec_from_file_location('session',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) ++time.sleep(1800) ++try:m.cleanup_all(json.loads(sys.argv[2]),{}) ++except Exception:pass ++""" ++ return subprocess.Popen([sys.executable,'-c',code,str(Path(__file__).resolve()),json.dumps(session)], ++ stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL, ++ env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':'unix:///var/run/docker.sock'}, ++ close_fds=True,start_new_session=True) ++ ++ ++def execute(transfer,admission,tls,receipt): ++ """Existing private custody/transfer/archive APIs; no second archive format.""" ++ target=local.preflight() ++ for image in (adapter.IMAGE,):run(['docker','image','inspect',image]) ++ local.offline.validate(transfer) ++ if transfer.get('independent_owner_retrieval_verified') is not True:raise adapter.Refused() ++ path=Path(transfer['fetched_path']);bundle.private(path,max_bytes=bundle.LIMIT) ++ if (path.stat().st_size!=transfer['ciphertext_bytes'] or local.offline.offsite.digest(path)!=transfer['ciphertext_sha256'] ++ or admission['ciphertext_sha256']!=transfer['ciphertext_sha256']):raise adapter.Refused() ++ if hashlib.sha256(tls['/recovery-tls/trust.pem']).hexdigest()!=admission['trust_sha256']:raise adapter.Refused() ++ if hashlib.sha256(tls['/recovery-tls/server.pem']).hexdigest()!=admission['tls_cert_sha256']:raise adapter.Refused() ++ # Validate exact private certificate/key pair and original issuer name before ++ # opening service containers; do not generate replacement trust or keys. ++ from cryptography import x509 ++ from cryptography.hazmat.primitives import serialization ++ cert=x509.load_pem_x509_certificate(tls['/recovery-tls/server.pem']) ++ key=serialization.load_pem_private_key(tls['/recovery-tls/server-key.pem'],password=None) ++ if cert.public_key().public_numbers()!=key.public_key().public_numbers():raise adapter.Refused() ++ keyfd=local.verify.custody.private_key_fd();work=None;session=None;reaper=None ++ receipt.update(status='failed',target=target,session_source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), ++ source_files_sha256={name:hashlib.sha256((HERE/name).read_bytes()).hexdigest() for name in ('isolated-keycape.py','isolated-keycape-session.py','run-isolated-keycape.sh','isolated-lldap.py','identity-bundle.py','verify-identity.py','retrieve-identity-offline.py','identity-offsite.py')}, ++ temporary_plaintext_cleanup_verified=False,container_cleanup_verified=False, ++ downstream_consumer_reconnect_proven=False,human_mfa_proven=False) ++ try: ++ if run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip()!=bundle.profile()['public_recipient'].encode():raise adapter.Refused() ++ with tempfile.TemporaryDirectory(prefix='nk49-keycape-',dir='/dev/shm') as temporary: ++ work=Path(temporary);archive=work/'archive.tar' ++ with archive.open('xb') as output: ++ p=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}',str(path)],stdout=output,stderr=subprocess.DEVNULL,pass_fds=(keyfd,),timeout=180) ++ if p.returncode or archive.stat().st_size>bundle.LIMIT+4*1024*1024:raise adapter.Refused() ++ root=work/'restored';root.mkdir(mode=0o700) ++ manifest,_=bundle.restore(archive,root) ++ payload=adapter.private_payload(root,manifest,admission['manifest_sha256']) ++ import yaml ++ cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml'])) ++ if cfg.get('port')!=8080 or cfg.get('issuer')!=admission['issuer']:raise adapter.Refused() ++ # Public executable readback uses the daemon archive API only on the ++ # original image rootfs, never a tmpfs or private user-created mount. ++ session=plan_session() ++ reaper=start_reaper(session) ++ receipt['bounded_cleanup_reaper_started']=True ++ probe='nk49-kc-'+session['run_id']+'-image-probe' ++ try: ++ run(['docker','create','--name',probe,'--network','none','--read-only','--label',adapter.LABEL+'='+session['run_id'],adapter.IMAGE]) ++ binary=adapter.container_file(probe,'/keycape') ++ if hashlib.sha256(binary).hexdigest()!=admission['verifier_sha256']:raise adapter.Refused() ++ finally: ++ receipt['public_image_probe_cleanup_obligations']=[{'container':probe,'status':'unverified'}] ++ run(['docker','rm',probe]) ++ if run(['docker','container','ls','--all','--filter','name=^/'+probe+'$','--format','{{.Names}}']).strip():raise adapter.Refused() ++ receipt['public_image_probe_cleanup_obligations'][0]['status']='absence_verified' ++ provision(session,payload,tls,admission['issuer']) ++ receipt.update(ciphertext_sha256=transfer['ciphertext_sha256'],all_manifest_hashes_verified=True,manifest_file_count=len(manifest['files'])) ++ checked_admission={**admission,'session':session,'trust_path':'/recovery-tls/trust.pem'} ++ # Native checker consumes no service secret argument or new grant. ++ # Retry only bounded public readiness; no account/factor attempt. ++ for attempt in range(40): ++ try: ++ numbers=serialization.load_pem_private_key(base64.b64decode(payload['files']['/etc/keycape/key.pem']),password=None).public_key().public_numbers() ++ adapter.check_jwks(session,checked_admission,numbers);break ++ except Exception: ++ if attempt==39:raise ++ time.sleep(.25) ++ adapter.native_checks(root,manifest,checked_admission,receipt,cleanup_session=False) ++ finally: ++ os.close(keyfd) ++ try: ++ if session is not None:cleanup_all(session,receipt) ++ if reaper is not None: ++ reaper.terminate();reaper.wait(timeout=10) ++ except Exception: ++ receipt['status']='failed' ++ raise ++ finally: ++ if work is not None and not work.exists():receipt['temporary_plaintext_cleanup_verified']=True ++ ++ ++def main(): ++ p=argparse.ArgumentParser(description=__doc__) ++ for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust','receipt'):p.add_argument('--'+name,required=True) ++ args=p.parse_args();os.umask(0o077) ++ fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) ++ receipt={'schema':'netkingdom.isolated-keycape-recovery.v1','status':'failed','production_contacted':False,'secret_values_exported':False} ++ try: ++ raw=Path(args.admission).read_bytes() ++ if hashlib.sha256(raw).hexdigest()!=args.admission_sha256:raise adapter.Refused() ++ admission=json.loads(raw) ++ tls={} ++ for name,path in (('server.pem',args.tls_cert),('server-key.pem',args.tls_key),('trust.pem',args.trust)): ++ source=Path(path);bundle.private(source,max_bytes=1024*1024);tls['/recovery-tls/'+name]=source.read_bytes() ++ execute(json.loads(Path(args.transfer_receipt).read_text()),admission,tls,receipt) ++ except (Exception,KeyboardInterrupt):receipt.update(status='failed',error='isolated_keycape_failed_private_diagnostics_suppressed') ++ finally: ++ receipt['finished_at']=datetime.now(timezone.utc).isoformat() ++ with os.fdopen(fd,'w') as out:json.dump(receipt,out,indent=2);out.write('\n') ++ print('OK: isolated KeyCape native checks and cleanup passed.' if receipt['status']=='isolated_keycape_native_registration_checked' else 'ERROR: isolated KeyCape stopped; review safe receipt.') ++ return int(receipt['status']!='isolated_keycape_native_registration_checked') ++ ++if __name__=='__main__': ++ for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,local.offline.interrupted) ++ raise SystemExit(main()) +--- /dev/null ++++ b/tools/bootstrap-recovery/run-isolated-keycape.sh +@@ -0,0 +1,40 @@ ++#!/usr/bin/env bash ++# Candidate owner launcher. Review and native fixture proof required before use. ++set +x ++set -euo pipefail ++ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" ++cd -- "$ROOT" ++umask 077 ++[[ $# == 6 ]] || { printf '%s\n' 'Usage: run-isolated-keycape.sh VERIFIED_TRANSFER_RECEIPT ADMISSION ADMISSION_SHA256 TLS_CERT TLS_KEY TRUST'; exit 1; } ++[[ "$3" =~ ^[a-f0-9]{64}$ ]] || { printf '%s\n' 'ERROR: exact reviewed admission digest required.'; exit 1; } ++for index in 1 2 4 5 6; do ++ [[ -f "${!index}" && ! -L "${!index}" ]] || { printf '%s\n' 'ERROR: provide regular owner-controlled input files.'; exit 1; } ++done ++[[ -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 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-keycape-session.py') ++spec=importlib.util.spec_from_file_location('session',p) ++m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) ++try: ++ m.local.preflight() ++ m.run(['docker','image','inspect',m.adapter.IMAGE]) ++except Exception: ++ raise SystemExit('ERROR: exact local Docker/images/tmpfs preflight failed; no key requested.') ++print('OK: fixed local target and cached pinned images available.') ++PY ++NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)" ++printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN" ++printf '%s\n' 'Owner-reviewed candidate: isolated original issuer/trust and current registered service-client checks only.' ++printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.' ++printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.' ++bash sso-mfa/bootstrap/sops-custody-unlock.sh \ ++ --expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \ ++ --prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \ ++ --transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \ ++ --tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json" ++printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN" +--- /dev/null ++++ b/tests/test_isolated_keycape_recovery.py +@@ -0,0 +1,218 @@ ++import base64 ++import hashlib ++import importlib.util ++import io ++import json ++import subprocess ++import tarfile ++import tempfile ++import unittest ++from pathlib import Path ++from unittest.mock import patch ++ ++SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape.py' ++spec=importlib.util.spec_from_file_location('adapter',SOURCE) ++a=importlib.util.module_from_spec(spec);spec.loader.exec_module(a) ++ ++class ArchiveTests(unittest.TestCase): ++ def setUp(self): ++ self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) ++ self.root=Path(self.tmp.name) ++ config={'data':{k:base64.b64encode(v).decode() for k,v in {'config.yaml':b'issuer: https://issuer.example','key.pem':b'fixture-key'}.items()}} ++ self.values={'resources/sso/secret/keycape-config.json':json.dumps(config).encode(), ++ 'resources/sso/deployment/keycape.json':json.dumps({'spec':{'template':{'spec':{'containers':[{'name':'keycape','image':a.IMAGE,'env':[ ++ {'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'}, ++ {'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'}, ++ {'name':'KEYCAPE_ORIGINAL_SECRET','valueFrom':{'secretKeyRef':{'name':'client','key':'secret'}}}]}]}}}}).encode(), ++ 'resources/sso/secret/client.json':json.dumps({'data':{'secret':base64.b64encode(b'fixture-private').decode()}}).encode(), ++ 'data/keycape/policy.json':b'{}','data/keycape/receipts/one.json':b'{"original":true}'} ++ for name,value in self.values.items(): ++ p=self.root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(value);p.chmod(0o600) ++ self.manifest={'profile':{'components':[{'namespace':'sso','deployment':'keycape','secret_refs':['keycape-config','client'],'configmap_refs':[]}]}, ++ 'files':[{'path':k,'bytes':len(v),'sha256':hashlib.sha256(v).hexdigest()} for k,v in self.values.items()]} ++ def payload(self): ++ digest=hashlib.sha256(json.dumps(self.manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest() ++ return a.private_payload(self.root,self.manifest,digest) ++ def test_original_state_and_secret_delivery_retained(self): ++ p=self.payload();self.assertEqual(p['env']['KEYCAPE_ORIGINAL_SECRET'],'fixture-private') ++ self.assertEqual(base64.b64decode(p['files']['/var/lib/keycape-policy/receipts/one.json']),b'{"original":true}') ++ def test_archive_digest_refused(self): ++ with self.assertRaises(a.Refused):a.private_payload(self.root,self.manifest,'0'*64) ++ def test_changed_file_refused(self): ++ (self.root/'data/keycape/policy.json').write_text('{"changed":true}') ++ with self.assertRaises(a.Refused):self.payload() ++ def test_symlink_refused(self): ++ p=self.root/'data/keycape/policy.json';p.unlink();p.symlink_to('/etc/hostname') ++ with self.assertRaises(Exception):self.payload() ++ def test_duplicate_manifest_refused(self): ++ self.manifest['files'].append(self.manifest['files'][0]) ++ with self.assertRaises(a.Refused):self.payload() ++ def test_image_mismatch_refused(self): ++ d=json.loads(self.values['resources/sso/deployment/keycape.json']);d['spec']['template']['spec']['containers'][0]['image']='keycape:latest' ++ raw=json.dumps(d).encode();p=self.root/'resources/sso/deployment/keycape.json';p.write_bytes(raw) ++ for row in self.manifest['files']: ++ if row['path'].endswith('/deployment/keycape.json'):row.update(bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest()) ++ with self.assertRaises(a.Refused):self.payload() ++ ++class NativeTests(unittest.TestCase): ++ def setUp(self): ++ from cryptography.hazmat.primitives import serialization ++ from cryptography.hazmat.primitives.asymmetric import rsa ++ self.key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()) ++ self.cfg=b'issuer: https://issuer.example\nclients:\n- clientId: existing\n clientType: confidential\n secretRef: env:KEYCAPE_ORIGINAL_SECRET\n grantTypes: [client_credentials]\n' ++ self.files={'/etc/keycape/config.yaml':self.cfg,'/etc/keycape/key.pem':self.key,'/var/lib/keycape-policy/policy.json':b'{}'} ++ self.payload={'env':{'KEYCAPE_ORIGINAL_SECRET':'fixture-private'},'files':{k:base64.b64encode(v).decode() for k,v in self.files.items()}} ++ self.admission={'issuer':'https://issuer.example','verifier_sha256':hashlib.sha256(b'binary').hexdigest(), ++ 'trust_path':'/trust.pem','trust_sha256':hashlib.sha256(b'original-trust').hexdigest(), ++ 'consumer':{'client_id':'existing','secret_env':'KEYCAPE_ORIGINAL_SECRET','scope':'read','audience':'consumer', ++ 'subject':'service:existing','tenant':'tenant:platform','roles':'reader','deny_scope':'write'}} ++ self.session={'issuer':'isolated','volumes':{x:{'target':x} for x in ('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')}} ++ self.output=''.join('PASS '+x+'\n' for x in ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings','principal_type is service','sub matches the registration','tenant matches the registration','roles present','granted scope carries no excess grant','predecessor secret refused','excess scope refused: write']) ++ def file(self,container,path): ++ return {**self.files,'/etc/hosts':b'127.0.0.1 issuer.example\n','/keycape':b'binary','/trust.pem':b'original-trust'}[path] ++ def command(self,argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem']).encode() ++ output=io.BytesIO() ++ with tarfile.open(fileobj=output,mode='w') as t: ++ m=tarfile.TarInfo('policy.json');m.size=2;t.addfile(m,io.BytesIO(b'{}')) ++ return output.getvalue() ++ def verify(self,code=0,output=None): ++ with patch.object(a,'check_jwks'),patch.object(a,'runtime_file',return_value=b'original-trust'),patch.object(a,'runtime_files',side_effect=lambda session,paths=(),tree=False:({k:v for k,v in self.files.items() if k.startswith('/var/lib/keycape-policy/')} if tree else dict(self.files))),patch.object(a,'container_file',side_effect=self.file),patch.object(a,'command',side_effect=self.command),patch.object(a.subprocess,'run',return_value=subprocess.CompletedProcess([],code,b'',(self.output if output is None else output).encode())) as run: ++ result=a.run_verifier(self.session,self.admission,self.payload) ++ argv=run.call_args.args[0] ++ self.assertNotIn('fixture-private',' '.join(argv)) ++ self.assertIn('-previous-secret-env',argv) ++ return result ++ def test_original_registration_and_unrelated_secret(self): ++ r=self.verify();self.assertTrue(r['wrong_unrelated_secret_explicitly_refused']);self.assertNotIn('fixture-private',json.dumps(r)) ++ def test_transport_failure_never_counts_as_wrong_secret(self): ++ with self.assertRaises(a.Refused):self.verify(1,'FAIL discovery and endpoint origin: TLS failure\n') ++ def test_missing_explicit_rejection_never_counts(self): ++ with self.assertRaises(a.Refused):self.verify(0,self.output.replace('PASS predecessor secret refused\n','')) ++ def test_fail_line_refused_even_zero_exit(self): ++ with self.assertRaises(a.Refused):self.verify(0,self.output+'FAIL transport\n') ++ def test_security_environment_override_refused(self): ++ original=self.command ++ def altered(argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem','SSL_CERT_DIR=/unreviewed']).encode() ++ return original(argv,**kwargs) ++ with patch.object(self,'command',side_effect=altered): ++ with self.assertRaises(a.Refused):self.verify() ++ def test_http_issuer_refused(self): ++ self.admission['issuer']='http://issuer.example' ++ with self.assertRaises(a.Refused):self.verify() ++ def test_changed_trust_refused(self): ++ self.admission['trust_sha256']='0'*64 ++ with self.assertRaises(a.Refused):self.verify() ++ def test_config_drift_refused(self): ++ self.payload['files']['/etc/keycape/config.yaml']=base64.b64encode(b'changed').decode() ++ with self.assertRaises(a.Refused):self.verify() ++ ++class BoundaryTests(unittest.TestCase): ++ def test_remote_docker_refused_before_info(self): ++ session={'run_id':'a'*32,'containers':['nk49-kc-'+('a'*32)+'-worker'],'worker':'nk49-kc-'+('a'*32)+'-worker','issuer':'nk49-kc-'+('a'*32)+'-worker'} ++ with patch.object(a,'command',return_value=b'[{"Endpoints":{"docker":{"Host":"tcp://remote:2375"}}}]') as command: ++ with self.assertRaises(a.Refused):a.inspect_session(session) ++ self.assertEqual(command.call_count,1) ++ def test_failed_verifier_cleanup_is_attempted(self): ++ receipt={};admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} ++ with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',side_effect=a.Refused),patch.object(a,'cleanup') as cleanup: ++ with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) ++ cleanup.assert_called_once();self.assertTrue(receipt['container_cleanup_verified']);self.assertEqual(receipt['status'],'failed') ++ ++ def test_cleanup_failure_overrides_success(self): ++ admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} ++ result={x:True for x in ('native_service_registration_verified','wrong_unrelated_secret_explicitly_refused','denied_scopes_refused','original_config_registry_policy_receipts_equal','original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks')} ++ result['public_signing_spki_sha256']='e'*64;receipt={} ++ with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',return_value=result),patch.object(a,'cleanup',side_effect=a.Refused): ++ with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) ++ self.assertEqual(receipt['status'],'failed');self.assertFalse(receipt['container_cleanup_verified']) ++ ++class ContainmentTests(unittest.TestCase): ++ def inspect(self,overrides=None,launch=None): ++ name='nk49-kc-'+('a'*32)+'-issuer' ++ session={'run_id':'a'*32,'containers':[name],'issuer':name,'worker':name,'images':{name:a.IMAGE}} ++ host={'NetworkMode':'none','ReadonlyRootfs':True,'Privileged':False,'CapDrop':['ALL'],'SecurityOpt':['no-new-privileges']} ++ host.update(overrides or {}) ++ def command(argv,**kwargs): ++ if argv[1:3]==['context','inspect']:return b'[{"Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]' ++ if argv[1]=='info':return b'{"ID":"74f93783-6cef-43a8-a3c4-f62586cd3bc1","Name":"bnt-lap001","OSType":"linux"}' ++ if argv[-1]=='{{.Id}}':return b'anchor-id' ++ if '"path"' in argv[-1]:return json.dumps(launch or {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}).encode() ++ return json.dumps({'host':host,'mounts':[],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode() ++ with patch.object(a,'command',side_effect=command),patch.dict('os.environ',{'DOCKER_HOST':'unix:///var/run/docker.sock'}):a.inspect_session(session) ++ def test_minimal_network_none_admitted(self):self.inspect() ++ def test_alternate_same_image_command_refused(self): ++ with self.assertRaises(a.Refused):self.inspect(launch={'entrypoint':['/keycape'],'cmd':['check-config'],'path':'/keycape','args':['check-config']}) ++ def test_cap_add_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'CapAdd':['NET_ADMIN']}) ++ def test_devices_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'Devices':[{'PathOnHost':'/dev/mem'}]}) ++ def test_device_requests_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'DeviceRequests':[{'Driver':'nvidia'}]}) ++ def test_host_namespace_refused(self): ++ for field in ('PidMode','IpcMode','UTSMode','UsernsMode','CgroupnsMode'): ++ with self.subTest(field=field),self.assertRaises(a.Refused):self.inspect({field:'host'}) ++ def test_security_override_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'SecurityOpt':['no-new-privileges','seccomp=unconfined']}) ++ ++class CleanupTests(unittest.TestCase): ++ def test_partial_cleanup_attempts_every_owned_container(self): ++ session={'run_id':'a'*32,'containers':['first','second','third']};receipt={} ++ removed=[] ++ def command(argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return ('a'*32).encode() ++ if argv[1]=='rm': ++ removed.append(argv[-1]) ++ if argv[-1]=='third':raise a.Refused() ++ return b'' ++ if 'name=^/third$' in argv:return b'third\n' ++ return b'' ++ with patch.object(a,'command',side_effect=command): ++ with self.assertRaises(a.Refused):a.cleanup(session,receipt) ++ self.assertEqual(removed,['third','second','first']) ++ self.assertEqual([r['status'] for r in receipt['container_cleanup_obligations']],['still_present','absence_verified','absence_verified']) ++ def test_ownership_mismatch_does_not_remove_and_continues(self): ++ session={'run_id':'a'*32,'containers':['first','second']};receipt={};removed=[] ++ def command(argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return (('b' if argv[3]=='second' else 'a')*32).encode() ++ if argv[1]=='rm':removed.append(argv[-1]) ++ return b'' ++ with patch.object(a,'command',side_effect=command): ++ with self.assertRaises(a.Refused):a.cleanup(session,receipt) ++ self.assertEqual(removed,['first']) ++ self.assertEqual(receipt['container_cleanup_obligations'][0]['status'],'ownership_mismatch_not_removed') ++ def test_inspect_failure_retains_uncertain_obligation(self): ++ receipt={} ++ with patch.object(a,'command',side_effect=a.Refused): ++ with self.assertRaises(a.Refused):a.cleanup({'run_id':'a'*32,'containers':['exact-owned-name']},receipt) ++ self.assertEqual(receipt['container_cleanup_obligations'],[{'container':'exact-owned-name','status':'unverified'}]) ++ ++class JWKSTests(unittest.TestCase): ++ def execute(self,n='123',endpoint='https://issuer.example/jwks',resolved='127.0.0.1',extra=False,kid='key-1'): ++ request={'issuer':'https://issuer.example','trust':'public-certificate','n':'123','e':'65537'} ++ def enc(i):return base64.urlsafe_b64encode(int(i).to_bytes((int(i).bit_length()+7)//8,'big')).decode().rstrip('=') ++ responses=[{'issuer':'https://issuer.example','jwks_uri':endpoint},{'keys':[{'kty':'RSA','kid':kid,'use':'sig','alg':'RS256','n':enc(n),'e':enc(65537)}]}] ++ if extra:responses[1]['keys'].append(dict(responses[1]['keys'][0],kid='key-2')) ++ class Response: ++ def __init__(self,value):self.value=value ++ def __enter__(self):return self ++ def __exit__(self,*args):pass ++ def read(self,*args):return json.dumps(self.value).encode() ++ class Opener: ++ def open(self,*args,**kwargs):return Response(responses.pop(0)) ++ with patch('sys.stdin',io.StringIO(json.dumps(request))),patch('sys.stdout',io.StringIO()),patch('socket.getaddrinfo',return_value=[(2,1,6,'',(resolved,443))]),patch('ssl.create_default_context'),patch('urllib.request.build_opener',return_value=Opener()): ++ exec(compile(a.JWKS_PROBE,'','exec',optimize=2),{}) ++ def test_exact_original_public_key(self):self.execute() ++ def test_extra_signing_key_refused_under_optimization(self): ++ with self.assertRaises(ValueError):self.execute(extra=True) ++ def test_wrong_kid_refused_under_optimization(self): ++ with self.assertRaises(ValueError):self.execute(kid='replacement') ++ def test_wrong_live_signing_key_refused(self): ++ with self.assertRaises(ValueError):self.execute(n='124') ++ def test_cross_origin_jwks_refused(self): ++ with self.assertRaises(ValueError):self.execute(endpoint='https://production.example/jwks') ++ def test_production_dns_refused_before_fetch(self): ++ with self.assertRaises(ValueError):self.execute(resolved='192.0.2.1') ++ ++if __name__=='__main__':unittest.main() +--- /dev/null ++++ b/tests/test_isolated_keycape_session.py +@@ -0,0 +1,62 @@ ++import base64 ++import importlib.util ++import json ++from pathlib import Path ++import unittest ++from unittest.mock import patch ++ ++SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape-session.py' ++spec=importlib.util.spec_from_file_location('session',SOURCE) ++s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s) ++ ++class SessionTests(unittest.TestCase): ++ def test_tmpfs_plan_has_four_exact_original_mounts(self): ++ session=s.plan_session() ++ self.assertEqual({v['target'] for v in session['volumes'].values()},set(s.RUNROOTS)) ++ self.assertTrue(all(v['options']==s.VOLUME_OPTIONS for v in session['volumes'].values())) ++ self.assertEqual(session['images'][session['issuer']],s.adapter.IMAGE) ++ def test_non_tmpfs_driver_options_refused(self): ++ session=s.plan_session();name,spec=next(iter(session['volumes'].items())) ++ wrong=[{'Name':name,'Driver':'local','Scope':'local','Options':{'type':'none','device':'/host/data'},'Labels':{s.adapter.LABEL:session['run_id']}}] ++ with patch.object(s,'run',return_value=json.dumps(wrong).encode()): ++ with self.assertRaises(s.adapter.Refused):s.verify_volume(name,spec,session['run_id']) ++ def test_owner_secret_is_not_docker_argv_and_import_is_after_inspection(self): ++ session=s.plan_session();events=[] ++ payload={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'original').decode()},'env':{'KEYCAPE_TEST_SECRET':'fixture-sensitive'}} ++ def run(argv,**kwargs): ++ events.append(('run',argv,kwargs)) ++ if argv[-1]=='{{.Id}}':return b'anchor' ++ return b'' ++ with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=lambda *args,**kwargs:events.append(('inspect',args,kwargs))): ++ s.provision(session,payload,{'/recovery-tls/trust.pem':b'original-trust'},'https://issuer.example') ++ joined=' '.join(' '.join(e[1]) for e in events if e[0]=='run') ++ self.assertNotIn('fixture-sensitive',joined) ++ private_index=next(i for i,e in enumerate(events) if e[0]=='run' and s.IMPORT in e[1]) ++ inspect_index=next(i for i,e in enumerate(events) if e[0]=='inspect') ++ self.assertLess(inspect_index,private_index) ++ issuer_call=next(e for e in events if e[0]=='run' and e[1][-1]==s.adapter.IMAGE) ++ self.assertEqual(issuer_call[2]['env']['KEYCAPE_TEST_SECRET'],'fixture-sensitive') ++ self.assertIn('KEYCAPE_TEST_SECRET',issuer_call[1]) ++ self.assertNotIn('--entrypoint',issuer_call[1]) ++ def test_non_original_https_port_refused_before_any_mutation(self): ++ with patch.object(s,'run') as run: ++ with self.assertRaises(s.adapter.Refused):s.provision(s.plan_session(),{'files':{},'env':{}},{},'http://issuer.example') ++ run.assert_not_called() ++ def test_container_failure_still_attempts_each_volume_cleanup(self): ++ session=s.plan_session();receipt={};removed=[] ++ def run(argv,**kwargs): ++ if argv[1:3]==['volume','rm']:removed.append(argv[-1]) ++ return b'' ++ with patch.object(s.adapter,'cleanup',side_effect=s.adapter.Refused),patch.object(s,'verify_volume'),patch.object(s,'run',side_effect=run): ++ with self.assertRaises(s.adapter.Refused):s.cleanup_all(session,receipt) ++ self.assertEqual(set(removed),set(session['volumes'])) ++ self.assertFalse(receipt['container_cleanup_verified']) ++ self.assertEqual(len(receipt['tmpfs_volume_cleanup_obligations']),4) ++ def test_reaper_inherits_no_custody_or_service_environment(self): ++ with patch.object(s.subprocess,'Popen') as spawn: ++ s.start_reaper(s.plan_session()) ++ kwargs=spawn.call_args.kwargs ++ self.assertEqual(set(kwargs['env']),{'PATH','DOCKER_HOST'}) ++ self.assertTrue(kwargs['close_fds']);self.assertTrue(kwargs['start_new_session']) ++ ++if __name__=='__main__':unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tests/test_isolated_keycape_recovery.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tests/test_isolated_keycape_recovery.py new file mode 100644 index 00000000..a4f67c72 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tests/test_isolated_keycape_recovery.py @@ -0,0 +1,218 @@ +import base64 +import hashlib +import importlib.util +import io +import json +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape.py' +spec=importlib.util.spec_from_file_location('adapter',SOURCE) +a=importlib.util.module_from_spec(spec);spec.loader.exec_module(a) + +class ArchiveTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) + self.root=Path(self.tmp.name) + config={'data':{k:base64.b64encode(v).decode() for k,v in {'config.yaml':b'issuer: https://issuer.example','key.pem':b'fixture-key'}.items()}} + self.values={'resources/sso/secret/keycape-config.json':json.dumps(config).encode(), + 'resources/sso/deployment/keycape.json':json.dumps({'spec':{'template':{'spec':{'containers':[{'name':'keycape','image':a.IMAGE,'env':[ + {'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'}, + {'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'}, + {'name':'KEYCAPE_ORIGINAL_SECRET','valueFrom':{'secretKeyRef':{'name':'client','key':'secret'}}}]}]}}}}).encode(), + 'resources/sso/secret/client.json':json.dumps({'data':{'secret':base64.b64encode(b'fixture-private').decode()}}).encode(), + 'data/keycape/policy.json':b'{}','data/keycape/receipts/one.json':b'{"original":true}'} + for name,value in self.values.items(): + p=self.root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(value);p.chmod(0o600) + self.manifest={'profile':{'components':[{'namespace':'sso','deployment':'keycape','secret_refs':['keycape-config','client'],'configmap_refs':[]}]}, + 'files':[{'path':k,'bytes':len(v),'sha256':hashlib.sha256(v).hexdigest()} for k,v in self.values.items()]} + def payload(self): + digest=hashlib.sha256(json.dumps(self.manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest() + return a.private_payload(self.root,self.manifest,digest) + def test_original_state_and_secret_delivery_retained(self): + p=self.payload();self.assertEqual(p['env']['KEYCAPE_ORIGINAL_SECRET'],'fixture-private') + self.assertEqual(base64.b64decode(p['files']['/var/lib/keycape-policy/receipts/one.json']),b'{"original":true}') + def test_archive_digest_refused(self): + with self.assertRaises(a.Refused):a.private_payload(self.root,self.manifest,'0'*64) + def test_changed_file_refused(self): + (self.root/'data/keycape/policy.json').write_text('{"changed":true}') + with self.assertRaises(a.Refused):self.payload() + def test_symlink_refused(self): + p=self.root/'data/keycape/policy.json';p.unlink();p.symlink_to('/etc/hostname') + with self.assertRaises(Exception):self.payload() + def test_duplicate_manifest_refused(self): + self.manifest['files'].append(self.manifest['files'][0]) + with self.assertRaises(a.Refused):self.payload() + def test_image_mismatch_refused(self): + d=json.loads(self.values['resources/sso/deployment/keycape.json']);d['spec']['template']['spec']['containers'][0]['image']='keycape:latest' + raw=json.dumps(d).encode();p=self.root/'resources/sso/deployment/keycape.json';p.write_bytes(raw) + for row in self.manifest['files']: + if row['path'].endswith('/deployment/keycape.json'):row.update(bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest()) + with self.assertRaises(a.Refused):self.payload() + +class NativeTests(unittest.TestCase): + def setUp(self): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + self.key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()) + self.cfg=b'issuer: https://issuer.example\nclients:\n- clientId: existing\n clientType: confidential\n secretRef: env:KEYCAPE_ORIGINAL_SECRET\n grantTypes: [client_credentials]\n' + self.files={'/etc/keycape/config.yaml':self.cfg,'/etc/keycape/key.pem':self.key,'/var/lib/keycape-policy/policy.json':b'{}'} + self.payload={'env':{'KEYCAPE_ORIGINAL_SECRET':'fixture-private'},'files':{k:base64.b64encode(v).decode() for k,v in self.files.items()}} + self.admission={'issuer':'https://issuer.example','verifier_sha256':hashlib.sha256(b'binary').hexdigest(), + 'trust_path':'/trust.pem','trust_sha256':hashlib.sha256(b'original-trust').hexdigest(), + 'consumer':{'client_id':'existing','secret_env':'KEYCAPE_ORIGINAL_SECRET','scope':'read','audience':'consumer', + 'subject':'service:existing','tenant':'tenant:platform','roles':'reader','deny_scope':'write'}} + self.session={'issuer':'isolated','volumes':{x:{'target':x} for x in ('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')}} + self.output=''.join('PASS '+x+'\n' for x in ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings','principal_type is service','sub matches the registration','tenant matches the registration','roles present','granted scope carries no excess grant','predecessor secret refused','excess scope refused: write']) + def file(self,container,path): + return {**self.files,'/etc/hosts':b'127.0.0.1 issuer.example\n','/keycape':b'binary','/trust.pem':b'original-trust'}[path] + def command(self,argv,**kwargs): + if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem']).encode() + output=io.BytesIO() + with tarfile.open(fileobj=output,mode='w') as t: + m=tarfile.TarInfo('policy.json');m.size=2;t.addfile(m,io.BytesIO(b'{}')) + return output.getvalue() + def verify(self,code=0,output=None): + with patch.object(a,'check_jwks'),patch.object(a,'runtime_file',return_value=b'original-trust'),patch.object(a,'runtime_files',side_effect=lambda session,paths=(),tree=False:({k:v for k,v in self.files.items() if k.startswith('/var/lib/keycape-policy/')} if tree else dict(self.files))),patch.object(a,'container_file',side_effect=self.file),patch.object(a,'command',side_effect=self.command),patch.object(a.subprocess,'run',return_value=subprocess.CompletedProcess([],code,b'',(self.output if output is None else output).encode())) as run: + result=a.run_verifier(self.session,self.admission,self.payload) + argv=run.call_args.args[0] + self.assertNotIn('fixture-private',' '.join(argv)) + self.assertIn('-previous-secret-env',argv) + return result + def test_original_registration_and_unrelated_secret(self): + r=self.verify();self.assertTrue(r['wrong_unrelated_secret_explicitly_refused']);self.assertNotIn('fixture-private',json.dumps(r)) + def test_transport_failure_never_counts_as_wrong_secret(self): + with self.assertRaises(a.Refused):self.verify(1,'FAIL discovery and endpoint origin: TLS failure\n') + def test_missing_explicit_rejection_never_counts(self): + with self.assertRaises(a.Refused):self.verify(0,self.output.replace('PASS predecessor secret refused\n','')) + def test_fail_line_refused_even_zero_exit(self): + with self.assertRaises(a.Refused):self.verify(0,self.output+'FAIL transport\n') + def test_security_environment_override_refused(self): + original=self.command + def altered(argv,**kwargs): + if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem','SSL_CERT_DIR=/unreviewed']).encode() + return original(argv,**kwargs) + with patch.object(self,'command',side_effect=altered): + with self.assertRaises(a.Refused):self.verify() + def test_http_issuer_refused(self): + self.admission['issuer']='http://issuer.example' + with self.assertRaises(a.Refused):self.verify() + def test_changed_trust_refused(self): + self.admission['trust_sha256']='0'*64 + with self.assertRaises(a.Refused):self.verify() + def test_config_drift_refused(self): + self.payload['files']['/etc/keycape/config.yaml']=base64.b64encode(b'changed').decode() + with self.assertRaises(a.Refused):self.verify() + +class BoundaryTests(unittest.TestCase): + def test_remote_docker_refused_before_info(self): + session={'run_id':'a'*32,'containers':['nk49-kc-'+('a'*32)+'-worker'],'worker':'nk49-kc-'+('a'*32)+'-worker','issuer':'nk49-kc-'+('a'*32)+'-worker'} + with patch.object(a,'command',return_value=b'[{"Endpoints":{"docker":{"Host":"tcp://remote:2375"}}}]') as command: + with self.assertRaises(a.Refused):a.inspect_session(session) + self.assertEqual(command.call_count,1) + def test_failed_verifier_cleanup_is_attempted(self): + receipt={};admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} + with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',side_effect=a.Refused),patch.object(a,'cleanup') as cleanup: + with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) + cleanup.assert_called_once();self.assertTrue(receipt['container_cleanup_verified']);self.assertEqual(receipt['status'],'failed') + + def test_cleanup_failure_overrides_success(self): + admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} + result={x:True for x in ('native_service_registration_verified','wrong_unrelated_secret_explicitly_refused','denied_scopes_refused','original_config_registry_policy_receipts_equal','original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks')} + result['public_signing_spki_sha256']='e'*64;receipt={} + with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',return_value=result),patch.object(a,'cleanup',side_effect=a.Refused): + with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) + self.assertEqual(receipt['status'],'failed');self.assertFalse(receipt['container_cleanup_verified']) + +class ContainmentTests(unittest.TestCase): + def inspect(self,overrides=None,launch=None): + name='nk49-kc-'+('a'*32)+'-issuer' + session={'run_id':'a'*32,'containers':[name],'issuer':name,'worker':name,'images':{name:a.IMAGE}} + host={'NetworkMode':'none','ReadonlyRootfs':True,'Privileged':False,'CapDrop':['ALL'],'SecurityOpt':['no-new-privileges']} + host.update(overrides or {}) + def command(argv,**kwargs): + if argv[1:3]==['context','inspect']:return b'[{"Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]' + if argv[1]=='info':return b'{"ID":"74f93783-6cef-43a8-a3c4-f62586cd3bc1","Name":"bnt-lap001","OSType":"linux"}' + if argv[-1]=='{{.Id}}':return b'anchor-id' + if '"path"' in argv[-1]:return json.dumps(launch or {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}).encode() + return json.dumps({'host':host,'mounts':[],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode() + with patch.object(a,'command',side_effect=command),patch.dict('os.environ',{'DOCKER_HOST':'unix:///var/run/docker.sock'}):a.inspect_session(session) + def test_minimal_network_none_admitted(self):self.inspect() + def test_alternate_same_image_command_refused(self): + with self.assertRaises(a.Refused):self.inspect(launch={'entrypoint':['/keycape'],'cmd':['check-config'],'path':'/keycape','args':['check-config']}) + def test_cap_add_refused(self): + with self.assertRaises(a.Refused):self.inspect({'CapAdd':['NET_ADMIN']}) + def test_devices_refused(self): + with self.assertRaises(a.Refused):self.inspect({'Devices':[{'PathOnHost':'/dev/mem'}]}) + def test_device_requests_refused(self): + with self.assertRaises(a.Refused):self.inspect({'DeviceRequests':[{'Driver':'nvidia'}]}) + def test_host_namespace_refused(self): + for field in ('PidMode','IpcMode','UTSMode','UsernsMode','CgroupnsMode'): + with self.subTest(field=field),self.assertRaises(a.Refused):self.inspect({field:'host'}) + def test_security_override_refused(self): + with self.assertRaises(a.Refused):self.inspect({'SecurityOpt':['no-new-privileges','seccomp=unconfined']}) + +class CleanupTests(unittest.TestCase): + def test_partial_cleanup_attempts_every_owned_container(self): + session={'run_id':'a'*32,'containers':['first','second','third']};receipt={} + removed=[] + def command(argv,**kwargs): + if argv[1:3]==['container','inspect']:return ('a'*32).encode() + if argv[1]=='rm': + removed.append(argv[-1]) + if argv[-1]=='third':raise a.Refused() + return b'' + if 'name=^/third$' in argv:return b'third\n' + return b'' + with patch.object(a,'command',side_effect=command): + with self.assertRaises(a.Refused):a.cleanup(session,receipt) + self.assertEqual(removed,['third','second','first']) + self.assertEqual([r['status'] for r in receipt['container_cleanup_obligations']],['still_present','absence_verified','absence_verified']) + def test_ownership_mismatch_does_not_remove_and_continues(self): + session={'run_id':'a'*32,'containers':['first','second']};receipt={};removed=[] + def command(argv,**kwargs): + if argv[1:3]==['container','inspect']:return (('b' if argv[3]=='second' else 'a')*32).encode() + if argv[1]=='rm':removed.append(argv[-1]) + return b'' + with patch.object(a,'command',side_effect=command): + with self.assertRaises(a.Refused):a.cleanup(session,receipt) + self.assertEqual(removed,['first']) + self.assertEqual(receipt['container_cleanup_obligations'][0]['status'],'ownership_mismatch_not_removed') + def test_inspect_failure_retains_uncertain_obligation(self): + receipt={} + with patch.object(a,'command',side_effect=a.Refused): + with self.assertRaises(a.Refused):a.cleanup({'run_id':'a'*32,'containers':['exact-owned-name']},receipt) + self.assertEqual(receipt['container_cleanup_obligations'],[{'container':'exact-owned-name','status':'unverified'}]) + +class JWKSTests(unittest.TestCase): + def execute(self,n='123',endpoint='https://issuer.example/jwks',resolved='127.0.0.1',extra=False,kid='key-1'): + request={'issuer':'https://issuer.example','trust':'public-certificate','n':'123','e':'65537'} + def enc(i):return base64.urlsafe_b64encode(int(i).to_bytes((int(i).bit_length()+7)//8,'big')).decode().rstrip('=') + responses=[{'issuer':'https://issuer.example','jwks_uri':endpoint},{'keys':[{'kty':'RSA','kid':kid,'use':'sig','alg':'RS256','n':enc(n),'e':enc(65537)}]}] + if extra:responses[1]['keys'].append(dict(responses[1]['keys'][0],kid='key-2')) + class Response: + def __init__(self,value):self.value=value + def __enter__(self):return self + def __exit__(self,*args):pass + def read(self,*args):return json.dumps(self.value).encode() + class Opener: + def open(self,*args,**kwargs):return Response(responses.pop(0)) + with patch('sys.stdin',io.StringIO(json.dumps(request))),patch('sys.stdout',io.StringIO()),patch('socket.getaddrinfo',return_value=[(2,1,6,'',(resolved,443))]),patch('ssl.create_default_context'),patch('urllib.request.build_opener',return_value=Opener()): + exec(compile(a.JWKS_PROBE,'','exec',optimize=2),{}) + def test_exact_original_public_key(self):self.execute() + def test_extra_signing_key_refused_under_optimization(self): + with self.assertRaises(ValueError):self.execute(extra=True) + def test_wrong_kid_refused_under_optimization(self): + with self.assertRaises(ValueError):self.execute(kid='replacement') + def test_wrong_live_signing_key_refused(self): + with self.assertRaises(ValueError):self.execute(n='124') + def test_cross_origin_jwks_refused(self): + with self.assertRaises(ValueError):self.execute(endpoint='https://production.example/jwks') + def test_production_dns_refused_before_fetch(self): + with self.assertRaises(ValueError):self.execute(resolved='192.0.2.1') + +if __name__=='__main__':unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tests/test_isolated_keycape_session.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tests/test_isolated_keycape_session.py new file mode 100644 index 00000000..8ae11668 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tests/test_isolated_keycape_session.py @@ -0,0 +1,62 @@ +import base64 +import importlib.util +import json +from pathlib import Path +import unittest +from unittest.mock import patch + +SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape-session.py' +spec=importlib.util.spec_from_file_location('session',SOURCE) +s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s) + +class SessionTests(unittest.TestCase): + def test_tmpfs_plan_has_four_exact_original_mounts(self): + session=s.plan_session() + self.assertEqual({v['target'] for v in session['volumes'].values()},set(s.RUNROOTS)) + self.assertTrue(all(v['options']==s.VOLUME_OPTIONS for v in session['volumes'].values())) + self.assertEqual(session['images'][session['issuer']],s.adapter.IMAGE) + def test_non_tmpfs_driver_options_refused(self): + session=s.plan_session();name,spec=next(iter(session['volumes'].items())) + wrong=[{'Name':name,'Driver':'local','Scope':'local','Options':{'type':'none','device':'/host/data'},'Labels':{s.adapter.LABEL:session['run_id']}}] + with patch.object(s,'run',return_value=json.dumps(wrong).encode()): + with self.assertRaises(s.adapter.Refused):s.verify_volume(name,spec,session['run_id']) + def test_owner_secret_is_not_docker_argv_and_import_is_after_inspection(self): + session=s.plan_session();events=[] + payload={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'original').decode()},'env':{'KEYCAPE_TEST_SECRET':'fixture-sensitive'}} + def run(argv,**kwargs): + events.append(('run',argv,kwargs)) + if argv[-1]=='{{.Id}}':return b'anchor' + return b'' + with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=lambda *args,**kwargs:events.append(('inspect',args,kwargs))): + s.provision(session,payload,{'/recovery-tls/trust.pem':b'original-trust'},'https://issuer.example') + joined=' '.join(' '.join(e[1]) for e in events if e[0]=='run') + self.assertNotIn('fixture-sensitive',joined) + private_index=next(i for i,e in enumerate(events) if e[0]=='run' and s.IMPORT in e[1]) + inspect_index=next(i for i,e in enumerate(events) if e[0]=='inspect') + self.assertLess(inspect_index,private_index) + issuer_call=next(e for e in events if e[0]=='run' and e[1][-1]==s.adapter.IMAGE) + self.assertEqual(issuer_call[2]['env']['KEYCAPE_TEST_SECRET'],'fixture-sensitive') + self.assertIn('KEYCAPE_TEST_SECRET',issuer_call[1]) + self.assertNotIn('--entrypoint',issuer_call[1]) + def test_non_original_https_port_refused_before_any_mutation(self): + with patch.object(s,'run') as run: + with self.assertRaises(s.adapter.Refused):s.provision(s.plan_session(),{'files':{},'env':{}},{},'http://issuer.example') + run.assert_not_called() + def test_container_failure_still_attempts_each_volume_cleanup(self): + session=s.plan_session();receipt={};removed=[] + def run(argv,**kwargs): + if argv[1:3]==['volume','rm']:removed.append(argv[-1]) + return b'' + with patch.object(s.adapter,'cleanup',side_effect=s.adapter.Refused),patch.object(s,'verify_volume'),patch.object(s,'run',side_effect=run): + with self.assertRaises(s.adapter.Refused):s.cleanup_all(session,receipt) + self.assertEqual(set(removed),set(session['volumes'])) + self.assertFalse(receipt['container_cleanup_verified']) + self.assertEqual(len(receipt['tmpfs_volume_cleanup_obligations']),4) + def test_reaper_inherits_no_custody_or_service_environment(self): + with patch.object(s.subprocess,'Popen') as spawn: + s.start_reaper(s.plan_session()) + kwargs=spawn.call_args.kwargs + self.assertEqual(set(kwargs['env']),{'PATH','DOCKER_HOST'}) + self.assertTrue(kwargs['close_fds']);self.assertTrue(kwargs['start_new_session']) + +if __name__=='__main__':unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/isolated-keycape-session.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/isolated-keycape-session.py new file mode 100644 index 00000000..85aa6ddd --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/isolated-keycape-session.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Owner-run T18 session assembly using the existing archive/containment pipeline. + +Candidate only: native pinned-image tests and review are required before use. +Shared Docker local-driver tmpfs is required because Docker cp does not support +private tmpfs/mount paths, and the original KeyCape image is distroless. +""" +import argparse +import base64 +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time +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 + +adapter=load('isolated-keycape') +local=load('isolated-lldap') +bundle=local.bundle +WORKER=local.CLIENT +RUNROOTS=('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls') +VOLUME_OPTIONS={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} + +IMPORT=r''' +import base64,json,os,sys +from pathlib import Path +os.umask(0o077) +p=json.load(sys.stdin) +allowed=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') +for name,encoded in p.items(): + f=Path(name) + if not name.startswith(allowed) or '..' in f.parts or any(x.is_symlink() for x in (f,*f.parents)):raise ValueError() + f.parent.mkdir(mode=0o700,parents=True,exist_ok=True) + fd=os.open(f,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as out:out.write(base64.b64decode(encoded,validate=True)) +''' + +# Small transport inside the already pinned helper; it has only loopback routes. +# No token handling, auth bypass, dependency imitation, reset or notifier path. +TLS=r''' +import select,socket,socketserver,ssl,threading +context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.minimum_version=ssl.TLSVersion.TLSv1_2 +context.load_cert_chain('/recovery-tls/server.pem','/recovery-tls/server-key.pem') +class Handler(socketserver.BaseRequestHandler): + def handle(self): + try: + with context.wrap_socket(self.request,server_side=True) as incoming: + with socket.create_connection(('127.0.0.1',8080),timeout=10) as outgoing: + incoming.settimeout(15);outgoing.settimeout(15) + while True: + ready,_,_=select.select([incoming,outgoing],[],[],15) + if not ready:return + for source in ready: + data=source.recv(65536) + if not data:return + (outgoing if source is incoming else incoming).sendall(data) + except Exception:pass +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address=True + daemon_threads=True +with Server(('127.0.0.1',443),Handler) as server:server.serve_forever() +''' + + +def run(argv,**kwargs):return adapter.command(argv,**kwargs) + + +def plan_session(): + run_id=uuid.uuid4().hex + stem='nk49-kc-'+run_id + worker=stem+'-transport';issuer=stem+'-issuer' + volumes={stem+'-v'+str(i):{'target':root,'options':dict(VOLUME_OPTIONS)} for i,root in enumerate(RUNROOTS)} + return {'run_id':run_id,'containers':[worker,issuer], 'worker':worker,'issuer':issuer, + 'images':{worker:WORKER,issuer:adapter.IMAGE},'volumes':volumes} + + +def verify_volume(name,spec,run_id): + obj=json.loads(run(['docker','volume','inspect',name])) + if (len(obj)!=1 or obj[0]['Name']!=name or obj[0]['Driver']!='local' or obj[0].get('Scope')!='local' + or obj[0].get('Options')!=VOLUME_OPTIONS or spec['options']!=VOLUME_OPTIONS + or obj[0].get('Labels',{}).get(adapter.LABEL)!=run_id):raise adapter.Refused() + + +def base_args(session,name,network): + return ['docker','run','--detach','--pull=never','--name',name,'--network',network, + '--label',adapter.LABEL+'='+session['run_id'],'--read-only','--cap-drop','ALL', + '--security-opt','no-new-privileges','--user','1000:1000','--memory','512m','--cpus','1', + '--pids-limit','64','--log-driver','none','--restart','no', + '--tmpfs','/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700'] + + +def mounts(session,readonly=False): + args=[] + for name,spec in session['volumes'].items(): + args+=['--mount','type=volume,source='+name+',target='+spec['target']+(',readonly' if readonly else '')] + return args + + +def provision(session,payload,tls,issuer): + """Inspect containment and driver options before the first private import.""" + from urllib.parse import urlsplit + address=urlsplit(issuer) + if address.scheme!='https' or not address.hostname or address.port not in (None,443) or address.path not in ('','/'): + raise adapter.Refused() + for name,spec in session['volumes'].items(): + run(['docker','volume','create','--driver','local','--label',adapter.LABEL+'='+session['run_id'], + '--opt','type=tmpfs','--opt','device=tmpfs','--opt','o='+VOLUME_OPTIONS['o'],name]) + verify_volume(name,spec,session['run_id']) + args=base_args(session,session['worker'],'none')+mounts(session) + args+=['--sysctl','net.ipv4.ip_unprivileged_port_start=0','--add-host',address.hostname+':127.0.0.1', + '--entrypoint','python3',WORKER,'-c','import time;time.sleep(1800)'] + run(args) + # Check helper and every private filesystem before touching the ciphertext's + # private contents. Issuer is created only after originals are present. + adapter.inspect_session({**session,'containers':[session['worker']],'issuer':session['worker']},worker_only=True) + files={**payload['files'],**{k:base64.b64encode(v).decode() for k,v in tls.items()}} + run(['docker','exec','-i',session['worker'],'python3','-c',IMPORT],input=json.dumps(files).encode()) + anchor=run(['docker','container','inspect',session['worker'],'--format','{{.Id}}']).decode().strip() + args=base_args(session,session['issuer'],'container:'+anchor)+mounts(session,readonly=True) + # CLI argv carries environment names only. Original credentials stay in the + # owner subprocess environment and Docker's private container configuration. + env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':'unix:///var/run/docker.sock',**payload['env'], + 'SSL_CERT_FILE':'/recovery-tls/trust.pem'} + for name in (*payload['env'],'SSL_CERT_FILE'):args+=['--env',name] + args+=[adapter.IMAGE] + run(args,env=env) + adapter.inspect_session(session) + run(['docker','exec','--detach',session['worker'],'python3','-c',TLS]) + + +def cleanup_all(session,receipt): + failed=False + try:adapter.cleanup(session,receipt) + except Exception:failed=True + rows=[] + for name,spec in session['volumes'].items(): + row={'volume':name,'status':'unverified'};rows.append(row) + try: + verify_volume(name,spec,session['run_id']) + run(['docker','volume','rm',name]) + remaining=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines() + if remaining:row['status']='still_present';failed=True + else:row['status']='absence_verified' + except Exception:failed=True + receipt['tmpfs_volume_cleanup_obligations']=rows + receipt['container_cleanup_verified']=not failed + if failed:raise adapter.Refused() + +def start_reaper(session): + # Scoped cleanup child contains only public exact run identifiers, no custody + # descriptors or secret environment. It survives loss of the parent owner + # process; normal successful cleanup cancels it. This is not a standing job. + code="""import importlib.util,json,sys,time +spec=importlib.util.spec_from_file_location('session',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +time.sleep(1800) +try:m.cleanup_all(json.loads(sys.argv[2]),{}) +except Exception:pass +""" + return subprocess.Popen([sys.executable,'-c',code,str(Path(__file__).resolve()),json.dumps(session)], + stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL, + env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':'unix:///var/run/docker.sock'}, + close_fds=True,start_new_session=True) + + +def execute(transfer,admission,tls,receipt): + """Existing private custody/transfer/archive APIs; no second archive format.""" + target=local.preflight() + for image in (adapter.IMAGE,):run(['docker','image','inspect',image]) + local.offline.validate(transfer) + if transfer.get('independent_owner_retrieval_verified') is not True:raise adapter.Refused() + path=Path(transfer['fetched_path']);bundle.private(path,max_bytes=bundle.LIMIT) + if (path.stat().st_size!=transfer['ciphertext_bytes'] or local.offline.offsite.digest(path)!=transfer['ciphertext_sha256'] + or admission['ciphertext_sha256']!=transfer['ciphertext_sha256']):raise adapter.Refused() + if hashlib.sha256(tls['/recovery-tls/trust.pem']).hexdigest()!=admission['trust_sha256']:raise adapter.Refused() + if hashlib.sha256(tls['/recovery-tls/server.pem']).hexdigest()!=admission['tls_cert_sha256']:raise adapter.Refused() + # Validate exact private certificate/key pair and original issuer name before + # opening service containers; do not generate replacement trust or keys. + from cryptography import x509 + from cryptography.hazmat.primitives import serialization + cert=x509.load_pem_x509_certificate(tls['/recovery-tls/server.pem']) + key=serialization.load_pem_private_key(tls['/recovery-tls/server-key.pem'],password=None) + if cert.public_key().public_numbers()!=key.public_key().public_numbers():raise adapter.Refused() + keyfd=local.verify.custody.private_key_fd();work=None;session=None;reaper=None + receipt.update(status='failed',target=target,session_source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + source_files_sha256={name:hashlib.sha256((HERE/name).read_bytes()).hexdigest() for name in ('isolated-keycape.py','isolated-keycape-session.py','run-isolated-keycape.sh','isolated-lldap.py','identity-bundle.py','verify-identity.py','retrieve-identity-offline.py','identity-offsite.py')}, + temporary_plaintext_cleanup_verified=False,container_cleanup_verified=False, + downstream_consumer_reconnect_proven=False,human_mfa_proven=False) + try: + if run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip()!=bundle.profile()['public_recipient'].encode():raise adapter.Refused() + with tempfile.TemporaryDirectory(prefix='nk49-keycape-',dir='/dev/shm') as temporary: + work=Path(temporary);archive=work/'archive.tar' + with archive.open('xb') as output: + p=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}',str(path)],stdout=output,stderr=subprocess.DEVNULL,pass_fds=(keyfd,),timeout=180) + if p.returncode or archive.stat().st_size>bundle.LIMIT+4*1024*1024:raise adapter.Refused() + root=work/'restored';root.mkdir(mode=0o700) + manifest,_=bundle.restore(archive,root) + payload=adapter.private_payload(root,manifest,admission['manifest_sha256']) + import yaml + cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml'])) + if cfg.get('port')!=8080 or cfg.get('issuer')!=admission['issuer']:raise adapter.Refused() + # Public executable readback uses the daemon archive API only on the + # original image rootfs, never a tmpfs or private user-created mount. + session=plan_session() + reaper=start_reaper(session) + receipt['bounded_cleanup_reaper_started']=True + probe='nk49-kc-'+session['run_id']+'-image-probe' + try: + run(['docker','create','--name',probe,'--network','none','--read-only','--label',adapter.LABEL+'='+session['run_id'],adapter.IMAGE]) + binary=adapter.container_file(probe,'/keycape') + if hashlib.sha256(binary).hexdigest()!=admission['verifier_sha256']:raise adapter.Refused() + finally: + receipt['public_image_probe_cleanup_obligations']=[{'container':probe,'status':'unverified'}] + run(['docker','rm',probe]) + if run(['docker','container','ls','--all','--filter','name=^/'+probe+'$','--format','{{.Names}}']).strip():raise adapter.Refused() + receipt['public_image_probe_cleanup_obligations'][0]['status']='absence_verified' + provision(session,payload,tls,admission['issuer']) + receipt.update(ciphertext_sha256=transfer['ciphertext_sha256'],all_manifest_hashes_verified=True,manifest_file_count=len(manifest['files'])) + checked_admission={**admission,'session':session,'trust_path':'/recovery-tls/trust.pem'} + # Native checker consumes no service secret argument or new grant. + # Retry only bounded public readiness; no account/factor attempt. + for attempt in range(40): + try: + numbers=serialization.load_pem_private_key(base64.b64decode(payload['files']['/etc/keycape/key.pem']),password=None).public_key().public_numbers() + adapter.check_jwks(session,checked_admission,numbers);break + except Exception: + if attempt==39:raise + time.sleep(.25) + adapter.native_checks(root,manifest,checked_admission,receipt,cleanup_session=False) + finally: + os.close(keyfd) + try: + if session is not None:cleanup_all(session,receipt) + if reaper is not None: + reaper.terminate();reaper.wait(timeout=10) + except Exception: + receipt['status']='failed' + raise + finally: + if work is not None and not work.exists():receipt['temporary_plaintext_cleanup_verified']=True + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust','receipt'):p.add_argument('--'+name,required=True) + args=p.parse_args();os.umask(0o077) + fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + receipt={'schema':'netkingdom.isolated-keycape-recovery.v1','status':'failed','production_contacted':False,'secret_values_exported':False} + try: + raw=Path(args.admission).read_bytes() + if hashlib.sha256(raw).hexdigest()!=args.admission_sha256:raise adapter.Refused() + admission=json.loads(raw) + tls={} + for name,path in (('server.pem',args.tls_cert),('server-key.pem',args.tls_key),('trust.pem',args.trust)): + source=Path(path);bundle.private(source,max_bytes=1024*1024);tls['/recovery-tls/'+name]=source.read_bytes() + execute(json.loads(Path(args.transfer_receipt).read_text()),admission,tls,receipt) + except (Exception,KeyboardInterrupt):receipt.update(status='failed',error='isolated_keycape_failed_private_diagnostics_suppressed') + finally: + receipt['finished_at']=datetime.now(timezone.utc).isoformat() + with os.fdopen(fd,'w') as out:json.dump(receipt,out,indent=2);out.write('\n') + print('OK: isolated KeyCape native checks and cleanup passed.' if receipt['status']=='isolated_keycape_native_registration_checked' else 'ERROR: isolated KeyCape stopped; review safe receipt.') + return int(receipt['status']!='isolated_keycape_native_registration_checked') + +if __name__=='__main__': + for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,local.offline.interrupted) + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/isolated-keycape.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/isolated-keycape.py new file mode 100644 index 00000000..5c960e17 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/isolated-keycape.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +"""T18 native adapter for an owner-admitted, already restored isolated session. + +Called after identity-bundle.restore, never with an unvalidated directory. This +module does not provision TLS, grant credentials, or claim downstream acceptance. +The existing decrypt/tmpfs wrapper remains responsible for archive cleanup. +""" +import base64 +import hashlib +import importlib.util +import json +import io +import secrets +import tarfile +import re +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent +IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010' +LABEL = 'net-kingdom.recovery-run' + + +class Refused(Exception): + pass + + +def load_bundle(): + spec = importlib.util.spec_from_file_location('identity_bundle', HERE / 'identity-bundle.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def private_payload(root, manifest, expected_manifest_sha256): + """Bind the complete validated archive before selecting KeyCape material. + + Private hashes never enter the public receipt. The digest is supplied by the + existing private archive admission, not inferred from an untrusted manifest. + """ + encoded = json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode() + if hashlib.sha256(encoded).hexdigest() != expected_manifest_sha256: + raise Refused() + entries = manifest['files'] + if len(entries) != len({e['path'] for e in entries}): + raise Refused() + values = {} + bundle = load_bundle() + for entry in entries: + if not bundle.allowed(entry['path'], manifest['profile']): + raise Refused() + path = root / entry['path'] + bundle.private(path) + value = path.read_bytes() + if len(value) != entry['bytes'] or hashlib.sha256(value).hexdigest() != entry['sha256']: + raise Refused() + values[entry['path']] = value + deployment = json.loads(values['resources/sso/deployment/keycape.json']) + main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'keycape') + if main.get('command') or main.get('args') or main.get('envFrom'): + raise Refused() + if main['image'] != IMAGE: + # Mutable-tag/historical mismatches need a separate explicit owner admission. + raise Refused() + secret = json.loads(values['resources/sso/secret/keycape-config.json'])['data'] + files = {'/etc/keycape/' + name: base64.b64decode(secret[name], validate=True) + for name in ('config.yaml', 'key.pem')} + for path, value in values.items(): + if path.startswith('data/keycape/'): + relative = path.removeprefix('data/keycape/') + files['/var/lib/keycape-policy/' + relative] = value + if '/var/lib/keycape-policy/policy.json' not in files: + raise Refused() + if 'resources/sso/secret/keycape-factor-read.json' in values: + factor=json.loads(values['resources/sso/secret/keycape-factor-read.json'])['data'] + files['/etc/keycape-factor/admin-token']=base64.b64decode(factor['admin-token'],validate=True) + # Resolve original delivered environment, including all referenced secrets. + env = {} + for item in main.get('env', []): + if not re.fullmatch(r'KEYCAPE_[A-Z0-9_]+',item['name']):raise Refused() + if item['name'] in env: + raise Refused() + if 'value' in item: + env[item['name']] = item['value'] + else: + ref = item['valueFrom']['secretKeyRef'] + source = json.loads(values['resources/sso/secret/' + ref['name'] + '.json'])['data'] + env[item['name']] = base64.b64decode(source[ref['key']], validate=True).decode() + if env.get('KEYCAPE_CONFIG') != '/etc/keycape/config.yaml' or env.get('KEYCAPE_POLICY_PATH') != '/var/lib/keycape-policy/policy.json': + raise Refused() + return {'files': {p: base64.b64encode(v).decode() for p, v in files.items()}, 'env': env} + + +def command(argv, **kwargs): + maximum = kwargs.pop('max_output', 32768) + result = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=kwargs.pop('timeout', 180), **kwargs) + if result.returncode or len(result.stdout) > maximum: + raise Refused() + return result.stdout + + +def inspect_session(session, worker_only=False): + """No credential environment is read by Docker inspect or returned to caller.""" + if not re.fullmatch(r'[a-f0-9]{32}', session['run_id']): + raise Refused() + names = session['containers'] + if not names or len(names) > 12 or len(set(names)) != len(names): + raise Refused() + for name in names: + if not re.fullmatch(r'nk49-kc-' + session['run_id'] + r'-[a-z0-9-]{1,24}', name): + raise Refused() + if session['worker'] not in names or session['issuer'] not in names: + raise Refused() + import os + contexts=json.loads(command(['docker','context','inspect'])) + endpoints=[contexts[0]['Endpoints']['docker']['Host'],os.environ.get('DOCKER_HOST','unix:///var/run/docker.sock')] + if not all(x.startswith('unix://') for x in endpoints):raise Refused() + info = json.loads(command(['docker', 'info', '--format', '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}'])) + if info != {'ID': '74f93783-6cef-43a8-a3c4-f62586cd3bc1', 'Name': 'bnt-lap001', 'OSType': 'linux'}: + raise Refused() + # The worker is the anchor; all dependencies/TLS/issuer share its network-none namespace. + anchor = command(['docker', 'container', 'inspect', session['worker'], '--format', '{{.Id}}']).decode().strip() + for name,spec in session.get('volumes',{}).items(): + volume=json.loads(command(['docker','volume','inspect',name])) + expected={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} + if len(volume)!=1 or volume[0]['Driver']!='local' or volume[0].get('Scope')!='local' or volume[0].get('Options')!=expected or spec['options']!=expected or volume[0].get('Labels',{}).get(LABEL)!=session['run_id']:raise Refused() + for name in names: + obj = json.loads(command(['docker', 'container', 'inspect', name, '--format', + '{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"labels":{{json .Config.Labels}},"image":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}}}'])) + host = obj['host'] + mounts=obj['mounts'] + destinations=[m['Destination'] for m in mounts] + if len(destinations)!=len(set(destinations)):raise Refused() + for volume,expected_spec in session.get('volumes',{}).items(): + matches=[m for m in mounts if m['Destination']==expected_spec['target']] + if len(matches)!=1 or matches[0]['Type']!='volume' or matches[0]['Name']!=volume or matches[0]['RW']!=(name==session['worker']):raise Refused() + if (obj['labels'].get(LABEL) != session['run_id'] or not obj['user'] or obj['user'].split(':')[0] in ('0', 'root') + or not host['ReadonlyRootfs'] or host['Privileged'] or host.get('Binds') or host.get('PortBindings') + or host.get('PidMode') or host.get('IpcMode') == 'host' + or host.get('UTSMode') or host.get('UsernsMode') == 'host' or host.get('CgroupnsMode') == 'host' + or host.get('Sysctls',{}) not in ({},{'net.ipv4.ip_unprivileged_port_start':'0'} if name==session['worker'] else {}) + or host.get('CapAdd') or host.get('Devices') or host.get('DeviceRequests') or host.get('VolumesFrom') + or host.get('CapDrop') != ['ALL'] + or host.get('SecurityOpt') not in (['no-new-privileges'], ['no-new-privileges:true']) + or any(m['Type']!='tmpfs' and not (m['Type']=='volume' and m['Name'] in session.get('volumes',{}) and m['Destination']==session['volumes'][m['Name']]['target']) for m in obj['mounts']) + or host['NetworkMode'] != ('none' if name == session['worker'] else 'container:' + anchor) + or obj['image'] != session['images'][name] or '@sha256:' not in obj['image']): + raise Refused() + if worker_only:return + if session['images'][session['issuer']] != IMAGE: + raise Refused() + # Known admitted archive has no command/args override; image default /keycape + # must be the actual issuer executable, not an alternate same-image command. + launch=json.loads(command(['docker','container','inspect',session['issuer'],'--format', + '{"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"path":{{json .Path}},"args":{{json .Args}}}'])) + if launch != {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}: + raise Refused() + + +def container_file(container, path): + # Docker streams one tar member into memory; never materialize private files. + raw = command(['docker', 'cp', container + ':' + path, '-'], max_output=128*1024*1024) + with tarfile.open(fileobj=io.BytesIO(raw), mode='r:') as archive: + members = archive.getmembers() + if len(members) != 1 or not members[0].isfile() or members[0].size > 96*1024*1024: + raise Refused() + return archive.extractfile(members[0]).read() + + +JWKS_PROBE = r""" +import base64,ipaddress,json,socket,ssl,sys,urllib.request +from urllib.parse import urlsplit +p=json.load(sys.stdin);issuer=p['issuer'];origin=urlsplit(issuer) +def require(condition): + if not condition:raise ValueError('public_jwks_binding_refused') +require(origin.scheme=='https') +resolved=socket.getaddrinfo(origin.hostname,origin.port or 443,type=socket.SOCK_STREAM) +require(bool(resolved) and all(ipaddress.ip_address(x[4][0]).is_loopback for x in resolved)) +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):return None +opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect(), + urllib.request.HTTPSHandler(context=ssl.create_default_context(cadata=p['trust']))) +def get(url): + u=urlsplit(url) + require((u.scheme,u.hostname,u.port or 443)==(origin.scheme,origin.hostname,origin.port or 443)) + require(not u.username and not u.password and not u.fragment) + with opener.open(url,timeout=10) as response: + raw=response.read(1024*1024+1);require(len(raw)<=1024*1024) + return json.loads(raw) +d=get(issuer.rstrip('/')+'/.well-known/openid-configuration');require(d['issuer']==issuer) +keys=get(d['jwks_uri'])['keys'] +def integer(value):return int.from_bytes(base64.urlsafe_b64decode(value+'='*(-len(value)%4)),'big') +# The exact admitted owner release constructs one key, kid key-1. Reject every +# extra key, including irrelevant keys: a native successful exchange can then +# only have used the original signer through the existing verifier. +require(isinstance(keys,list) and len(keys)==1) +k=keys[0] +require(k.get('kty')=='RSA' and k.get('kid')=='key-1' and k.get('use')=='sig' and k.get('alg')=='RS256') +require(integer(k['n'])==int(p['n']) and integer(k['e'])==int(p['e'])) +print('{"original_public_signing_key_in_live_jwks":true}') +""" + + +def check_jwks(session, admission, public_numbers): + trust=runtime_file(session,admission['trust_path']).decode() + request={'issuer':admission['issuer'],'trust':trust,'n':str(public_numbers.n),'e':str(public_numbers.e)} + result=json.loads(command(['docker','exec','-i',session['worker'],'python3','-c',JWKS_PROBE],input=json.dumps(request).encode())) + if result != {'original_public_signing_key_in_live_jwks': True}:raise Refused() + + +RUNTIME_FILES = r""" +import base64,json,sys +from pathlib import Path +p=json.load(sys.stdin) +roots=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') +result={} +paths=[] +if p.get('tree'): + paths=list(Path('/var/lib/keycape-policy').rglob('*')) +else:paths=[Path(x) for x in p['paths']] +for f in paths: + if f.is_dir():continue + if not str(f).startswith(roots) or not f.is_file() or any(x.is_symlink() for x in (f,*f.parents)) or f.stat().st_size>8*1024*1024:raise ValueError() + result[str(f)]=base64.b64encode(f.read_bytes()).decode() + if len(result)>4096:raise ValueError() +print(json.dumps(result)) +""" + + +def runtime_files(session, paths=(), tree=False): + raw=command(['docker','exec','-i',session['worker'],'python3','-c',RUNTIME_FILES], + input=json.dumps({'paths':list(paths),'tree':tree}).encode(),max_output=48*1024*1024) + return {k:base64.b64decode(v,validate=True) for k,v in json.loads(raw).items()} + + +def runtime_file(session,path): + return runtime_files(session,[path])[path] + + +def run_verifier(session, admission, payload): + issuer = session['issuer'] + if {v['target'] for v in session.get('volumes',{}).values()}!={'/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls'}:raise Refused() + from urllib.parse import urlsplit + import ipaddress + address = urlsplit(admission['issuer']) + if (address.scheme != 'https' or not address.hostname or address.username or address.password + or address.query or address.fragment): + raise Refused() + if hashlib.sha256(container_file(issuer, '/keycape')).hexdigest() != admission['verifier_sha256']: + raise Refused() + if hashlib.sha256(runtime_file(session, admission['trust_path'])).hexdigest() != admission['trust_sha256']: + raise Refused() + # Capture private environment only in owner process memory, never receipt. + environment = json.loads(command(['docker','container','inspect',issuer,'--format','{{json .Config.Env}}'])) + env = dict(item.split('=',1) for item in environment) + if len(env)!=len(environment):raise Refused() + if set(env)-set(payload['env'])-{'PATH','HOME','HOSTNAME','SSL_CERT_FILE'}:raise Refused() + if env.get('SSL_CERT_FILE') != admission['trust_path'] or any(k.lower().endswith('_proxy') and v for k,v in env.items()): + raise Refused() + if {k:v for k,v in env.items() if k.startswith('KEYCAPE_')}!={k:v for k,v in payload['env'].items() if k.startswith('KEYCAPE_')}:raise Refused() + for name,value in payload['env'].items(): + if env.get(name) != value: + raise Refused() + files = {k:base64.b64decode(v,validate=True) for k,v in payload['files'].items()} + def equal(): + if runtime_files(session,files)!=files:raise Refused() + durable=runtime_files(session,tree=True) + if durable!={k:v for k,v in files.items() if k.startswith('/var/lib/keycape-policy/')}: + raise Refused() + equal() + # Reading YAML is data parsing only; no archived executable is evaluated. + import yaml + cfg = yaml.safe_load(files['/etc/keycape/config.yaml']) + if cfg['issuer'] != admission['issuer']: + raise Refused() + consumer = admission['consumer'] + client = next(x for x in cfg['clients'] if x['clientId']==consumer['client_id']) + if (client['secretRef']!='env:'+consumer['secret_env'] or client['clientType']!='confidential' + or 'client_credentials' not in client['grantTypes'] or not env.get(consumer['secret_env'])): + raise Refused() + from cryptography.hazmat.primitives import serialization + key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) + check_jwks(session,admission,key.public_key().public_numbers()) + wrong = secrets.token_urlsafe(48) + if wrong == env[consumer['secret_env']]:raise Refused() + args = ['docker','exec','--env','NK49_UNRELATED_NEGATIVE_SECRET='+wrong,issuer,'/keycape','verify-client', + '-issuer',admission['issuer'],'-previous-secret-env','NK49_UNRELATED_NEGATIVE_SECRET'] + flags = {'client_id':'client-id','secret_env':'secret-env','audience':'audience','scope':'scope', + 'subject':'expect-subject','tenant':'expect-tenant','roles':'expect-roles','deny_scope':'deny-scope'} + for name,flag in flags.items():args+=['-'+flag,consumer[name]] + # Native command reports on stderr. Do not return or print its raw response. + result = subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=150) + raw = (result.stdout+result.stderr).decode() + required = ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings', + 'principal_type is service','sub matches the registration','tenant matches the registration','roles present', + 'granted scope carries no excess grant','predecessor secret refused'] + required += ['excess scope refused: '+scope for scope in consumer['deny_scope'].split()] + if result.returncode or any('PASS '+name+'\n' not in raw for name in required) or 'FAIL ' in raw: + raise Refused() + equal() + from cryptography.hazmat.primitives import serialization + key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) + check_jwks(session,admission,key.public_key().public_numbers()) + public=key.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) + return {'native_service_registration_verified':True,'wrong_unrelated_secret_explicitly_refused':True, + 'denied_scopes_refused':True,'original_config_registry_policy_receipts_equal':True, + 'public_signing_spki_sha256':hashlib.sha256(public).hexdigest(), + 'original_https_issuer_and_trust_preserved':True,'original_public_signing_key_in_live_jwks':True} + + +def cleanup(session, receipt=None): + """Attempt every owned cleanup even when one obligation fails. + + Preserve exact safe container names and outcomes so the owner can recover an + uncertain/partial cleanup. An inspect/daemon failure never means absence. + """ + obligations = [] + failed = False + for name in reversed(session['containers']): + row = {'container': name, 'status': 'unverified'} + obligations.append(row) + try: + label = command(['docker', 'container', 'inspect', name, '--format', + '{{index .Config.Labels "' + LABEL + '"}}']).decode().strip() + if label != session['run_id']: + row['status'] = 'ownership_mismatch_not_removed' + failed = True + continue + # A failed remove is followed by a successful explicit inventory; + # interrupted remove may actually have completed. + try: + command(['docker', 'rm', '--force', '--volumes', name]) + except Exception: + pass + remaining = command(['docker','container','ls','--all','--filter', + 'name=^/' + name + '$','--format','{{.Names}}']).decode().splitlines() + if remaining: + row['status'] = 'still_present' + failed = True + else: + row['status'] = 'absence_verified' + except Exception: + failed = True + if receipt is not None: + receipt['container_cleanup_obligations'] = obligations + if failed: + raise Refused() + + + +def native_checks(root, manifest, admission, receipt, cleanup_session=True): + """Owner wrapper provides a reviewed session after existing restore pipeline. + + Admission must bind ciphertext, canonical private manifest digest, executable + and trust bytes, exact image/target list, original issuer and selected consumer. + None of these admission fields grant authority or carry a client secret. + """ + session = admission['session'] + # Validate ownership/containment before permitting either exec or cleanup. + inspect_session(session) + receipt.update(status='failed', source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + ciphertext_sha256=admission['ciphertext_sha256'], production_contacted=False, + human_mfa_proven=False, downstream_consumer_reconnect_proven=False, + container_cleanup_verified=False) + try: + for field in ('ciphertext_sha256', 'manifest_sha256', 'verifier_sha256', 'trust_sha256'): + if not re.fullmatch('[a-f0-9]{64}', admission[field]): + raise Refused() + for field in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope'): + if not isinstance(admission['consumer'].get(field), str) or not admission['consumer'][field].strip(): + raise Refused() + payload = private_payload(root, manifest, admission['manifest_sha256']) + result = run_verifier(session, admission, payload) + required = {'native_service_registration_verified','wrong_unrelated_secret_explicitly_refused', + 'denied_scopes_refused','original_config_registry_policy_receipts_equal', + 'original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks'} + if any(result.get(k) is not True for k in required) or not re.fullmatch('[a-f0-9]{64}', result.get('public_signing_spki_sha256','')): + raise Refused() + # Closed receipt allowlist: never copy arbitrary worker output. + receipt.update({k: True for k in required}) + receipt['public_signing_spki_sha256'] = result['public_signing_spki_sha256'] + receipt['status'] = 'isolated_keycape_native_registration_checked' + finally: + try: + if cleanup_session: + cleanup(session, receipt) + receipt['container_cleanup_verified'] = True + except Exception: + receipt['status'] = 'failed' + raise diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/run-isolated-keycape.sh b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/run-isolated-keycape.sh new file mode 100644 index 00000000..74bc6e9f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r2/tools/bootstrap-recovery/run-isolated-keycape.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Candidate owner launcher. Review and native fixture proof required before use. +set +x +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +cd -- "$ROOT" +umask 077 +[[ $# == 6 ]] || { printf '%s\n' 'Usage: run-isolated-keycape.sh VERIFIED_TRANSFER_RECEIPT ADMISSION ADMISSION_SHA256 TLS_CERT TLS_KEY TRUST'; exit 1; } +[[ "$3" =~ ^[a-f0-9]{64}$ ]] || { printf '%s\n' 'ERROR: exact reviewed admission digest required.'; exit 1; } +for index in 1 2 4 5 6; do + [[ -f "${!index}" && ! -L "${!index}" ]] || { printf '%s\n' 'ERROR: provide regular owner-controlled input files.'; exit 1; } +done +[[ -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 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-keycape-session.py') +spec=importlib.util.spec_from_file_location('session',p) +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +try: + m.local.preflight() + m.run(['docker','image','inspect',m.adapter.IMAGE]) +except Exception: + raise SystemExit('ERROR: exact local Docker/images/tmpfs preflight failed; no key requested.') +print('OK: fixed local target and cached pinned images available.') +PY +NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)" +printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN" +printf '%s\n' 'Owner-reviewed candidate: isolated original issuer/trust and current registered service-client checks only.' +printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.' +printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.' +bash sso-mfa/bootstrap/sops-custody-unlock.sh \ + --expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \ + --prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \ + --transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \ + --tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json" +printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN" diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/README.md b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/README.md new file mode 100644 index 00000000..77e7baee --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/README.md @@ -0,0 +1,19 @@ +# Frozen revision3 — known deadline race, not approved + +This exact snapshot passed52 synthetic tests. No Docker mutations, credentials, +actual recovery or canonical changes occurred. It preserves the target/plaintext +registration fixes while retaining the known deadline race: a live parent or +in-flight daemon request can outlast fixed-timer cleanup. Do not execute its +owner launcher. A successor is being prepared. + +Reproduce without racing edits: + +```sh +python3 -m unittest discover -s /home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tests -q +``` + +`dependency-closure-sha256.json` binds every source/test/profile in the frozen +test root, including unchanged existing pipeline dependencies. These are source +and synthetic proof only, with Docker/native outcomes mocked. Original issuer +hostname resolution in container-network sharing still requires native fixture +proof after review. diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/candidate.patch b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/candidate.patch new file mode 100644 index 00000000..e1d44aed --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/candidate.patch @@ -0,0 +1,1216 @@ +--- /dev/null ++++ b/tools/bootstrap-recovery/isolated-keycape.py +@@ -0,0 +1,419 @@ ++#!/usr/bin/env python3 ++"""T18 native adapter for an owner-admitted, already restored isolated session. ++ ++Called after identity-bundle.restore, never with an unvalidated directory. This ++module does not provision TLS, grant credentials, or claim downstream acceptance. ++The existing decrypt/tmpfs wrapper remains responsible for archive cleanup. ++""" ++import base64 ++import hashlib ++import importlib.util ++import json ++import io ++import os ++import secrets ++import tarfile ++import re ++import subprocess ++from pathlib import Path ++ ++HERE = Path(__file__).resolve().parent ++IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010' ++LABEL = 'net-kingdom.recovery-run' ++ ++ ++class Refused(Exception): ++ pass ++ ++ ++def load_bundle(): ++ spec = importlib.util.spec_from_file_location('identity_bundle', HERE / 'identity-bundle.py') ++ module = importlib.util.module_from_spec(spec) ++ spec.loader.exec_module(module) ++ return module ++ ++ ++def private_payload(root, manifest, expected_manifest_sha256): ++ """Bind the complete validated archive before selecting KeyCape material. ++ ++ Private hashes never enter the public receipt. The digest is supplied by the ++ existing private archive admission, not inferred from an untrusted manifest. ++ """ ++ encoded = json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode() ++ if hashlib.sha256(encoded).hexdigest() != expected_manifest_sha256: ++ raise Refused() ++ entries = manifest['files'] ++ if len(entries) != len({e['path'] for e in entries}): ++ raise Refused() ++ values = {} ++ bundle = load_bundle() ++ for entry in entries: ++ if not bundle.allowed(entry['path'], manifest['profile']): ++ raise Refused() ++ path = root / entry['path'] ++ bundle.private(path) ++ value = path.read_bytes() ++ if len(value) != entry['bytes'] or hashlib.sha256(value).hexdigest() != entry['sha256']: ++ raise Refused() ++ values[entry['path']] = value ++ deployment = json.loads(values['resources/sso/deployment/keycape.json']) ++ main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'keycape') ++ if main.get('command') or main.get('args') or main.get('envFrom'): ++ raise Refused() ++ if main['image'] != IMAGE: ++ # Mutable-tag/historical mismatches need a separate explicit owner admission. ++ raise Refused() ++ secret = json.loads(values['resources/sso/secret/keycape-config.json'])['data'] ++ files = {'/etc/keycape/' + name: base64.b64decode(secret[name], validate=True) ++ for name in ('config.yaml', 'key.pem')} ++ for path, value in values.items(): ++ if path.startswith('data/keycape/'): ++ relative = path.removeprefix('data/keycape/') ++ files['/var/lib/keycape-policy/' + relative] = value ++ if '/var/lib/keycape-policy/policy.json' not in files: ++ raise Refused() ++ if 'resources/sso/secret/keycape-factor-read.json' in values: ++ factor=json.loads(values['resources/sso/secret/keycape-factor-read.json'])['data'] ++ files['/etc/keycape-factor/admin-token']=base64.b64decode(factor['admin-token'],validate=True) ++ # Resolve original delivered environment, including all referenced secrets. ++ env = {} ++ for item in main.get('env', []): ++ if not re.fullmatch(r'KEYCAPE_[A-Z0-9_]+',item['name']):raise Refused() ++ if item['name'] in env: ++ raise Refused() ++ if 'value' in item: ++ env[item['name']] = item['value'] ++ else: ++ ref = item['valueFrom']['secretKeyRef'] ++ source = json.loads(values['resources/sso/secret/' + ref['name'] + '.json'])['data'] ++ env[item['name']] = base64.b64decode(source[ref['key']], validate=True).decode() ++ if env.get('KEYCAPE_CONFIG') != '/etc/keycape/config.yaml' or env.get('KEYCAPE_POLICY_PATH') != '/var/lib/keycape-policy/policy.json': ++ raise Refused() ++ return {'files': {p: base64.b64encode(v).decode() for p, v in files.items()}, 'env': env} ++ ++ ++ENDPOINT = 'unix:///var/run/docker.sock' ++ ++ ++def docker_environment(environment=None): ++ # Even an admitted daemon exposed by another Unix socket is outside this ++ # exact session binding. Conflicting ambient routing is refused, not ignored. ++ for source in (os.environ, environment or {}): ++ if source.get('DOCKER_HOST') not in (None,'',ENDPOINT):raise Refused() ++ if source.get('DOCKER_CONTEXT') not in (None,'','default'):raise Refused() ++ if any(source.get(k) for k in ('DOCKER_TLS_VERIFY','DOCKER_CERT_PATH')):raise Refused() ++ env=dict(os.environ if environment is None else environment) ++ for key in ('DOCKER_CONTEXT','DOCKER_TLS_VERIFY','DOCKER_CERT_PATH'):env.pop(key,None) ++ env['DOCKER_HOST']=ENDPOINT ++ return env ++ ++ ++def process(argv, **kwargs): ++ if argv[0]=='docker': ++ argv=['docker','--host',ENDPOINT,*argv[1:]] ++ kwargs['env']=docker_environment(kwargs.get('env')) ++ return subprocess.run(argv,**kwargs) ++ ++ ++def command(argv, **kwargs): ++ maximum = kwargs.pop('max_output', 32768) ++ result = process(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ++ timeout=kwargs.pop('timeout', 180), **kwargs) ++ if result.returncode or len(result.stdout) > maximum: ++ raise Refused() ++ return result.stdout ++ ++ ++def inspect_session(session, worker_only=False): ++ """No credential environment is read by Docker inspect or returned to caller.""" ++ if not re.fullmatch(r'[a-f0-9]{32}', session['run_id']): ++ raise Refused() ++ names = session['containers'] ++ if not names or len(names) > 12 or len(set(names)) != len(names): ++ raise Refused() ++ for name in names: ++ if not re.fullmatch(r'nk49-kc-' + session['run_id'] + r'-[a-z0-9-]{1,24}', name): ++ raise Refused() ++ if session['worker'] not in names or session['issuer'] not in names: ++ raise Refused() ++ import os ++ contexts=json.loads(command(['docker','context','inspect'])) ++ endpoints=[contexts[0]['Endpoints']['docker']['Host'],os.environ.get('DOCKER_HOST') or ENDPOINT] ++ if not all(x==ENDPOINT for x in endpoints):raise Refused() ++ info = json.loads(command(['docker', 'info', '--format', '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}'])) ++ if info != {'ID': '74f93783-6cef-43a8-a3c4-f62586cd3bc1', 'Name': 'bnt-lap001', 'OSType': 'linux'}: ++ raise Refused() ++ # The worker is the anchor; all dependencies/TLS/issuer share its network-none namespace. ++ anchor = command(['docker', 'container', 'inspect', session['worker'], '--format', '{{.Id}}']).decode().strip() ++ for name,spec in session.get('volumes',{}).items(): ++ volume=json.loads(command(['docker','volume','inspect',name])) ++ expected={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} ++ if len(volume)!=1 or volume[0]['Driver']!='local' or volume[0].get('Scope')!='local' or volume[0].get('Options')!=expected or spec['options']!=expected or volume[0].get('Labels',{}).get(LABEL)!=session['run_id']:raise Refused() ++ for name in names: ++ obj = json.loads(command(['docker', 'container', 'inspect', name, '--format', ++ '{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"labels":{{json .Config.Labels}},"image":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}}}'])) ++ host = obj['host'] ++ mounts=obj['mounts'] ++ destinations=[m['Destination'] for m in mounts] ++ if len(destinations)!=len(set(destinations)):raise Refused() ++ for volume,expected_spec in session.get('volumes',{}).items(): ++ matches=[m for m in mounts if m['Destination']==expected_spec['target']] ++ if len(matches)!=1 or matches[0]['Type']!='volume' or matches[0]['Name']!=volume or matches[0]['RW']!=(name==session['worker']):raise Refused() ++ if (obj['labels'].get(LABEL) != session['run_id'] or not obj['user'] or obj['user'].split(':')[0] in ('0', 'root') ++ or not host['ReadonlyRootfs'] or host['Privileged'] or host.get('Binds') or host.get('PortBindings') ++ or host.get('PidMode') or host.get('IpcMode') == 'host' ++ or host.get('UTSMode') or host.get('UsernsMode') == 'host' or host.get('CgroupnsMode') == 'host' ++ or host.get('Sysctls',{}) not in ({},{'net.ipv4.ip_unprivileged_port_start':'0'} if name==session['worker'] else {}) ++ or host.get('CapAdd') or host.get('Devices') or host.get('DeviceRequests') or host.get('VolumesFrom') ++ or host.get('CapDrop') != ['ALL'] ++ or host.get('SecurityOpt') not in (['no-new-privileges'], ['no-new-privileges:true']) ++ or any(m['Type']!='tmpfs' and not (m['Type']=='volume' and m['Name'] in session.get('volumes',{}) and m['Destination']==session['volumes'][m['Name']]['target']) for m in obj['mounts']) ++ or host['NetworkMode'] != ('none' if name == session['worker'] else 'container:' + anchor) ++ or obj['image'] != session['images'][name] or '@sha256:' not in obj['image']): ++ raise Refused() ++ if worker_only:return ++ if session['images'][session['issuer']] != IMAGE: ++ raise Refused() ++ # Known admitted archive has no command/args override; image default /keycape ++ # must be the actual issuer executable, not an alternate same-image command. ++ launch=json.loads(command(['docker','container','inspect',session['issuer'],'--format', ++ '{"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"path":{{json .Path}},"args":{{json .Args}}}'])) ++ if launch != {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}: ++ raise Refused() ++ ++ ++def container_file(container, path): ++ # Docker streams one tar member into memory; never materialize private files. ++ raw = command(['docker', 'cp', container + ':' + path, '-'], max_output=128*1024*1024) ++ with tarfile.open(fileobj=io.BytesIO(raw), mode='r:') as archive: ++ members = archive.getmembers() ++ if len(members) != 1 or not members[0].isfile() or members[0].size > 96*1024*1024: ++ raise Refused() ++ return archive.extractfile(members[0]).read() ++ ++ ++JWKS_PROBE = r""" ++import base64,ipaddress,json,socket,ssl,sys,urllib.request ++from urllib.parse import urlsplit ++p=json.load(sys.stdin);issuer=p['issuer'];origin=urlsplit(issuer) ++def require(condition): ++ if not condition:raise ValueError('public_jwks_binding_refused') ++require(origin.scheme=='https') ++resolved=socket.getaddrinfo(origin.hostname,origin.port or 443,type=socket.SOCK_STREAM) ++require(bool(resolved) and all(ipaddress.ip_address(x[4][0]).is_loopback for x in resolved)) ++class NoRedirect(urllib.request.HTTPRedirectHandler): ++ def redirect_request(self,*args,**kwargs):return None ++opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect(), ++ urllib.request.HTTPSHandler(context=ssl.create_default_context(cadata=p['trust']))) ++def get(url): ++ u=urlsplit(url) ++ require((u.scheme,u.hostname,u.port or 443)==(origin.scheme,origin.hostname,origin.port or 443)) ++ require(not u.username and not u.password and not u.fragment) ++ with opener.open(url,timeout=10) as response: ++ raw=response.read(1024*1024+1);require(len(raw)<=1024*1024) ++ return json.loads(raw) ++d=get(issuer.rstrip('/')+'/.well-known/openid-configuration');require(d['issuer']==issuer) ++keys=get(d['jwks_uri'])['keys'] ++def integer(value):return int.from_bytes(base64.urlsafe_b64decode(value+'='*(-len(value)%4)),'big') ++# The exact admitted owner release constructs one key, kid key-1. Reject every ++# extra key, including irrelevant keys: a native successful exchange can then ++# only have used the original signer through the existing verifier. ++require(isinstance(keys,list) and len(keys)==1) ++k=keys[0] ++require(k.get('kty')=='RSA' and k.get('kid')=='key-1' and k.get('use')=='sig' and k.get('alg')=='RS256') ++require(integer(k['n'])==int(p['n']) and integer(k['e'])==int(p['e'])) ++print('{"original_public_signing_key_in_live_jwks":true}') ++""" ++ ++ ++def check_jwks(session, admission, public_numbers): ++ trust=runtime_file(session,admission['trust_path']).decode() ++ request={'issuer':admission['issuer'],'trust':trust,'n':str(public_numbers.n),'e':str(public_numbers.e)} ++ result=json.loads(command(['docker','exec','-i',session['worker'],'python3','-c',JWKS_PROBE],input=json.dumps(request).encode())) ++ if result != {'original_public_signing_key_in_live_jwks': True}:raise Refused() ++ ++ ++RUNTIME_FILES = r""" ++import base64,json,sys ++from pathlib import Path ++p=json.load(sys.stdin) ++roots=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') ++result={} ++paths=[] ++if p.get('tree'): ++ paths=list(Path('/var/lib/keycape-policy').rglob('*')) ++else:paths=[Path(x) for x in p['paths']] ++for f in paths: ++ if f.is_dir():continue ++ if not str(f).startswith(roots) or not f.is_file() or any(x.is_symlink() for x in (f,*f.parents)) or f.stat().st_size>8*1024*1024:raise ValueError() ++ result[str(f)]=base64.b64encode(f.read_bytes()).decode() ++ if len(result)>4096:raise ValueError() ++print(json.dumps(result)) ++""" ++ ++ ++def runtime_files(session, paths=(), tree=False): ++ raw=command(['docker','exec','-i',session['worker'],'python3','-c',RUNTIME_FILES], ++ input=json.dumps({'paths':list(paths),'tree':tree}).encode(),max_output=48*1024*1024) ++ return {k:base64.b64decode(v,validate=True) for k,v in json.loads(raw).items()} ++ ++ ++def runtime_file(session,path): ++ return runtime_files(session,[path])[path] ++ ++ ++def run_verifier(session, admission, payload): ++ issuer = session['issuer'] ++ if {v['target'] for v in session.get('volumes',{}).values()}!={'/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls'}:raise Refused() ++ from urllib.parse import urlsplit ++ import ipaddress ++ address = urlsplit(admission['issuer']) ++ if (address.scheme != 'https' or not address.hostname or address.username or address.password ++ or address.query or address.fragment): ++ raise Refused() ++ if hashlib.sha256(container_file(issuer, '/keycape')).hexdigest() != admission['verifier_sha256']: ++ raise Refused() ++ if hashlib.sha256(runtime_file(session, admission['trust_path'])).hexdigest() != admission['trust_sha256']: ++ raise Refused() ++ # Capture private environment only in owner process memory, never receipt. ++ environment = json.loads(command(['docker','container','inspect',issuer,'--format','{{json .Config.Env}}'])) ++ env = dict(item.split('=',1) for item in environment) ++ if len(env)!=len(environment):raise Refused() ++ if set(env)-set(payload['env'])-{'PATH','HOME','HOSTNAME','SSL_CERT_FILE'}:raise Refused() ++ if env.get('SSL_CERT_FILE') != admission['trust_path'] or any(k.lower().endswith('_proxy') and v for k,v in env.items()): ++ raise Refused() ++ if {k:v for k,v in env.items() if k.startswith('KEYCAPE_')}!={k:v for k,v in payload['env'].items() if k.startswith('KEYCAPE_')}:raise Refused() ++ for name,value in payload['env'].items(): ++ if env.get(name) != value: ++ raise Refused() ++ files = {k:base64.b64decode(v,validate=True) for k,v in payload['files'].items()} ++ def equal(): ++ if runtime_files(session,files)!=files:raise Refused() ++ durable=runtime_files(session,tree=True) ++ if durable!={k:v for k,v in files.items() if k.startswith('/var/lib/keycape-policy/')}: ++ raise Refused() ++ equal() ++ # Reading YAML is data parsing only; no archived executable is evaluated. ++ import yaml ++ cfg = yaml.safe_load(files['/etc/keycape/config.yaml']) ++ if cfg['issuer'] != admission['issuer']: ++ raise Refused() ++ consumer = admission['consumer'] ++ client = next(x for x in cfg['clients'] if x['clientId']==consumer['client_id']) ++ if (client['secretRef']!='env:'+consumer['secret_env'] or client['clientType']!='confidential' ++ or 'client_credentials' not in client['grantTypes'] or not env.get(consumer['secret_env'])): ++ raise Refused() ++ from cryptography.hazmat.primitives import serialization ++ key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) ++ check_jwks(session,admission,key.public_key().public_numbers()) ++ wrong = secrets.token_urlsafe(48) ++ if wrong == env[consumer['secret_env']]:raise Refused() ++ args = ['docker','exec','--env','NK49_UNRELATED_NEGATIVE_SECRET='+wrong,issuer,'/keycape','verify-client', ++ '-issuer',admission['issuer'],'-previous-secret-env','NK49_UNRELATED_NEGATIVE_SECRET'] ++ flags = {'client_id':'client-id','secret_env':'secret-env','audience':'audience','scope':'scope', ++ 'subject':'expect-subject','tenant':'expect-tenant','roles':'expect-roles','deny_scope':'deny-scope'} ++ for name,flag in flags.items():args+=['-'+flag,consumer[name]] ++ # Native command reports on stderr. Do not return or print its raw response. ++ result = process(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=150) ++ raw = (result.stdout+result.stderr).decode() ++ required = ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings', ++ 'principal_type is service','sub matches the registration','tenant matches the registration','roles present', ++ 'granted scope carries no excess grant','predecessor secret refused'] ++ required += ['excess scope refused: '+scope for scope in consumer['deny_scope'].split()] ++ if result.returncode or any('PASS '+name+'\n' not in raw for name in required) or 'FAIL ' in raw: ++ raise Refused() ++ equal() ++ from cryptography.hazmat.primitives import serialization ++ key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) ++ check_jwks(session,admission,key.public_key().public_numbers()) ++ public=key.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) ++ return {'native_service_registration_verified':True,'wrong_unrelated_secret_explicitly_refused':True, ++ 'denied_scopes_refused':True,'original_config_registry_policy_receipts_equal':True, ++ 'public_signing_spki_sha256':hashlib.sha256(public).hexdigest(), ++ 'original_https_issuer_and_trust_preserved':True,'original_public_signing_key_in_live_jwks':True} ++ ++ ++def cleanup(session, receipt=None): ++ """Attempt every owned cleanup even when one obligation fails. ++ ++ Preserve exact safe container names and outcomes so the owner can recover an ++ uncertain/partial cleanup. An inspect/daemon failure never means absence. ++ """ ++ obligations = [] ++ failed = False ++ for name in reversed(session['containers']): ++ row = {'container': name, 'status': 'unverified'} ++ obligations.append(row) ++ try: ++ present=command(['docker','container','ls','--all','--filter','name=^/'+name+'$','--format','{{.Names}}']).decode().splitlines() ++ if not present: ++ row['status']='absence_verified' ++ continue ++ label = command(['docker', 'container', 'inspect', name, '--format', ++ '{{index .Config.Labels "' + LABEL + '"}}']).decode().strip() ++ if label != session['run_id']: ++ row['status'] = 'ownership_mismatch_not_removed' ++ failed = True ++ continue ++ # A failed remove is followed by a successful explicit inventory; ++ # interrupted remove may actually have completed. ++ try: ++ command(['docker', 'rm', '--force', '--volumes', name]) ++ except Exception: ++ pass ++ remaining = command(['docker','container','ls','--all','--filter', ++ 'name=^/' + name + '$','--format','{{.Names}}']).decode().splitlines() ++ if remaining: ++ row['status'] = 'still_present' ++ failed = True ++ else: ++ row['status'] = 'absence_verified' ++ except Exception: ++ failed = True ++ if receipt is not None: ++ receipt['container_cleanup_obligations'] = obligations ++ if failed: ++ raise Refused() ++ ++ ++ ++def native_checks(root, manifest, admission, receipt, cleanup_session=True): ++ """Owner wrapper provides a reviewed session after existing restore pipeline. ++ ++ Admission must bind ciphertext, canonical private manifest digest, executable ++ and trust bytes, exact image/target list, original issuer and selected consumer. ++ None of these admission fields grant authority or carry a client secret. ++ """ ++ session = admission['session'] ++ # Validate ownership/containment before permitting either exec or cleanup. ++ inspect_session(session) ++ receipt.update(status='failed', source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), ++ ciphertext_sha256=admission['ciphertext_sha256'], production_contacted=False, ++ human_mfa_proven=False, downstream_consumer_reconnect_proven=False, ++ container_cleanup_verified=False) ++ try: ++ for field in ('ciphertext_sha256', 'manifest_sha256', 'verifier_sha256', 'trust_sha256'): ++ if not re.fullmatch('[a-f0-9]{64}', admission[field]): ++ raise Refused() ++ for field in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope'): ++ if not isinstance(admission['consumer'].get(field), str) or not admission['consumer'][field].strip(): ++ raise Refused() ++ payload = private_payload(root, manifest, admission['manifest_sha256']) ++ result = run_verifier(session, admission, payload) ++ required = {'native_service_registration_verified','wrong_unrelated_secret_explicitly_refused', ++ 'denied_scopes_refused','original_config_registry_policy_receipts_equal', ++ 'original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks'} ++ if any(result.get(k) is not True for k in required) or not re.fullmatch('[a-f0-9]{64}', result.get('public_signing_spki_sha256','')): ++ raise Refused() ++ # Closed receipt allowlist: never copy arbitrary worker output. ++ receipt.update({k: True for k in required}) ++ receipt['public_signing_spki_sha256'] = result['public_signing_spki_sha256'] ++ receipt['status'] = 'isolated_keycape_native_registration_checked' ++ finally: ++ try: ++ if cleanup_session: ++ cleanup(session, receipt) ++ receipt['container_cleanup_verified'] = True ++ except Exception: ++ receipt['status'] = 'failed' ++ raise +--- /dev/null ++++ b/tools/bootstrap-recovery/isolated-keycape-session.py +@@ -0,0 +1,381 @@ ++#!/usr/bin/env python3 ++"""Owner-run T18 session assembly using the existing archive/containment pipeline. ++ ++Candidate only: native pinned-image tests and review are required before use. ++Shared Docker local-driver tmpfs is required because Docker cp does not support ++private tmpfs/mount paths, and the original KeyCape image is distroless. ++""" ++import argparse ++import base64 ++from datetime import datetime, timezone ++import hashlib ++import importlib.util ++import json ++import os ++from pathlib import Path ++import signal ++import re ++import stat ++import select ++import subprocess ++import sys ++import tempfile ++import time ++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 ++ ++adapter=load('isolated-keycape') ++local=load('isolated-lldap') ++bundle=local.bundle ++# Bind the reused preflight itself, not only this module's direct Docker calls. ++local.command=adapter.command ++_original_preflight=local.preflight ++def preflight(): ++ contexts=json.loads(adapter.command(['docker','context','inspect'])) ++ if len(contexts)!=1 or contexts[0]['Endpoints']['docker']['Host']!=adapter.ENDPOINT:raise adapter.Refused() ++ result=_original_preflight();result['docker_endpoint']=adapter.ENDPOINT;return result ++local.preflight=preflight ++WORKER=local.CLIENT ++STAGING_ROOT=Path('/dev/shm') ++RUNROOTS=('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls') ++VOLUME_OPTIONS={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} ++ ++IMPORT=r''' ++import base64,json,os,sys ++from pathlib import Path ++os.umask(0o077) ++p=json.load(sys.stdin) ++allowed=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') ++for name,encoded in p.items(): ++ f=Path(name) ++ if not name.startswith(allowed) or '..' in f.parts or any(x.is_symlink() for x in (f,*f.parents)):raise ValueError() ++ f.parent.mkdir(mode=0o700,parents=True,exist_ok=True) ++ fd=os.open(f,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) ++ with os.fdopen(fd,'wb') as out:out.write(base64.b64decode(encoded,validate=True)) ++''' ++ ++# Small transport inside the already pinned helper; it has only loopback routes. ++# No token handling, auth bypass, dependency imitation, reset or notifier path. ++TLS=r''' ++import select,socket,socketserver,ssl,threading ++context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ++context.minimum_version=ssl.TLSVersion.TLSv1_2 ++context.load_cert_chain('/recovery-tls/server.pem','/recovery-tls/server-key.pem') ++class Handler(socketserver.BaseRequestHandler): ++ def handle(self): ++ try: ++ with context.wrap_socket(self.request,server_side=True) as incoming: ++ with socket.create_connection(('127.0.0.1',8080),timeout=10) as outgoing: ++ incoming.settimeout(15);outgoing.settimeout(15) ++ while True: ++ ready,_,_=select.select([incoming,outgoing],[],[],15) ++ if not ready:return ++ for source in ready: ++ data=source.recv(65536) ++ if not data:return ++ (outgoing if source is incoming else incoming).sendall(data) ++ except Exception:pass ++class Server(socketserver.ThreadingTCPServer): ++ allow_reuse_address=True ++ daemon_threads=True ++with Server(('127.0.0.1',443),Handler) as server:server.serve_forever() ++''' ++ ++ ++def run(argv,**kwargs):return adapter.command(argv,**kwargs) ++ ++ ++def plan_session(): ++ run_id=uuid.uuid4().hex ++ stem='nk49-kc-'+run_id ++ worker=stem+'-transport';issuer=stem+'-issuer' ++ volumes={stem+'-v'+str(i):{'target':root,'options':dict(VOLUME_OPTIONS)} for i,root in enumerate(RUNROOTS)} ++ return {'run_id':run_id,'containers':[worker,issuer], 'worker':worker,'issuer':issuer, ++ 'images':{worker:WORKER,issuer:adapter.IMAGE},'volumes':volumes, ++ 'public_image_probe':stem+'-image-probe','docker_endpoint':adapter.ENDPOINT} ++ ++ ++def verify_volume(name,spec,run_id): ++ obj=json.loads(run(['docker','volume','inspect',name])) ++ if (len(obj)!=1 or obj[0]['Name']!=name or obj[0]['Driver']!='local' or obj[0].get('Scope')!='local' ++ or obj[0].get('Options')!=VOLUME_OPTIONS or spec['options']!=VOLUME_OPTIONS ++ or obj[0].get('Labels',{}).get(adapter.LABEL)!=run_id):raise adapter.Refused() ++ ++ ++def base_args(session,name,network): ++ return ['docker','run','--detach','--pull=never','--name',name,'--network',network, ++ '--label',adapter.LABEL+'='+session['run_id'],'--read-only','--cap-drop','ALL', ++ '--security-opt','no-new-privileges','--user','1000:1000','--memory','512m','--cpus','1', ++ '--pids-limit','64','--log-driver','none','--restart','no', ++ '--tmpfs','/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700'] ++ ++ ++def mounts(session,readonly=False): ++ args=[] ++ for name,spec in session['volumes'].items(): ++ args+=['--mount','type=volume,source='+name+',target='+spec['target']+(',readonly' if readonly else '')] ++ return args ++ ++ ++def provision(session,payload,tls,issuer): ++ """Inspect containment and driver options before the first private import.""" ++ from urllib.parse import urlsplit ++ address=urlsplit(issuer) ++ if address.scheme!='https' or not address.hostname or address.port not in (None,443) or address.path not in ('','/'): ++ raise adapter.Refused() ++ for name,spec in session['volumes'].items(): ++ run(['docker','volume','create','--driver','local','--label',adapter.LABEL+'='+session['run_id'], ++ '--opt','type=tmpfs','--opt','device=tmpfs','--opt','o='+VOLUME_OPTIONS['o'],name]) ++ verify_volume(name,spec,session['run_id']) ++ args=base_args(session,session['worker'],'none')+mounts(session) ++ args+=['--sysctl','net.ipv4.ip_unprivileged_port_start=0','--add-host',address.hostname+':127.0.0.1', ++ '--entrypoint','python3',WORKER,'-c','import time;time.sleep(1800)'] ++ run(args) ++ # Check helper and every private filesystem before touching the ciphertext's ++ # private contents. Issuer is created only after originals are present. ++ adapter.inspect_session({**session,'containers':[session['worker']],'issuer':session['worker']},worker_only=True) ++ files={**payload['files'],**{k:base64.b64encode(v).decode() for k,v in tls.items()}} ++ run(['docker','exec','-i',session['worker'],'python3','-c',IMPORT],input=json.dumps(files).encode()) ++ anchor=run(['docker','container','inspect',session['worker'],'--format','{{.Id}}']).decode().strip() ++ args=base_args(session,session['issuer'],'container:'+anchor)+mounts(session,readonly=True) ++ # CLI argv carries environment names only. Original credentials stay in the ++ # owner subprocess environment and Docker's private container configuration. ++ env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':'unix:///var/run/docker.sock',**payload['env'], ++ 'SSL_CERT_FILE':'/recovery-tls/trust.pem'} ++ for name in (*payload['env'],'SSL_CERT_FILE'):args+=['--env',name] ++ args+=[adapter.IMAGE] ++ run(args,env=env) ++ adapter.inspect_session(session) ++ run(['docker','exec','--detach',session['worker'],'python3','-c',TLS]) ++ ++ ++def cleanup_all(session,receipt): ++ failed=False ++ try:adapter.cleanup({**session,'containers':session['containers']+[session['public_image_probe']]},receipt) ++ except Exception:failed=True ++ rows=[] ++ for name,spec in session['volumes'].items(): ++ row={'volume':name,'status':'unverified'};rows.append(row) ++ try: ++ exists=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines() ++ if not exists: ++ row['status']='absence_verified' ++ continue ++ verify_volume(name,spec,session['run_id']) ++ run(['docker','volume','rm',name]) ++ remaining=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines() ++ if remaining:row['status']='still_present';failed=True ++ else:row['status']='absence_verified' ++ except Exception:failed=True ++ receipt['tmpfs_volume_cleanup_obligations']=rows ++ receipt['container_cleanup_verified']=not failed ++ if failed:raise adapter.Refused() ++ ++def register_workdir(work, run_id): ++ if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+run_id+'-[a-z0-9_]{8}',work.name):raise adapter.Refused() ++ if any(p.is_symlink() for p in (work,*work.parents)):raise adapter.Refused() ++ info=work.lstat() ++ if not stat.S_ISDIR(info.st_mode) or info.st_uid!=os.getuid() or stat.S_IMODE(info.st_mode)!=0o700:raise adapter.Refused() ++ return {'path':str(work),'run_id':run_id,'device':info.st_dev,'inode':info.st_ino,'owner_uid':info.st_uid} ++ ++ ++def cleanup_workdir(staging, receipt): ++ """Delete only the registered inode below actual /dev/shm, never a replacement.""" ++ row={'path':staging['path'],'device':staging['device'],'inode':staging['inode'],'status':'unverified'} ++ receipt['plaintext_cleanup_obligation']=row ++ work=Path(staging['path']) ++ if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+staging['run_id']+'-[a-z0-9_]{8}',work.name):raise adapter.Refused() ++ if not any(line.split()[1:3]==['/dev/shm','tmpfs'] for line in Path('/proc/mounts').read_text().splitlines()):raise adapter.Refused() ++ if any(p.is_symlink() for p in work.parents):raise adapter.Refused() ++ parentfd=os.open(work.parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW) ++ try: ++ try:info=os.stat(work.name,dir_fd=parentfd,follow_symlinks=False) ++ except FileNotFoundError: ++ row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return ++ if (not stat.S_ISDIR(info.st_mode) or info.st_uid!=staging['owner_uid'] or info.st_uid!=os.getuid() ++ or info.st_dev!=staging['device'] or info.st_ino!=staging['inode'] or stat.S_IMODE(info.st_mode)!=0o700): ++ row['status']='identity_mismatch_not_removed';raise adapter.Refused() ++ def remove(parent,name,expected): ++ fd=os.open(name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=parent) ++ try: ++ current=os.fstat(fd) ++ if (current.st_dev,current.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused() ++ for child in os.listdir(fd): ++ before=os.stat(child,dir_fd=fd,follow_symlinks=False) ++ if before.st_uid!=staging['owner_uid'] or before.st_dev!=staging['device']:raise adapter.Refused() ++ if stat.S_ISDIR(before.st_mode):remove(fd,child,before) ++ elif stat.S_ISREG(before.st_mode) and before.st_nlink==1:os.unlink(child,dir_fd=fd) ++ else:raise adapter.Refused() # symlinks/special files/hardlinks are uncertain, never followed ++ finally:os.close(fd) ++ after=os.stat(name,dir_fd=parent,follow_symlinks=False) ++ if (after.st_dev,after.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused() ++ os.rmdir(name,dir_fd=parent) ++ remove(parentfd,work.name,info) ++ try:os.stat(work.name,dir_fd=parentfd,follow_symlinks=False) ++ except FileNotFoundError: ++ row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return ++ raise adapter.Refused() ++ finally:os.close(parentfd) ++ ++ ++def recover_obligations(session, staging, receipt): ++ failed=False ++ try:cleanup_all(session,receipt) ++ except Exception:failed=True ++ # The plaintext obligation is attempted even when Docker is unreachable. ++ try:cleanup_workdir(staging,receipt) ++ except Exception:failed=True ++ if failed:raise adapter.Refused() ++ ++ ++def start_reaper(session, staging): ++ # Starts and ACKs exact obligations before any plaintext archive write. ++ # The child gets no custody descriptors or credential environment and uses ++ # the same explicit endpoint for every Docker operation after parent loss. ++ adapter.docker_environment() ++ if session.get('docker_endpoint')!=adapter.ENDPOINT or staging.get('run_id')!=session['run_id']:raise adapter.Refused() ++ if register_workdir(Path(staging['path']),session['run_id'])!=staging:raise adapter.Refused() ++ code="""import importlib.util,json,os,sys,time ++spec=importlib.util.spec_from_file_location('session',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) ++session=json.loads(sys.argv[2]);staging=json.loads(sys.argv[3]);receipt={'status':'failed'} ++print('registered',flush=True) ++time.sleep(1800) ++try: ++ m.recover_obligations(session,staging,receipt);receipt['status']='bounded_cleanup_verified' ++except Exception:receipt['status']='bounded_cleanup_uncertain' ++# Retain bounded outcome beside the exact registered staging directory; this ++# child owns only this exclusive receipt path, never an arbitrary output path. ++try: ++ fd=os.open(staging['path']+'-reaper.json',os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) ++ with os.fdopen(fd,'w') as out:json.dump(receipt,out) ++except Exception:pass ++""" ++ child=subprocess.Popen([sys.executable,'-c',code,str(Path(__file__).resolve()),json.dumps(session),json.dumps(staging)], ++ stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL, ++ env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':adapter.ENDPOINT}, ++ close_fds=True,start_new_session=True) ++ ready,_,_=select.select([child.stdout],[],[],10) ++ if not ready or child.stdout.readline()!=b'registered\n': ++ child.terminate();child.wait(timeout=5);raise adapter.Refused() ++ child.stdout.close() ++ return child ++ ++ ++def execute(transfer,admission,tls,receipt,checkpoint=None): ++ """Existing private custody/transfer/archive APIs; no second archive format.""" ++ target=local.preflight() ++ for image in (adapter.IMAGE,):run(['docker','image','inspect',image]) ++ local.offline.validate(transfer) ++ if transfer.get('independent_owner_retrieval_verified') is not True:raise adapter.Refused() ++ path=Path(transfer['fetched_path']);bundle.private(path,max_bytes=bundle.LIMIT) ++ if (path.stat().st_size!=transfer['ciphertext_bytes'] or local.offline.offsite.digest(path)!=transfer['ciphertext_sha256'] ++ or admission['ciphertext_sha256']!=transfer['ciphertext_sha256']):raise adapter.Refused() ++ if hashlib.sha256(tls['/recovery-tls/trust.pem']).hexdigest()!=admission['trust_sha256']:raise adapter.Refused() ++ if hashlib.sha256(tls['/recovery-tls/server.pem']).hexdigest()!=admission['tls_cert_sha256']:raise adapter.Refused() ++ # Validate exact private certificate/key pair and original issuer name before ++ # opening service containers; do not generate replacement trust or keys. ++ from cryptography import x509 ++ from cryptography.hazmat.primitives import serialization ++ cert=x509.load_pem_x509_certificate(tls['/recovery-tls/server.pem']) ++ key=serialization.load_pem_private_key(tls['/recovery-tls/server-key.pem'],password=None) ++ if cert.public_key().public_numbers()!=key.public_key().public_numbers():raise adapter.Refused() ++ keyfd=local.verify.custody.private_key_fd();work=None;session=None;reaper=None;staging=None ++ receipt.update(status='failed',target=target,session_source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), ++ source_files_sha256={name:hashlib.sha256((HERE/name).read_bytes()).hexdigest() for name in ('isolated-keycape.py','isolated-keycape-session.py','run-isolated-keycape.sh','isolated-lldap.py','identity-bundle.py','verify-identity.py','retrieve-identity-offline.py','identity-offsite.py')}, ++ temporary_plaintext_cleanup_verified=False,container_cleanup_verified=False, ++ downstream_consumer_reconnect_proven=False,human_mfa_proven=False) ++ try: ++ if run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip()!=bundle.profile()['public_recipient'].encode():raise adapter.Refused() ++ session=plan_session() ++ work=Path(tempfile.mkdtemp(prefix='nk49-keycape-'+session['run_id']+'-',dir='/dev/shm')) ++ staging=register_workdir(work,session['run_id']) ++ reaper=start_reaper(session,staging) ++ receipt.update(bounded_cleanup_reaper_started=True,plaintext_cleanup_obligation={**staging,'status':'registered_before_plaintext'},docker_endpoint=adapter.ENDPOINT, ++ reaper_outcome_path=staging['path']+'-reaper.json', ++ registered_cleanup={'containers':session['containers']+[session['public_image_probe']],'tmpfs_volumes':list(session['volumes'])}) ++ if checkpoint is not None:checkpoint(receipt) ++ archive=work/'archive.tar' ++ with archive.open('xb') as output: ++ p=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}',str(path)],stdout=output,stderr=subprocess.DEVNULL,pass_fds=(keyfd,),timeout=180) ++ if p.returncode or archive.stat().st_size>bundle.LIMIT+4*1024*1024:raise adapter.Refused() ++ root=work/'restored';root.mkdir(mode=0o700) ++ manifest,_=bundle.restore(archive,root) ++ payload=adapter.private_payload(root,manifest,admission['manifest_sha256']) ++ import yaml ++ cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml'])) ++ if cfg.get('port')!=8080 or cfg.get('issuer')!=admission['issuer']:raise adapter.Refused() ++ # Public executable readback uses the daemon archive API only on the ++ # original image rootfs, never a tmpfs or private user-created mount. ++ probe=session['public_image_probe'] ++ try: ++ run(['docker','create','--name',probe,'--network','none','--read-only','--label',adapter.LABEL+'='+session['run_id'],adapter.IMAGE]) ++ binary=adapter.container_file(probe,'/keycape') ++ if hashlib.sha256(binary).hexdigest()!=admission['verifier_sha256']:raise adapter.Refused() ++ finally: ++ receipt['public_image_probe_cleanup_obligations']=[{'container':probe,'status':'unverified'}] ++ run(['docker','rm',probe]) ++ if run(['docker','container','ls','--all','--filter','name=^/'+probe+'$','--format','{{.Names}}']).strip():raise adapter.Refused() ++ receipt['public_image_probe_cleanup_obligations'][0]['status']='absence_verified' ++ provision(session,payload,tls,admission['issuer']) ++ receipt.update(ciphertext_sha256=transfer['ciphertext_sha256'],all_manifest_hashes_verified=True,manifest_file_count=len(manifest['files'])) ++ checked_admission={**admission,'session':session,'trust_path':'/recovery-tls/trust.pem'} ++ # Native checker consumes no service secret argument or new grant. ++ # Retry only bounded public readiness; no account/factor attempt. ++ for attempt in range(40): ++ try: ++ numbers=serialization.load_pem_private_key(base64.b64decode(payload['files']['/etc/keycape/key.pem']),password=None).public_key().public_numbers() ++ adapter.check_jwks(session,checked_admission,numbers);break ++ except Exception: ++ if attempt==39:raise ++ time.sleep(.25) ++ adapter.native_checks(root,manifest,checked_admission,receipt,cleanup_session=False) ++ finally: ++ os.close(keyfd) ++ try: ++ if session is not None and staging is not None:recover_obligations(session,staging,receipt) ++ if reaper is not None: ++ reaper.terminate();reaper.wait(timeout=10) ++ except Exception: ++ receipt['status']='failed' ++ raise ++ finally: ++ if work is not None and not work.exists():receipt['temporary_plaintext_cleanup_verified']=True ++ ++ ++def persist_receipt(fd,receipt): ++ raw=(json.dumps(receipt,indent=2)+'\n').encode() ++ os.lseek(fd,0,os.SEEK_SET);os.ftruncate(fd,0) ++ with os.fdopen(os.dup(fd),'wb') as output: ++ output.write(raw);output.flush();os.fsync(output.fileno()) ++ ++ ++def main(): ++ p=argparse.ArgumentParser(description=__doc__) ++ for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust','receipt'):p.add_argument('--'+name,required=True) ++ args=p.parse_args();os.umask(0o077) ++ fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) ++ receipt={'schema':'netkingdom.isolated-keycape-recovery.v1','status':'failed','production_contacted':False,'secret_values_exported':False} ++ try: ++ raw=Path(args.admission).read_bytes() ++ if hashlib.sha256(raw).hexdigest()!=args.admission_sha256:raise adapter.Refused() ++ admission=json.loads(raw) ++ tls={} ++ for name,path in (('server.pem',args.tls_cert),('server-key.pem',args.tls_key),('trust.pem',args.trust)): ++ source=Path(path);bundle.private(source,max_bytes=1024*1024);tls['/recovery-tls/'+name]=source.read_bytes() ++ execute(json.loads(Path(args.transfer_receipt).read_text()),admission,tls,receipt,checkpoint=lambda r:persist_receipt(fd,r)) ++ except (Exception,KeyboardInterrupt):receipt.update(status='failed',error='isolated_keycape_failed_private_diagnostics_suppressed') ++ finally: ++ receipt['finished_at']=datetime.now(timezone.utc).isoformat() ++ try:persist_receipt(fd,receipt) ++ finally:os.close(fd) ++ print('OK: isolated KeyCape native checks and cleanup passed.' if receipt['status']=='isolated_keycape_native_registration_checked' else 'ERROR: isolated KeyCape stopped; review safe receipt.') ++ return int(receipt['status']!='isolated_keycape_native_registration_checked') ++ ++if __name__=='__main__': ++ for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,local.offline.interrupted) ++ raise SystemExit(main()) +--- /dev/null ++++ b/tools/bootstrap-recovery/run-isolated-keycape.sh +@@ -0,0 +1,40 @@ ++#!/usr/bin/env bash ++# Candidate owner launcher. Review and native fixture proof required before use. ++set +x ++set -euo pipefail ++ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" ++cd -- "$ROOT" ++umask 077 ++[[ $# == 6 ]] || { printf '%s\n' 'Usage: run-isolated-keycape.sh VERIFIED_TRANSFER_RECEIPT ADMISSION ADMISSION_SHA256 TLS_CERT TLS_KEY TRUST'; exit 1; } ++[[ "$3" =~ ^[a-f0-9]{64}$ ]] || { printf '%s\n' 'ERROR: exact reviewed admission digest required.'; exit 1; } ++for index in 1 2 4 5 6; do ++ [[ -f "${!index}" && ! -L "${!index}" ]] || { printf '%s\n' 'ERROR: provide regular owner-controlled input files.'; exit 1; } ++done ++[[ -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 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-keycape-session.py') ++spec=importlib.util.spec_from_file_location('session',p) ++m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) ++try: ++ m.local.preflight() ++ m.run(['docker','image','inspect',m.adapter.IMAGE]) ++except Exception: ++ raise SystemExit('ERROR: exact local Docker/images/tmpfs preflight failed; no key requested.') ++print('OK: fixed local target and cached pinned images available.') ++PY ++NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)" ++printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN" ++printf '%s\n' 'Owner-reviewed candidate: isolated original issuer/trust and current registered service-client checks only.' ++printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.' ++printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.' ++bash sso-mfa/bootstrap/sops-custody-unlock.sh \ ++ --expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \ ++ --prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \ ++ --transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \ ++ --tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json" ++printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN" +--- /dev/null ++++ b/tests/test_isolated_keycape_recovery.py +@@ -0,0 +1,223 @@ ++import base64 ++import hashlib ++import importlib.util ++import io ++import json ++import subprocess ++import tarfile ++import tempfile ++import unittest ++from pathlib import Path ++from unittest.mock import patch ++ ++SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape.py' ++spec=importlib.util.spec_from_file_location('adapter',SOURCE) ++a=importlib.util.module_from_spec(spec);spec.loader.exec_module(a) ++ ++class ArchiveTests(unittest.TestCase): ++ def setUp(self): ++ self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) ++ self.root=Path(self.tmp.name) ++ config={'data':{k:base64.b64encode(v).decode() for k,v in {'config.yaml':b'issuer: https://issuer.example','key.pem':b'fixture-key'}.items()}} ++ self.values={'resources/sso/secret/keycape-config.json':json.dumps(config).encode(), ++ 'resources/sso/deployment/keycape.json':json.dumps({'spec':{'template':{'spec':{'containers':[{'name':'keycape','image':a.IMAGE,'env':[ ++ {'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'}, ++ {'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'}, ++ {'name':'KEYCAPE_ORIGINAL_SECRET','valueFrom':{'secretKeyRef':{'name':'client','key':'secret'}}}]}]}}}}).encode(), ++ 'resources/sso/secret/client.json':json.dumps({'data':{'secret':base64.b64encode(b'fixture-private').decode()}}).encode(), ++ 'data/keycape/policy.json':b'{}','data/keycape/receipts/one.json':b'{"original":true}'} ++ for name,value in self.values.items(): ++ p=self.root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(value);p.chmod(0o600) ++ self.manifest={'profile':{'components':[{'namespace':'sso','deployment':'keycape','secret_refs':['keycape-config','client'],'configmap_refs':[]}]}, ++ 'files':[{'path':k,'bytes':len(v),'sha256':hashlib.sha256(v).hexdigest()} for k,v in self.values.items()]} ++ def payload(self): ++ digest=hashlib.sha256(json.dumps(self.manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest() ++ return a.private_payload(self.root,self.manifest,digest) ++ def test_original_state_and_secret_delivery_retained(self): ++ p=self.payload();self.assertEqual(p['env']['KEYCAPE_ORIGINAL_SECRET'],'fixture-private') ++ self.assertEqual(base64.b64decode(p['files']['/var/lib/keycape-policy/receipts/one.json']),b'{"original":true}') ++ def test_archive_digest_refused(self): ++ with self.assertRaises(a.Refused):a.private_payload(self.root,self.manifest,'0'*64) ++ def test_changed_file_refused(self): ++ (self.root/'data/keycape/policy.json').write_text('{"changed":true}') ++ with self.assertRaises(a.Refused):self.payload() ++ def test_symlink_refused(self): ++ p=self.root/'data/keycape/policy.json';p.unlink();p.symlink_to('/etc/hostname') ++ with self.assertRaises(Exception):self.payload() ++ def test_duplicate_manifest_refused(self): ++ self.manifest['files'].append(self.manifest['files'][0]) ++ with self.assertRaises(a.Refused):self.payload() ++ def test_image_mismatch_refused(self): ++ d=json.loads(self.values['resources/sso/deployment/keycape.json']);d['spec']['template']['spec']['containers'][0]['image']='keycape:latest' ++ raw=json.dumps(d).encode();p=self.root/'resources/sso/deployment/keycape.json';p.write_bytes(raw) ++ for row in self.manifest['files']: ++ if row['path'].endswith('/deployment/keycape.json'):row.update(bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest()) ++ with self.assertRaises(a.Refused):self.payload() ++ ++class NativeTests(unittest.TestCase): ++ def setUp(self): ++ from cryptography.hazmat.primitives import serialization ++ from cryptography.hazmat.primitives.asymmetric import rsa ++ self.key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()) ++ self.cfg=b'issuer: https://issuer.example\nclients:\n- clientId: existing\n clientType: confidential\n secretRef: env:KEYCAPE_ORIGINAL_SECRET\n grantTypes: [client_credentials]\n' ++ self.files={'/etc/keycape/config.yaml':self.cfg,'/etc/keycape/key.pem':self.key,'/var/lib/keycape-policy/policy.json':b'{}'} ++ self.payload={'env':{'KEYCAPE_ORIGINAL_SECRET':'fixture-private'},'files':{k:base64.b64encode(v).decode() for k,v in self.files.items()}} ++ self.admission={'issuer':'https://issuer.example','verifier_sha256':hashlib.sha256(b'binary').hexdigest(), ++ 'trust_path':'/trust.pem','trust_sha256':hashlib.sha256(b'original-trust').hexdigest(), ++ 'consumer':{'client_id':'existing','secret_env':'KEYCAPE_ORIGINAL_SECRET','scope':'read','audience':'consumer', ++ 'subject':'service:existing','tenant':'tenant:platform','roles':'reader','deny_scope':'write'}} ++ self.session={'issuer':'isolated','volumes':{x:{'target':x} for x in ('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')}} ++ self.output=''.join('PASS '+x+'\n' for x in ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings','principal_type is service','sub matches the registration','tenant matches the registration','roles present','granted scope carries no excess grant','predecessor secret refused','excess scope refused: write']) ++ def file(self,container,path): ++ return {**self.files,'/etc/hosts':b'127.0.0.1 issuer.example\n','/keycape':b'binary','/trust.pem':b'original-trust'}[path] ++ def command(self,argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem']).encode() ++ output=io.BytesIO() ++ with tarfile.open(fileobj=output,mode='w') as t: ++ m=tarfile.TarInfo('policy.json');m.size=2;t.addfile(m,io.BytesIO(b'{}')) ++ return output.getvalue() ++ def verify(self,code=0,output=None): ++ with patch.object(a,'check_jwks'),patch.object(a,'runtime_file',return_value=b'original-trust'),patch.object(a,'runtime_files',side_effect=lambda session,paths=(),tree=False:({k:v for k,v in self.files.items() if k.startswith('/var/lib/keycape-policy/')} if tree else dict(self.files))),patch.object(a,'container_file',side_effect=self.file),patch.object(a,'command',side_effect=self.command),patch.object(a.subprocess,'run',return_value=subprocess.CompletedProcess([],code,b'',(self.output if output is None else output).encode())) as run: ++ result=a.run_verifier(self.session,self.admission,self.payload) ++ argv=run.call_args.args[0] ++ self.assertNotIn('fixture-private',' '.join(argv)) ++ self.assertIn('-previous-secret-env',argv) ++ return result ++ def test_original_registration_and_unrelated_secret(self): ++ r=self.verify();self.assertTrue(r['wrong_unrelated_secret_explicitly_refused']);self.assertNotIn('fixture-private',json.dumps(r)) ++ def test_transport_failure_never_counts_as_wrong_secret(self): ++ with self.assertRaises(a.Refused):self.verify(1,'FAIL discovery and endpoint origin: TLS failure\n') ++ def test_missing_explicit_rejection_never_counts(self): ++ with self.assertRaises(a.Refused):self.verify(0,self.output.replace('PASS predecessor secret refused\n','')) ++ def test_fail_line_refused_even_zero_exit(self): ++ with self.assertRaises(a.Refused):self.verify(0,self.output+'FAIL transport\n') ++ def test_security_environment_override_refused(self): ++ original=self.command ++ def altered(argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem','SSL_CERT_DIR=/unreviewed']).encode() ++ return original(argv,**kwargs) ++ with patch.object(self,'command',side_effect=altered): ++ with self.assertRaises(a.Refused):self.verify() ++ def test_http_issuer_refused(self): ++ self.admission['issuer']='http://issuer.example' ++ with self.assertRaises(a.Refused):self.verify() ++ def test_changed_trust_refused(self): ++ self.admission['trust_sha256']='0'*64 ++ with self.assertRaises(a.Refused):self.verify() ++ def test_config_drift_refused(self): ++ self.payload['files']['/etc/keycape/config.yaml']=base64.b64encode(b'changed').decode() ++ with self.assertRaises(a.Refused):self.verify() ++ ++class BoundaryTests(unittest.TestCase): ++ def test_remote_docker_refused_before_info(self): ++ session={'run_id':'a'*32,'containers':['nk49-kc-'+('a'*32)+'-worker'],'worker':'nk49-kc-'+('a'*32)+'-worker','issuer':'nk49-kc-'+('a'*32)+'-worker'} ++ with patch.object(a,'command',return_value=b'[{"Endpoints":{"docker":{"Host":"tcp://remote:2375"}}}]') as command: ++ with self.assertRaises(a.Refused):a.inspect_session(session) ++ self.assertEqual(command.call_count,1) ++ def test_failed_verifier_cleanup_is_attempted(self): ++ receipt={};admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} ++ with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',side_effect=a.Refused),patch.object(a,'cleanup') as cleanup: ++ with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) ++ cleanup.assert_called_once();self.assertTrue(receipt['container_cleanup_verified']);self.assertEqual(receipt['status'],'failed') ++ ++ def test_cleanup_failure_overrides_success(self): ++ admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} ++ result={x:True for x in ('native_service_registration_verified','wrong_unrelated_secret_explicitly_refused','denied_scopes_refused','original_config_registry_policy_receipts_equal','original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks')} ++ result['public_signing_spki_sha256']='e'*64;receipt={} ++ with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',return_value=result),patch.object(a,'cleanup',side_effect=a.Refused): ++ with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) ++ self.assertEqual(receipt['status'],'failed');self.assertFalse(receipt['container_cleanup_verified']) ++ ++class ContainmentTests(unittest.TestCase): ++ def inspect(self,overrides=None,launch=None): ++ name='nk49-kc-'+('a'*32)+'-issuer' ++ session={'run_id':'a'*32,'containers':[name],'issuer':name,'worker':name,'images':{name:a.IMAGE}} ++ host={'NetworkMode':'none','ReadonlyRootfs':True,'Privileged':False,'CapDrop':['ALL'],'SecurityOpt':['no-new-privileges']} ++ host.update(overrides or {}) ++ def command(argv,**kwargs): ++ if argv[1:3]==['context','inspect']:return b'[{"Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]' ++ if argv[1]=='info':return b'{"ID":"74f93783-6cef-43a8-a3c4-f62586cd3bc1","Name":"bnt-lap001","OSType":"linux"}' ++ if argv[-1]=='{{.Id}}':return b'anchor-id' ++ if '"path"' in argv[-1]:return json.dumps(launch or {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}).encode() ++ return json.dumps({'host':host,'mounts':[],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode() ++ with patch.object(a,'command',side_effect=command),patch.dict('os.environ',{'DOCKER_HOST':'unix:///var/run/docker.sock'}):a.inspect_session(session) ++ def test_minimal_network_none_admitted(self):self.inspect() ++ def test_alternate_same_image_command_refused(self): ++ with self.assertRaises(a.Refused):self.inspect(launch={'entrypoint':['/keycape'],'cmd':['check-config'],'path':'/keycape','args':['check-config']}) ++ def test_cap_add_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'CapAdd':['NET_ADMIN']}) ++ def test_devices_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'Devices':[{'PathOnHost':'/dev/mem'}]}) ++ def test_device_requests_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'DeviceRequests':[{'Driver':'nvidia'}]}) ++ def test_host_namespace_refused(self): ++ for field in ('PidMode','IpcMode','UTSMode','UsernsMode','CgroupnsMode'): ++ with self.subTest(field=field),self.assertRaises(a.Refused):self.inspect({field:'host'}) ++ def test_security_override_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'SecurityOpt':['no-new-privileges','seccomp=unconfined']}) ++ ++class CleanupTests(unittest.TestCase): ++ def test_partial_cleanup_attempts_every_owned_container(self): ++ session={'run_id':'a'*32,'containers':['first','second','third']};receipt={} ++ removed=[] ++ def command(argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return ('a'*32).encode() ++ if argv[1]=='rm': ++ removed.append(argv[-1]) ++ if argv[-1]=='third':raise a.Refused() ++ return b'' ++ if argv[1:3]==['container','ls']: ++ name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$') ++ return (name+'\n').encode() if name=='third' or name not in removed else b'' ++ return b'' ++ with patch.object(a,'command',side_effect=command): ++ with self.assertRaises(a.Refused):a.cleanup(session,receipt) ++ self.assertEqual(removed,['third','second','first']) ++ self.assertEqual([r['status'] for r in receipt['container_cleanup_obligations']],['still_present','absence_verified','absence_verified']) ++ def test_ownership_mismatch_does_not_remove_and_continues(self): ++ session={'run_id':'a'*32,'containers':['first','second']};receipt={};removed=[] ++ def command(argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return (('b' if argv[3]=='second' else 'a')*32).encode() ++ if argv[1]=='rm':removed.append(argv[-1]) ++ if argv[1:3]==['container','ls']: ++ name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$') ++ return (name+'\n').encode() if name not in removed else b'' ++ return b'' ++ with patch.object(a,'command',side_effect=command): ++ with self.assertRaises(a.Refused):a.cleanup(session,receipt) ++ self.assertEqual(removed,['first']) ++ self.assertEqual(receipt['container_cleanup_obligations'][0]['status'],'ownership_mismatch_not_removed') ++ def test_inspect_failure_retains_uncertain_obligation(self): ++ receipt={} ++ with patch.object(a,'command',side_effect=a.Refused): ++ with self.assertRaises(a.Refused):a.cleanup({'run_id':'a'*32,'containers':['exact-owned-name']},receipt) ++ self.assertEqual(receipt['container_cleanup_obligations'],[{'container':'exact-owned-name','status':'unverified'}]) ++ ++class JWKSTests(unittest.TestCase): ++ def execute(self,n='123',endpoint='https://issuer.example/jwks',resolved='127.0.0.1',extra=False,kid='key-1'): ++ request={'issuer':'https://issuer.example','trust':'public-certificate','n':'123','e':'65537'} ++ def enc(i):return base64.urlsafe_b64encode(int(i).to_bytes((int(i).bit_length()+7)//8,'big')).decode().rstrip('=') ++ responses=[{'issuer':'https://issuer.example','jwks_uri':endpoint},{'keys':[{'kty':'RSA','kid':kid,'use':'sig','alg':'RS256','n':enc(n),'e':enc(65537)}]}] ++ if extra:responses[1]['keys'].append(dict(responses[1]['keys'][0],kid='key-2')) ++ class Response: ++ def __init__(self,value):self.value=value ++ def __enter__(self):return self ++ def __exit__(self,*args):pass ++ def read(self,*args):return json.dumps(self.value).encode() ++ class Opener: ++ def open(self,*args,**kwargs):return Response(responses.pop(0)) ++ with patch('sys.stdin',io.StringIO(json.dumps(request))),patch('sys.stdout',io.StringIO()),patch('socket.getaddrinfo',return_value=[(2,1,6,'',(resolved,443))]),patch('ssl.create_default_context'),patch('urllib.request.build_opener',return_value=Opener()): ++ exec(compile(a.JWKS_PROBE,'','exec',optimize=2),{}) ++ def test_exact_original_public_key(self):self.execute() ++ def test_extra_signing_key_refused_under_optimization(self): ++ with self.assertRaises(ValueError):self.execute(extra=True) ++ def test_wrong_kid_refused_under_optimization(self): ++ with self.assertRaises(ValueError):self.execute(kid='replacement') ++ def test_wrong_live_signing_key_refused(self): ++ with self.assertRaises(ValueError):self.execute(n='124') ++ def test_cross_origin_jwks_refused(self): ++ with self.assertRaises(ValueError):self.execute(endpoint='https://production.example/jwks') ++ def test_production_dns_refused_before_fetch(self): ++ with self.assertRaises(ValueError):self.execute(resolved='192.0.2.1') ++ ++if __name__=='__main__':unittest.main() +--- /dev/null ++++ b/tests/test_isolated_keycape_session.py +@@ -0,0 +1,138 @@ ++import base64 ++import importlib.util ++import json ++import os ++import tempfile ++from pathlib import Path ++import unittest ++from unittest.mock import patch ++ ++SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape-session.py' ++spec=importlib.util.spec_from_file_location('session',SOURCE) ++s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s) ++ ++class SessionTests(unittest.TestCase): ++ def test_tmpfs_plan_has_four_exact_original_mounts(self): ++ session=s.plan_session() ++ self.assertEqual({v['target'] for v in session['volumes'].values()},set(s.RUNROOTS)) ++ self.assertTrue(all(v['options']==s.VOLUME_OPTIONS for v in session['volumes'].values())) ++ self.assertEqual(session['images'][session['issuer']],s.adapter.IMAGE) ++ def test_non_tmpfs_driver_options_refused(self): ++ session=s.plan_session();name,spec=next(iter(session['volumes'].items())) ++ wrong=[{'Name':name,'Driver':'local','Scope':'local','Options':{'type':'none','device':'/host/data'},'Labels':{s.adapter.LABEL:session['run_id']}}] ++ with patch.object(s,'run',return_value=json.dumps(wrong).encode()): ++ with self.assertRaises(s.adapter.Refused):s.verify_volume(name,spec,session['run_id']) ++ def test_owner_secret_is_not_docker_argv_and_import_is_after_inspection(self): ++ session=s.plan_session();events=[] ++ payload={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'original').decode()},'env':{'KEYCAPE_TEST_SECRET':'fixture-sensitive'}} ++ def run(argv,**kwargs): ++ events.append(('run',argv,kwargs)) ++ if argv[-1]=='{{.Id}}':return b'anchor' ++ return b'' ++ with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=lambda *args,**kwargs:events.append(('inspect',args,kwargs))): ++ s.provision(session,payload,{'/recovery-tls/trust.pem':b'original-trust'},'https://issuer.example') ++ joined=' '.join(' '.join(e[1]) for e in events if e[0]=='run') ++ self.assertNotIn('fixture-sensitive',joined) ++ private_index=next(i for i,e in enumerate(events) if e[0]=='run' and s.IMPORT in e[1]) ++ inspect_index=next(i for i,e in enumerate(events) if e[0]=='inspect') ++ self.assertLess(inspect_index,private_index) ++ issuer_call=next(e for e in events if e[0]=='run' and e[1][-1]==s.adapter.IMAGE) ++ self.assertEqual(issuer_call[2]['env']['KEYCAPE_TEST_SECRET'],'fixture-sensitive') ++ self.assertIn('KEYCAPE_TEST_SECRET',issuer_call[1]) ++ self.assertNotIn('--entrypoint',issuer_call[1]) ++ def test_non_original_https_port_refused_before_any_mutation(self): ++ with patch.object(s,'run') as run: ++ with self.assertRaises(s.adapter.Refused):s.provision(s.plan_session(),{'files':{},'env':{}},{},'http://issuer.example') ++ run.assert_not_called() ++ def test_container_failure_still_attempts_each_volume_cleanup(self): ++ session=s.plan_session();receipt={};removed=[] ++ def run(argv,**kwargs): ++ if argv[1:3]==['volume','rm']:removed.append(argv[-1]) ++ if argv[1:3]==['volume','ls']: ++ name=next(x for x in argv if x.startswith('name=^')).removeprefix('name=^').removesuffix('$') ++ return (name+'\n').encode() if name not in removed else b'' ++ return b'' ++ with patch.object(s.adapter,'cleanup',side_effect=s.adapter.Refused),patch.object(s,'verify_volume'),patch.object(s,'run',side_effect=run): ++ with self.assertRaises(s.adapter.Refused):s.cleanup_all(session,receipt) ++ self.assertEqual(set(removed),set(session['volumes'])) ++ self.assertFalse(receipt['container_cleanup_verified']) ++ self.assertEqual(len(receipt['tmpfs_volume_cleanup_obligations']),4) ++ def test_reaper_inherits_no_custody_or_service_environment(self): ++ session=s.plan_session();staging={'path':'/dev/shm/registered','run_id':session['run_id']} ++ with patch.object(s.subprocess,'Popen') as spawn,patch.object(s,'register_workdir',return_value=staging),patch.object(s.select,'select',side_effect=lambda *args:([spawn.return_value.stdout],[],[])): ++ spawn.return_value.stdout.readline.return_value=b'registered\n' ++ s.start_reaper(session,staging) ++ kwargs=spawn.call_args.kwargs ++ self.assertEqual(set(kwargs['env']),{'PATH','DOCKER_HOST'}) ++ self.assertTrue(kwargs['close_fds']);self.assertTrue(kwargs['start_new_session']) ++ ++class TargetBindingTests(unittest.TestCase): ++ def test_every_direct_docker_command_is_explicitly_bound(self): ++ with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run',return_value=__import__('subprocess').CompletedProcess([],0,b'ok')) as run: ++ self.assertEqual(s.run(['docker','info']),b'ok') ++ argv=run.call_args.args[0] ++ self.assertEqual(argv[:3],['docker','--host',s.adapter.ENDPOINT]) ++ self.assertEqual(run.call_args.kwargs['env']['DOCKER_HOST'],s.adapter.ENDPOINT) ++ def test_nondefault_unix_socket_is_refused_before_any_docker_call(self): ++ with patch.dict('os.environ',{'DOCKER_HOST':'unix:///tmp/other-daemon.sock'},clear=True),patch.object(s.adapter.subprocess,'run') as run: ++ with self.assertRaises(s.adapter.Refused):s.run(['docker','info']) ++ run.assert_not_called() ++ def test_conflicting_context_is_refused(self): ++ with patch.dict('os.environ',{'DOCKER_CONTEXT':'production'},clear=True),patch.object(s.adapter.subprocess,'run') as run: ++ with self.assertRaises(s.adapter.Refused):s.run(['docker','info']) ++ run.assert_not_called() ++ def test_conflicting_per_call_host_is_refused(self): ++ with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run') as run: ++ with self.assertRaises(s.adapter.Refused):s.run(['docker','info'],env={'DOCKER_HOST':'unix:///tmp/other.sock'}) ++ run.assert_not_called() ++ def test_reused_preflight_uses_same_bound_command(self): ++ self.assertIs(s.local.command,s.adapter.command) ++ def test_probe_is_registered_before_session_creation(self): ++ session=s.plan_session() ++ self.assertEqual(session['public_image_probe'],'nk49-kc-'+session['run_id']+'-image-probe') ++ self.assertEqual(session['docker_endpoint'],s.adapter.ENDPOINT) ++ ++class PlaintextLifecycleTests(unittest.TestCase): ++ def setUp(self): ++ self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) ++ self.root=Path(self.tmp.name) ++ self.override=patch.object(s,'STAGING_ROOT',self.root);self.override.start();self.addCleanup(self.override.stop) ++ self.run_id='a'*32 ++ self.work=self.root/('nk49-keycape-'+self.run_id+'-abcdefgh');self.work.mkdir(mode=0o700) ++ self.staging=s.register_workdir(self.work,self.run_id) ++ def test_exact_plaintext_directory_removed_and_recorded(self): ++ (self.work/'archive.tar').write_bytes(b'synthetic-private-archive') ++ restored=self.work/'restored';restored.mkdir(mode=0o700);(restored/'fixture').write_bytes(b'synthetic') ++ receipt={};s.cleanup_workdir(self.staging,receipt) ++ self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified']) ++ self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'absence_verified') ++ def test_docker_failure_does_not_skip_plaintext_cleanup(self): ++ (self.work/'archive.tar').write_bytes(b'synthetic') ++ receipt={} ++ with patch.object(s,'cleanup_all',side_effect=s.adapter.Refused): ++ with self.assertRaises(s.adapter.Refused):s.recover_obligations({},self.staging,receipt) ++ self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified']) ++ def test_replacement_inode_is_not_removed(self): ++ # Preserve original inode while replacing its registered pathname. ++ old=self.root/'retained-original';self.work.rename(old);self.work.mkdir(mode=0o700) ++ (self.work/'replacement').write_bytes(b'not-owned-by-this-obligation');receipt={} ++ with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,receipt) ++ self.assertTrue((self.work/'replacement').exists());self.assertTrue(old.exists()) ++ self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'identity_mismatch_not_removed') ++ def test_symlink_root_is_not_followed(self): ++ self.work.rmdir();outside=self.root/'outside';outside.mkdir();(outside/'kept').write_bytes(b'keep') ++ self.work.symlink_to(outside,target_is_directory=True) ++ with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{}) ++ self.assertTrue((outside/'kept').exists()) ++ def test_descendant_symlink_is_not_followed(self): ++ outside=self.root/'kept';outside.write_bytes(b'keep');(self.work/'link').symlink_to(outside) ++ with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{}) ++ self.assertEqual(outside.read_bytes(),b'keep') ++ def test_wrong_owner_identity_is_not_removed(self): ++ changed={**self.staging,'owner_uid':os.getuid()+1} ++ with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(changed,{}) ++ self.assertTrue(self.work.exists()) ++ def test_unregistered_path_refused(self): ++ with self.assertRaises(s.adapter.Refused):s.cleanup_workdir({**self.staging,'path':str(self.root)}, {}) ++ ++if __name__=='__main__':unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/sso-mfa/bootstrap/sops-private.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/sso-mfa/bootstrap/sops-private.py new file mode 100644 index 00000000..21c20b2e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/sso-mfa/bootstrap/sops-private.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Age-only SOPS recovery to a new private file; no plaintext process output.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import uuid + +ROOT = Path(__file__).resolve().parents[2] +RECIPIENT = re.compile(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}\Z') + + +class Refused(Exception): + pass + + +def public_recipient(): + lines = (ROOT / 'keys/age.pub').read_text().splitlines() + values = [line.strip() for line in lines if line.strip() and not line.startswith('#')] + if len(values) != 1 or not RECIPIENT.fullmatch(values[0]): + raise Refused('registered public recipient is invalid') + result = subprocess.run(['age', '-r', values[0]], input=b'', stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=10) + if result.returncode: + raise Refused('registered public recipient is invalid') + return values[0] + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Refused('duplicate encrypted document key') + result[key] = value + return result + + +def parse_document(raw, suffix): + if suffix == '.json': + data = json.loads(raw, object_pairs_hook=unique_object) + kind = 'json' + elif suffix in {'.yaml', '.yml'}: + import yaml + class UniqueLoader(yaml.SafeLoader): + def construct_mapping(self, node, deep=False): + self.flatten_mapping(node) + return unique_object((self.construct_object(key, deep=deep), + self.construct_object(value, deep=deep)) + for key, value in node.value) + try: + data = yaml.load(raw, Loader=UniqueLoader) + except yaml.YAMLError: + raise Refused('invalid encrypted YAML input') from None + kind = 'yaml' + else: + raise Refused('age recovery supports SOPS JSON/YAML input') + return data, kind + + +def age_document(source, recipient, *, raw=None): + # Inspect ciphertext metadata before giving a credential to any subprocess. + # This repository's admitted SOPS profile is age-only, not arbitrary cloud + # KMS, key services, SSH identities or executable age plugins. + if raw is None: + with source.open("rb") as encrypted: + raw = encrypted.read(8 * 1024 * 1024 + 1) + if len(raw) > 8 * 1024 * 1024: + raise Refused('encrypted input is too large') + data, kind = parse_document(raw, source.suffix) + metadata = data.get('sops') if isinstance(data, dict) else None + if not isinstance(metadata, dict): + raise Refused('SOPS metadata is missing') + known = {'age', 'kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp', + 'key_groups', 'shamir_threshold', 'lastmodified', 'mac', 'version', + 'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', + 'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex', + 'mac_only_encrypted'} + if set(metadata) - known or metadata.get('key_groups'): + raise Refused('unsupported SOPS custody profile') + if any(metadata.get(key) for key in ('kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp')): + raise Refused('recovery requires age-only metadata') + recipients = metadata.get('age') + if not isinstance(recipients, list) or not recipients: + raise Refused('age recipient metadata is missing') + if any(not isinstance(row, dict) or not RECIPIENT.fullmatch(str(row.get('recipient', ''))) + for row in recipients): + raise Refused('unsupported age recipient') + if recipient not in [row['recipient'] for row in recipients]: + raise Refused('document does not include the registered recipient') + return raw, kind + + +def private_key_fd(): + name = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FILE', '') + if not name: + raise Refused('explicit temporary custody key required') + path = Path(os.path.abspath(name)) + if any(p.is_symlink() for p in (path, *path.parents)): + raise Refused('custody key path must not contain symlinks') + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 16384: + os.close(fd) + raise Refused('custody key must be an owned private regular file') + return fd + + +def decrypt(recipient): + source_name = os.environ.get('NK_SOPS_FILE', '') + output_name = os.environ.get('NK_SOPS_OUTPUT', '') + if not source_name or not output_name: + raise Refused('supply FILE and OUTPUT; plaintext stdout is disabled') + source = Path(source_name).resolve(strict=True) + output = Path(output_name).absolute() + parent = output.parent.resolve(strict=True) + if any((p / '.git').is_file() or (p / '.git/HEAD').is_file() + or ((p / 'HEAD').is_file() and (p / 'objects').is_dir() and (p / 'refs').is_dir()) + for p in [parent, *parent.parents]): + raise Refused('plaintext output must be outside Git worktrees') + raw, kind = age_document(source, recipient) + binary = shutil.which('sops') + if binary is None: + raise Refused('sops executable is unavailable') + directory = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + temporary = '.netkingdom-sops-' + uuid.uuid4().hex + keyfd = None + try: + info = os.fstat(directory) + if info.st_uid != os.getuid() or info.st_mode & 0o077: + raise Refused('OUTPUT requires an owned private parent directory') + try: + os.stat(output.name, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise Refused('OUTPUT already exists') + keyfd = private_key_fd() + derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + pass_fds=(keyfd,), timeout=10) + if derived.returncode or derived.stdout.strip() != recipient.encode(): + raise Refused('custody key does not match the registered recipient') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=directory) + with os.fdopen(fd, 'wb') as plain, tempfile.TemporaryDirectory(prefix='netkingdom-sops-home-') as isolated: + # HOME/XDG isolation matters: SOPS loads ambient keys in addition to + # an explicit key file. No inline keys, SSH keys, key commands, + # proxies or remote keyservice configuration are inherited. + env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': isolated, + 'XDG_CONFIG_HOME': isolated, 'GNUPGHOME': isolated, + 'SOPS_AGE_KEY_FILE': f'/proc/self/fd/{keyfd}', + 'AWS_EC2_METADATA_DISABLED': 'true'} + result = subprocess.run([binary, '--decrypt', '--input-type', kind, + '--output-type', kind, '/dev/stdin'], + input=raw, stdout=plain, stderr=subprocess.DEVNULL, + env=env, cwd=isolated, pass_fds=(keyfd,), timeout=30) + if result.returncode: + raise Refused('SOPS decryption failed; no plaintext published') + plain.flush() + os.fsync(plain.fileno()) + # Atomic no-overwrite publication within the private directory. + os.link(temporary, output.name, src_dir_fd=directory, dst_dir_fd=directory, + follow_symlinks=False) + os.unlink(temporary, dir_fd=directory) + os.fsync(directory) + finally: + if keyfd is not None: + os.close(keyfd) + try: + os.unlink(temporary, dir_fd=directory) + except FileNotFoundError: + pass + os.close(directory) + print('Decrypted to the requested private file. Remove it after the approved operation.') + + +def interrupted(_signum, _frame): + raise Refused('operation interrupted') + + +def main(): + for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): + signal.signal(sig, interrupted) + try: + if len(sys.argv) != 2 or sys.argv[1] not in {'setup', 'decrypt'}: + raise Refused('unsupported custody operation') + recipient = public_recipient() + if sys.argv[1] == 'setup': + print('Registered public recipient validated. No private key was read, copied or generated.') + else: + decrypt(recipient) + return 0 + except Refused as exc: + print('ERROR: ' + str(exc), file=sys.stderr) + except (OSError, ValueError, TypeError, ImportError, subprocess.SubprocessError): + # Parser, filesystem and tool errors can include user data. Never echo. + print('ERROR: custody operation failed; input and tool diagnostics suppressed.', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tests/test_isolated_keycape_recovery.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tests/test_isolated_keycape_recovery.py new file mode 100644 index 00000000..93941201 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tests/test_isolated_keycape_recovery.py @@ -0,0 +1,223 @@ +import base64 +import hashlib +import importlib.util +import io +import json +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape.py' +spec=importlib.util.spec_from_file_location('adapter',SOURCE) +a=importlib.util.module_from_spec(spec);spec.loader.exec_module(a) + +class ArchiveTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) + self.root=Path(self.tmp.name) + config={'data':{k:base64.b64encode(v).decode() for k,v in {'config.yaml':b'issuer: https://issuer.example','key.pem':b'fixture-key'}.items()}} + self.values={'resources/sso/secret/keycape-config.json':json.dumps(config).encode(), + 'resources/sso/deployment/keycape.json':json.dumps({'spec':{'template':{'spec':{'containers':[{'name':'keycape','image':a.IMAGE,'env':[ + {'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'}, + {'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'}, + {'name':'KEYCAPE_ORIGINAL_SECRET','valueFrom':{'secretKeyRef':{'name':'client','key':'secret'}}}]}]}}}}).encode(), + 'resources/sso/secret/client.json':json.dumps({'data':{'secret':base64.b64encode(b'fixture-private').decode()}}).encode(), + 'data/keycape/policy.json':b'{}','data/keycape/receipts/one.json':b'{"original":true}'} + for name,value in self.values.items(): + p=self.root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(value);p.chmod(0o600) + self.manifest={'profile':{'components':[{'namespace':'sso','deployment':'keycape','secret_refs':['keycape-config','client'],'configmap_refs':[]}]}, + 'files':[{'path':k,'bytes':len(v),'sha256':hashlib.sha256(v).hexdigest()} for k,v in self.values.items()]} + def payload(self): + digest=hashlib.sha256(json.dumps(self.manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest() + return a.private_payload(self.root,self.manifest,digest) + def test_original_state_and_secret_delivery_retained(self): + p=self.payload();self.assertEqual(p['env']['KEYCAPE_ORIGINAL_SECRET'],'fixture-private') + self.assertEqual(base64.b64decode(p['files']['/var/lib/keycape-policy/receipts/one.json']),b'{"original":true}') + def test_archive_digest_refused(self): + with self.assertRaises(a.Refused):a.private_payload(self.root,self.manifest,'0'*64) + def test_changed_file_refused(self): + (self.root/'data/keycape/policy.json').write_text('{"changed":true}') + with self.assertRaises(a.Refused):self.payload() + def test_symlink_refused(self): + p=self.root/'data/keycape/policy.json';p.unlink();p.symlink_to('/etc/hostname') + with self.assertRaises(Exception):self.payload() + def test_duplicate_manifest_refused(self): + self.manifest['files'].append(self.manifest['files'][0]) + with self.assertRaises(a.Refused):self.payload() + def test_image_mismatch_refused(self): + d=json.loads(self.values['resources/sso/deployment/keycape.json']);d['spec']['template']['spec']['containers'][0]['image']='keycape:latest' + raw=json.dumps(d).encode();p=self.root/'resources/sso/deployment/keycape.json';p.write_bytes(raw) + for row in self.manifest['files']: + if row['path'].endswith('/deployment/keycape.json'):row.update(bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest()) + with self.assertRaises(a.Refused):self.payload() + +class NativeTests(unittest.TestCase): + def setUp(self): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + self.key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()) + self.cfg=b'issuer: https://issuer.example\nclients:\n- clientId: existing\n clientType: confidential\n secretRef: env:KEYCAPE_ORIGINAL_SECRET\n grantTypes: [client_credentials]\n' + self.files={'/etc/keycape/config.yaml':self.cfg,'/etc/keycape/key.pem':self.key,'/var/lib/keycape-policy/policy.json':b'{}'} + self.payload={'env':{'KEYCAPE_ORIGINAL_SECRET':'fixture-private'},'files':{k:base64.b64encode(v).decode() for k,v in self.files.items()}} + self.admission={'issuer':'https://issuer.example','verifier_sha256':hashlib.sha256(b'binary').hexdigest(), + 'trust_path':'/trust.pem','trust_sha256':hashlib.sha256(b'original-trust').hexdigest(), + 'consumer':{'client_id':'existing','secret_env':'KEYCAPE_ORIGINAL_SECRET','scope':'read','audience':'consumer', + 'subject':'service:existing','tenant':'tenant:platform','roles':'reader','deny_scope':'write'}} + self.session={'issuer':'isolated','volumes':{x:{'target':x} for x in ('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')}} + self.output=''.join('PASS '+x+'\n' for x in ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings','principal_type is service','sub matches the registration','tenant matches the registration','roles present','granted scope carries no excess grant','predecessor secret refused','excess scope refused: write']) + def file(self,container,path): + return {**self.files,'/etc/hosts':b'127.0.0.1 issuer.example\n','/keycape':b'binary','/trust.pem':b'original-trust'}[path] + def command(self,argv,**kwargs): + if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem']).encode() + output=io.BytesIO() + with tarfile.open(fileobj=output,mode='w') as t: + m=tarfile.TarInfo('policy.json');m.size=2;t.addfile(m,io.BytesIO(b'{}')) + return output.getvalue() + def verify(self,code=0,output=None): + with patch.object(a,'check_jwks'),patch.object(a,'runtime_file',return_value=b'original-trust'),patch.object(a,'runtime_files',side_effect=lambda session,paths=(),tree=False:({k:v for k,v in self.files.items() if k.startswith('/var/lib/keycape-policy/')} if tree else dict(self.files))),patch.object(a,'container_file',side_effect=self.file),patch.object(a,'command',side_effect=self.command),patch.object(a.subprocess,'run',return_value=subprocess.CompletedProcess([],code,b'',(self.output if output is None else output).encode())) as run: + result=a.run_verifier(self.session,self.admission,self.payload) + argv=run.call_args.args[0] + self.assertNotIn('fixture-private',' '.join(argv)) + self.assertIn('-previous-secret-env',argv) + return result + def test_original_registration_and_unrelated_secret(self): + r=self.verify();self.assertTrue(r['wrong_unrelated_secret_explicitly_refused']);self.assertNotIn('fixture-private',json.dumps(r)) + def test_transport_failure_never_counts_as_wrong_secret(self): + with self.assertRaises(a.Refused):self.verify(1,'FAIL discovery and endpoint origin: TLS failure\n') + def test_missing_explicit_rejection_never_counts(self): + with self.assertRaises(a.Refused):self.verify(0,self.output.replace('PASS predecessor secret refused\n','')) + def test_fail_line_refused_even_zero_exit(self): + with self.assertRaises(a.Refused):self.verify(0,self.output+'FAIL transport\n') + def test_security_environment_override_refused(self): + original=self.command + def altered(argv,**kwargs): + if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem','SSL_CERT_DIR=/unreviewed']).encode() + return original(argv,**kwargs) + with patch.object(self,'command',side_effect=altered): + with self.assertRaises(a.Refused):self.verify() + def test_http_issuer_refused(self): + self.admission['issuer']='http://issuer.example' + with self.assertRaises(a.Refused):self.verify() + def test_changed_trust_refused(self): + self.admission['trust_sha256']='0'*64 + with self.assertRaises(a.Refused):self.verify() + def test_config_drift_refused(self): + self.payload['files']['/etc/keycape/config.yaml']=base64.b64encode(b'changed').decode() + with self.assertRaises(a.Refused):self.verify() + +class BoundaryTests(unittest.TestCase): + def test_remote_docker_refused_before_info(self): + session={'run_id':'a'*32,'containers':['nk49-kc-'+('a'*32)+'-worker'],'worker':'nk49-kc-'+('a'*32)+'-worker','issuer':'nk49-kc-'+('a'*32)+'-worker'} + with patch.object(a,'command',return_value=b'[{"Endpoints":{"docker":{"Host":"tcp://remote:2375"}}}]') as command: + with self.assertRaises(a.Refused):a.inspect_session(session) + self.assertEqual(command.call_count,1) + def test_failed_verifier_cleanup_is_attempted(self): + receipt={};admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} + with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',side_effect=a.Refused),patch.object(a,'cleanup') as cleanup: + with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) + cleanup.assert_called_once();self.assertTrue(receipt['container_cleanup_verified']);self.assertEqual(receipt['status'],'failed') + + def test_cleanup_failure_overrides_success(self): + admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} + result={x:True for x in ('native_service_registration_verified','wrong_unrelated_secret_explicitly_refused','denied_scopes_refused','original_config_registry_policy_receipts_equal','original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks')} + result['public_signing_spki_sha256']='e'*64;receipt={} + with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',return_value=result),patch.object(a,'cleanup',side_effect=a.Refused): + with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) + self.assertEqual(receipt['status'],'failed');self.assertFalse(receipt['container_cleanup_verified']) + +class ContainmentTests(unittest.TestCase): + def inspect(self,overrides=None,launch=None): + name='nk49-kc-'+('a'*32)+'-issuer' + session={'run_id':'a'*32,'containers':[name],'issuer':name,'worker':name,'images':{name:a.IMAGE}} + host={'NetworkMode':'none','ReadonlyRootfs':True,'Privileged':False,'CapDrop':['ALL'],'SecurityOpt':['no-new-privileges']} + host.update(overrides or {}) + def command(argv,**kwargs): + if argv[1:3]==['context','inspect']:return b'[{"Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]' + if argv[1]=='info':return b'{"ID":"74f93783-6cef-43a8-a3c4-f62586cd3bc1","Name":"bnt-lap001","OSType":"linux"}' + if argv[-1]=='{{.Id}}':return b'anchor-id' + if '"path"' in argv[-1]:return json.dumps(launch or {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}).encode() + return json.dumps({'host':host,'mounts':[],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode() + with patch.object(a,'command',side_effect=command),patch.dict('os.environ',{'DOCKER_HOST':'unix:///var/run/docker.sock'}):a.inspect_session(session) + def test_minimal_network_none_admitted(self):self.inspect() + def test_alternate_same_image_command_refused(self): + with self.assertRaises(a.Refused):self.inspect(launch={'entrypoint':['/keycape'],'cmd':['check-config'],'path':'/keycape','args':['check-config']}) + def test_cap_add_refused(self): + with self.assertRaises(a.Refused):self.inspect({'CapAdd':['NET_ADMIN']}) + def test_devices_refused(self): + with self.assertRaises(a.Refused):self.inspect({'Devices':[{'PathOnHost':'/dev/mem'}]}) + def test_device_requests_refused(self): + with self.assertRaises(a.Refused):self.inspect({'DeviceRequests':[{'Driver':'nvidia'}]}) + def test_host_namespace_refused(self): + for field in ('PidMode','IpcMode','UTSMode','UsernsMode','CgroupnsMode'): + with self.subTest(field=field),self.assertRaises(a.Refused):self.inspect({field:'host'}) + def test_security_override_refused(self): + with self.assertRaises(a.Refused):self.inspect({'SecurityOpt':['no-new-privileges','seccomp=unconfined']}) + +class CleanupTests(unittest.TestCase): + def test_partial_cleanup_attempts_every_owned_container(self): + session={'run_id':'a'*32,'containers':['first','second','third']};receipt={} + removed=[] + def command(argv,**kwargs): + if argv[1:3]==['container','inspect']:return ('a'*32).encode() + if argv[1]=='rm': + removed.append(argv[-1]) + if argv[-1]=='third':raise a.Refused() + return b'' + if argv[1:3]==['container','ls']: + name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$') + return (name+'\n').encode() if name=='third' or name not in removed else b'' + return b'' + with patch.object(a,'command',side_effect=command): + with self.assertRaises(a.Refused):a.cleanup(session,receipt) + self.assertEqual(removed,['third','second','first']) + self.assertEqual([r['status'] for r in receipt['container_cleanup_obligations']],['still_present','absence_verified','absence_verified']) + def test_ownership_mismatch_does_not_remove_and_continues(self): + session={'run_id':'a'*32,'containers':['first','second']};receipt={};removed=[] + def command(argv,**kwargs): + if argv[1:3]==['container','inspect']:return (('b' if argv[3]=='second' else 'a')*32).encode() + if argv[1]=='rm':removed.append(argv[-1]) + if argv[1:3]==['container','ls']: + name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$') + return (name+'\n').encode() if name not in removed else b'' + return b'' + with patch.object(a,'command',side_effect=command): + with self.assertRaises(a.Refused):a.cleanup(session,receipt) + self.assertEqual(removed,['first']) + self.assertEqual(receipt['container_cleanup_obligations'][0]['status'],'ownership_mismatch_not_removed') + def test_inspect_failure_retains_uncertain_obligation(self): + receipt={} + with patch.object(a,'command',side_effect=a.Refused): + with self.assertRaises(a.Refused):a.cleanup({'run_id':'a'*32,'containers':['exact-owned-name']},receipt) + self.assertEqual(receipt['container_cleanup_obligations'],[{'container':'exact-owned-name','status':'unverified'}]) + +class JWKSTests(unittest.TestCase): + def execute(self,n='123',endpoint='https://issuer.example/jwks',resolved='127.0.0.1',extra=False,kid='key-1'): + request={'issuer':'https://issuer.example','trust':'public-certificate','n':'123','e':'65537'} + def enc(i):return base64.urlsafe_b64encode(int(i).to_bytes((int(i).bit_length()+7)//8,'big')).decode().rstrip('=') + responses=[{'issuer':'https://issuer.example','jwks_uri':endpoint},{'keys':[{'kty':'RSA','kid':kid,'use':'sig','alg':'RS256','n':enc(n),'e':enc(65537)}]}] + if extra:responses[1]['keys'].append(dict(responses[1]['keys'][0],kid='key-2')) + class Response: + def __init__(self,value):self.value=value + def __enter__(self):return self + def __exit__(self,*args):pass + def read(self,*args):return json.dumps(self.value).encode() + class Opener: + def open(self,*args,**kwargs):return Response(responses.pop(0)) + with patch('sys.stdin',io.StringIO(json.dumps(request))),patch('sys.stdout',io.StringIO()),patch('socket.getaddrinfo',return_value=[(2,1,6,'',(resolved,443))]),patch('ssl.create_default_context'),patch('urllib.request.build_opener',return_value=Opener()): + exec(compile(a.JWKS_PROBE,'','exec',optimize=2),{}) + def test_exact_original_public_key(self):self.execute() + def test_extra_signing_key_refused_under_optimization(self): + with self.assertRaises(ValueError):self.execute(extra=True) + def test_wrong_kid_refused_under_optimization(self): + with self.assertRaises(ValueError):self.execute(kid='replacement') + def test_wrong_live_signing_key_refused(self): + with self.assertRaises(ValueError):self.execute(n='124') + def test_cross_origin_jwks_refused(self): + with self.assertRaises(ValueError):self.execute(endpoint='https://production.example/jwks') + def test_production_dns_refused_before_fetch(self): + with self.assertRaises(ValueError):self.execute(resolved='192.0.2.1') + +if __name__=='__main__':unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tests/test_isolated_keycape_session.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tests/test_isolated_keycape_session.py new file mode 100644 index 00000000..a98636f1 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tests/test_isolated_keycape_session.py @@ -0,0 +1,138 @@ +import base64 +import importlib.util +import json +import os +import tempfile +from pathlib import Path +import unittest +from unittest.mock import patch + +SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape-session.py' +spec=importlib.util.spec_from_file_location('session',SOURCE) +s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s) + +class SessionTests(unittest.TestCase): + def test_tmpfs_plan_has_four_exact_original_mounts(self): + session=s.plan_session() + self.assertEqual({v['target'] for v in session['volumes'].values()},set(s.RUNROOTS)) + self.assertTrue(all(v['options']==s.VOLUME_OPTIONS for v in session['volumes'].values())) + self.assertEqual(session['images'][session['issuer']],s.adapter.IMAGE) + def test_non_tmpfs_driver_options_refused(self): + session=s.plan_session();name,spec=next(iter(session['volumes'].items())) + wrong=[{'Name':name,'Driver':'local','Scope':'local','Options':{'type':'none','device':'/host/data'},'Labels':{s.adapter.LABEL:session['run_id']}}] + with patch.object(s,'run',return_value=json.dumps(wrong).encode()): + with self.assertRaises(s.adapter.Refused):s.verify_volume(name,spec,session['run_id']) + def test_owner_secret_is_not_docker_argv_and_import_is_after_inspection(self): + session=s.plan_session();events=[] + payload={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'original').decode()},'env':{'KEYCAPE_TEST_SECRET':'fixture-sensitive'}} + def run(argv,**kwargs): + events.append(('run',argv,kwargs)) + if argv[-1]=='{{.Id}}':return b'anchor' + return b'' + with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=lambda *args,**kwargs:events.append(('inspect',args,kwargs))): + s.provision(session,payload,{'/recovery-tls/trust.pem':b'original-trust'},'https://issuer.example') + joined=' '.join(' '.join(e[1]) for e in events if e[0]=='run') + self.assertNotIn('fixture-sensitive',joined) + private_index=next(i for i,e in enumerate(events) if e[0]=='run' and s.IMPORT in e[1]) + inspect_index=next(i for i,e in enumerate(events) if e[0]=='inspect') + self.assertLess(inspect_index,private_index) + issuer_call=next(e for e in events if e[0]=='run' and e[1][-1]==s.adapter.IMAGE) + self.assertEqual(issuer_call[2]['env']['KEYCAPE_TEST_SECRET'],'fixture-sensitive') + self.assertIn('KEYCAPE_TEST_SECRET',issuer_call[1]) + self.assertNotIn('--entrypoint',issuer_call[1]) + def test_non_original_https_port_refused_before_any_mutation(self): + with patch.object(s,'run') as run: + with self.assertRaises(s.adapter.Refused):s.provision(s.plan_session(),{'files':{},'env':{}},{},'http://issuer.example') + run.assert_not_called() + def test_container_failure_still_attempts_each_volume_cleanup(self): + session=s.plan_session();receipt={};removed=[] + def run(argv,**kwargs): + if argv[1:3]==['volume','rm']:removed.append(argv[-1]) + if argv[1:3]==['volume','ls']: + name=next(x for x in argv if x.startswith('name=^')).removeprefix('name=^').removesuffix('$') + return (name+'\n').encode() if name not in removed else b'' + return b'' + with patch.object(s.adapter,'cleanup',side_effect=s.adapter.Refused),patch.object(s,'verify_volume'),patch.object(s,'run',side_effect=run): + with self.assertRaises(s.adapter.Refused):s.cleanup_all(session,receipt) + self.assertEqual(set(removed),set(session['volumes'])) + self.assertFalse(receipt['container_cleanup_verified']) + self.assertEqual(len(receipt['tmpfs_volume_cleanup_obligations']),4) + def test_reaper_inherits_no_custody_or_service_environment(self): + session=s.plan_session();staging={'path':'/dev/shm/registered','run_id':session['run_id']} + with patch.object(s.subprocess,'Popen') as spawn,patch.object(s,'register_workdir',return_value=staging),patch.object(s.select,'select',side_effect=lambda *args:([spawn.return_value.stdout],[],[])): + spawn.return_value.stdout.readline.return_value=b'registered\n' + s.start_reaper(session,staging) + kwargs=spawn.call_args.kwargs + self.assertEqual(set(kwargs['env']),{'PATH','DOCKER_HOST'}) + self.assertTrue(kwargs['close_fds']);self.assertTrue(kwargs['start_new_session']) + +class TargetBindingTests(unittest.TestCase): + def test_every_direct_docker_command_is_explicitly_bound(self): + with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run',return_value=__import__('subprocess').CompletedProcess([],0,b'ok')) as run: + self.assertEqual(s.run(['docker','info']),b'ok') + argv=run.call_args.args[0] + self.assertEqual(argv[:3],['docker','--host',s.adapter.ENDPOINT]) + self.assertEqual(run.call_args.kwargs['env']['DOCKER_HOST'],s.adapter.ENDPOINT) + def test_nondefault_unix_socket_is_refused_before_any_docker_call(self): + with patch.dict('os.environ',{'DOCKER_HOST':'unix:///tmp/other-daemon.sock'},clear=True),patch.object(s.adapter.subprocess,'run') as run: + with self.assertRaises(s.adapter.Refused):s.run(['docker','info']) + run.assert_not_called() + def test_conflicting_context_is_refused(self): + with patch.dict('os.environ',{'DOCKER_CONTEXT':'production'},clear=True),patch.object(s.adapter.subprocess,'run') as run: + with self.assertRaises(s.adapter.Refused):s.run(['docker','info']) + run.assert_not_called() + def test_conflicting_per_call_host_is_refused(self): + with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run') as run: + with self.assertRaises(s.adapter.Refused):s.run(['docker','info'],env={'DOCKER_HOST':'unix:///tmp/other.sock'}) + run.assert_not_called() + def test_reused_preflight_uses_same_bound_command(self): + self.assertIs(s.local.command,s.adapter.command) + def test_probe_is_registered_before_session_creation(self): + session=s.plan_session() + self.assertEqual(session['public_image_probe'],'nk49-kc-'+session['run_id']+'-image-probe') + self.assertEqual(session['docker_endpoint'],s.adapter.ENDPOINT) + +class PlaintextLifecycleTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) + self.root=Path(self.tmp.name) + self.override=patch.object(s,'STAGING_ROOT',self.root);self.override.start();self.addCleanup(self.override.stop) + self.run_id='a'*32 + self.work=self.root/('nk49-keycape-'+self.run_id+'-abcdefgh');self.work.mkdir(mode=0o700) + self.staging=s.register_workdir(self.work,self.run_id) + def test_exact_plaintext_directory_removed_and_recorded(self): + (self.work/'archive.tar').write_bytes(b'synthetic-private-archive') + restored=self.work/'restored';restored.mkdir(mode=0o700);(restored/'fixture').write_bytes(b'synthetic') + receipt={};s.cleanup_workdir(self.staging,receipt) + self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified']) + self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'absence_verified') + def test_docker_failure_does_not_skip_plaintext_cleanup(self): + (self.work/'archive.tar').write_bytes(b'synthetic') + receipt={} + with patch.object(s,'cleanup_all',side_effect=s.adapter.Refused): + with self.assertRaises(s.adapter.Refused):s.recover_obligations({},self.staging,receipt) + self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified']) + def test_replacement_inode_is_not_removed(self): + # Preserve original inode while replacing its registered pathname. + old=self.root/'retained-original';self.work.rename(old);self.work.mkdir(mode=0o700) + (self.work/'replacement').write_bytes(b'not-owned-by-this-obligation');receipt={} + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,receipt) + self.assertTrue((self.work/'replacement').exists());self.assertTrue(old.exists()) + self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'identity_mismatch_not_removed') + def test_symlink_root_is_not_followed(self): + self.work.rmdir();outside=self.root/'outside';outside.mkdir();(outside/'kept').write_bytes(b'keep') + self.work.symlink_to(outside,target_is_directory=True) + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{}) + self.assertTrue((outside/'kept').exists()) + def test_descendant_symlink_is_not_followed(self): + outside=self.root/'kept';outside.write_bytes(b'keep');(self.work/'link').symlink_to(outside) + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{}) + self.assertEqual(outside.read_bytes(),b'keep') + def test_wrong_owner_identity_is_not_removed(self): + changed={**self.staging,'owner_uid':os.getuid()+1} + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(changed,{}) + self.assertTrue(self.work.exists()) + def test_unregistered_path_refused(self): + with self.assertRaises(s.adapter.Refused):s.cleanup_workdir({**self.staging,'path':str(self.root)}, {}) + +if __name__=='__main__':unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/check-privacyidea-database.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/check-privacyidea-database.py new file mode 100644 index 00000000..d00361be --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/check-privacyidea-database.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Restore the fetched PI database in an isolated, disposable local PostgreSQL. + +Run only through the attended custody helper. No cluster, production database, +network, ports, host data mounts, IAM login or token decryption is involved. +""" +import argparse +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time +import uuid + +ROOT=Path(__file__).resolve().parents[2] +spec=importlib.util.spec_from_file_location('custody',ROOT/'sso-mfa/bootstrap/sops-private.py') +custody=importlib.util.module_from_spec(spec);spec.loader.exec_module(custody) +RECIPIENT='age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz' +IMAGE='postgres@sha256:ff23cdce56cac62ada6f66013e1a50864c0abbe79d132d40b6e05bd80f378a70' +OBJECT='r01-net-kingdom-pg/net-kingdom-pg-privacyidea_db-20261015T023000Z.dump.age' +SIZE=89129309 + + +class Refused(Exception):pass + + +def run(args,**kwargs): + result=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=600,**kwargs) + if result.returncode:raise Refused() + return result.stdout + + +def transfer_input(receipt): + r=json.loads(receipt.read_text()) + if (r.get('schema')!='netkingdom.privacyidea-offhost-transfer.v1' or + r.get('status')!='ciphertext_fetched' or r.get('object')!=OBJECT or + r.get('ciphertext_bytes')!=SIZE or r.get('object_unchanged_during_transfer') is not True):raise Refused() + path=Path(r['ciphertext_path']) + if not path.is_absolute() or path.parent.parent!=Path('/tmp') or not path.parent.name.startswith('nk49-offhost-'):raise Refused() + if any(p.is_symlink() for p in (path,*path.parents)):raise Refused() + st=path.stat() + if st.st_uid!=os.getuid() or st.st_mode&0o077 or st.st_nlink!=1 or not path.is_file() or st.st_size!=SIZE:raise Refused() + blob=path.read_bytes() + if len(blob)!=SIZE or hashlib.sha256(blob).hexdigest()!=r['ciphertext_sha256']:raise Refused() + return blob,r['ciphertext_sha256'] + + +def restore(dump,container): + # Require the exact image already present; do not pull through a mutable tag. + image_id=run(['docker','image','inspect','--format','{{.Id}}',IMAGE]).decode().strip() + run(['docker','run','--detach','--pull=never','--name',container,'--network','none', + '--security-opt','no-new-privileges','--memory','2g','--cpus','2', + '--tmpfs','/var/lib/postgresql/data:rw,nosuid,nodev,size=1073741824', + '--env','POSTGRES_HOST_AUTH_METHOD=trust','--env','POSTGRES_DB=recovery', + IMAGE,'postgres','-c','listen_addresses=']) + for _ in range(60): + # The image starts a temporary server during initdb. Wait until the + # entrypoint has exec'd the final server before attempting the restore. + pid1=subprocess.run(['docker','exec',container,'cat','/proc/1/comm'], + stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10) + if pid1.returncode or pid1.stdout.strip()!=b'postgres': + time.sleep(1);continue + ready=subprocess.run(['docker','exec',container,'pg_isready','-U','postgres','-d','recovery'], + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=10) + if ready.returncode==0:break + time.sleep(1) + else:raise Refused() + version=run(['docker','exec',container,'pg_restore','--version']).decode().strip() + with dump.open('rb') as source: + run(['docker','exec','-i',container,'pg_restore','-U','postgres','-d','recovery', + '--no-owner','--no-privileges','--exit-on-error'],stdin=source) + # Fixed aggregate queries only. No identity, resolver config or token values. + counts={} + for table in ('token','resolver','realm'): + raw=run(['docker','exec',container,'psql','-X','-U','postgres','-d','recovery','-At', + '-v','ON_ERROR_STOP=1','-c',f'SELECT count(*) FROM public.{table};']).strip() + if not raw.isdigit():raise Refused() + counts[table]=int(raw) + return {'image':IMAGE,'image_id':image_id,'pg_restore_version':version,'aggregate_row_counts':counts} + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--transfer-receipt',required=True);p.add_argument('--receipt',required=True) + a=p.parse_args();os.umask(0o077) + # Reserve the receipt before any recovery action; never overwrite evidence. + fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'schema':'netkingdom.privacyidea-database-drill.v1','status':'failed', + 'started_at':datetime.now(timezone.utc).isoformat(),'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'production_contacted':False,'secret_values_exported':False,'native_identity_recovery_proven':False, + 'token_decryption_proven':False,'audit_signature_continuity_proven':False, + 'full_identity_file_coverage_proven':False,'container_cleanup_verified':False, + 'temporary_plaintext_cleanup_verified':False} + container='nk49-pi-recovery-'+uuid.uuid4().hex + keyfd=None;work=None + try: + blob,digest=transfer_input(Path(a.transfer_receipt));r['ciphertext_sha256']=digest + keyfd=custody.private_key_fd() + derived=run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip() + if derived!=RECIPIENT.encode():raise Refused() + r['registered_recipient_match']=True + with tempfile.TemporaryDirectory(prefix='nk49-pi-drill-',dir='/tmp') as temporary: + work=Path(temporary) + env={'PATH':os.environ.get('PATH',os.defpath),'HOME':temporary,'XDG_CONFIG_HOME':temporary} + wrong=work/'wrong.key' + run(['age-keygen','-o',str(wrong)],env=env) + negative=subprocess.run(['age','-d','-i',str(wrong)],input=blob,stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL,timeout=60,env=env) + if negative.returncode==0:raise Refused() + r['wrong_key_refused']=True + dump=work/'privacyidea.dump' + with dump.open('xb') as output: + result=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}'],input=blob, + stdout=output,stderr=subprocess.DEVNULL,timeout=120,pass_fds=(keyfd,),env=env) + if result.returncode or not 5max_bytes:raise Refused() + + +def allowed(name,profile_data=None): + p=PurePosixPath(name) + if p.is_absolute() or any(not NAME.fullmatch(x) or x in {'.','..'} for x in p.parts):return False + if len(p.parts)>=3 and p.parts[:2] in [('data','lldap'),('data','authelia'),('data','keycape'),('data','privacyidea')]:return True + if name=='databases/privacyidea.dump':return True + for c in (profile_data or profile())['components']: + prefix=f"resources/{c['namespace']}/" + if name==prefix+'deployment/'+c['deployment']+'.json':return True + if any(name==prefix+'secret/'+n+'.json' for n in c['secret_refs']):return True + if any(name==prefix+'configmap/'+n+'.json' for n in c['configmap_refs']):return True + return False + + +def save(root,name,value,profile_data=None): + if not allowed(name,profile_data) or len(value)>FILE_LIMIT or b'AGE-SECRET-KEY-' in value:raise Refused() + target=root/name;target.parent.mkdir(mode=0o700,parents=True,exist_ok=True) + fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as out:out.write(value) + + +def import_files(raw,root,mode): + names=set();total=0 + with tarfile.open(fileobj=io.BytesIO(raw),mode='r:') as archive: + for member in archive: + expected=('data/privacyidea/',) if mode=='mfa' else ('data/lldap/','data/authelia/','data/keycape/') + if (not member.isfile() or not member.name.startswith(expected) or member.name in names + or member.size<0 or member.size>FILE_LIMIT or len(names)>=2048):raise Refused() + names.add(member.name);total+=member.size + if total>LIMIT:raise Refused() + data=archive.extractfile(member).read() + if len(data)!=member.size:raise Refused() + save(root,member.name,data) + + +def resource(root,ns,kind,name): + p=root/f'resources/{ns}/{kind}/{name}.json' + data=json.loads(p.read_bytes()) + if data['metadata']['name']!=name or data['metadata']['namespace']!=ns:raise Refused() + return data + + +def checks(root,profile_data=None): + # Checks describe stored formats/integrity, not current login validity. + for c in (profile_data or profile())['components']: + deployment=resource(root,c['namespace'],'deployment',c['deployment']) + if deployment.get('kind')!='Deployment' or deployment['metadata']['uid']!=c['deployment_uid']:raise Refused() + for name in c['secret_refs']: + data=resource(root,c['namespace'],'secret',name) + if data.get('kind')!='Secret' or not isinstance(data.get('data'),dict) or not data['data']:raise Refused() + for value in data['data'].values(): + if b'AGE-SECRET-KEY-' in base64.b64decode(value,validate=True):raise Refused() + for name in c['configmap_refs']: + if resource(root,c['namespace'],'configmap',name).get('kind')!='ConfigMap':raise Refused() + for name,file in [('lldap','users.db'),('authelia','db.sqlite3')]: + db=root/f'data/{name}/{file}' + with sqlite3.connect(db.as_uri()+'?mode=ro',uri=True) as con: + if con.execute('PRAGMA integrity_check').fetchall()!=[('ok',)]:raise Refused() + with (root/'databases/privacyidea.dump').open('rb') as source: + if source.read(5)!=b'PGDMP':raise Refused() + spec=importlib.util.spec_from_file_location('pi_keys',ROOT/'sso-mfa/k8s/privacyidea/key-custody-remote.py') + keys=importlib.util.module_from_spec(spec);spec.loader.exec_module(keys) + keys.validate({name:(root/'data/privacyidea'/name).read_bytes() for name in ('enckey','private.pem','public.pem')}) + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + config=resource(root,'sso','secret','keycape-config')['data'] + if not base64.b64decode(config['config.yaml'],validate=True):raise Refused() + signing=serialization.load_pem_private_key(base64.b64decode(config['key.pem'],validate=True),password=None) + if not isinstance(signing,rsa.RSAPrivateKey) or signing.key_size<2048:raise Refused() + # Policy must exist, even for an empty set of receipts; do not silently + # restore an issuer without its current authentication policy. + policy=json.loads((root/'data/keycape/policy.json').read_bytes()) + if not isinstance(policy,dict):raise Refused() + return {'sqlite_integrity_passed':True,'privacyidea_key_format_and_pair_passed':True, + 'keycape_signing_key_format_passed':True,'policy_json_present':True, + 'database_custom_format_present':True,'native_identity_recovery_proven':False} + + +def inventory(root): + private(root,True);entries=[];total=0 + for path in sorted(root.rglob('*')): + if path.is_dir():private(path,True);continue + private(path);name=path.relative_to(root).as_posix() + if not allowed(name):raise Refused() + with path.open('rb') as source:value=source.read(FILE_LIMIT+1) + if len(value)>FILE_LIMIT or b'AGE-SECRET-KEY-' in value:raise Refused() + total+=len(value) + if total>LIMIT or len(entries)>=4096:raise Refused() + entries.append({'path':name,'bytes':len(value),'sha256':hashlib.sha256(value).hexdigest()}) + if not entries:raise Refused() + return entries + + +def seal(root,output,capture): + output=output.absolute();private(output.parent,True) + if output.exists() or output.is_symlink():raise Refused() + checked=checks(root);entries=inventory(root) + manifest={'schema':'netkingdom.identity-archive.v1','profile_sha256':hashlib.sha256(PROFILE.read_bytes()).hexdigest(), + 'profile':profile(), + 'captured_at':datetime.now(timezone.utc).isoformat(),'capture':capture,'files':entries,'checks':checked} + with tempfile.TemporaryDirectory(prefix='.identity-seal-',dir=output.parent) as temporary: + ciphertext=Path(temporary)/'ciphertext' + fd=os.open(ciphertext,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) + proc=subprocess.Popen(['age','-r',profile()['public_recipient']],stdin=subprocess.PIPE,stdout=fd,stderr=subprocess.DEVNULL) + os.close(fd) + try: + with tarfile.open(fileobj=proc.stdin,mode='w|') as archive: + raw=json.dumps(manifest,sort_keys=True).encode();m=tarfile.TarInfo('manifest.json');m.size=len(raw);m.mode=0o600 + archive.addfile(m,io.BytesIO(raw)) + for entry in entries: + with (root/entry['path']).open('rb') as source: + value=source.read(FILE_LIMIT+1) + if hashlib.sha256(value).hexdigest()!=entry['sha256']:raise Refused() + m=tarfile.TarInfo(entry['path']);m.size=len(value);m.mode=0o600 + archive.addfile(m,io.BytesIO(value)) + proc.stdin.close() + if proc.wait(timeout=120) or not 04096:raise Refused() + seen=set();total=0 + # Iteration starts with the already-read first member; explicitly skip + # only that member. A later duplicate manifest is never accepted. + for index,member in enumerate(archive): + if index==0 and member is first:continue + name=member.name + if (not member.isfile() or name in seen or name not in expected or not allowed(name,captured) + or not 0<=member.size<=FILE_LIMIT or member.size!=expected[name]['bytes']):raise Refused() + seen.add(name);total+=member.size + if total>LIMIT:raise Refused() + raw=archive.extractfile(member).read() + if len(raw)!=member.size or hashlib.sha256(raw).hexdigest()!=expected[name]['sha256']:raise Refused() + save(root,name,raw,captured) + if seen!=set(expected):raise Refused() + return manifest,checks(root,captured) + + +def require_runtime_host(): + import socket + host=profile()['runtime_host'] + if (os.geteuid()!=0 or socket.gethostname()!=host['hostname'] + or hashlib.sha256(Path('/etc/machine-id').read_bytes()).hexdigest()!=host['machine_id_sha256']):raise Refused() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/identity-offsite.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/identity-offsite.py new file mode 100644 index 00000000..33707e5a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/identity-offsite.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Folder-scoped Storage Share ciphertext publication and verified readback. + +Runtime gets only a dedicated read/create share token. Account administration, +deletion and age private keys have no route in this publisher. +""" +import base64 +from datetime import datetime,timezone +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET + +HOST='https://nx4069.your-storageshare.de' +BASE=HOST+'/public.php/webdav/' +NAME=re.compile(r'net-kingdom-identity-\d{8}T\d{6}Z-[a-f0-9]{12}\.tar\.age\Z') +LIMIT=512*1024*1024 +HEADROOM=2*1024**3 + + +class Refused(Exception):pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):raise Refused() + + +def opener():return urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect()) + + +def headers(token): + if not isinstance(token,str) or not re.fullmatch('[A-Za-z0-9]{10,128}',token):raise Refused() + return {'Authorization':'Basic '+base64.b64encode((token+':').encode()).decode(), + 'X-Requested-With':'XMLHttpRequest','Accept-Encoding':'identity'} + + +def digest(path): + with path.open('rb') as source:return hashlib.file_digest(source,'sha256').hexdigest() + + +def quota(client,token): + body=b'' + h=headers(token);h.update(Depth='0');h['Content-Type']='application/xml' + with client.open(urllib.request.Request(BASE,data=body,method='PROPFIND',headers=h),timeout=30) as response: + if response.status!=207 or response.url!=BASE:raise Refused() + raw=response.read(1024*1024+1) + if len(raw)>1024*1024:raise Refused() + amount=ET.fromstring(raw).findtext('.//{DAV:}quota-available-bytes') or '' + if not amount.isdigit():raise Refused() + return int(amount) + + +def etag(response,url,size): + value=response.headers.get('ETag','') + if (response.status!=200 or response.url!=url or response.headers.get('Content-Length')!=str(size) + or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"',value)):raise Refused() + return value + + +def readback(client,token,name,size,expected,target): + valid=NAME.fullmatch(name) or (name.endswith('.receipt.json') and NAME.fullmatch(name.removesuffix('.receipt.json')) and size<=65536) + if not valid or not 0size:raise Refused() + sha.update(block);out.write(block) + out.flush();os.fsync(out.fileno()) + if count!=size or sha.hexdigest()!=expected:raise Refused() + with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response: + if etag(response,url,size)!=version:raise Refused() + return version + + +def publish(client,token,source,capture,target): + if (not NAME.fullmatch(source.name) or source.is_symlink() or source.stat().st_mode&0o077 + or source.stat().st_uid!=os.getuid() or source.stat().st_nlink!=1 or not source.is_file() + or source.stat().st_size>LIMIT or capture.get('status')!='encrypted_bundle_created' + or capture.get('helper_cleanup_verified') is not True or capture.get('temporary_plaintext_cleanup_verified') is not True + or digest(source)!=capture.get('ciphertext_sha256') or source.stat().st_size!=capture.get('ciphertext_bytes')):raise Refused() + with source.open('rb') as stream: + if stream.read(22)!=b'age-encryption.org/v1\n':raise Refused() + available=quota(client,token) + if availablebundle.LIMIT+4*1024*1024:raise adapter.Refused() + root=work/'restored';root.mkdir(mode=0o700) + manifest,_=bundle.restore(archive,root) + payload=adapter.private_payload(root,manifest,admission['manifest_sha256']) + import yaml + cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml'])) + if cfg.get('port')!=8080 or cfg.get('issuer')!=admission['issuer']:raise adapter.Refused() + # Public executable readback uses the daemon archive API only on the + # original image rootfs, never a tmpfs or private user-created mount. + probe=session['public_image_probe'] + try: + run(['docker','create','--name',probe,'--network','none','--read-only','--label',adapter.LABEL+'='+session['run_id'],adapter.IMAGE]) + binary=adapter.container_file(probe,'/keycape') + if hashlib.sha256(binary).hexdigest()!=admission['verifier_sha256']:raise adapter.Refused() + finally: + receipt['public_image_probe_cleanup_obligations']=[{'container':probe,'status':'unverified'}] + run(['docker','rm',probe]) + if run(['docker','container','ls','--all','--filter','name=^/'+probe+'$','--format','{{.Names}}']).strip():raise adapter.Refused() + receipt['public_image_probe_cleanup_obligations'][0]['status']='absence_verified' + provision(session,payload,tls,admission['issuer']) + receipt.update(ciphertext_sha256=transfer['ciphertext_sha256'],all_manifest_hashes_verified=True,manifest_file_count=len(manifest['files'])) + checked_admission={**admission,'session':session,'trust_path':'/recovery-tls/trust.pem'} + # Native checker consumes no service secret argument or new grant. + # Retry only bounded public readiness; no account/factor attempt. + for attempt in range(40): + try: + numbers=serialization.load_pem_private_key(base64.b64decode(payload['files']['/etc/keycape/key.pem']),password=None).public_key().public_numbers() + adapter.check_jwks(session,checked_admission,numbers);break + except Exception: + if attempt==39:raise + time.sleep(.25) + adapter.native_checks(root,manifest,checked_admission,receipt,cleanup_session=False) + finally: + os.close(keyfd) + try: + if session is not None and staging is not None:recover_obligations(session,staging,receipt) + if reaper is not None: + reaper.terminate();reaper.wait(timeout=10) + except Exception: + receipt['status']='failed' + raise + finally: + if work is not None and not work.exists():receipt['temporary_plaintext_cleanup_verified']=True + + +def persist_receipt(fd,receipt): + raw=(json.dumps(receipt,indent=2)+'\n').encode() + os.lseek(fd,0,os.SEEK_SET);os.ftruncate(fd,0) + with os.fdopen(os.dup(fd),'wb') as output: + output.write(raw);output.flush();os.fsync(output.fileno()) + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust','receipt'):p.add_argument('--'+name,required=True) + args=p.parse_args();os.umask(0o077) + fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + receipt={'schema':'netkingdom.isolated-keycape-recovery.v1','status':'failed','production_contacted':False,'secret_values_exported':False} + try: + raw=Path(args.admission).read_bytes() + if hashlib.sha256(raw).hexdigest()!=args.admission_sha256:raise adapter.Refused() + admission=json.loads(raw) + tls={} + for name,path in (('server.pem',args.tls_cert),('server-key.pem',args.tls_key),('trust.pem',args.trust)): + source=Path(path);bundle.private(source,max_bytes=1024*1024);tls['/recovery-tls/'+name]=source.read_bytes() + execute(json.loads(Path(args.transfer_receipt).read_text()),admission,tls,receipt,checkpoint=lambda r:persist_receipt(fd,r)) + except (Exception,KeyboardInterrupt):receipt.update(status='failed',error='isolated_keycape_failed_private_diagnostics_suppressed') + finally: + receipt['finished_at']=datetime.now(timezone.utc).isoformat() + try:persist_receipt(fd,receipt) + finally:os.close(fd) + print('OK: isolated KeyCape native checks and cleanup passed.' if receipt['status']=='isolated_keycape_native_registration_checked' else 'ERROR: isolated KeyCape stopped; review safe receipt.') + return int(receipt['status']!='isolated_keycape_native_registration_checked') + +if __name__=='__main__': + for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,local.offline.interrupted) + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/isolated-keycape.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/isolated-keycape.py new file mode 100644 index 00000000..3d33459e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/isolated-keycape.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""T18 native adapter for an owner-admitted, already restored isolated session. + +Called after identity-bundle.restore, never with an unvalidated directory. This +module does not provision TLS, grant credentials, or claim downstream acceptance. +The existing decrypt/tmpfs wrapper remains responsible for archive cleanup. +""" +import base64 +import hashlib +import importlib.util +import json +import io +import os +import secrets +import tarfile +import re +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent +IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010' +LABEL = 'net-kingdom.recovery-run' + + +class Refused(Exception): + pass + + +def load_bundle(): + spec = importlib.util.spec_from_file_location('identity_bundle', HERE / 'identity-bundle.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def private_payload(root, manifest, expected_manifest_sha256): + """Bind the complete validated archive before selecting KeyCape material. + + Private hashes never enter the public receipt. The digest is supplied by the + existing private archive admission, not inferred from an untrusted manifest. + """ + encoded = json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode() + if hashlib.sha256(encoded).hexdigest() != expected_manifest_sha256: + raise Refused() + entries = manifest['files'] + if len(entries) != len({e['path'] for e in entries}): + raise Refused() + values = {} + bundle = load_bundle() + for entry in entries: + if not bundle.allowed(entry['path'], manifest['profile']): + raise Refused() + path = root / entry['path'] + bundle.private(path) + value = path.read_bytes() + if len(value) != entry['bytes'] or hashlib.sha256(value).hexdigest() != entry['sha256']: + raise Refused() + values[entry['path']] = value + deployment = json.loads(values['resources/sso/deployment/keycape.json']) + main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'keycape') + if main.get('command') or main.get('args') or main.get('envFrom'): + raise Refused() + if main['image'] != IMAGE: + # Mutable-tag/historical mismatches need a separate explicit owner admission. + raise Refused() + secret = json.loads(values['resources/sso/secret/keycape-config.json'])['data'] + files = {'/etc/keycape/' + name: base64.b64decode(secret[name], validate=True) + for name in ('config.yaml', 'key.pem')} + for path, value in values.items(): + if path.startswith('data/keycape/'): + relative = path.removeprefix('data/keycape/') + files['/var/lib/keycape-policy/' + relative] = value + if '/var/lib/keycape-policy/policy.json' not in files: + raise Refused() + if 'resources/sso/secret/keycape-factor-read.json' in values: + factor=json.loads(values['resources/sso/secret/keycape-factor-read.json'])['data'] + files['/etc/keycape-factor/admin-token']=base64.b64decode(factor['admin-token'],validate=True) + # Resolve original delivered environment, including all referenced secrets. + env = {} + for item in main.get('env', []): + if not re.fullmatch(r'KEYCAPE_[A-Z0-9_]+',item['name']):raise Refused() + if item['name'] in env: + raise Refused() + if 'value' in item: + env[item['name']] = item['value'] + else: + ref = item['valueFrom']['secretKeyRef'] + source = json.loads(values['resources/sso/secret/' + ref['name'] + '.json'])['data'] + env[item['name']] = base64.b64decode(source[ref['key']], validate=True).decode() + if env.get('KEYCAPE_CONFIG') != '/etc/keycape/config.yaml' or env.get('KEYCAPE_POLICY_PATH') != '/var/lib/keycape-policy/policy.json': + raise Refused() + return {'files': {p: base64.b64encode(v).decode() for p, v in files.items()}, 'env': env} + + +ENDPOINT = 'unix:///var/run/docker.sock' + + +def docker_environment(environment=None): + # Even an admitted daemon exposed by another Unix socket is outside this + # exact session binding. Conflicting ambient routing is refused, not ignored. + for source in (os.environ, environment or {}): + if source.get('DOCKER_HOST') not in (None,'',ENDPOINT):raise Refused() + if source.get('DOCKER_CONTEXT') not in (None,'','default'):raise Refused() + if any(source.get(k) for k in ('DOCKER_TLS_VERIFY','DOCKER_CERT_PATH')):raise Refused() + env=dict(os.environ if environment is None else environment) + for key in ('DOCKER_CONTEXT','DOCKER_TLS_VERIFY','DOCKER_CERT_PATH'):env.pop(key,None) + env['DOCKER_HOST']=ENDPOINT + return env + + +def process(argv, **kwargs): + if argv[0]=='docker': + argv=['docker','--host',ENDPOINT,*argv[1:]] + kwargs['env']=docker_environment(kwargs.get('env')) + return subprocess.run(argv,**kwargs) + + +def command(argv, **kwargs): + maximum = kwargs.pop('max_output', 32768) + result = process(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=kwargs.pop('timeout', 180), **kwargs) + if result.returncode or len(result.stdout) > maximum: + raise Refused() + return result.stdout + + +def inspect_session(session, worker_only=False): + """No credential environment is read by Docker inspect or returned to caller.""" + if not re.fullmatch(r'[a-f0-9]{32}', session['run_id']): + raise Refused() + names = session['containers'] + if not names or len(names) > 12 or len(set(names)) != len(names): + raise Refused() + for name in names: + if not re.fullmatch(r'nk49-kc-' + session['run_id'] + r'-[a-z0-9-]{1,24}', name): + raise Refused() + if session['worker'] not in names or session['issuer'] not in names: + raise Refused() + import os + contexts=json.loads(command(['docker','context','inspect'])) + endpoints=[contexts[0]['Endpoints']['docker']['Host'],os.environ.get('DOCKER_HOST') or ENDPOINT] + if not all(x==ENDPOINT for x in endpoints):raise Refused() + info = json.loads(command(['docker', 'info', '--format', '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}'])) + if info != {'ID': '74f93783-6cef-43a8-a3c4-f62586cd3bc1', 'Name': 'bnt-lap001', 'OSType': 'linux'}: + raise Refused() + # The worker is the anchor; all dependencies/TLS/issuer share its network-none namespace. + anchor = command(['docker', 'container', 'inspect', session['worker'], '--format', '{{.Id}}']).decode().strip() + for name,spec in session.get('volumes',{}).items(): + volume=json.loads(command(['docker','volume','inspect',name])) + expected={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} + if len(volume)!=1 or volume[0]['Driver']!='local' or volume[0].get('Scope')!='local' or volume[0].get('Options')!=expected or spec['options']!=expected or volume[0].get('Labels',{}).get(LABEL)!=session['run_id']:raise Refused() + for name in names: + obj = json.loads(command(['docker', 'container', 'inspect', name, '--format', + '{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"labels":{{json .Config.Labels}},"image":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}}}'])) + host = obj['host'] + mounts=obj['mounts'] + destinations=[m['Destination'] for m in mounts] + if len(destinations)!=len(set(destinations)):raise Refused() + for volume,expected_spec in session.get('volumes',{}).items(): + matches=[m for m in mounts if m['Destination']==expected_spec['target']] + if len(matches)!=1 or matches[0]['Type']!='volume' or matches[0]['Name']!=volume or matches[0]['RW']!=(name==session['worker']):raise Refused() + if (obj['labels'].get(LABEL) != session['run_id'] or not obj['user'] or obj['user'].split(':')[0] in ('0', 'root') + or not host['ReadonlyRootfs'] or host['Privileged'] or host.get('Binds') or host.get('PortBindings') + or host.get('PidMode') or host.get('IpcMode') == 'host' + or host.get('UTSMode') or host.get('UsernsMode') == 'host' or host.get('CgroupnsMode') == 'host' + or host.get('Sysctls',{}) not in ({},{'net.ipv4.ip_unprivileged_port_start':'0'} if name==session['worker'] else {}) + or host.get('CapAdd') or host.get('Devices') or host.get('DeviceRequests') or host.get('VolumesFrom') + or host.get('CapDrop') != ['ALL'] + or host.get('SecurityOpt') not in (['no-new-privileges'], ['no-new-privileges:true']) + or any(m['Type']!='tmpfs' and not (m['Type']=='volume' and m['Name'] in session.get('volumes',{}) and m['Destination']==session['volumes'][m['Name']]['target']) for m in obj['mounts']) + or host['NetworkMode'] != ('none' if name == session['worker'] else 'container:' + anchor) + or obj['image'] != session['images'][name] or '@sha256:' not in obj['image']): + raise Refused() + if worker_only:return + if session['images'][session['issuer']] != IMAGE: + raise Refused() + # Known admitted archive has no command/args override; image default /keycape + # must be the actual issuer executable, not an alternate same-image command. + launch=json.loads(command(['docker','container','inspect',session['issuer'],'--format', + '{"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"path":{{json .Path}},"args":{{json .Args}}}'])) + if launch != {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}: + raise Refused() + + +def container_file(container, path): + # Docker streams one tar member into memory; never materialize private files. + raw = command(['docker', 'cp', container + ':' + path, '-'], max_output=128*1024*1024) + with tarfile.open(fileobj=io.BytesIO(raw), mode='r:') as archive: + members = archive.getmembers() + if len(members) != 1 or not members[0].isfile() or members[0].size > 96*1024*1024: + raise Refused() + return archive.extractfile(members[0]).read() + + +JWKS_PROBE = r""" +import base64,ipaddress,json,socket,ssl,sys,urllib.request +from urllib.parse import urlsplit +p=json.load(sys.stdin);issuer=p['issuer'];origin=urlsplit(issuer) +def require(condition): + if not condition:raise ValueError('public_jwks_binding_refused') +require(origin.scheme=='https') +resolved=socket.getaddrinfo(origin.hostname,origin.port or 443,type=socket.SOCK_STREAM) +require(bool(resolved) and all(ipaddress.ip_address(x[4][0]).is_loopback for x in resolved)) +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):return None +opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect(), + urllib.request.HTTPSHandler(context=ssl.create_default_context(cadata=p['trust']))) +def get(url): + u=urlsplit(url) + require((u.scheme,u.hostname,u.port or 443)==(origin.scheme,origin.hostname,origin.port or 443)) + require(not u.username and not u.password and not u.fragment) + with opener.open(url,timeout=10) as response: + raw=response.read(1024*1024+1);require(len(raw)<=1024*1024) + return json.loads(raw) +d=get(issuer.rstrip('/')+'/.well-known/openid-configuration');require(d['issuer']==issuer) +keys=get(d['jwks_uri'])['keys'] +def integer(value):return int.from_bytes(base64.urlsafe_b64decode(value+'='*(-len(value)%4)),'big') +# The exact admitted owner release constructs one key, kid key-1. Reject every +# extra key, including irrelevant keys: a native successful exchange can then +# only have used the original signer through the existing verifier. +require(isinstance(keys,list) and len(keys)==1) +k=keys[0] +require(k.get('kty')=='RSA' and k.get('kid')=='key-1' and k.get('use')=='sig' and k.get('alg')=='RS256') +require(integer(k['n'])==int(p['n']) and integer(k['e'])==int(p['e'])) +print('{"original_public_signing_key_in_live_jwks":true}') +""" + + +def check_jwks(session, admission, public_numbers): + trust=runtime_file(session,admission['trust_path']).decode() + request={'issuer':admission['issuer'],'trust':trust,'n':str(public_numbers.n),'e':str(public_numbers.e)} + result=json.loads(command(['docker','exec','-i',session['worker'],'python3','-c',JWKS_PROBE],input=json.dumps(request).encode())) + if result != {'original_public_signing_key_in_live_jwks': True}:raise Refused() + + +RUNTIME_FILES = r""" +import base64,json,sys +from pathlib import Path +p=json.load(sys.stdin) +roots=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') +result={} +paths=[] +if p.get('tree'): + paths=list(Path('/var/lib/keycape-policy').rglob('*')) +else:paths=[Path(x) for x in p['paths']] +for f in paths: + if f.is_dir():continue + if not str(f).startswith(roots) or not f.is_file() or any(x.is_symlink() for x in (f,*f.parents)) or f.stat().st_size>8*1024*1024:raise ValueError() + result[str(f)]=base64.b64encode(f.read_bytes()).decode() + if len(result)>4096:raise ValueError() +print(json.dumps(result)) +""" + + +def runtime_files(session, paths=(), tree=False): + raw=command(['docker','exec','-i',session['worker'],'python3','-c',RUNTIME_FILES], + input=json.dumps({'paths':list(paths),'tree':tree}).encode(),max_output=48*1024*1024) + return {k:base64.b64decode(v,validate=True) for k,v in json.loads(raw).items()} + + +def runtime_file(session,path): + return runtime_files(session,[path])[path] + + +def run_verifier(session, admission, payload): + issuer = session['issuer'] + if {v['target'] for v in session.get('volumes',{}).values()}!={'/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls'}:raise Refused() + from urllib.parse import urlsplit + import ipaddress + address = urlsplit(admission['issuer']) + if (address.scheme != 'https' or not address.hostname or address.username or address.password + or address.query or address.fragment): + raise Refused() + if hashlib.sha256(container_file(issuer, '/keycape')).hexdigest() != admission['verifier_sha256']: + raise Refused() + if hashlib.sha256(runtime_file(session, admission['trust_path'])).hexdigest() != admission['trust_sha256']: + raise Refused() + # Capture private environment only in owner process memory, never receipt. + environment = json.loads(command(['docker','container','inspect',issuer,'--format','{{json .Config.Env}}'])) + env = dict(item.split('=',1) for item in environment) + if len(env)!=len(environment):raise Refused() + if set(env)-set(payload['env'])-{'PATH','HOME','HOSTNAME','SSL_CERT_FILE'}:raise Refused() + if env.get('SSL_CERT_FILE') != admission['trust_path'] or any(k.lower().endswith('_proxy') and v for k,v in env.items()): + raise Refused() + if {k:v for k,v in env.items() if k.startswith('KEYCAPE_')}!={k:v for k,v in payload['env'].items() if k.startswith('KEYCAPE_')}:raise Refused() + for name,value in payload['env'].items(): + if env.get(name) != value: + raise Refused() + files = {k:base64.b64decode(v,validate=True) for k,v in payload['files'].items()} + def equal(): + if runtime_files(session,files)!=files:raise Refused() + durable=runtime_files(session,tree=True) + if durable!={k:v for k,v in files.items() if k.startswith('/var/lib/keycape-policy/')}: + raise Refused() + equal() + # Reading YAML is data parsing only; no archived executable is evaluated. + import yaml + cfg = yaml.safe_load(files['/etc/keycape/config.yaml']) + if cfg['issuer'] != admission['issuer']: + raise Refused() + consumer = admission['consumer'] + client = next(x for x in cfg['clients'] if x['clientId']==consumer['client_id']) + if (client['secretRef']!='env:'+consumer['secret_env'] or client['clientType']!='confidential' + or 'client_credentials' not in client['grantTypes'] or not env.get(consumer['secret_env'])): + raise Refused() + from cryptography.hazmat.primitives import serialization + key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) + check_jwks(session,admission,key.public_key().public_numbers()) + wrong = secrets.token_urlsafe(48) + if wrong == env[consumer['secret_env']]:raise Refused() + args = ['docker','exec','--env','NK49_UNRELATED_NEGATIVE_SECRET='+wrong,issuer,'/keycape','verify-client', + '-issuer',admission['issuer'],'-previous-secret-env','NK49_UNRELATED_NEGATIVE_SECRET'] + flags = {'client_id':'client-id','secret_env':'secret-env','audience':'audience','scope':'scope', + 'subject':'expect-subject','tenant':'expect-tenant','roles':'expect-roles','deny_scope':'deny-scope'} + for name,flag in flags.items():args+=['-'+flag,consumer[name]] + # Native command reports on stderr. Do not return or print its raw response. + result = process(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=150) + raw = (result.stdout+result.stderr).decode() + required = ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings', + 'principal_type is service','sub matches the registration','tenant matches the registration','roles present', + 'granted scope carries no excess grant','predecessor secret refused'] + required += ['excess scope refused: '+scope for scope in consumer['deny_scope'].split()] + if result.returncode or any('PASS '+name+'\n' not in raw for name in required) or 'FAIL ' in raw: + raise Refused() + equal() + from cryptography.hazmat.primitives import serialization + key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) + check_jwks(session,admission,key.public_key().public_numbers()) + public=key.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) + return {'native_service_registration_verified':True,'wrong_unrelated_secret_explicitly_refused':True, + 'denied_scopes_refused':True,'original_config_registry_policy_receipts_equal':True, + 'public_signing_spki_sha256':hashlib.sha256(public).hexdigest(), + 'original_https_issuer_and_trust_preserved':True,'original_public_signing_key_in_live_jwks':True} + + +def cleanup(session, receipt=None): + """Attempt every owned cleanup even when one obligation fails. + + Preserve exact safe container names and outcomes so the owner can recover an + uncertain/partial cleanup. An inspect/daemon failure never means absence. + """ + obligations = [] + failed = False + for name in reversed(session['containers']): + row = {'container': name, 'status': 'unverified'} + obligations.append(row) + try: + present=command(['docker','container','ls','--all','--filter','name=^/'+name+'$','--format','{{.Names}}']).decode().splitlines() + if not present: + row['status']='absence_verified' + continue + label = command(['docker', 'container', 'inspect', name, '--format', + '{{index .Config.Labels "' + LABEL + '"}}']).decode().strip() + if label != session['run_id']: + row['status'] = 'ownership_mismatch_not_removed' + failed = True + continue + # A failed remove is followed by a successful explicit inventory; + # interrupted remove may actually have completed. + try: + command(['docker', 'rm', '--force', '--volumes', name]) + except Exception: + pass + remaining = command(['docker','container','ls','--all','--filter', + 'name=^/' + name + '$','--format','{{.Names}}']).decode().splitlines() + if remaining: + row['status'] = 'still_present' + failed = True + else: + row['status'] = 'absence_verified' + except Exception: + failed = True + if receipt is not None: + receipt['container_cleanup_obligations'] = obligations + if failed: + raise Refused() + + + +def native_checks(root, manifest, admission, receipt, cleanup_session=True): + """Owner wrapper provides a reviewed session after existing restore pipeline. + + Admission must bind ciphertext, canonical private manifest digest, executable + and trust bytes, exact image/target list, original issuer and selected consumer. + None of these admission fields grant authority or carry a client secret. + """ + session = admission['session'] + # Validate ownership/containment before permitting either exec or cleanup. + inspect_session(session) + receipt.update(status='failed', source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + ciphertext_sha256=admission['ciphertext_sha256'], production_contacted=False, + human_mfa_proven=False, downstream_consumer_reconnect_proven=False, + container_cleanup_verified=False) + try: + for field in ('ciphertext_sha256', 'manifest_sha256', 'verifier_sha256', 'trust_sha256'): + if not re.fullmatch('[a-f0-9]{64}', admission[field]): + raise Refused() + for field in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope'): + if not isinstance(admission['consumer'].get(field), str) or not admission['consumer'][field].strip(): + raise Refused() + payload = private_payload(root, manifest, admission['manifest_sha256']) + result = run_verifier(session, admission, payload) + required = {'native_service_registration_verified','wrong_unrelated_secret_explicitly_refused', + 'denied_scopes_refused','original_config_registry_policy_receipts_equal', + 'original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks'} + if any(result.get(k) is not True for k in required) or not re.fullmatch('[a-f0-9]{64}', result.get('public_signing_spki_sha256','')): + raise Refused() + # Closed receipt allowlist: never copy arbitrary worker output. + receipt.update({k: True for k in required}) + receipt['public_signing_spki_sha256'] = result['public_signing_spki_sha256'] + receipt['status'] = 'isolated_keycape_native_registration_checked' + finally: + try: + if cleanup_session: + cleanup(session, receipt) + receipt['container_cleanup_verified'] = True + except Exception: + receipt['status'] = 'failed' + raise diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/isolated-lldap.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/isolated-lldap.py new file mode 100644 index 00000000..abe0865c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/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/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/retrieve-identity-offline.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/retrieve-identity-offline.py new file mode 100644 index 00000000..a6173235 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/retrieve-identity-offline.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Download one reviewed ciphertext using privately entered provider access. + +No OpenBao, SSO, Kubernetes, credential discovery, provider writes or age key. +Run by the operator in their own terminal, never through a recorded agent PTY. +""" +import argparse +import base64 +from datetime import datetime, timezone +import getpass +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import signal +import sys +import tempfile +import urllib.request +import urllib.error +import warnings + +HERE = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location('offsite', HERE / 'identity-offsite.py') +offsite = importlib.util.module_from_spec(spec) +spec.loader.exec_module(offsite) +ACCOUNT_BASE = offsite.HOST + '/remote.php/dav/files/Backup/railiance-backups/net-kingdom-identity/' + + +def private_prompt(label): + # getpass opens the terminal with os.open, which does not require seeking. + # Reject redirected input and turn its echoing fallback warning into an + # exception before it can read a credential without echo suppression. + if not sys.stdin.isatty() or not sys.stderr.isatty(): + raise offsite.Refused() + with warnings.catch_warnings(): + warnings.simplefilter('error', getpass.GetPassWarning) + return getpass.getpass(label) + + +def failure_metadata(error): + # No exception messages, response bodies, URLs or headers enter receipts. + if isinstance(error, urllib.error.HTTPError): + return {'failure_kind': 'provider_http_error', 'http_status': int(error.code)} + if isinstance(error, urllib.error.URLError): + return {'failure_kind': 'provider_transport_error'} + if isinstance(error, getpass.GetPassWarning): + return {'failure_kind': 'terminal_echo_suppression_unavailable'} + if isinstance(error, offsite.Refused): + return {'failure_kind': 'scope_or_integrity_refusal'} + return {'failure_kind': 'local_operation_failed'} + + +def validate(record): + if (record.get('schema') != 'netkingdom.identity-offsite.v1' + or record.get('status') != 'readback_verified' + or record.get('provider') != 'Storage Share' + or record.get('folder') != 'Backup/railiance-backups/net-kingdom-identity' + or not offsite.NAME.fullmatch(record.get('artifact', '')) + or not re.fullmatch('[a-f0-9]{64}', record.get('ciphertext_sha256', '')) + or type(record.get('ciphertext_bytes')) is not int + or not 0 < record['ciphertext_bytes'] <= offsite.LIMIT + or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"', record.get('etag', ''))): + raise offsite.Refused() + + +def retrieve(record, secret, mode, client=None): + validate(record) + if mode not in {'account', 'folder-share'} or not secret or any(c in secret for c in '\r\n\x00'): + raise offsite.Refused() + auth = offsite.headers(secret) if mode == 'folder-share' else { + 'Authorization': 'Basic ' + base64.b64encode(('Backup:' + secret).encode()).decode(), + 'Accept-Encoding': 'identity'} + url = (offsite.BASE if mode == 'folder-share' else ACCOUNT_BASE) + record['artifact'] + client = client or offsite.opener() + directory = None + complete = False + try: + with client.open(urllib.request.Request(url, method='HEAD', headers=auth), timeout=30) as response: + if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']: + raise offsite.Refused() + directory = Path(tempfile.mkdtemp(prefix='nk49-identity-fetched-', dir='/tmp')) + target = directory / record['artifact'] + auth['If-Match'] = record['etag'] + count = 0 + digest = hashlib.sha256() + with client.open(urllib.request.Request(url, headers=auth), timeout=180) as response: + if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']: + raise offsite.Refused() + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'wb') as output: + while block := response.read(1024 * 1024): + count += len(block) + if count > record['ciphertext_bytes']: + raise offsite.Refused() + digest.update(block) + output.write(block) + output.flush() + os.fsync(output.fileno()) + if count != record['ciphertext_bytes'] or digest.hexdigest() != record['ciphertext_sha256']: + raise offsite.Refused() + with client.open(urllib.request.Request(url, method='HEAD', headers=auth), timeout=30) as response: + if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']: + raise offsite.Refused() + complete = True + # Closed allowlist: never copy arbitrary source-receipt fields to output. + result = {k: record[k] for k in ('schema', 'provider', 'folder', 'artifact', + 'ciphertext_bytes', 'ciphertext_sha256', 'etag')} + result.update(status='readback_verified', fetched_path=str(target), + independent_owner_retrieval_verified=True, + credential_source='independently_held_provider_access', + provider_auth_mode=mode, openbao_contacted=False, sso_contacted=False, + provider_writes_performed=False, age_private_key_used=False, + secret_values_exported=False, object_unchanged_during_transfer=True, + downloaded_at=datetime.now(timezone.utc).isoformat()) + return result + finally: + if directory is not None and not complete: + shutil.rmtree(directory) + + +def interrupted(signum, frame): + raise InterruptedError() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source-receipt', required=True) + parser.add_argument('--receipt', required=True) + parser.add_argument('--auth', choices=('account', 'folder-share'), default='account') + 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 = {'status': 'failed', 'error': 'offline_retrieval_failed_private_diagnostics_suppressed'} + stage = 'source_receipt_validation' + try: + record = json.loads(Path(args.source_receipt).read_text()) + validate(record) + # getpass must never fall back to echoed stdin (e.g. a captured agent tool). + stage = 'private_provider_prompt' + secret = private_prompt('Storage Share Backup app password or exact folder-share token: ') + try: + stage = 'provider_download' + result = retrieve(record, secret, args.auth) + finally: + secret = None + except (Exception, KeyboardInterrupt) as error: + result.update(failed_stage=stage, **failure_metadata(error)) + finally: + with os.fdopen(fd, 'w') as output: + json.dump(result, output, indent=2) + output.write('\n') + ok = result['status'] == 'readback_verified' + print('OK: ciphertext retrieved without OpenBao or SSO; ETag, size and SHA256 verified.' if ok + else 'ERROR: retrieval refused; private diagnostics suppressed. Use a new receipt path to retry.') + return 0 if ok else 1 + + +if __name__ == '__main__': + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(sig, interrupted) + sys.exit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/retrieve-identity.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/retrieve-identity.py new file mode 100644 index 00000000..ed386d9a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/retrieve-identity.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Silent attended owner retrieval of an existing identity ciphertext. + +Uses Backup-account recovery custody only in memory; no private age key, upload, +share, grant, deletion or production restore operation. +""" +import argparse +import base64 +from datetime import datetime,timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import tempfile +import urllib.request + +HERE=Path(__file__).resolve().parent + + +def load(name): + spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py')) + m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m + + +inventory=load('inventory-storage-share');offsite=load('identity-offsite') + + +def retrieve(record,out): + if (record.get('schema')!='netkingdom.identity-offsite.v1' or record.get('status')!='readback_verified' + or not offsite.NAME.fullmatch(record.get('artifact','')) or not re.fullmatch('[a-f0-9]{64}',record.get('ciphertext_sha256','')) + or not 0record['ciphertext_bytes']:raise offsite.Refused() + digest.update(block);output.write(block) + if count!=record['ciphertext_bytes'] or digest.hexdigest()!=record['ciphertext_sha256']:raise offsite.Refused() + with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response: + if offsite.etag(response,url,record['ciphertext_bytes'])!=version:raise offsite.Refused() + out.update(record);out.update(status='readback_verified',fetched_path=str(target), + independent_owner_retrieval_verified=True,caller_entity_id=identity.get('entity_id'), + age_private_key_used=False,secret_values_exported=False,downloaded_at=datetime.now(timezone.utc).isoformat()) + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--source-receipt',required=True);p.add_argument('--receipt',required=True) + a=p.parse_args();os.umask(0o077);fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'status':'failed','source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()} + try:retrieve(json.loads(Path(a.source_receipt).read_text()),r) + except Exception:r.update(status='failed',error='contained_owner_retrieval_failed') + finally: + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + return int(r['status']=='failed') + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/run-isolated-keycape.sh b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/run-isolated-keycape.sh new file mode 100644 index 00000000..74bc6e9f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/run-isolated-keycape.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Candidate owner launcher. Review and native fixture proof required before use. +set +x +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +cd -- "$ROOT" +umask 077 +[[ $# == 6 ]] || { printf '%s\n' 'Usage: run-isolated-keycape.sh VERIFIED_TRANSFER_RECEIPT ADMISSION ADMISSION_SHA256 TLS_CERT TLS_KEY TRUST'; exit 1; } +[[ "$3" =~ ^[a-f0-9]{64}$ ]] || { printf '%s\n' 'ERROR: exact reviewed admission digest required.'; exit 1; } +for index in 1 2 4 5 6; do + [[ -f "${!index}" && ! -L "${!index}" ]] || { printf '%s\n' 'ERROR: provide regular owner-controlled input files.'; exit 1; } +done +[[ -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 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-keycape-session.py') +spec=importlib.util.spec_from_file_location('session',p) +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +try: + m.local.preflight() + m.run(['docker','image','inspect',m.adapter.IMAGE]) +except Exception: + raise SystemExit('ERROR: exact local Docker/images/tmpfs preflight failed; no key requested.') +print('OK: fixed local target and cached pinned images available.') +PY +NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)" +printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN" +printf '%s\n' 'Owner-reviewed candidate: isolated original issuer/trust and current registered service-client checks only.' +printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.' +printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.' +bash sso-mfa/bootstrap/sops-custody-unlock.sh \ + --expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \ + --prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \ + --transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \ + --tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json" +printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN" diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/verify-identity.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/verify-identity.py new file mode 100644 index 00000000..11bfec14 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r3/test-root/tools/bootstrap-recovery/verify-identity.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Attended independent-key recovery of the new data-bearing off-host bundle.""" +import argparse +from datetime import datetime,timezone +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import uuid + +HERE=Path(__file__).resolve().parent + + +def load(name,path=None): + spec=importlib.util.spec_from_file_location(name,path or HERE/(name+'.py')) + m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m + + +bundle=load('identity-bundle');pg=load('check-privacyidea-database') +custody=load('sops_private',HERE.parents[1]/'sso-mfa/bootstrap/sops-private.py') + + +def verify(transfer,keyfd,receipt): + if (transfer.get('schema')!='netkingdom.identity-offsite.v1' or transfer.get('status')!='readback_verified' + or transfer.get('independent_owner_retrieval_verified') is not True + or transfer.get('ciphertext_bytes',0)>bundle.LIMIT):raise bundle.Refused() + path=Path(transfer['fetched_path']);bundle.private(path,max_bytes=bundle.LIMIT) + with path.open('rb') as source:digest=hashlib.file_digest(source,'sha256').hexdigest() + if path.stat().st_size!=transfer['ciphertext_bytes'] or digest!=transfer['ciphertext_sha256']:raise bundle.Refused() + derived=pg.run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip() + if derived!=bundle.profile()['public_recipient'].encode():raise bundle.Refused() + receipt.update(ciphertext_sha256=digest,artifact=transfer['artifact'],registered_recipient_match=True) + with tempfile.TemporaryDirectory(prefix='nk49-identity-restore-',dir='/tmp') as temporary: + work=Path(temporary);env={'PATH':os.environ.get('PATH',os.defpath),'HOME':temporary,'XDG_CONFIG_HOME':temporary} + wrong=work/'wrong.key';pg.run(['age-keygen','-o',str(wrong)],env=env) + negative=subprocess.run(['age','-d','-i',str(wrong),str(path)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env=env,timeout=120) + if negative.returncode==0:raise bundle.Refused() + receipt['wrong_key_refused']=True + plain=work/'archive.tar' + with plain.open('xb') as output: + result=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}',str(path)],stdout=output, + stderr=subprocess.DEVNULL,env=env,pass_fds=(keyfd,),timeout=180) + if result.returncode or plain.stat().st_size>bundle.LIMIT+4*1024*1024:raise bundle.Refused() + restored=work/'restored';restored.mkdir(mode=0o700) + manifest,checks=bundle.restore(plain,restored) + receipt.update(checks=checks,manifest_file_count=len(manifest['files']),all_manifest_hashes_verified=True, + profile_sha256=manifest['profile_sha256']) + container='nk49-identity-db-'+uuid.uuid4().hex + try: + receipt['database_restore']=pg.restore(restored/'databases/privacyidea.dump',container) + finally: + pg.run(['docker','rm','--force','--volumes',container]) + absent=subprocess.run(['docker','container','inspect',container],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=15) + pg.run(['docker','info','--format','{{.ServerVersion}}']) + if absent.returncode==0:raise bundle.Refused() + receipt['container_cleanup_verified']=True + if work.exists():raise bundle.Refused() + receipt.update(temporary_plaintext_cleanup_verified=True,status='identity_archive_verified', + full_service_authentication_proven=False,existing_token_decryption_proven=False,audit_signature_continuity_proven=False) + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--transfer-receipt',required=True);p.add_argument('--receipt',required=True) + a=p.parse_args();os.umask(0o077) + fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'schema':'netkingdom.identity-recovery.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(), + '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 + try: + transfer=json.loads(Path(a.transfer_receipt).read_text());keyfd=custody.private_key_fd() + verify(transfer,keyfd,r) + except Exception:r.update(status='failed',error='identity_recovery_failed_private_diagnostics_suppressed') + finally: + if keyfd is not None:os.close(keyfd) + r['finished_at']=datetime.now(timezone.utc).isoformat() + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + if r['status']=='identity_archive_verified': + print('OK: off-host identity bundle decrypted; complete manifest, key pairs, SQLite integrity and PostgreSQL restore passed; disposable data removed. Service authentication remains separate acceptance.') + return 0 + print('ERROR: identity recovery failed; review safe receipt. Private diagnostics suppressed.');return 1 + + +if __name__=='__main__': + for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,custody.interrupted) + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/LIFECYCLE-R4.md b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/LIFECYCLE-R4.md new file mode 100644 index 00000000..f6d0c4bb --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/LIFECYCLE-R4.md @@ -0,0 +1,81 @@ +# Revision4 lifecycle rationale and independent review boundary + +Revision3's timer could delete data while a living parent or its CLI children +created more resources. Revision4 makes the source invariant explicit: **cleanup +cannot start until the exclusive owner execution has no live creator**. + +The main process creates a new SID/PGID before private input, binds its own PID, +UID and kernel start ticks, and refuses inheriting a shell/custody/user group. +The independent reaper has a pidfd for that exact parent. On parent loss or the +fixed 1800-second deadline, it stops that parent and only its verified fresh +session members, using per-process pidfds after SID/PGID/UID/start-time checks. +A changed leader identity or unknown member refuses signaling and cleanup; a +survivor that cannot exit refuses cleanup. Normal host subprocesses inherit this +exclusive group and cannot request a session/group escape. The reaper itself is +in another session and carries no credential environment or custody FD. + +The plaintext directory and all future Docker object names, including the public +image probe, are registered and acknowledged before the first decrypted byte is +written. A safe fsynced checkpoint preserves those exact obligations. Cleanup +checks the registered directory inode/device/owner/mode and uses directory FDs; +it never follows symlinks or expands deletion to a replacement path. Docker +failure still allows an independent attempt at the exact plaintext obligation. + +All Docker commands, reused preflight, native verifier and reaper are bound to +one explicit `unix:///var/run/docker.sock` endpoint. Conflicting ambient or +per-call hosts/contexts/TLS routing and a differently configured context endpoint +are refused. Actual engine ID/name/Linux checks remain required. + +Quiescing a CLI does not cancel an already submitted daemon request. Exact +non-secret sibling markers retain this uncertainty outside removable plaintext. +The outcome remains `bounded_cleanup_uncertain` even if subsequent readback +finds all named objects absent. No T18 acceptance follows such a result. + +## Frozen verification + +Run the frozen suite without racing further candidate edits: + +```sh +python3 -m unittest discover -s /home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tests -q +``` + +61 tests pass. Two spawn actual **disposable synthetic processes**, creating a +new owner session and a child that would later write a marker. Deadline shutdown +and SIGKILL parent-loss both quiesce those processes and prevent late creation. +Other tests check deadline refusal before Docker starts, foreign/reused identity +refusal, cleanup ordering, daemon uncertainty surviving plaintext removal, +checkpoint rewriting, exact staging identity safeguards and fixed daemon routing. +Docker calls/native responses remain mocked; no Docker mutation, real recovery, +custody input or production route was executed. These tests are not native +compatibility or actual owner acceptance. + +## Native fixture oracle, after independent review only + +Use only a complete synthetic identity archive and synthetic independently +trusted TLS pair with the existing pinned cached images and fixed local engine. +The fixture's original HTTPS issuer/registry/key/policy/receipts must survive +unchanged; never substitute a production archive or actual credentials. + +1. Verify local-driver tmpfs options, source/target/RW sharing, pre-import + containment, UID/modes, original image default launch and private cleanup. +2. Demonstrate the **issuer container's** native `verify-client` resolves and + authenticates the original HTTPS hostname with the admitted trust. A helper's + successful DNS/JWKS check alone is insufficient. Missing/wrong issuer hosts + mapping must fail the native command even if the helper can fetch JWKS. + Docker's container-network hosts-file behavior is not assumed proven. If the + fixture fails this check, refuse the candidate and review the supported + hostname-delivery translation; do not change issuer or bypass TLS. +3. Exercise correct existing synthetic client delivery, explicit wrong-secret + denial, denied scope, singleton original `key-1` JWKS, and config/state drift + negatives through the shipped verifier. Compare every durable file/tree. +4. Kill the exact synthetic owner at the recorded decrypt/restore/session stages. + Verify its children are quiescent before exact plaintext/container/volume/ + public-probe cleanup. Use the checkpoint's reaper outcome path. +5. In a synthetic harness, tighten the execution deadline and arrange a pending + marker-creation/daemon request. Prove no live creator remains before cleanup; + if a daemon call was in flight, retain uncertainty even after absent readback. + Never declare that uncertain run accepted or silently retry it. + +No native fixture is authorized/executed by this document. Root review remains +pending. Actual owner recovery, downstream reconnect, browser continuity and T19 +retained-factor MFA remain separate gates. diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/README.md b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/README.md new file mode 100644 index 00000000..3d7e0390 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/README.md @@ -0,0 +1,168 @@ +# T18 KeyCape recovery adapter candidate, revision 4 + +**Pending root review and native fixture proof. T18 remains open.** Source and +61 passing synthetic tests are prepared in `/tmp/nk49-keycape-adapter-next` and +copied here. No canonical owner file, credential, actual recovery, Docker run, +production route or secret-bearing archive was touched. `candidate.patch` adds +five files to NetKingdom; exact digests/check provenance are in `preparation.json`. + +Files: + +- `isolated-keycape.py`: archive/source/containment/native acceptance checks. +- `isolated-keycape-session.py`: existing-pipeline decryption, private import, + isolated original-image session, HTTPS transport, checks and cleanup. +- `run-isolated-keycape.sh`: attended owner launcher through the existing hidden + custody helper, with target preflight before the backup-key prompt. +- Two focused test files: check-stage negatives and session/cleanup ordering. + +The wrapper uses existing `isolated-lldap`, offline transfer validation, +`verify-identity.custody` and `identity-bundle.restore` APIs. It checks the exact +independently retrieved ciphertext length/digest, custody recipient, complete +archive manifest and original immutable archived KeyCape image. Decryption and +archive restore remain on `/dev/shm`. The private manifest digest comes from +owner admission; it is never emitted publicly. Archive commands/args/envFrom and +non-KeyCape environment overrides are refused. Runtime environment permits only +original KeyCape deliveries, PATH/HOME/HOSTNAME and the exact admitted +SSL_CERT_FILE; duplicate or additional security environment (including +SSL_CERT_DIR/GODEBUG/PYTHONOPTIMIZE/proxy overrides) is refused. Original config/complete static +registry, signing key, policy/receipt tree, factor delivery and original delivered +environment are preserved. Secrets reach Docker by environment **names** only, +with values in the owner subprocess environment, never argv or printed output. + +The original KeyCape image is distroless. Docker's documented +[cp corner cases](https://docs.docker.com/reference/cli/docker/container/cp/#corner-cases) +exclude tmpfs and user-created mounts. Consequently the candidate shares exactly +four Docker **local-driver tmpfs** volumes between the existing pinned Python +helper and original KeyCape issuer. Driver, scope, exact options, owner label, +source, target and read/write flags are inspected before private import. There +are no host directory mounts, disk volumes, new image, JWT verifier or execution +service. The helper's private import and comparison occur inside those exact +mounts; daemon `cp` is used only for public `/keycape` executable readback from +the original image root filesystem. This volume extension still needs native +pinned-image fixture verification before approval. + +Both containers use the fixed local Docker engine, no network interfaces beyond +loopback, readonly root, nonroot UID, no capabilities/devices/privilege/host +namespaces or unadmitted security options. Peers share the exact network-none +anchor. Only namespace-local unprivileged-port sysctl `0` is admitted so the +existing nonroot Python helper can present original HTTPS on loopback443 and +forward to the original KeyCape process on loopback8080. The issuer uses its exact +original image default `/keycape` launch: Entrypoint/Cmd and actual Path/Args are +inspected; the original file/environment bytes and executable digest are bound. +Production dependency addresses remain unreachable. No readiness, LDAP, +Authelia, MFA, tenant-engine or notification endpoint is imitated. A selected +service registration can be checked without claiming `/readyz` passed. + +The owner supplies an existing trusted issuer certificate/private key and trust +bundle through private regular files. No certificate, CA, signing key, client +registration or credential is generated/reissued. Original HTTPS hostname, trust +and same-origin discovery/JWKS are retained, without redirect/proxy/insecure +bypass. The public JWKS probe requires exactly the original owner's single RSA +signing key with `use:sig`, `alg:RS256`, `kid:key-1`, matching the original key's +public numbers. It checks before and after the native exchange. Every security +condition uses explicit refusal, including when Python optimization is enabled. +This probe only compares public discovery/JWKS data; JWT/exchange/claim/scope and +wrong-secret verification exclusively use shipped `/keycape verify-client`. + +The negative check supplies a generated unrelated random secret through the +existing `-previous-secret-env` refusal mechanism. The bounded receipt says +**wrong unrelated secret refused**, never rotation. An absent exact PASS, +nonzero exit, TLS/discovery error, malformed output or any FAIL refuses evidence. +All durable private files and the full receipt tree are compared before/after; +extra receipt files refuse continuity. Public receipts expose only booleans, +public signing SPKI fingerprint, ciphertext/source hashes and exact safe cleanup +obligations. No config, env value, private digest, credential, JWT, response body, +subject or client ID is copied into receipts. + +Normal/failure cleanup attempts every safely owned container and tmpfs volume, +records exact verified/uncertain obligations and refuses success if any removal +is uncertain. Before the first plaintext write, the wrapper registers the exact +private `/dev/shm` directory inode/device/owner/mode and every future container, +volume and public-image-probe obligation; it requires a reaper ACK and fsyncs a +safe receipt checkpoint. Plaintext removal uses directory FDs and refuses +replacement inode, owner mismatch, symlink, hardlink or special-file traversal. +Docker failure does not skip this separate plaintext obligation. + +Revision4 creates a **fresh exclusive execution session/process group** before +reading TLS or custody inputs. It never reuses a shell/custody/user group. The +independent reaper receives only the exact parent pidfd, public identifiers and +fixed endpoint, with no custody descriptor or service environment. Parent loss +or the 1800-second deadline first quiesces the owner invocation and its children: +parent identity is bound by pidfd; each remaining same-SID/PGID/UID member is +compared by stable process start time and signaled through its own pidfd. Unknown +membership, PID reuse or uninterruptible survivors refuses cleanup. No broad +user/inherited-group kill is used. Only after no live creator remains may the +reaper remove resources. Every Docker call also refuses an expired or mismatched +execution binding; host subprocesses cannot request a new session/group escape. + +A CLI can die after submitting a daemon request. Public exact sibling +`-docker-inflight`/`-docker-uncertain` markers survive plaintext removal, so even +clear later resource readback cannot turn an in-flight request into proven +cleanup. The reaper writes an exclusive bounded `-reaper.json` outcome next to +the registered staging directory; its exact path is in the pre-decrypt receipt. +Normal verified cleanup cancels the reaper. These are scoped invocation processes, +not standing jobs. This revision's real disposable process tests prove the +local shutdown/quiescence behavior only; Docker tmpfs/import/issuer behavior +still requires independent native fixture proof. + +## Exact owner inputs and invocation, after approval/native fixture proof + +The launcher takes six paths/public arguments: + +``` +bash tools/bootstrap-recovery/run-isolated-keycape.sh \ + VERIFIED_TRANSFER_RECEIPT PRIVATE_ADMISSION ADMISSION_SHA256 \ + PRIVATE_EXISTING_TLS_CERT PRIVATE_EXISTING_TLS_KEY PRIVATE_ORIGINAL_TRUST +``` + +Do not run this candidate until root review and synthetic pinned-image runtime +proof admit the exact source. It prompts only for the independently held age key +using the existing custody helper. Current client credentials come only from the +original encrypted archive's already delivered registration/environment; no key, +password, factor or TLS private input is passed to the agent. + +The hash-bound owner admission JSON has `ciphertext_sha256`, `manifest_sha256`, +`verifier_sha256` (original image `/keycape` binary), `trust_sha256`, +`tls_cert_sha256`, original `issuer`, and `consumer` containing existing exact +`client_id`, `secret_env`, `audience`, `scope`, `subject`, `tenant`, `roles`, +`deny_scope`. Private `manifest_sha256` is SHA256 of +`json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode()` from the +admitted original archive. The wrapper checks it against the validated manifest, +not an arbitrary directory. Trust path/session names are derived internally; +operator input cannot select a remote Docker engine or arbitrary container set. +The selected original issuer configuration must use HTTPS443 and local service +port8080, as the admitted owner release does. Other configurations fail closed +and need explicit source review, rather than silent rewrite. + +## Remaining proof and retained review findings + +The 61 tests use synthetic data and mocked Docker/native outcomes. They exercise +archive integrity/symlink/lineage drift, optimizer-safe JWKS wrong-key/extra-key/ +wrong-kid/cross-origin/DNS negatives, default launch binding, capabilities/devices/ +namespace/security overrides, transport-vs-wrong-secret distinction, private +import ordering, secret-free argv, shared-tmpfs planning, and partial/uncertain +cleanup. Two additional real disposable process tests exercise only synthetic owner/ +grandchild shutdown and hard parent loss, without Docker. The suite does +**not** prove the actual pinned image supports the complete +session or that the selected current credential/factor works. + +Next: root review; native disposable pinned-image fixture with original hostname +and a synthetic trusted TLS pair; positive/negative service behavior and hard +interruption cleanup; then an admitted owner-run actual recovery. Actual T18 +still needs selected downstream-consumer reconnect and owner acceptance. Human/ +browser flow uses the existing `keycape login`; retained-factor MFA stays T19. +Both remain explicitly unproven in the receipt. + +Prior exact source snapshots are retained under `revisions/r1/`, `revisions/r2/` +and `revisions/r3/`; frozen test roots/closure manifests accompany R3 and R4. Root's findings were added +as regression tests: added caps/devices/security exceptions; cleanup failure +incorrectly preserving success or abandoning later obligations; Python assert +optimization; extra JWKS signer ambiguity; same-image alternate launch; and +unsupported private tmpfs daemon-copy assumptions. Revision4 addresses these in source, plus the later lifecycle and target +findings. See `LIFECYCLE-R4.md` for the remaining native fixture oracle. No root approval or native acceptance is fabricated. + +Knowledge orientation: emergent-security0.2.6, digest +`8d1d6a9d4d07fec7b619618de7dfe2254c1790f85f4198c7fe19db9751c02`, +withheld/changed-source status; current owner AGENTS/companion/recovery contract +and verifier source were reviewed. Supervised assignment; no autopilot or +privileged execution claimed. diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/candidate.patch b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/candidate.patch new file mode 100644 index 00000000..8fb8af08 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/candidate.patch @@ -0,0 +1,1449 @@ +--- /dev/null ++++ b/tools/bootstrap-recovery/isolated-keycape.py +@@ -0,0 +1,441 @@ ++#!/usr/bin/env python3 ++"""T18 native adapter for an owner-admitted, already restored isolated session. ++ ++Called after identity-bundle.restore, never with an unvalidated directory. This ++module does not provision TLS, grant credentials, or claim downstream acceptance. ++The existing decrypt/tmpfs wrapper remains responsible for archive cleanup. ++""" ++import base64 ++import hashlib ++import importlib.util ++import json ++import io ++import os ++import secrets ++import tarfile ++import time ++import re ++import subprocess ++from pathlib import Path ++ ++HERE = Path(__file__).resolve().parent ++IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010' ++LABEL = 'net-kingdom.recovery-run' ++ ++ ++class Refused(Exception): ++ pass ++ ++ ++def load_bundle(): ++ spec = importlib.util.spec_from_file_location('identity_bundle', HERE / 'identity-bundle.py') ++ module = importlib.util.module_from_spec(spec) ++ spec.loader.exec_module(module) ++ return module ++ ++ ++def private_payload(root, manifest, expected_manifest_sha256): ++ """Bind the complete validated archive before selecting KeyCape material. ++ ++ Private hashes never enter the public receipt. The digest is supplied by the ++ existing private archive admission, not inferred from an untrusted manifest. ++ """ ++ encoded = json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode() ++ if hashlib.sha256(encoded).hexdigest() != expected_manifest_sha256: ++ raise Refused() ++ entries = manifest['files'] ++ if len(entries) != len({e['path'] for e in entries}): ++ raise Refused() ++ values = {} ++ bundle = load_bundle() ++ for entry in entries: ++ if not bundle.allowed(entry['path'], manifest['profile']): ++ raise Refused() ++ path = root / entry['path'] ++ bundle.private(path) ++ value = path.read_bytes() ++ if len(value) != entry['bytes'] or hashlib.sha256(value).hexdigest() != entry['sha256']: ++ raise Refused() ++ values[entry['path']] = value ++ deployment = json.loads(values['resources/sso/deployment/keycape.json']) ++ main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'keycape') ++ if main.get('command') or main.get('args') or main.get('envFrom'): ++ raise Refused() ++ if main['image'] != IMAGE: ++ # Mutable-tag/historical mismatches need a separate explicit owner admission. ++ raise Refused() ++ secret = json.loads(values['resources/sso/secret/keycape-config.json'])['data'] ++ files = {'/etc/keycape/' + name: base64.b64decode(secret[name], validate=True) ++ for name in ('config.yaml', 'key.pem')} ++ for path, value in values.items(): ++ if path.startswith('data/keycape/'): ++ relative = path.removeprefix('data/keycape/') ++ files['/var/lib/keycape-policy/' + relative] = value ++ if '/var/lib/keycape-policy/policy.json' not in files: ++ raise Refused() ++ if 'resources/sso/secret/keycape-factor-read.json' in values: ++ factor=json.loads(values['resources/sso/secret/keycape-factor-read.json'])['data'] ++ files['/etc/keycape-factor/admin-token']=base64.b64decode(factor['admin-token'],validate=True) ++ # Resolve original delivered environment, including all referenced secrets. ++ env = {} ++ for item in main.get('env', []): ++ if not re.fullmatch(r'KEYCAPE_[A-Z0-9_]+',item['name']):raise Refused() ++ if item['name'] in env: ++ raise Refused() ++ if 'value' in item: ++ env[item['name']] = item['value'] ++ else: ++ ref = item['valueFrom']['secretKeyRef'] ++ source = json.loads(values['resources/sso/secret/' + ref['name'] + '.json'])['data'] ++ env[item['name']] = base64.b64decode(source[ref['key']], validate=True).decode() ++ if env.get('KEYCAPE_CONFIG') != '/etc/keycape/config.yaml' or env.get('KEYCAPE_POLICY_PATH') != '/var/lib/keycape-policy/policy.json': ++ raise Refused() ++ return {'files': {p: base64.b64encode(v).decode() for p, v in files.items()}, 'env': env} ++ ++ ++ENDPOINT = 'unix:///var/run/docker.sock' ++EXECUTION = None ++ ++ ++def docker_environment(environment=None): ++ # Even an admitted daemon exposed by another Unix socket is outside this ++ # exact session binding. Conflicting ambient routing is refused, not ignored. ++ for source in (os.environ, environment or {}): ++ if source.get('DOCKER_HOST') not in (None,'',ENDPOINT):raise Refused() ++ if source.get('DOCKER_CONTEXT') not in (None,'','default'):raise Refused() ++ if any(source.get(k) for k in ('DOCKER_TLS_VERIFY','DOCKER_CERT_PATH')):raise Refused() ++ env=dict(os.environ if environment is None else environment) ++ for key in ('DOCKER_CONTEXT','DOCKER_TLS_VERIFY','DOCKER_CERT_PATH'):env.pop(key,None) ++ env['DOCKER_HOST']=ENDPOINT ++ return env ++ ++ ++def process(argv, **kwargs): ++ marker=None ++ if EXECUTION is not None and (kwargs.get('start_new_session') or kwargs.get('process_group') not in (None,-1)):raise Refused() ++ if argv[0]=='docker': ++ argv=['docker','--host',ENDPOINT,*argv[1:]] ++ kwargs['env']=docker_environment(kwargs.get('env')) ++ if EXECUTION is not None: ++ if (os.getpid()!=EXECUTION['pid'] or os.getsid(0)!=EXECUTION['sid'] or os.getpgrp()!=EXECUTION['pgid'] ++ or time.monotonic()>=EXECUTION['deadline']):raise Refused() ++ marker=Path(EXECUTION['staging']+'-docker-inflight') ++ fd=os.open(marker,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) ++ os.close(fd) ++ try: ++ result=subprocess.run(argv,**kwargs) ++ return result ++ except BaseException: ++ # Parent loss leaves .docker-inflight; caught timeout/transport failures ++ # retain a sticky uncertainty marker even if later cleanup can run. ++ if marker is not None: ++ fd=os.open(Path(EXECUTION['staging']+'-docker-uncertain'),os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600);os.close(fd) ++ raise ++ finally: ++ if marker is not None: ++ try:marker.unlink() ++ except FileNotFoundError:pass ++ ++ ++def command(argv, **kwargs): ++ maximum = kwargs.pop('max_output', 32768) ++ result = process(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ++ timeout=kwargs.pop('timeout', 180), **kwargs) ++ if result.returncode or len(result.stdout) > maximum: ++ raise Refused() ++ return result.stdout ++ ++ ++def inspect_session(session, worker_only=False): ++ """No credential environment is read by Docker inspect or returned to caller.""" ++ if not re.fullmatch(r'[a-f0-9]{32}', session['run_id']): ++ raise Refused() ++ names = session['containers'] ++ if not names or len(names) > 12 or len(set(names)) != len(names): ++ raise Refused() ++ for name in names: ++ if not re.fullmatch(r'nk49-kc-' + session['run_id'] + r'-[a-z0-9-]{1,24}', name): ++ raise Refused() ++ if session['worker'] not in names or session['issuer'] not in names: ++ raise Refused() ++ import os ++ contexts=json.loads(command(['docker','context','inspect'])) ++ endpoints=[contexts[0]['Endpoints']['docker']['Host'],os.environ.get('DOCKER_HOST') or ENDPOINT] ++ if not all(x==ENDPOINT for x in endpoints):raise Refused() ++ info = json.loads(command(['docker', 'info', '--format', '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}'])) ++ if info != {'ID': '74f93783-6cef-43a8-a3c4-f62586cd3bc1', 'Name': 'bnt-lap001', 'OSType': 'linux'}: ++ raise Refused() ++ # The worker is the anchor; all dependencies/TLS/issuer share its network-none namespace. ++ anchor = command(['docker', 'container', 'inspect', session['worker'], '--format', '{{.Id}}']).decode().strip() ++ for name,spec in session.get('volumes',{}).items(): ++ volume=json.loads(command(['docker','volume','inspect',name])) ++ expected={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} ++ if len(volume)!=1 or volume[0]['Driver']!='local' or volume[0].get('Scope')!='local' or volume[0].get('Options')!=expected or spec['options']!=expected or volume[0].get('Labels',{}).get(LABEL)!=session['run_id']:raise Refused() ++ for name in names: ++ obj = json.loads(command(['docker', 'container', 'inspect', name, '--format', ++ '{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"labels":{{json .Config.Labels}},"image":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}}}'])) ++ host = obj['host'] ++ mounts=obj['mounts'] ++ destinations=[m['Destination'] for m in mounts] ++ if len(destinations)!=len(set(destinations)):raise Refused() ++ for volume,expected_spec in session.get('volumes',{}).items(): ++ matches=[m for m in mounts if m['Destination']==expected_spec['target']] ++ if len(matches)!=1 or matches[0]['Type']!='volume' or matches[0]['Name']!=volume or matches[0]['RW']!=(name==session['worker']):raise Refused() ++ if (obj['labels'].get(LABEL) != session['run_id'] or not obj['user'] or obj['user'].split(':')[0] in ('0', 'root') ++ or not host['ReadonlyRootfs'] or host['Privileged'] or host.get('Binds') or host.get('PortBindings') ++ or host.get('PidMode') or host.get('IpcMode') == 'host' ++ or host.get('UTSMode') or host.get('UsernsMode') == 'host' or host.get('CgroupnsMode') == 'host' ++ or host.get('Sysctls',{}) not in ({},{'net.ipv4.ip_unprivileged_port_start':'0'} if name==session['worker'] else {}) ++ or host.get('CapAdd') or host.get('Devices') or host.get('DeviceRequests') or host.get('VolumesFrom') ++ or host.get('CapDrop') != ['ALL'] ++ or host.get('SecurityOpt') not in (['no-new-privileges'], ['no-new-privileges:true']) ++ or any(m['Type']!='tmpfs' and not (m['Type']=='volume' and m['Name'] in session.get('volumes',{}) and m['Destination']==session['volumes'][m['Name']]['target']) for m in obj['mounts']) ++ or host['NetworkMode'] != ('none' if name == session['worker'] else 'container:' + anchor) ++ or obj['image'] != session['images'][name] or '@sha256:' not in obj['image']): ++ raise Refused() ++ if worker_only:return ++ if session['images'][session['issuer']] != IMAGE: ++ raise Refused() ++ # Known admitted archive has no command/args override; image default /keycape ++ # must be the actual issuer executable, not an alternate same-image command. ++ launch=json.loads(command(['docker','container','inspect',session['issuer'],'--format', ++ '{"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"path":{{json .Path}},"args":{{json .Args}}}'])) ++ if launch != {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}: ++ raise Refused() ++ ++ ++def container_file(container, path): ++ # Docker streams one tar member into memory; never materialize private files. ++ raw = command(['docker', 'cp', container + ':' + path, '-'], max_output=128*1024*1024) ++ with tarfile.open(fileobj=io.BytesIO(raw), mode='r:') as archive: ++ members = archive.getmembers() ++ if len(members) != 1 or not members[0].isfile() or members[0].size > 96*1024*1024: ++ raise Refused() ++ return archive.extractfile(members[0]).read() ++ ++ ++JWKS_PROBE = r""" ++import base64,ipaddress,json,socket,ssl,sys,urllib.request ++from urllib.parse import urlsplit ++p=json.load(sys.stdin);issuer=p['issuer'];origin=urlsplit(issuer) ++def require(condition): ++ if not condition:raise ValueError('public_jwks_binding_refused') ++require(origin.scheme=='https') ++resolved=socket.getaddrinfo(origin.hostname,origin.port or 443,type=socket.SOCK_STREAM) ++require(bool(resolved) and all(ipaddress.ip_address(x[4][0]).is_loopback for x in resolved)) ++class NoRedirect(urllib.request.HTTPRedirectHandler): ++ def redirect_request(self,*args,**kwargs):return None ++opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect(), ++ urllib.request.HTTPSHandler(context=ssl.create_default_context(cadata=p['trust']))) ++def get(url): ++ u=urlsplit(url) ++ require((u.scheme,u.hostname,u.port or 443)==(origin.scheme,origin.hostname,origin.port or 443)) ++ require(not u.username and not u.password and not u.fragment) ++ with opener.open(url,timeout=10) as response: ++ raw=response.read(1024*1024+1);require(len(raw)<=1024*1024) ++ return json.loads(raw) ++d=get(issuer.rstrip('/')+'/.well-known/openid-configuration');require(d['issuer']==issuer) ++keys=get(d['jwks_uri'])['keys'] ++def integer(value):return int.from_bytes(base64.urlsafe_b64decode(value+'='*(-len(value)%4)),'big') ++# The exact admitted owner release constructs one key, kid key-1. Reject every ++# extra key, including irrelevant keys: a native successful exchange can then ++# only have used the original signer through the existing verifier. ++require(isinstance(keys,list) and len(keys)==1) ++k=keys[0] ++require(k.get('kty')=='RSA' and k.get('kid')=='key-1' and k.get('use')=='sig' and k.get('alg')=='RS256') ++require(integer(k['n'])==int(p['n']) and integer(k['e'])==int(p['e'])) ++print('{"original_public_signing_key_in_live_jwks":true}') ++""" ++ ++ ++def check_jwks(session, admission, public_numbers): ++ trust=runtime_file(session,admission['trust_path']).decode() ++ request={'issuer':admission['issuer'],'trust':trust,'n':str(public_numbers.n),'e':str(public_numbers.e)} ++ result=json.loads(command(['docker','exec','-i',session['worker'],'python3','-c',JWKS_PROBE],input=json.dumps(request).encode())) ++ if result != {'original_public_signing_key_in_live_jwks': True}:raise Refused() ++ ++ ++RUNTIME_FILES = r""" ++import base64,json,sys ++from pathlib import Path ++p=json.load(sys.stdin) ++roots=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') ++result={} ++paths=[] ++if p.get('tree'): ++ paths=list(Path('/var/lib/keycape-policy').rglob('*')) ++else:paths=[Path(x) for x in p['paths']] ++for f in paths: ++ if f.is_dir():continue ++ if not str(f).startswith(roots) or not f.is_file() or any(x.is_symlink() for x in (f,*f.parents)) or f.stat().st_size>8*1024*1024:raise ValueError() ++ result[str(f)]=base64.b64encode(f.read_bytes()).decode() ++ if len(result)>4096:raise ValueError() ++print(json.dumps(result)) ++""" ++ ++ ++def runtime_files(session, paths=(), tree=False): ++ raw=command(['docker','exec','-i',session['worker'],'python3','-c',RUNTIME_FILES], ++ input=json.dumps({'paths':list(paths),'tree':tree}).encode(),max_output=48*1024*1024) ++ return {k:base64.b64decode(v,validate=True) for k,v in json.loads(raw).items()} ++ ++ ++def runtime_file(session,path): ++ return runtime_files(session,[path])[path] ++ ++ ++def run_verifier(session, admission, payload): ++ issuer = session['issuer'] ++ if {v['target'] for v in session.get('volumes',{}).values()}!={'/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls'}:raise Refused() ++ from urllib.parse import urlsplit ++ import ipaddress ++ address = urlsplit(admission['issuer']) ++ if (address.scheme != 'https' or not address.hostname or address.username or address.password ++ or address.query or address.fragment): ++ raise Refused() ++ if hashlib.sha256(container_file(issuer, '/keycape')).hexdigest() != admission['verifier_sha256']: ++ raise Refused() ++ if hashlib.sha256(runtime_file(session, admission['trust_path'])).hexdigest() != admission['trust_sha256']: ++ raise Refused() ++ # Capture private environment only in owner process memory, never receipt. ++ environment = json.loads(command(['docker','container','inspect',issuer,'--format','{{json .Config.Env}}'])) ++ env = dict(item.split('=',1) for item in environment) ++ if len(env)!=len(environment):raise Refused() ++ if set(env)-set(payload['env'])-{'PATH','HOME','HOSTNAME','SSL_CERT_FILE'}:raise Refused() ++ if env.get('SSL_CERT_FILE') != admission['trust_path'] or any(k.lower().endswith('_proxy') and v for k,v in env.items()): ++ raise Refused() ++ if {k:v for k,v in env.items() if k.startswith('KEYCAPE_')}!={k:v for k,v in payload['env'].items() if k.startswith('KEYCAPE_')}:raise Refused() ++ for name,value in payload['env'].items(): ++ if env.get(name) != value: ++ raise Refused() ++ files = {k:base64.b64decode(v,validate=True) for k,v in payload['files'].items()} ++ def equal(): ++ if runtime_files(session,files)!=files:raise Refused() ++ durable=runtime_files(session,tree=True) ++ if durable!={k:v for k,v in files.items() if k.startswith('/var/lib/keycape-policy/')}: ++ raise Refused() ++ equal() ++ # Reading YAML is data parsing only; no archived executable is evaluated. ++ import yaml ++ cfg = yaml.safe_load(files['/etc/keycape/config.yaml']) ++ if cfg['issuer'] != admission['issuer']: ++ raise Refused() ++ consumer = admission['consumer'] ++ client = next(x for x in cfg['clients'] if x['clientId']==consumer['client_id']) ++ if (client['secretRef']!='env:'+consumer['secret_env'] or client['clientType']!='confidential' ++ or 'client_credentials' not in client['grantTypes'] or not env.get(consumer['secret_env'])): ++ raise Refused() ++ from cryptography.hazmat.primitives import serialization ++ key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) ++ check_jwks(session,admission,key.public_key().public_numbers()) ++ wrong = secrets.token_urlsafe(48) ++ if wrong == env[consumer['secret_env']]:raise Refused() ++ args = ['docker','exec','--env','NK49_UNRELATED_NEGATIVE_SECRET='+wrong,issuer,'/keycape','verify-client', ++ '-issuer',admission['issuer'],'-previous-secret-env','NK49_UNRELATED_NEGATIVE_SECRET'] ++ flags = {'client_id':'client-id','secret_env':'secret-env','audience':'audience','scope':'scope', ++ 'subject':'expect-subject','tenant':'expect-tenant','roles':'expect-roles','deny_scope':'deny-scope'} ++ for name,flag in flags.items():args+=['-'+flag,consumer[name]] ++ # Native command reports on stderr. Do not return or print its raw response. ++ result = process(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=150) ++ raw = (result.stdout+result.stderr).decode() ++ required = ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings', ++ 'principal_type is service','sub matches the registration','tenant matches the registration','roles present', ++ 'granted scope carries no excess grant','predecessor secret refused'] ++ required += ['excess scope refused: '+scope for scope in consumer['deny_scope'].split()] ++ if result.returncode or any('PASS '+name+'\n' not in raw for name in required) or 'FAIL ' in raw: ++ raise Refused() ++ equal() ++ from cryptography.hazmat.primitives import serialization ++ key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None) ++ check_jwks(session,admission,key.public_key().public_numbers()) ++ public=key.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) ++ return {'native_service_registration_verified':True,'wrong_unrelated_secret_explicitly_refused':True, ++ 'denied_scopes_refused':True,'original_config_registry_policy_receipts_equal':True, ++ 'public_signing_spki_sha256':hashlib.sha256(public).hexdigest(), ++ 'original_https_issuer_and_trust_preserved':True,'original_public_signing_key_in_live_jwks':True} ++ ++ ++def cleanup(session, receipt=None): ++ """Attempt every owned cleanup even when one obligation fails. ++ ++ Preserve exact safe container names and outcomes so the owner can recover an ++ uncertain/partial cleanup. An inspect/daemon failure never means absence. ++ """ ++ obligations = [] ++ failed = False ++ for name in reversed(session['containers']): ++ row = {'container': name, 'status': 'unverified'} ++ obligations.append(row) ++ try: ++ present=command(['docker','container','ls','--all','--filter','name=^/'+name+'$','--format','{{.Names}}']).decode().splitlines() ++ if not present: ++ row['status']='absence_verified' ++ continue ++ label = command(['docker', 'container', 'inspect', name, '--format', ++ '{{index .Config.Labels "' + LABEL + '"}}']).decode().strip() ++ if label != session['run_id']: ++ row['status'] = 'ownership_mismatch_not_removed' ++ failed = True ++ continue ++ # A failed remove is followed by a successful explicit inventory; ++ # interrupted remove may actually have completed. ++ try: ++ command(['docker', 'rm', '--force', '--volumes', name]) ++ except Exception: ++ pass ++ remaining = command(['docker','container','ls','--all','--filter', ++ 'name=^/' + name + '$','--format','{{.Names}}']).decode().splitlines() ++ if remaining: ++ row['status'] = 'still_present' ++ failed = True ++ else: ++ row['status'] = 'absence_verified' ++ except Exception: ++ failed = True ++ if receipt is not None: ++ receipt['container_cleanup_obligations'] = obligations ++ if failed: ++ raise Refused() ++ ++ ++ ++def native_checks(root, manifest, admission, receipt, cleanup_session=True): ++ """Owner wrapper provides a reviewed session after existing restore pipeline. ++ ++ Admission must bind ciphertext, canonical private manifest digest, executable ++ and trust bytes, exact image/target list, original issuer and selected consumer. ++ None of these admission fields grant authority or carry a client secret. ++ """ ++ session = admission['session'] ++ # Validate ownership/containment before permitting either exec or cleanup. ++ inspect_session(session) ++ receipt.update(status='failed', source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), ++ ciphertext_sha256=admission['ciphertext_sha256'], production_contacted=False, ++ human_mfa_proven=False, downstream_consumer_reconnect_proven=False, ++ container_cleanup_verified=False) ++ try: ++ for field in ('ciphertext_sha256', 'manifest_sha256', 'verifier_sha256', 'trust_sha256'): ++ if not re.fullmatch('[a-f0-9]{64}', admission[field]): ++ raise Refused() ++ for field in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope'): ++ if not isinstance(admission['consumer'].get(field), str) or not admission['consumer'][field].strip(): ++ raise Refused() ++ payload = private_payload(root, manifest, admission['manifest_sha256']) ++ result = run_verifier(session, admission, payload) ++ required = {'native_service_registration_verified','wrong_unrelated_secret_explicitly_refused', ++ 'denied_scopes_refused','original_config_registry_policy_receipts_equal', ++ 'original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks'} ++ if any(result.get(k) is not True for k in required) or not re.fullmatch('[a-f0-9]{64}', result.get('public_signing_spki_sha256','')): ++ raise Refused() ++ # Closed receipt allowlist: never copy arbitrary worker output. ++ receipt.update({k: True for k in required}) ++ receipt['public_signing_spki_sha256'] = result['public_signing_spki_sha256'] ++ receipt['status'] = 'isolated_keycape_native_registration_checked' ++ finally: ++ try: ++ if cleanup_session: ++ cleanup(session, receipt) ++ receipt['container_cleanup_verified'] = True ++ except Exception: ++ receipt['status'] = 'failed' ++ raise +--- /dev/null ++++ b/tools/bootstrap-recovery/isolated-keycape-session.py +@@ -0,0 +1,487 @@ ++#!/usr/bin/env python3 ++"""Owner-run T18 session assembly using the existing archive/containment pipeline. ++ ++Candidate only: native pinned-image tests and review are required before use. ++Shared Docker local-driver tmpfs is required because Docker cp does not support ++private tmpfs/mount paths, and the original KeyCape image is distroless. ++""" ++import argparse ++import base64 ++from datetime import datetime, timezone ++import hashlib ++import importlib.util ++import json ++import os ++from pathlib import Path ++import signal ++import re ++import stat ++import select ++import subprocess ++import sys ++import tempfile ++import time ++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 ++ ++adapter=load('isolated-keycape') ++local=load('isolated-lldap') ++bundle=local.bundle ++# Bind the reused preflight itself, not only this module's direct Docker calls. ++local.command=adapter.command ++_original_preflight=local.preflight ++def preflight(): ++ contexts=json.loads(adapter.command(['docker','context','inspect'])) ++ if len(contexts)!=1 or contexts[0]['Endpoints']['docker']['Host']!=adapter.ENDPOINT:raise adapter.Refused() ++ result=_original_preflight();result['docker_endpoint']=adapter.ENDPOINT;return result ++local.preflight=preflight ++WORKER=local.CLIENT ++STAGING_ROOT=Path('/dev/shm') ++RUNROOTS=('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls') ++VOLUME_OPTIONS={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'} ++ ++IMPORT=r''' ++import base64,json,os,sys ++from pathlib import Path ++os.umask(0o077) ++p=json.load(sys.stdin) ++allowed=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/') ++for name,encoded in p.items(): ++ f=Path(name) ++ if not name.startswith(allowed) or '..' in f.parts or any(x.is_symlink() for x in (f,*f.parents)):raise ValueError() ++ f.parent.mkdir(mode=0o700,parents=True,exist_ok=True) ++ fd=os.open(f,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) ++ with os.fdopen(fd,'wb') as out:out.write(base64.b64decode(encoded,validate=True)) ++''' ++ ++# Small transport inside the already pinned helper; it has only loopback routes. ++# No token handling, auth bypass, dependency imitation, reset or notifier path. ++TLS=r''' ++import select,socket,socketserver,ssl,threading ++context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ++context.minimum_version=ssl.TLSVersion.TLSv1_2 ++context.load_cert_chain('/recovery-tls/server.pem','/recovery-tls/server-key.pem') ++class Handler(socketserver.BaseRequestHandler): ++ def handle(self): ++ try: ++ with context.wrap_socket(self.request,server_side=True) as incoming: ++ with socket.create_connection(('127.0.0.1',8080),timeout=10) as outgoing: ++ incoming.settimeout(15);outgoing.settimeout(15) ++ while True: ++ ready,_,_=select.select([incoming,outgoing],[],[],15) ++ if not ready:return ++ for source in ready: ++ data=source.recv(65536) ++ if not data:return ++ (outgoing if source is incoming else incoming).sendall(data) ++ except Exception:pass ++class Server(socketserver.ThreadingTCPServer): ++ allow_reuse_address=True ++ daemon_threads=True ++with Server(('127.0.0.1',443),Handler) as server:server.serve_forever() ++''' ++ ++ ++def run(argv,**kwargs):return adapter.command(argv,**kwargs) ++ ++ ++def plan_session(): ++ run_id=uuid.uuid4().hex ++ stem='nk49-kc-'+run_id ++ worker=stem+'-transport';issuer=stem+'-issuer' ++ volumes={stem+'-v'+str(i):{'target':root,'options':dict(VOLUME_OPTIONS)} for i,root in enumerate(RUNROOTS)} ++ return {'run_id':run_id,'containers':[worker,issuer], 'worker':worker,'issuer':issuer, ++ 'images':{worker:WORKER,issuer:adapter.IMAGE},'volumes':volumes, ++ 'public_image_probe':stem+'-image-probe','docker_endpoint':adapter.ENDPOINT} ++ ++ ++def verify_volume(name,spec,run_id): ++ obj=json.loads(run(['docker','volume','inspect',name])) ++ if (len(obj)!=1 or obj[0]['Name']!=name or obj[0]['Driver']!='local' or obj[0].get('Scope')!='local' ++ or obj[0].get('Options')!=VOLUME_OPTIONS or spec['options']!=VOLUME_OPTIONS ++ or obj[0].get('Labels',{}).get(adapter.LABEL)!=run_id):raise adapter.Refused() ++ ++ ++def base_args(session,name,network): ++ return ['docker','run','--detach','--pull=never','--name',name,'--network',network, ++ '--label',adapter.LABEL+'='+session['run_id'],'--read-only','--cap-drop','ALL', ++ '--security-opt','no-new-privileges','--user','1000:1000','--memory','512m','--cpus','1', ++ '--pids-limit','64','--log-driver','none','--restart','no', ++ '--tmpfs','/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700'] ++ ++ ++def mounts(session,readonly=False): ++ args=[] ++ for name,spec in session['volumes'].items(): ++ args+=['--mount','type=volume,source='+name+',target='+spec['target']+(',readonly' if readonly else '')] ++ return args ++ ++ ++def provision(session,payload,tls,issuer): ++ """Inspect containment and driver options before the first private import.""" ++ from urllib.parse import urlsplit ++ address=urlsplit(issuer) ++ if address.scheme!='https' or not address.hostname or address.port not in (None,443) or address.path not in ('','/'): ++ raise adapter.Refused() ++ for name,spec in session['volumes'].items(): ++ run(['docker','volume','create','--driver','local','--label',adapter.LABEL+'='+session['run_id'], ++ '--opt','type=tmpfs','--opt','device=tmpfs','--opt','o='+VOLUME_OPTIONS['o'],name]) ++ verify_volume(name,spec,session['run_id']) ++ args=base_args(session,session['worker'],'none')+mounts(session) ++ args+=['--sysctl','net.ipv4.ip_unprivileged_port_start=0','--add-host',address.hostname+':127.0.0.1', ++ '--entrypoint','python3',WORKER,'-c','import time;time.sleep(1800)'] ++ run(args) ++ # Check helper and every private filesystem before touching the ciphertext's ++ # private contents. Issuer is created only after originals are present. ++ adapter.inspect_session({**session,'containers':[session['worker']],'issuer':session['worker']},worker_only=True) ++ files={**payload['files'],**{k:base64.b64encode(v).decode() for k,v in tls.items()}} ++ run(['docker','exec','-i',session['worker'],'python3','-c',IMPORT],input=json.dumps(files).encode()) ++ anchor=run(['docker','container','inspect',session['worker'],'--format','{{.Id}}']).decode().strip() ++ args=base_args(session,session['issuer'],'container:'+anchor)+mounts(session,readonly=True) ++ # CLI argv carries environment names only. Original credentials stay in the ++ # owner subprocess environment and Docker's private container configuration. ++ env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':'unix:///var/run/docker.sock',**payload['env'], ++ 'SSL_CERT_FILE':'/recovery-tls/trust.pem'} ++ for name in (*payload['env'],'SSL_CERT_FILE'):args+=['--env',name] ++ args+=[adapter.IMAGE] ++ run(args,env=env) ++ adapter.inspect_session(session) ++ run(['docker','exec','--detach',session['worker'],'python3','-c',TLS]) ++ ++ ++def cleanup_all(session,receipt): ++ failed=False ++ try:adapter.cleanup({**session,'containers':session['containers']+[session['public_image_probe']]},receipt) ++ except Exception:failed=True ++ rows=[] ++ for name,spec in session['volumes'].items(): ++ row={'volume':name,'status':'unverified'};rows.append(row) ++ try: ++ exists=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines() ++ if not exists: ++ row['status']='absence_verified' ++ continue ++ verify_volume(name,spec,session['run_id']) ++ run(['docker','volume','rm',name]) ++ remaining=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines() ++ if remaining:row['status']='still_present';failed=True ++ else:row['status']='absence_verified' ++ except Exception:failed=True ++ receipt['tmpfs_volume_cleanup_obligations']=rows ++ receipt['container_cleanup_verified']=not failed ++ if failed:raise adapter.Refused() ++ ++def register_workdir(work, run_id): ++ if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+run_id+'-[a-z0-9_]{8}',work.name):raise adapter.Refused() ++ if any(p.is_symlink() for p in (work,*work.parents)):raise adapter.Refused() ++ info=work.lstat() ++ if not stat.S_ISDIR(info.st_mode) or info.st_uid!=os.getuid() or stat.S_IMODE(info.st_mode)!=0o700:raise adapter.Refused() ++ return {'path':str(work),'run_id':run_id,'device':info.st_dev,'inode':info.st_ino,'owner_uid':info.st_uid} ++ ++ ++def cleanup_workdir(staging, receipt): ++ """Delete only the registered inode below actual /dev/shm, never a replacement.""" ++ row={'path':staging['path'],'device':staging['device'],'inode':staging['inode'],'status':'unverified'} ++ receipt['plaintext_cleanup_obligation']=row ++ work=Path(staging['path']) ++ if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+staging['run_id']+'-[a-z0-9_]{8}',work.name):raise adapter.Refused() ++ if not any(line.split()[1:3]==['/dev/shm','tmpfs'] for line in Path('/proc/mounts').read_text().splitlines()):raise adapter.Refused() ++ if any(p.is_symlink() for p in work.parents):raise adapter.Refused() ++ parentfd=os.open(work.parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW) ++ try: ++ try:info=os.stat(work.name,dir_fd=parentfd,follow_symlinks=False) ++ except FileNotFoundError: ++ row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return ++ if (not stat.S_ISDIR(info.st_mode) or info.st_uid!=staging['owner_uid'] or info.st_uid!=os.getuid() ++ or info.st_dev!=staging['device'] or info.st_ino!=staging['inode'] or stat.S_IMODE(info.st_mode)!=0o700): ++ row['status']='identity_mismatch_not_removed';raise adapter.Refused() ++ def remove(parent,name,expected): ++ fd=os.open(name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=parent) ++ try: ++ current=os.fstat(fd) ++ if (current.st_dev,current.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused() ++ for child in os.listdir(fd): ++ before=os.stat(child,dir_fd=fd,follow_symlinks=False) ++ if before.st_uid!=staging['owner_uid'] or before.st_dev!=staging['device']:raise adapter.Refused() ++ if stat.S_ISDIR(before.st_mode):remove(fd,child,before) ++ elif stat.S_ISREG(before.st_mode) and before.st_nlink==1:os.unlink(child,dir_fd=fd) ++ else:raise adapter.Refused() # symlinks/special files/hardlinks are uncertain, never followed ++ finally:os.close(fd) ++ after=os.stat(name,dir_fd=parent,follow_symlinks=False) ++ if (after.st_dev,after.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused() ++ os.rmdir(name,dir_fd=parent) ++ remove(parentfd,work.name,info) ++ try:os.stat(work.name,dir_fd=parentfd,follow_symlinks=False) ++ except FileNotFoundError: ++ row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return ++ raise adapter.Refused() ++ finally:os.close(parentfd) ++ ++ ++def recover_obligations(session, staging, receipt): ++ failed=False ++ try:cleanup_all(session,receipt) ++ except Exception:failed=True ++ # The plaintext obligation is attempted even when Docker is unreachable. ++ try:cleanup_workdir(staging,receipt) ++ except Exception:failed=True ++ if failed:raise adapter.Refused() ++ ++ ++def process_facts(pid): ++ raw=Path('/proc')/str(pid)/'stat' ++ try: ++ fields=raw.read_text().rsplit(')',1)[1].split() ++ return {'pid':pid,'state':fields[0],'pgid':int(fields[2]),'sid':int(fields[3]), ++ 'start_ticks':int(fields[19]),'uid':raw.parent.stat().st_uid} ++ except FileNotFoundError:return None ++ ++ ++def isolate_execution(): ++ # Called by main before reading TLS/key inputs. Never reuse an inherited ++ # shell, custody-helper, user or unrelated process group. ++ os.setsid() ++ pid=os.getpid();facts=process_facts(pid) ++ if os.getsid(0)!=pid or os.getpgrp()!=pid or facts['sid']!=pid or facts['pgid']!=pid:raise adapter.Refused() ++ return {k:facts[k] for k in ('pid','pgid','sid','start_ticks','uid')}|{'deadline':time.monotonic()+1800} ++ ++ ++def verify_execution(execution): ++ if execution['pid']!=execution['sid'] or execution['pid']!=execution['pgid'] or execution['uid']!=os.getuid():raise adapter.Refused() ++ current=process_facts(execution['pid']) ++ if current is not None and any(current[k]!=execution[k] for k in ('pid','pgid','sid','start_ticks','uid')): ++ # Numeric leader PID reuse is not authority to signal a new group. ++ raise adapter.Refused() ++ ++ ++def group_members(execution): ++ verify_execution(execution) ++ members=[] ++ for path in Path('/proc').iterdir(): ++ if not path.name.isdecimal():continue ++ facts=process_facts(int(path.name)) ++ if facts is None or facts['pgid']!=execution['pgid']:continue ++ if facts['sid']!=execution['sid'] or facts['uid']!=execution['uid'] or facts['start_ticks']bundle.LIMIT+4*1024*1024:raise adapter.Refused() ++ root=work/'restored';root.mkdir(mode=0o700) ++ manifest,_=bundle.restore(archive,root) ++ payload=adapter.private_payload(root,manifest,admission['manifest_sha256']) ++ import yaml ++ cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml'])) ++ if cfg.get('port')!=8080 or cfg.get('issuer')!=admission['issuer']:raise adapter.Refused() ++ # Public executable readback uses the daemon archive API only on the ++ # original image rootfs, never a tmpfs or private user-created mount. ++ probe=session['public_image_probe'] ++ try: ++ run(['docker','create','--name',probe,'--network','none','--read-only','--label',adapter.LABEL+'='+session['run_id'],adapter.IMAGE]) ++ binary=adapter.container_file(probe,'/keycape') ++ if hashlib.sha256(binary).hexdigest()!=admission['verifier_sha256']:raise adapter.Refused() ++ finally: ++ receipt['public_image_probe_cleanup_obligations']=[{'container':probe,'status':'unverified'}] ++ run(['docker','rm',probe]) ++ if run(['docker','container','ls','--all','--filter','name=^/'+probe+'$','--format','{{.Names}}']).strip():raise adapter.Refused() ++ receipt['public_image_probe_cleanup_obligations'][0]['status']='absence_verified' ++ provision(session,payload,tls,admission['issuer']) ++ receipt.update(ciphertext_sha256=transfer['ciphertext_sha256'],all_manifest_hashes_verified=True,manifest_file_count=len(manifest['files'])) ++ checked_admission={**admission,'session':session,'trust_path':'/recovery-tls/trust.pem'} ++ # Native checker consumes no service secret argument or new grant. ++ # Retry only bounded public readiness; no account/factor attempt. ++ for attempt in range(40): ++ try: ++ numbers=serialization.load_pem_private_key(base64.b64decode(payload['files']['/etc/keycape/key.pem']),password=None).public_key().public_numbers() ++ adapter.check_jwks(session,checked_admission,numbers);break ++ except Exception: ++ if attempt==39:raise ++ time.sleep(.25) ++ adapter.native_checks(root,manifest,checked_admission,receipt,cleanup_session=False) ++ finally: ++ os.close(keyfd) ++ try: ++ if session is not None and staging is not None: ++ uncertain=daemon_uncertain(staging) ++ receipt['in_flight_daemon_request_uncertain']=uncertain ++ recover_obligations(session,staging,receipt) ++ if uncertain:raise adapter.Refused() ++ if reaper is not None: ++ reaper.terminate();reaper.wait(timeout=10) ++ except Exception: ++ receipt['status']='failed' ++ raise ++ finally: ++ adapter.EXECUTION=None ++ if work is not None and not work.exists():receipt['temporary_plaintext_cleanup_verified']=True ++ ++ ++def persist_receipt(fd,receipt): ++ raw=(json.dumps(receipt,indent=2)+'\n').encode() ++ os.lseek(fd,0,os.SEEK_SET);os.ftruncate(fd,0) ++ with os.fdopen(os.dup(fd),'wb') as output: ++ output.write(raw);output.flush();os.fsync(output.fileno()) ++ ++ ++def main(): ++ p=argparse.ArgumentParser(description=__doc__) ++ for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust','receipt'):p.add_argument('--'+name,required=True) ++ args=p.parse_args();os.umask(0o077) ++ fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) ++ receipt={'schema':'netkingdom.isolated-keycape-recovery.v1','status':'failed','production_contacted':False,'secret_values_exported':False} ++ try: ++ execution=isolate_execution() ++ raw=Path(args.admission).read_bytes() ++ if hashlib.sha256(raw).hexdigest()!=args.admission_sha256:raise adapter.Refused() ++ admission=json.loads(raw) ++ tls={} ++ for name,path in (('server.pem',args.tls_cert),('server-key.pem',args.tls_key),('trust.pem',args.trust)): ++ source=Path(path);bundle.private(source,max_bytes=1024*1024);tls['/recovery-tls/'+name]=source.read_bytes() ++ execute(json.loads(Path(args.transfer_receipt).read_text()),admission,tls,receipt,execution,checkpoint=lambda r:persist_receipt(fd,r)) ++ except (Exception,KeyboardInterrupt):receipt.update(status='failed',error='isolated_keycape_failed_private_diagnostics_suppressed') ++ finally: ++ receipt['finished_at']=datetime.now(timezone.utc).isoformat() ++ try:persist_receipt(fd,receipt) ++ finally:os.close(fd) ++ print('OK: isolated KeyCape native checks and cleanup passed.' if receipt['status']=='isolated_keycape_native_registration_checked' else 'ERROR: isolated KeyCape stopped; review safe receipt.') ++ return int(receipt['status']!='isolated_keycape_native_registration_checked') ++ ++if __name__=='__main__': ++ for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,local.offline.interrupted) ++ raise SystemExit(main()) +--- /dev/null ++++ b/tools/bootstrap-recovery/run-isolated-keycape.sh +@@ -0,0 +1,40 @@ ++#!/usr/bin/env bash ++# Candidate owner launcher. Review and native fixture proof required before use. ++set +x ++set -euo pipefail ++ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" ++cd -- "$ROOT" ++umask 077 ++[[ $# == 6 ]] || { printf '%s\n' 'Usage: run-isolated-keycape.sh VERIFIED_TRANSFER_RECEIPT ADMISSION ADMISSION_SHA256 TLS_CERT TLS_KEY TRUST'; exit 1; } ++[[ "$3" =~ ^[a-f0-9]{64}$ ]] || { printf '%s\n' 'ERROR: exact reviewed admission digest required.'; exit 1; } ++for index in 1 2 4 5 6; do ++ [[ -f "${!index}" && ! -L "${!index}" ]] || { printf '%s\n' 'ERROR: provide regular owner-controlled input files.'; exit 1; } ++done ++[[ -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 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-keycape-session.py') ++spec=importlib.util.spec_from_file_location('session',p) ++m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) ++try: ++ m.local.preflight() ++ m.run(['docker','image','inspect',m.adapter.IMAGE]) ++except Exception: ++ raise SystemExit('ERROR: exact local Docker/images/tmpfs preflight failed; no key requested.') ++print('OK: fixed local target and cached pinned images available.') ++PY ++NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)" ++printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN" ++printf '%s\n' 'Owner-reviewed candidate: isolated original issuer/trust and current registered service-client checks only.' ++printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.' ++printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.' ++bash sso-mfa/bootstrap/sops-custody-unlock.sh \ ++ --expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \ ++ --prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \ ++ --transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \ ++ --tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json" ++printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN" +--- /dev/null ++++ b/tests/test_isolated_keycape_recovery.py +@@ -0,0 +1,223 @@ ++import base64 ++import hashlib ++import importlib.util ++import io ++import json ++import subprocess ++import tarfile ++import tempfile ++import unittest ++from pathlib import Path ++from unittest.mock import patch ++ ++SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape.py' ++spec=importlib.util.spec_from_file_location('adapter',SOURCE) ++a=importlib.util.module_from_spec(spec);spec.loader.exec_module(a) ++ ++class ArchiveTests(unittest.TestCase): ++ def setUp(self): ++ self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) ++ self.root=Path(self.tmp.name) ++ config={'data':{k:base64.b64encode(v).decode() for k,v in {'config.yaml':b'issuer: https://issuer.example','key.pem':b'fixture-key'}.items()}} ++ self.values={'resources/sso/secret/keycape-config.json':json.dumps(config).encode(), ++ 'resources/sso/deployment/keycape.json':json.dumps({'spec':{'template':{'spec':{'containers':[{'name':'keycape','image':a.IMAGE,'env':[ ++ {'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'}, ++ {'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'}, ++ {'name':'KEYCAPE_ORIGINAL_SECRET','valueFrom':{'secretKeyRef':{'name':'client','key':'secret'}}}]}]}}}}).encode(), ++ 'resources/sso/secret/client.json':json.dumps({'data':{'secret':base64.b64encode(b'fixture-private').decode()}}).encode(), ++ 'data/keycape/policy.json':b'{}','data/keycape/receipts/one.json':b'{"original":true}'} ++ for name,value in self.values.items(): ++ p=self.root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(value);p.chmod(0o600) ++ self.manifest={'profile':{'components':[{'namespace':'sso','deployment':'keycape','secret_refs':['keycape-config','client'],'configmap_refs':[]}]}, ++ 'files':[{'path':k,'bytes':len(v),'sha256':hashlib.sha256(v).hexdigest()} for k,v in self.values.items()]} ++ def payload(self): ++ digest=hashlib.sha256(json.dumps(self.manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest() ++ return a.private_payload(self.root,self.manifest,digest) ++ def test_original_state_and_secret_delivery_retained(self): ++ p=self.payload();self.assertEqual(p['env']['KEYCAPE_ORIGINAL_SECRET'],'fixture-private') ++ self.assertEqual(base64.b64decode(p['files']['/var/lib/keycape-policy/receipts/one.json']),b'{"original":true}') ++ def test_archive_digest_refused(self): ++ with self.assertRaises(a.Refused):a.private_payload(self.root,self.manifest,'0'*64) ++ def test_changed_file_refused(self): ++ (self.root/'data/keycape/policy.json').write_text('{"changed":true}') ++ with self.assertRaises(a.Refused):self.payload() ++ def test_symlink_refused(self): ++ p=self.root/'data/keycape/policy.json';p.unlink();p.symlink_to('/etc/hostname') ++ with self.assertRaises(Exception):self.payload() ++ def test_duplicate_manifest_refused(self): ++ self.manifest['files'].append(self.manifest['files'][0]) ++ with self.assertRaises(a.Refused):self.payload() ++ def test_image_mismatch_refused(self): ++ d=json.loads(self.values['resources/sso/deployment/keycape.json']);d['spec']['template']['spec']['containers'][0]['image']='keycape:latest' ++ raw=json.dumps(d).encode();p=self.root/'resources/sso/deployment/keycape.json';p.write_bytes(raw) ++ for row in self.manifest['files']: ++ if row['path'].endswith('/deployment/keycape.json'):row.update(bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest()) ++ with self.assertRaises(a.Refused):self.payload() ++ ++class NativeTests(unittest.TestCase): ++ def setUp(self): ++ from cryptography.hazmat.primitives import serialization ++ from cryptography.hazmat.primitives.asymmetric import rsa ++ self.key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()) ++ self.cfg=b'issuer: https://issuer.example\nclients:\n- clientId: existing\n clientType: confidential\n secretRef: env:KEYCAPE_ORIGINAL_SECRET\n grantTypes: [client_credentials]\n' ++ self.files={'/etc/keycape/config.yaml':self.cfg,'/etc/keycape/key.pem':self.key,'/var/lib/keycape-policy/policy.json':b'{}'} ++ self.payload={'env':{'KEYCAPE_ORIGINAL_SECRET':'fixture-private'},'files':{k:base64.b64encode(v).decode() for k,v in self.files.items()}} ++ self.admission={'issuer':'https://issuer.example','verifier_sha256':hashlib.sha256(b'binary').hexdigest(), ++ 'trust_path':'/trust.pem','trust_sha256':hashlib.sha256(b'original-trust').hexdigest(), ++ 'consumer':{'client_id':'existing','secret_env':'KEYCAPE_ORIGINAL_SECRET','scope':'read','audience':'consumer', ++ 'subject':'service:existing','tenant':'tenant:platform','roles':'reader','deny_scope':'write'}} ++ self.session={'issuer':'isolated','volumes':{x:{'target':x} for x in ('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')}} ++ self.output=''.join('PASS '+x+'\n' for x in ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings','principal_type is service','sub matches the registration','tenant matches the registration','roles present','granted scope carries no excess grant','predecessor secret refused','excess scope refused: write']) ++ def file(self,container,path): ++ return {**self.files,'/etc/hosts':b'127.0.0.1 issuer.example\n','/keycape':b'binary','/trust.pem':b'original-trust'}[path] ++ def command(self,argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem']).encode() ++ output=io.BytesIO() ++ with tarfile.open(fileobj=output,mode='w') as t: ++ m=tarfile.TarInfo('policy.json');m.size=2;t.addfile(m,io.BytesIO(b'{}')) ++ return output.getvalue() ++ def verify(self,code=0,output=None): ++ with patch.object(a,'check_jwks'),patch.object(a,'runtime_file',return_value=b'original-trust'),patch.object(a,'runtime_files',side_effect=lambda session,paths=(),tree=False:({k:v for k,v in self.files.items() if k.startswith('/var/lib/keycape-policy/')} if tree else dict(self.files))),patch.object(a,'container_file',side_effect=self.file),patch.object(a,'command',side_effect=self.command),patch.object(a.subprocess,'run',return_value=subprocess.CompletedProcess([],code,b'',(self.output if output is None else output).encode())) as run: ++ result=a.run_verifier(self.session,self.admission,self.payload) ++ argv=run.call_args.args[0] ++ self.assertNotIn('fixture-private',' '.join(argv)) ++ self.assertIn('-previous-secret-env',argv) ++ return result ++ def test_original_registration_and_unrelated_secret(self): ++ r=self.verify();self.assertTrue(r['wrong_unrelated_secret_explicitly_refused']);self.assertNotIn('fixture-private',json.dumps(r)) ++ def test_transport_failure_never_counts_as_wrong_secret(self): ++ with self.assertRaises(a.Refused):self.verify(1,'FAIL discovery and endpoint origin: TLS failure\n') ++ def test_missing_explicit_rejection_never_counts(self): ++ with self.assertRaises(a.Refused):self.verify(0,self.output.replace('PASS predecessor secret refused\n','')) ++ def test_fail_line_refused_even_zero_exit(self): ++ with self.assertRaises(a.Refused):self.verify(0,self.output+'FAIL transport\n') ++ def test_security_environment_override_refused(self): ++ original=self.command ++ def altered(argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem','SSL_CERT_DIR=/unreviewed']).encode() ++ return original(argv,**kwargs) ++ with patch.object(self,'command',side_effect=altered): ++ with self.assertRaises(a.Refused):self.verify() ++ def test_http_issuer_refused(self): ++ self.admission['issuer']='http://issuer.example' ++ with self.assertRaises(a.Refused):self.verify() ++ def test_changed_trust_refused(self): ++ self.admission['trust_sha256']='0'*64 ++ with self.assertRaises(a.Refused):self.verify() ++ def test_config_drift_refused(self): ++ self.payload['files']['/etc/keycape/config.yaml']=base64.b64encode(b'changed').decode() ++ with self.assertRaises(a.Refused):self.verify() ++ ++class BoundaryTests(unittest.TestCase): ++ def test_remote_docker_refused_before_info(self): ++ session={'run_id':'a'*32,'containers':['nk49-kc-'+('a'*32)+'-worker'],'worker':'nk49-kc-'+('a'*32)+'-worker','issuer':'nk49-kc-'+('a'*32)+'-worker'} ++ with patch.object(a,'command',return_value=b'[{"Endpoints":{"docker":{"Host":"tcp://remote:2375"}}}]') as command: ++ with self.assertRaises(a.Refused):a.inspect_session(session) ++ self.assertEqual(command.call_count,1) ++ def test_failed_verifier_cleanup_is_attempted(self): ++ receipt={};admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} ++ with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',side_effect=a.Refused),patch.object(a,'cleanup') as cleanup: ++ with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) ++ cleanup.assert_called_once();self.assertTrue(receipt['container_cleanup_verified']);self.assertEqual(receipt['status'],'failed') ++ ++ def test_cleanup_failure_overrides_success(self): ++ admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} ++ result={x:True for x in ('native_service_registration_verified','wrong_unrelated_secret_explicitly_refused','denied_scopes_refused','original_config_registry_policy_receipts_equal','original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks')} ++ result['public_signing_spki_sha256']='e'*64;receipt={} ++ with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',return_value=result),patch.object(a,'cleanup',side_effect=a.Refused): ++ with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) ++ self.assertEqual(receipt['status'],'failed');self.assertFalse(receipt['container_cleanup_verified']) ++ ++class ContainmentTests(unittest.TestCase): ++ def inspect(self,overrides=None,launch=None): ++ name='nk49-kc-'+('a'*32)+'-issuer' ++ session={'run_id':'a'*32,'containers':[name],'issuer':name,'worker':name,'images':{name:a.IMAGE}} ++ host={'NetworkMode':'none','ReadonlyRootfs':True,'Privileged':False,'CapDrop':['ALL'],'SecurityOpt':['no-new-privileges']} ++ host.update(overrides or {}) ++ def command(argv,**kwargs): ++ if argv[1:3]==['context','inspect']:return b'[{"Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]' ++ if argv[1]=='info':return b'{"ID":"74f93783-6cef-43a8-a3c4-f62586cd3bc1","Name":"bnt-lap001","OSType":"linux"}' ++ if argv[-1]=='{{.Id}}':return b'anchor-id' ++ if '"path"' in argv[-1]:return json.dumps(launch or {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}).encode() ++ return json.dumps({'host':host,'mounts':[],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode() ++ with patch.object(a,'command',side_effect=command),patch.dict('os.environ',{'DOCKER_HOST':'unix:///var/run/docker.sock'}):a.inspect_session(session) ++ def test_minimal_network_none_admitted(self):self.inspect() ++ def test_alternate_same_image_command_refused(self): ++ with self.assertRaises(a.Refused):self.inspect(launch={'entrypoint':['/keycape'],'cmd':['check-config'],'path':'/keycape','args':['check-config']}) ++ def test_cap_add_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'CapAdd':['NET_ADMIN']}) ++ def test_devices_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'Devices':[{'PathOnHost':'/dev/mem'}]}) ++ def test_device_requests_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'DeviceRequests':[{'Driver':'nvidia'}]}) ++ def test_host_namespace_refused(self): ++ for field in ('PidMode','IpcMode','UTSMode','UsernsMode','CgroupnsMode'): ++ with self.subTest(field=field),self.assertRaises(a.Refused):self.inspect({field:'host'}) ++ def test_security_override_refused(self): ++ with self.assertRaises(a.Refused):self.inspect({'SecurityOpt':['no-new-privileges','seccomp=unconfined']}) ++ ++class CleanupTests(unittest.TestCase): ++ def test_partial_cleanup_attempts_every_owned_container(self): ++ session={'run_id':'a'*32,'containers':['first','second','third']};receipt={} ++ removed=[] ++ def command(argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return ('a'*32).encode() ++ if argv[1]=='rm': ++ removed.append(argv[-1]) ++ if argv[-1]=='third':raise a.Refused() ++ return b'' ++ if argv[1:3]==['container','ls']: ++ name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$') ++ return (name+'\n').encode() if name=='third' or name not in removed else b'' ++ return b'' ++ with patch.object(a,'command',side_effect=command): ++ with self.assertRaises(a.Refused):a.cleanup(session,receipt) ++ self.assertEqual(removed,['third','second','first']) ++ self.assertEqual([r['status'] for r in receipt['container_cleanup_obligations']],['still_present','absence_verified','absence_verified']) ++ def test_ownership_mismatch_does_not_remove_and_continues(self): ++ session={'run_id':'a'*32,'containers':['first','second']};receipt={};removed=[] ++ def command(argv,**kwargs): ++ if argv[1:3]==['container','inspect']:return (('b' if argv[3]=='second' else 'a')*32).encode() ++ if argv[1]=='rm':removed.append(argv[-1]) ++ if argv[1:3]==['container','ls']: ++ name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$') ++ return (name+'\n').encode() if name not in removed else b'' ++ return b'' ++ with patch.object(a,'command',side_effect=command): ++ with self.assertRaises(a.Refused):a.cleanup(session,receipt) ++ self.assertEqual(removed,['first']) ++ self.assertEqual(receipt['container_cleanup_obligations'][0]['status'],'ownership_mismatch_not_removed') ++ def test_inspect_failure_retains_uncertain_obligation(self): ++ receipt={} ++ with patch.object(a,'command',side_effect=a.Refused): ++ with self.assertRaises(a.Refused):a.cleanup({'run_id':'a'*32,'containers':['exact-owned-name']},receipt) ++ self.assertEqual(receipt['container_cleanup_obligations'],[{'container':'exact-owned-name','status':'unverified'}]) ++ ++class JWKSTests(unittest.TestCase): ++ def execute(self,n='123',endpoint='https://issuer.example/jwks',resolved='127.0.0.1',extra=False,kid='key-1'): ++ request={'issuer':'https://issuer.example','trust':'public-certificate','n':'123','e':'65537'} ++ def enc(i):return base64.urlsafe_b64encode(int(i).to_bytes((int(i).bit_length()+7)//8,'big')).decode().rstrip('=') ++ responses=[{'issuer':'https://issuer.example','jwks_uri':endpoint},{'keys':[{'kty':'RSA','kid':kid,'use':'sig','alg':'RS256','n':enc(n),'e':enc(65537)}]}] ++ if extra:responses[1]['keys'].append(dict(responses[1]['keys'][0],kid='key-2')) ++ class Response: ++ def __init__(self,value):self.value=value ++ def __enter__(self):return self ++ def __exit__(self,*args):pass ++ def read(self,*args):return json.dumps(self.value).encode() ++ class Opener: ++ def open(self,*args,**kwargs):return Response(responses.pop(0)) ++ with patch('sys.stdin',io.StringIO(json.dumps(request))),patch('sys.stdout',io.StringIO()),patch('socket.getaddrinfo',return_value=[(2,1,6,'',(resolved,443))]),patch('ssl.create_default_context'),patch('urllib.request.build_opener',return_value=Opener()): ++ exec(compile(a.JWKS_PROBE,'','exec',optimize=2),{}) ++ def test_exact_original_public_key(self):self.execute() ++ def test_extra_signing_key_refused_under_optimization(self): ++ with self.assertRaises(ValueError):self.execute(extra=True) ++ def test_wrong_kid_refused_under_optimization(self): ++ with self.assertRaises(ValueError):self.execute(kid='replacement') ++ def test_wrong_live_signing_key_refused(self): ++ with self.assertRaises(ValueError):self.execute(n='124') ++ def test_cross_origin_jwks_refused(self): ++ with self.assertRaises(ValueError):self.execute(endpoint='https://production.example/jwks') ++ def test_production_dns_refused_before_fetch(self): ++ with self.assertRaises(ValueError):self.execute(resolved='192.0.2.1') ++ ++if __name__=='__main__':unittest.main() +--- /dev/null ++++ b/tests/test_isolated_keycape_session.py +@@ -0,0 +1,243 @@ ++import base64 ++import importlib.util ++import json ++import io ++import os ++import tempfile ++import signal ++import subprocess ++import sys ++import time ++from pathlib import Path ++import unittest ++from unittest.mock import patch ++ ++SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape-session.py' ++spec=importlib.util.spec_from_file_location('session',SOURCE) ++s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s) ++ ++class SessionTests(unittest.TestCase): ++ def test_tmpfs_plan_has_four_exact_original_mounts(self): ++ session=s.plan_session() ++ self.assertEqual({v['target'] for v in session['volumes'].values()},set(s.RUNROOTS)) ++ self.assertTrue(all(v['options']==s.VOLUME_OPTIONS for v in session['volumes'].values())) ++ self.assertEqual(session['images'][session['issuer']],s.adapter.IMAGE) ++ def test_non_tmpfs_driver_options_refused(self): ++ session=s.plan_session();name,spec=next(iter(session['volumes'].items())) ++ wrong=[{'Name':name,'Driver':'local','Scope':'local','Options':{'type':'none','device':'/host/data'},'Labels':{s.adapter.LABEL:session['run_id']}}] ++ with patch.object(s,'run',return_value=json.dumps(wrong).encode()): ++ with self.assertRaises(s.adapter.Refused):s.verify_volume(name,spec,session['run_id']) ++ def test_owner_secret_is_not_docker_argv_and_import_is_after_inspection(self): ++ session=s.plan_session();events=[] ++ payload={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'original').decode()},'env':{'KEYCAPE_TEST_SECRET':'fixture-sensitive'}} ++ def run(argv,**kwargs): ++ events.append(('run',argv,kwargs)) ++ if argv[-1]=='{{.Id}}':return b'anchor' ++ return b'' ++ with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=lambda *args,**kwargs:events.append(('inspect',args,kwargs))): ++ s.provision(session,payload,{'/recovery-tls/trust.pem':b'original-trust'},'https://issuer.example') ++ joined=' '.join(' '.join(e[1]) for e in events if e[0]=='run') ++ self.assertNotIn('fixture-sensitive',joined) ++ private_index=next(i for i,e in enumerate(events) if e[0]=='run' and s.IMPORT in e[1]) ++ inspect_index=next(i for i,e in enumerate(events) if e[0]=='inspect') ++ self.assertLess(inspect_index,private_index) ++ issuer_call=next(e for e in events if e[0]=='run' and e[1][-1]==s.adapter.IMAGE) ++ self.assertEqual(issuer_call[2]['env']['KEYCAPE_TEST_SECRET'],'fixture-sensitive') ++ self.assertIn('KEYCAPE_TEST_SECRET',issuer_call[1]) ++ self.assertNotIn('--entrypoint',issuer_call[1]) ++ def test_non_original_https_port_refused_before_any_mutation(self): ++ with patch.object(s,'run') as run: ++ with self.assertRaises(s.adapter.Refused):s.provision(s.plan_session(),{'files':{},'env':{}},{},'http://issuer.example') ++ run.assert_not_called() ++ def test_container_failure_still_attempts_each_volume_cleanup(self): ++ session=s.plan_session();receipt={};removed=[] ++ def run(argv,**kwargs): ++ if argv[1:3]==['volume','rm']:removed.append(argv[-1]) ++ if argv[1:3]==['volume','ls']: ++ name=next(x for x in argv if x.startswith('name=^')).removeprefix('name=^').removesuffix('$') ++ return (name+'\n').encode() if name not in removed else b'' ++ return b'' ++ with patch.object(s.adapter,'cleanup',side_effect=s.adapter.Refused),patch.object(s,'verify_volume'),patch.object(s,'run',side_effect=run): ++ with self.assertRaises(s.adapter.Refused):s.cleanup_all(session,receipt) ++ self.assertEqual(set(removed),set(session['volumes'])) ++ self.assertFalse(receipt['container_cleanup_verified']) ++ self.assertEqual(len(receipt['tmpfs_volume_cleanup_obligations']),4) ++ def test_reaper_inherits_no_custody_or_service_environment(self): ++ session=s.plan_session();staging={'path':'/dev/shm/registered','run_id':session['run_id']} ++ with patch.object(s.subprocess,'Popen') as spawn,patch.object(s,'verify_execution'),patch.object(s.os,'pidfd_open',return_value=99),patch.object(s.os,'close'),patch.object(s,'register_workdir',return_value=staging),patch.object(s.select,'select',side_effect=lambda *args:([spawn.return_value.stdout],[],[])): ++ spawn.return_value.stdout.readline.return_value=b'registered\n' ++ s.start_reaper(session,staging,{'pid':123}) ++ kwargs=spawn.call_args.kwargs ++ self.assertEqual(set(kwargs['env']),{'PATH','DOCKER_HOST'}) ++ self.assertTrue(kwargs['close_fds']);self.assertTrue(kwargs['start_new_session']);self.assertEqual(kwargs['pass_fds'],(99,)) ++ ++class TargetBindingTests(unittest.TestCase): ++ def test_every_direct_docker_command_is_explicitly_bound(self): ++ with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run',return_value=__import__('subprocess').CompletedProcess([],0,b'ok')) as run: ++ self.assertEqual(s.run(['docker','info']),b'ok') ++ argv=run.call_args.args[0] ++ self.assertEqual(argv[:3],['docker','--host',s.adapter.ENDPOINT]) ++ self.assertEqual(run.call_args.kwargs['env']['DOCKER_HOST'],s.adapter.ENDPOINT) ++ def test_nondefault_unix_socket_is_refused_before_any_docker_call(self): ++ with patch.dict('os.environ',{'DOCKER_HOST':'unix:///tmp/other-daemon.sock'},clear=True),patch.object(s.adapter.subprocess,'run') as run: ++ with self.assertRaises(s.adapter.Refused):s.run(['docker','info']) ++ run.assert_not_called() ++ def test_conflicting_context_is_refused(self): ++ with patch.dict('os.environ',{'DOCKER_CONTEXT':'production'},clear=True),patch.object(s.adapter.subprocess,'run') as run: ++ with self.assertRaises(s.adapter.Refused):s.run(['docker','info']) ++ run.assert_not_called() ++ def test_conflicting_per_call_host_is_refused(self): ++ with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run') as run: ++ with self.assertRaises(s.adapter.Refused):s.run(['docker','info'],env={'DOCKER_HOST':'unix:///tmp/other.sock'}) ++ run.assert_not_called() ++ def test_reused_preflight_uses_same_bound_command(self): ++ self.assertIs(s.local.command,s.adapter.command) ++ def test_probe_is_registered_before_session_creation(self): ++ session=s.plan_session() ++ self.assertEqual(session['public_image_probe'],'nk49-kc-'+session['run_id']+'-image-probe') ++ self.assertEqual(session['docker_endpoint'],s.adapter.ENDPOINT) ++ ++class PlaintextLifecycleTests(unittest.TestCase): ++ def setUp(self): ++ self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) ++ self.root=Path(self.tmp.name) ++ self.override=patch.object(s,'STAGING_ROOT',self.root);self.override.start();self.addCleanup(self.override.stop) ++ self.run_id='a'*32 ++ self.work=self.root/('nk49-keycape-'+self.run_id+'-abcdefgh');self.work.mkdir(mode=0o700) ++ self.staging=s.register_workdir(self.work,self.run_id) ++ def test_exact_plaintext_directory_removed_and_recorded(self): ++ (self.work/'archive.tar').write_bytes(b'synthetic-private-archive') ++ restored=self.work/'restored';restored.mkdir(mode=0o700);(restored/'fixture').write_bytes(b'synthetic') ++ receipt={};s.cleanup_workdir(self.staging,receipt) ++ self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified']) ++ self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'absence_verified') ++ def test_docker_failure_does_not_skip_plaintext_cleanup(self): ++ (self.work/'archive.tar').write_bytes(b'synthetic') ++ receipt={} ++ with patch.object(s,'cleanup_all',side_effect=s.adapter.Refused): ++ with self.assertRaises(s.adapter.Refused):s.recover_obligations({},self.staging,receipt) ++ self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified']) ++ def test_replacement_inode_is_not_removed(self): ++ # Preserve original inode while replacing its registered pathname. ++ old=self.root/'retained-original';self.work.rename(old);self.work.mkdir(mode=0o700) ++ (self.work/'replacement').write_bytes(b'not-owned-by-this-obligation');receipt={} ++ with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,receipt) ++ self.assertTrue((self.work/'replacement').exists());self.assertTrue(old.exists()) ++ self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'identity_mismatch_not_removed') ++ def test_symlink_root_is_not_followed(self): ++ self.work.rmdir();outside=self.root/'outside';outside.mkdir();(outside/'kept').write_bytes(b'keep') ++ self.work.symlink_to(outside,target_is_directory=True) ++ with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{}) ++ self.assertTrue((outside/'kept').exists()) ++ def test_descendant_symlink_is_not_followed(self): ++ outside=self.root/'kept';outside.write_bytes(b'keep');(self.work/'link').symlink_to(outside) ++ with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{}) ++ self.assertEqual(outside.read_bytes(),b'keep') ++ def test_wrong_owner_identity_is_not_removed(self): ++ changed={**self.staging,'owner_uid':os.getuid()+1} ++ with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(changed,{}) ++ self.assertTrue(self.work.exists()) ++ def test_unregistered_path_refused(self): ++ with self.assertRaises(s.adapter.Refused):s.cleanup_workdir({**self.staging,'path':str(self.root)}, {}) ++ ++@unittest.skipUnless(hasattr(os,'pidfd_open') and hasattr(signal,'pidfd_send_signal'),'Linux pidfd required') ++class ExecutionDeadlineTests(unittest.TestCase): ++ def start_synthetic_owner(self,root): ++ late=str(root/'late-creation') ++ code="""import importlib.util,json,subprocess,sys,time ++spec=importlib.util.spec_from_file_location('session',sys.argv[1]);s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s) ++execution=s.isolate_execution() ++child=subprocess.Popen([sys.executable,'-c',"import pathlib,sys,time;time.sleep(2);pathlib.Path(sys.argv[1]).write_text('late')",sys.argv[2]]) ++print(json.dumps({'execution':execution,'child_pid':child.pid}),flush=True) ++time.sleep(20) ++""" ++ parent=subprocess.Popen([sys.executable,'-c',code,str(SOURCE),late],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL) ++ message=json.loads(parent.stdout.readline());parent.stdout.close() ++ def finish(): ++ if parent.poll() is None:parent.kill() ++ parent.wait(timeout=5) ++ try: ++ before=s.process_facts(message['child_pid']) ++ if before is None or before['sid']!=message['execution']['sid'] or before['pgid']!=message['execution']['pgid']:return ++ fd=os.pidfd_open(message['child_pid']) ++ try: ++ after=s.process_facts(message['child_pid']) ++ if after is not None and all(after[k]==before[k] for k in ('pid','sid','pgid','start_ticks','uid')): ++ signal.pidfd_send_signal(fd,signal.SIGKILL) ++ except ProcessLookupError:pass ++ finally:os.close(fd) ++ except ProcessLookupError:pass ++ self.addCleanup(finish) ++ return parent,message['execution'],Path(late) ++ def test_deadline_quiesces_only_fresh_owner_group_before_cleanup(self): ++ with tempfile.TemporaryDirectory() as directory: ++ parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid) ++ try: ++ receipt={};s.quiesce_execution(execution,fd,receipt) ++ self.assertTrue(receipt['execution_quiescence_verified']) ++ self.assertFalse(s.group_members(execution));self.assertFalse(late.exists()) ++ finally:os.close(fd) ++ def test_hard_parent_loss_stops_surviving_marker_creator(self): ++ with tempfile.TemporaryDirectory() as directory: ++ parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid) ++ try: ++ signal.pidfd_send_signal(fd,signal.SIGKILL) ++ poll=__import__('select').poll();poll.register(fd,__import__('select').POLLIN);self.assertTrue(poll.poll(2000)) ++ receipt={};s.quiesce_execution(execution,fd,receipt) ++ self.assertTrue(receipt['execution_quiescence_verified']);self.assertFalse(late.exists()) ++ self.assertFalse(s.group_members(execution)) ++ finally:os.close(fd) ++ def test_expired_execution_cannot_start_any_docker_command(self): ++ binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()-1,'staging':'/tmp/not-created'} ++ with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run') as run: ++ with self.assertRaises(s.adapter.Refused):s.run(['docker','create','fixed-image']) ++ run.assert_not_called() ++ def test_reused_or_foreign_session_refuses_signals(self): ++ binding={'pid':123,'sid':123,'pgid':123,'start_ticks':10,'uid':os.getuid()} ++ wrong={**binding,'sid':456,'state':'S'} ++ with patch.object(s,'process_facts',return_value=wrong),patch.object(s.signal,'pidfd_send_signal') as send: ++ with self.assertRaises(s.adapter.Refused):s.quiesce_execution(binding,99,{}) ++ send.assert_not_called() ++ def test_inflight_uncertainty_survives_plaintext_directory_removal(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path=Path(directory)/'owned-stage';path.mkdir();marker=Path(str(path)+'-docker-inflight');marker.touch() ++ staging={'path':str(path)};self.assertTrue(s.daemon_uncertain(staging));path.rmdir() ++ self.assertTrue(s.daemon_uncertain(staging)) ++ ++class ReaperOrderingTests(unittest.TestCase): ++ def exercise(self,quiescence_fails=False): ++ events=[];records=[] ++ def quiet(*args): ++ events.append('quiesce') ++ if quiescence_fails:raise s.adapter.Refused() ++ args[-1]['execution_quiescence_verified']=True ++ def recover(*args):events.append('cleanup') ++ with patch.object(s,'verify_execution'),patch.object(s,'pidfd_dead',return_value=False),patch.object(s.select,'poll') as poll,patch.object(s,'quiesce_execution',side_effect=quiet),patch.object(s,'daemon_uncertain',return_value=True),patch.object(s,'recover_obligations',side_effect=recover),patch.object(s.os,'close'),patch.object(s.os,'open',return_value=99),patch.object(s.os,'fdopen'),patch.object(s.json,'dump',side_effect=lambda r,*args:records.append(dict(r))),patch('sys.stdout',io.StringIO()): ++ poll.return_value.poll.return_value=[] ++ s.reaper_main({}, {'path':'/dev/shm/exact-staging'}, {'deadline':time.monotonic()-1},77) ++ return events,records[0] ++ def test_daemon_uncertainty_survives_verified_resource_readback(self): ++ events,receipt=self.exercise();self.assertEqual(events,['quiesce','cleanup']) ++ self.assertTrue(receipt['execution_quiescence_verified']);self.assertTrue(receipt['in_flight_daemon_request_uncertain']) ++ self.assertEqual(receipt['status'],'bounded_cleanup_uncertain') ++ def test_unverified_quiescence_prevents_any_cleanup(self): ++ events,receipt=self.exercise(True);self.assertEqual(events,['quiesce']) ++ self.assertFalse(receipt['execution_quiescence_verified']);self.assertEqual(receipt['status'],'bounded_cleanup_uncertain') ++ def test_checkpoint_rewrites_one_valid_json_document(self): ++ with tempfile.TemporaryFile() as stream: ++ s.persist_receipt(stream.fileno(),{'status':'registered','long':'x'*100}) ++ s.persist_receipt(stream.fileno(),{'status':'failed'}) ++ stream.seek(0);self.assertEqual(json.load(stream),{'status':'failed'}) ++ def test_docker_timeout_retains_independent_uncertainty_marker(self): ++ with tempfile.TemporaryDirectory() as directory: ++ staging=str(Path(directory)/'stage');Path(staging).mkdir() ++ binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()+10,'staging':staging} ++ def timeout(*args,**kwargs): ++ self.assertTrue(Path(staging+'-docker-inflight').exists()) ++ raise subprocess.TimeoutExpired('synthetic Docker',1) ++ with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run',side_effect=timeout): ++ with self.assertRaises(subprocess.TimeoutExpired):s.run(['docker','create','fixed-image']) ++ self.assertFalse(Path(staging+'-docker-inflight').exists()) ++ self.assertTrue(Path(staging+'-docker-uncertain').exists());self.assertTrue(s.daemon_uncertain({'path':staging})) ++ ++if __name__=='__main__':unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/sso-mfa/bootstrap/sops-private.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/sso-mfa/bootstrap/sops-private.py new file mode 100644 index 00000000..21c20b2e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/sso-mfa/bootstrap/sops-private.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Age-only SOPS recovery to a new private file; no plaintext process output.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import uuid + +ROOT = Path(__file__).resolve().parents[2] +RECIPIENT = re.compile(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}\Z') + + +class Refused(Exception): + pass + + +def public_recipient(): + lines = (ROOT / 'keys/age.pub').read_text().splitlines() + values = [line.strip() for line in lines if line.strip() and not line.startswith('#')] + if len(values) != 1 or not RECIPIENT.fullmatch(values[0]): + raise Refused('registered public recipient is invalid') + result = subprocess.run(['age', '-r', values[0]], input=b'', stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=10) + if result.returncode: + raise Refused('registered public recipient is invalid') + return values[0] + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Refused('duplicate encrypted document key') + result[key] = value + return result + + +def parse_document(raw, suffix): + if suffix == '.json': + data = json.loads(raw, object_pairs_hook=unique_object) + kind = 'json' + elif suffix in {'.yaml', '.yml'}: + import yaml + class UniqueLoader(yaml.SafeLoader): + def construct_mapping(self, node, deep=False): + self.flatten_mapping(node) + return unique_object((self.construct_object(key, deep=deep), + self.construct_object(value, deep=deep)) + for key, value in node.value) + try: + data = yaml.load(raw, Loader=UniqueLoader) + except yaml.YAMLError: + raise Refused('invalid encrypted YAML input') from None + kind = 'yaml' + else: + raise Refused('age recovery supports SOPS JSON/YAML input') + return data, kind + + +def age_document(source, recipient, *, raw=None): + # Inspect ciphertext metadata before giving a credential to any subprocess. + # This repository's admitted SOPS profile is age-only, not arbitrary cloud + # KMS, key services, SSH identities or executable age plugins. + if raw is None: + with source.open("rb") as encrypted: + raw = encrypted.read(8 * 1024 * 1024 + 1) + if len(raw) > 8 * 1024 * 1024: + raise Refused('encrypted input is too large') + data, kind = parse_document(raw, source.suffix) + metadata = data.get('sops') if isinstance(data, dict) else None + if not isinstance(metadata, dict): + raise Refused('SOPS metadata is missing') + known = {'age', 'kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp', + 'key_groups', 'shamir_threshold', 'lastmodified', 'mac', 'version', + 'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', + 'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex', + 'mac_only_encrypted'} + if set(metadata) - known or metadata.get('key_groups'): + raise Refused('unsupported SOPS custody profile') + if any(metadata.get(key) for key in ('kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp')): + raise Refused('recovery requires age-only metadata') + recipients = metadata.get('age') + if not isinstance(recipients, list) or not recipients: + raise Refused('age recipient metadata is missing') + if any(not isinstance(row, dict) or not RECIPIENT.fullmatch(str(row.get('recipient', ''))) + for row in recipients): + raise Refused('unsupported age recipient') + if recipient not in [row['recipient'] for row in recipients]: + raise Refused('document does not include the registered recipient') + return raw, kind + + +def private_key_fd(): + name = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FILE', '') + if not name: + raise Refused('explicit temporary custody key required') + path = Path(os.path.abspath(name)) + if any(p.is_symlink() for p in (path, *path.parents)): + raise Refused('custody key path must not contain symlinks') + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 16384: + os.close(fd) + raise Refused('custody key must be an owned private regular file') + return fd + + +def decrypt(recipient): + source_name = os.environ.get('NK_SOPS_FILE', '') + output_name = os.environ.get('NK_SOPS_OUTPUT', '') + if not source_name or not output_name: + raise Refused('supply FILE and OUTPUT; plaintext stdout is disabled') + source = Path(source_name).resolve(strict=True) + output = Path(output_name).absolute() + parent = output.parent.resolve(strict=True) + if any((p / '.git').is_file() or (p / '.git/HEAD').is_file() + or ((p / 'HEAD').is_file() and (p / 'objects').is_dir() and (p / 'refs').is_dir()) + for p in [parent, *parent.parents]): + raise Refused('plaintext output must be outside Git worktrees') + raw, kind = age_document(source, recipient) + binary = shutil.which('sops') + if binary is None: + raise Refused('sops executable is unavailable') + directory = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + temporary = '.netkingdom-sops-' + uuid.uuid4().hex + keyfd = None + try: + info = os.fstat(directory) + if info.st_uid != os.getuid() or info.st_mode & 0o077: + raise Refused('OUTPUT requires an owned private parent directory') + try: + os.stat(output.name, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise Refused('OUTPUT already exists') + keyfd = private_key_fd() + derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + pass_fds=(keyfd,), timeout=10) + if derived.returncode or derived.stdout.strip() != recipient.encode(): + raise Refused('custody key does not match the registered recipient') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=directory) + with os.fdopen(fd, 'wb') as plain, tempfile.TemporaryDirectory(prefix='netkingdom-sops-home-') as isolated: + # HOME/XDG isolation matters: SOPS loads ambient keys in addition to + # an explicit key file. No inline keys, SSH keys, key commands, + # proxies or remote keyservice configuration are inherited. + env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': isolated, + 'XDG_CONFIG_HOME': isolated, 'GNUPGHOME': isolated, + 'SOPS_AGE_KEY_FILE': f'/proc/self/fd/{keyfd}', + 'AWS_EC2_METADATA_DISABLED': 'true'} + result = subprocess.run([binary, '--decrypt', '--input-type', kind, + '--output-type', kind, '/dev/stdin'], + input=raw, stdout=plain, stderr=subprocess.DEVNULL, + env=env, cwd=isolated, pass_fds=(keyfd,), timeout=30) + if result.returncode: + raise Refused('SOPS decryption failed; no plaintext published') + plain.flush() + os.fsync(plain.fileno()) + # Atomic no-overwrite publication within the private directory. + os.link(temporary, output.name, src_dir_fd=directory, dst_dir_fd=directory, + follow_symlinks=False) + os.unlink(temporary, dir_fd=directory) + os.fsync(directory) + finally: + if keyfd is not None: + os.close(keyfd) + try: + os.unlink(temporary, dir_fd=directory) + except FileNotFoundError: + pass + os.close(directory) + print('Decrypted to the requested private file. Remove it after the approved operation.') + + +def interrupted(_signum, _frame): + raise Refused('operation interrupted') + + +def main(): + for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): + signal.signal(sig, interrupted) + try: + if len(sys.argv) != 2 or sys.argv[1] not in {'setup', 'decrypt'}: + raise Refused('unsupported custody operation') + recipient = public_recipient() + if sys.argv[1] == 'setup': + print('Registered public recipient validated. No private key was read, copied or generated.') + else: + decrypt(recipient) + return 0 + except Refused as exc: + print('ERROR: ' + str(exc), file=sys.stderr) + except (OSError, ValueError, TypeError, ImportError, subprocess.SubprocessError): + # Parser, filesystem and tool errors can include user data. Never echo. + print('ERROR: custody operation failed; input and tool diagnostics suppressed.', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tests/test_isolated_keycape_recovery.py b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tests/test_isolated_keycape_recovery.py new file mode 100644 index 00000000..93941201 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r4/test-root/tests/test_isolated_keycape_recovery.py @@ -0,0 +1,223 @@ +import base64 +import hashlib +import importlib.util +import io +import json +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape.py' +spec=importlib.util.spec_from_file_location('adapter',SOURCE) +a=importlib.util.module_from_spec(spec);spec.loader.exec_module(a) + +class ArchiveTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) + self.root=Path(self.tmp.name) + config={'data':{k:base64.b64encode(v).decode() for k,v in {'config.yaml':b'issuer: https://issuer.example','key.pem':b'fixture-key'}.items()}} + self.values={'resources/sso/secret/keycape-config.json':json.dumps(config).encode(), + 'resources/sso/deployment/keycape.json':json.dumps({'spec':{'template':{'spec':{'containers':[{'name':'keycape','image':a.IMAGE,'env':[ + {'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'}, + {'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'}, + {'name':'KEYCAPE_ORIGINAL_SECRET','valueFrom':{'secretKeyRef':{'name':'client','key':'secret'}}}]}]}}}}).encode(), + 'resources/sso/secret/client.json':json.dumps({'data':{'secret':base64.b64encode(b'fixture-private').decode()}}).encode(), + 'data/keycape/policy.json':b'{}','data/keycape/receipts/one.json':b'{"original":true}'} + for name,value in self.values.items(): + p=self.root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(value);p.chmod(0o600) + self.manifest={'profile':{'components':[{'namespace':'sso','deployment':'keycape','secret_refs':['keycape-config','client'],'configmap_refs':[]}]}, + 'files':[{'path':k,'bytes':len(v),'sha256':hashlib.sha256(v).hexdigest()} for k,v in self.values.items()]} + def payload(self): + digest=hashlib.sha256(json.dumps(self.manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest() + return a.private_payload(self.root,self.manifest,digest) + def test_original_state_and_secret_delivery_retained(self): + p=self.payload();self.assertEqual(p['env']['KEYCAPE_ORIGINAL_SECRET'],'fixture-private') + self.assertEqual(base64.b64decode(p['files']['/var/lib/keycape-policy/receipts/one.json']),b'{"original":true}') + def test_archive_digest_refused(self): + with self.assertRaises(a.Refused):a.private_payload(self.root,self.manifest,'0'*64) + def test_changed_file_refused(self): + (self.root/'data/keycape/policy.json').write_text('{"changed":true}') + with self.assertRaises(a.Refused):self.payload() + def test_symlink_refused(self): + p=self.root/'data/keycape/policy.json';p.unlink();p.symlink_to('/etc/hostname') + with self.assertRaises(Exception):self.payload() + def test_duplicate_manifest_refused(self): + self.manifest['files'].append(self.manifest['files'][0]) + with self.assertRaises(a.Refused):self.payload() + def test_image_mismatch_refused(self): + d=json.loads(self.values['resources/sso/deployment/keycape.json']);d['spec']['template']['spec']['containers'][0]['image']='keycape:latest' + raw=json.dumps(d).encode();p=self.root/'resources/sso/deployment/keycape.json';p.write_bytes(raw) + for row in self.manifest['files']: + if row['path'].endswith('/deployment/keycape.json'):row.update(bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest()) + with self.assertRaises(a.Refused):self.payload() + +class NativeTests(unittest.TestCase): + def setUp(self): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + self.key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()) + self.cfg=b'issuer: https://issuer.example\nclients:\n- clientId: existing\n clientType: confidential\n secretRef: env:KEYCAPE_ORIGINAL_SECRET\n grantTypes: [client_credentials]\n' + self.files={'/etc/keycape/config.yaml':self.cfg,'/etc/keycape/key.pem':self.key,'/var/lib/keycape-policy/policy.json':b'{}'} + self.payload={'env':{'KEYCAPE_ORIGINAL_SECRET':'fixture-private'},'files':{k:base64.b64encode(v).decode() for k,v in self.files.items()}} + self.admission={'issuer':'https://issuer.example','verifier_sha256':hashlib.sha256(b'binary').hexdigest(), + 'trust_path':'/trust.pem','trust_sha256':hashlib.sha256(b'original-trust').hexdigest(), + 'consumer':{'client_id':'existing','secret_env':'KEYCAPE_ORIGINAL_SECRET','scope':'read','audience':'consumer', + 'subject':'service:existing','tenant':'tenant:platform','roles':'reader','deny_scope':'write'}} + self.session={'issuer':'isolated','volumes':{x:{'target':x} for x in ('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')}} + self.output=''.join('PASS '+x+'\n' for x in ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings','principal_type is service','sub matches the registration','tenant matches the registration','roles present','granted scope carries no excess grant','predecessor secret refused','excess scope refused: write']) + def file(self,container,path): + return {**self.files,'/etc/hosts':b'127.0.0.1 issuer.example\n','/keycape':b'binary','/trust.pem':b'original-trust'}[path] + def command(self,argv,**kwargs): + if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem']).encode() + output=io.BytesIO() + with tarfile.open(fileobj=output,mode='w') as t: + m=tarfile.TarInfo('policy.json');m.size=2;t.addfile(m,io.BytesIO(b'{}')) + return output.getvalue() + def verify(self,code=0,output=None): + with patch.object(a,'check_jwks'),patch.object(a,'runtime_file',return_value=b'original-trust'),patch.object(a,'runtime_files',side_effect=lambda session,paths=(),tree=False:({k:v for k,v in self.files.items() if k.startswith('/var/lib/keycape-policy/')} if tree else dict(self.files))),patch.object(a,'container_file',side_effect=self.file),patch.object(a,'command',side_effect=self.command),patch.object(a.subprocess,'run',return_value=subprocess.CompletedProcess([],code,b'',(self.output if output is None else output).encode())) as run: + result=a.run_verifier(self.session,self.admission,self.payload) + argv=run.call_args.args[0] + self.assertNotIn('fixture-private',' '.join(argv)) + self.assertIn('-previous-secret-env',argv) + return result + def test_original_registration_and_unrelated_secret(self): + r=self.verify();self.assertTrue(r['wrong_unrelated_secret_explicitly_refused']);self.assertNotIn('fixture-private',json.dumps(r)) + def test_transport_failure_never_counts_as_wrong_secret(self): + with self.assertRaises(a.Refused):self.verify(1,'FAIL discovery and endpoint origin: TLS failure\n') + def test_missing_explicit_rejection_never_counts(self): + with self.assertRaises(a.Refused):self.verify(0,self.output.replace('PASS predecessor secret refused\n','')) + def test_fail_line_refused_even_zero_exit(self): + with self.assertRaises(a.Refused):self.verify(0,self.output+'FAIL transport\n') + def test_security_environment_override_refused(self): + original=self.command + def altered(argv,**kwargs): + if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem','SSL_CERT_DIR=/unreviewed']).encode() + return original(argv,**kwargs) + with patch.object(self,'command',side_effect=altered): + with self.assertRaises(a.Refused):self.verify() + def test_http_issuer_refused(self): + self.admission['issuer']='http://issuer.example' + with self.assertRaises(a.Refused):self.verify() + def test_changed_trust_refused(self): + self.admission['trust_sha256']='0'*64 + with self.assertRaises(a.Refused):self.verify() + def test_config_drift_refused(self): + self.payload['files']['/etc/keycape/config.yaml']=base64.b64encode(b'changed').decode() + with self.assertRaises(a.Refused):self.verify() + +class BoundaryTests(unittest.TestCase): + def test_remote_docker_refused_before_info(self): + session={'run_id':'a'*32,'containers':['nk49-kc-'+('a'*32)+'-worker'],'worker':'nk49-kc-'+('a'*32)+'-worker','issuer':'nk49-kc-'+('a'*32)+'-worker'} + with patch.object(a,'command',return_value=b'[{"Endpoints":{"docker":{"Host":"tcp://remote:2375"}}}]') as command: + with self.assertRaises(a.Refused):a.inspect_session(session) + self.assertEqual(command.call_count,1) + def test_failed_verifier_cleanup_is_attempted(self): + receipt={};admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} + with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',side_effect=a.Refused),patch.object(a,'cleanup') as cleanup: + with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) + cleanup.assert_called_once();self.assertTrue(receipt['container_cleanup_verified']);self.assertEqual(receipt['status'],'failed') + + def test_cleanup_failure_overrides_success(self): + admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}} + result={x:True for x in ('native_service_registration_verified','wrong_unrelated_secret_explicitly_refused','denied_scopes_refused','original_config_registry_policy_receipts_equal','original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks')} + result['public_signing_spki_sha256']='e'*64;receipt={} + with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',return_value=result),patch.object(a,'cleanup',side_effect=a.Refused): + with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt) + self.assertEqual(receipt['status'],'failed');self.assertFalse(receipt['container_cleanup_verified']) + +class ContainmentTests(unittest.TestCase): + def inspect(self,overrides=None,launch=None): + name='nk49-kc-'+('a'*32)+'-issuer' + session={'run_id':'a'*32,'containers':[name],'issuer':name,'worker':name,'images':{name:a.IMAGE}} + host={'NetworkMode':'none','ReadonlyRootfs':True,'Privileged':False,'CapDrop':['ALL'],'SecurityOpt':['no-new-privileges']} + host.update(overrides or {}) + def command(argv,**kwargs): + if argv[1:3]==['context','inspect']:return b'[{"Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]' + if argv[1]=='info':return b'{"ID":"74f93783-6cef-43a8-a3c4-f62586cd3bc1","Name":"bnt-lap001","OSType":"linux"}' + if argv[-1]=='{{.Id}}':return b'anchor-id' + if '"path"' in argv[-1]:return json.dumps(launch or {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}).encode() + return json.dumps({'host':host,'mounts':[],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode() + with patch.object(a,'command',side_effect=command),patch.dict('os.environ',{'DOCKER_HOST':'unix:///var/run/docker.sock'}):a.inspect_session(session) + def test_minimal_network_none_admitted(self):self.inspect() + def test_alternate_same_image_command_refused(self): + with self.assertRaises(a.Refused):self.inspect(launch={'entrypoint':['/keycape'],'cmd':['check-config'],'path':'/keycape','args':['check-config']}) + def test_cap_add_refused(self): + with self.assertRaises(a.Refused):self.inspect({'CapAdd':['NET_ADMIN']}) + def test_devices_refused(self): + with self.assertRaises(a.Refused):self.inspect({'Devices':[{'PathOnHost':'/dev/mem'}]}) + def test_device_requests_refused(self): + with self.assertRaises(a.Refused):self.inspect({'DeviceRequests':[{'Driver':'nvidia'}]}) + def test_host_namespace_refused(self): + for field in ('PidMode','IpcMode','UTSMode','UsernsMode','CgroupnsMode'): + with self.subTest(field=field),self.assertRaises(a.Refused):self.inspect({field:'host'}) + def test_security_override_refused(self): + with self.assertRaises(a.Refused):self.inspect({'SecurityOpt':['no-new-privileges','seccomp=unconfined']}) + +class CleanupTests(unittest.TestCase): + def test_partial_cleanup_attempts_every_owned_container(self): + session={'run_id':'a'*32,'containers':['first','second','third']};receipt={} + removed=[] + def command(argv,**kwargs): + if argv[1:3]==['container','inspect']:return ('a'*32).encode() + if argv[1]=='rm': + removed.append(argv[-1]) + if argv[-1]=='third':raise a.Refused() + return b'' + if argv[1:3]==['container','ls']: + name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$') + return (name+'\n').encode() if name=='third' or name not in removed else b'' + return b'' + with patch.object(a,'command',side_effect=command): + with self.assertRaises(a.Refused):a.cleanup(session,receipt) + self.assertEqual(removed,['third','second','first']) + self.assertEqual([r['status'] for r in receipt['container_cleanup_obligations']],['still_present','absence_verified','absence_verified']) + def test_ownership_mismatch_does_not_remove_and_continues(self): + session={'run_id':'a'*32,'containers':['first','second']};receipt={};removed=[] + def command(argv,**kwargs): + if argv[1:3]==['container','inspect']:return (('b' if argv[3]=='second' else 'a')*32).encode() + if argv[1]=='rm':removed.append(argv[-1]) + if argv[1:3]==['container','ls']: + name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$') + return (name+'\n').encode() if name not in removed else b'' + return b'' + with patch.object(a,'command',side_effect=command): + with self.assertRaises(a.Refused):a.cleanup(session,receipt) + self.assertEqual(removed,['first']) + self.assertEqual(receipt['container_cleanup_obligations'][0]['status'],'ownership_mismatch_not_removed') + def test_inspect_failure_retains_uncertain_obligation(self): + receipt={} + with patch.object(a,'command',side_effect=a.Refused): + with self.assertRaises(a.Refused):a.cleanup({'run_id':'a'*32,'containers':['exact-owned-name']},receipt) + self.assertEqual(receipt['container_cleanup_obligations'],[{'container':'exact-owned-name','status':'unverified'}]) + +class JWKSTests(unittest.TestCase): + def execute(self,n='123',endpoint='https://issuer.example/jwks',resolved='127.0.0.1',extra=False,kid='key-1'): + request={'issuer':'https://issuer.example','trust':'public-certificate','n':'123','e':'65537'} + def enc(i):return base64.urlsafe_b64encode(int(i).to_bytes((int(i).bit_length()+7)//8,'big')).decode().rstrip('=') + responses=[{'issuer':'https://issuer.example','jwks_uri':endpoint},{'keys':[{'kty':'RSA','kid':kid,'use':'sig','alg':'RS256','n':enc(n),'e':enc(65537)}]}] + if extra:responses[1]['keys'].append(dict(responses[1]['keys'][0],kid='key-2')) + class Response: + def __init__(self,value):self.value=value + def __enter__(self):return self + def __exit__(self,*args):pass + def read(self,*args):return json.dumps(self.value).encode() + class Opener: + def open(self,*args,**kwargs):return Response(responses.pop(0)) + with patch('sys.stdin',io.StringIO(json.dumps(request))),patch('sys.stdout',io.StringIO()),patch('socket.getaddrinfo',return_value=[(2,1,6,'',(resolved,443))]),patch('ssl.create_default_context'),patch('urllib.request.build_opener',return_value=Opener()): + exec(compile(a.JWKS_PROBE,'','exec',optimize=2),{}) + def test_exact_original_public_key(self):self.execute() + def test_extra_signing_key_refused_under_optimization(self): + with self.assertRaises(ValueError):self.execute(extra=True) + def test_wrong_kid_refused_under_optimization(self): + with self.assertRaises(ValueError):self.execute(kid='replacement') + def test_wrong_live_signing_key_refused(self): + with self.assertRaises(ValueError):self.execute(n='124') + def test_cross_origin_jwks_refused(self): + with self.assertRaises(ValueError):self.execute(endpoint='https://production.example/jwks') + def test_production_dns_refused_before_fetch(self): + with self.assertRaises(ValueError):self.execute(resolved='192.0.2.1') + +if __name__=='__main__':unittest.main()