docs: preserve reviewed NetKingdom session artifacts
This commit is contained in:
parent
2565caacd9
commit
bdf71998e9
44 changed files with 11287 additions and 0 deletions
|
|
@ -0,0 +1 @@
|
|||
100644 ea9ccd0b2bd2cc1ec89723b35c44c53f0d79ba98 0 .custodian-brief.md
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# 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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"path": "workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md",
|
||||
"before_sha256": "8b6e12a53b0d5e88d71bb1d3e11bedd0ee3a66b30a9ea2b86e6ac1ff38539baf",
|
||||
"after_sha256": "29c10e410449ebf1f4b0fcc804177895cb91b47c35adb33fbe9246265ad57cbc"
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# 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.
|
||||
|
|
@ -0,0 +1 @@
|
|||
100644 ea9ccd0b2bd2cc1ec89723b35c44c53f0d79ba98 0 .custodian-brief.md
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# 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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"exit_code": 0}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WARN: git commit failed for writeback: Generated-only commits rejected: staged
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,'<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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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())
|
||||
|
|
@ -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,'<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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 5<dump.stat().st_size<SIZE:raise Refused()
|
||||
with dump.open('rb') as source:
|
||||
if source.read(5)!=b'PGDMP':raise Refused()
|
||||
r['offhost_ciphertext_decryption_verified']=True
|
||||
try:
|
||||
r.update(restore(dump,container));r['database_restore_and_readback_passed']=True
|
||||
finally:
|
||||
removed=subprocess.run(['docker','rm','--force','--volumes',container],stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,timeout=60)
|
||||
# A removal error is a failed ceremony even if the restore passed.
|
||||
if removed.returncode:raise Refused()
|
||||
absent=subprocess.run(['docker','container','inspect',container],stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,timeout=15)
|
||||
# Confirm the daemon remains reachable, so inspect failure means absent.
|
||||
run(['docker','info','--format','{{.ServerVersion}}'])
|
||||
if absent.returncode==0:raise Refused()
|
||||
r['container_cleanup_verified']=True
|
||||
if work.exists():raise Refused()
|
||||
r['temporary_plaintext_cleanup_verified']=True;r['status']='isolated_database_drill_passed'
|
||||
except Exception:
|
||||
r['error']='database_drill_failed_diagnostics_suppressed'
|
||||
finally:
|
||||
if keyfd is not None:os.close(keyfd)
|
||||
if work is not None and not work.exists():r['temporary_plaintext_cleanup_verified']=True
|
||||
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']=='isolated_database_drill_passed':
|
||||
print('OK: off-host database decrypted, isolated restore/readback passed, disposable data removed. Full identity recovery remains open.')
|
||||
return 0
|
||||
print('ERROR: database drill failed; private diagnostics suppressed. Retain the safe receipt for review.',file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,custody.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
{
|
||||
"schema": "netkingdom.identity-backup-profile.v1",
|
||||
"status": "implemented_pending_owner_admission",
|
||||
"owner": "net-kingdom with railiance-platform/rapp-postgres runtime and storage admission",
|
||||
"public_recipient": "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz",
|
||||
"archive_profile": "netkingdom-identity-v1; new profile, not the bootstrap secrets-only tar profile",
|
||||
"components": [
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "lldap",
|
||||
"deployment_uid": "743a86bd-5cb0-4ca0-9c56-a3438f4746a8",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "lldap-data",
|
||||
"uid": "0a06b65a-e7b3-42d3-916e-e74dbe5fc7da"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"lldap-secrets"
|
||||
],
|
||||
"configmap_refs": [],
|
||||
"required_assets": [
|
||||
"SQLite online-backup snapshot of /data/users.db with integrity check",
|
||||
"remaining non-backup durable /data files, including any private identity material",
|
||||
"current deployment configuration and exact lldap-secrets fields"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"directory user/group/membership preservation",
|
||||
"existing-user password authentication in isolated environment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "authelia",
|
||||
"deployment_uid": "dc40a925-2306-4a3b-89c0-e714187503c3",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "authelia-data",
|
||||
"uid": "86826554-b014-4df4-8b28-866aa64f6413"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"authelia-secrets",
|
||||
"authelia-smtp"
|
||||
],
|
||||
"configmap_refs": [
|
||||
"authelia-config"
|
||||
],
|
||||
"required_assets": [
|
||||
"SQLite online-backup snapshot of /var/authelia/data/db.sqlite3 with integrity check",
|
||||
"current configuration.yml and all Secret-mounted encryption/OIDC/session fields",
|
||||
"explicit disposition for notifier credentials and remaining durable files"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"storage decryption",
|
||||
"same OIDC signing identity and preserved client configuration",
|
||||
"isolated authentication flow with outbound notification disabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "keycape",
|
||||
"deployment_uid": "99ddd83c-cb3f-4847-bcf8-35f1aa87627f",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "keycape-authentication-policy",
|
||||
"uid": "423f558f-8c11-4edf-956b-243559df4a8e"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"keycape-approval-engine-operator-client",
|
||||
"keycape-config",
|
||||
"keycape-factor-read",
|
||||
"keycape-informed-decision-sitting-requester-client",
|
||||
"keycape-rapp-qonto-client",
|
||||
"keycape-secrets-engine-approval-client",
|
||||
"keycape-secrets-engine-openbao-client",
|
||||
"keycape-secrets-engine-requester-client"
|
||||
],
|
||||
"configmap_refs": [],
|
||||
"required_assets": [
|
||||
"exact current keycape-config config.yaml and key.pem",
|
||||
"complete stable policy/receipt PVC snapshot",
|
||||
"current deployment and owner disposition for every referenced credential: encrypted recovery or independently recoverable provider reissue path"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"same issuer/JWKS signing identity",
|
||||
"registry, authentication policy and receipt continuity",
|
||||
"current consumer positive/negative authentication under isolated test endpoints"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "mfa",
|
||||
"deployment": "privacyidea",
|
||||
"deployment_uid": "58c7f96d-61cb-4dd4-bca2-54661c0ac375",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "privacyidea-data",
|
||||
"uid": "65650fc1-02d8-4c0f-84fc-e0015c8f3ed9"
|
||||
},
|
||||
{
|
||||
"name": "privacyidea-logs",
|
||||
"uid": "39fa90d9-bc54-4a03-a0d6-ae85165e0cb9"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"privacyidea-config"
|
||||
],
|
||||
"configmap_refs": [
|
||||
"factor-recovery-01119187d90d7b76",
|
||||
"keycape-onboarding-9465e5e0b98f7986",
|
||||
"privacyidea-cfg"
|
||||
],
|
||||
"required_assets": [
|
||||
"original /etc/privacyidea/enckey, private.pem and public.pem with matching RSA pair validation",
|
||||
"current privacyidea-config and mounted configuration/scripts",
|
||||
"bounded native PostgreSQL custom dump with exact database identity and key-generation linkage",
|
||||
"explicit disposition for service logs; audit database/signatures must be preserved"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"decrypt existing retained token material without exporting it",
|
||||
"verify pre-backup audit signatures and create/verify a new isolated audit entry",
|
||||
"existing-token MFA and negative-key refusal"
|
||||
]
|
||||
}
|
||||
],
|
||||
"capture_contract": {
|
||||
"plaintext": "private task staging outside Git; never process output, State Hub or logs; verified cleanup after encryption",
|
||||
"consistency": "native SQLite online-backup and PostgreSQL logical snapshot; explicit capture interval per asset; no global atomic snapshot claim; before/after source identities/config versions and directory inventories must match",
|
||||
"key_policy": "preserve original service keys; no age private key in the payload, no replacement generation, no root/unseal collection",
|
||||
"encryption": "public-recipient-only streaming archive to age; private fresh ciphertext, no overwrite; manifest inside ciphertext with source identities, times, complete required paths, digests and omissions",
|
||||
"limits": "measure first; proposed 512 MiB encrypted admission ceiling, no silent truncation/exclusions",
|
||||
"historical_database": "existing verified October15 dump remains protected; new bundle must link its own exact database/key capture generation and recovery receipt"
|
||||
},
|
||||
"proposed_destinations": {
|
||||
"initial": "Storage Share Backup/railiance-backups/net-kingdom-identity/",
|
||||
"future_secondary": "s3://railiance-platform-pg-backup/platform-pg/application-archives/net-kingdom/; not implemented/admitted in this rollout",
|
||||
"admitted": false,
|
||||
"publisher": "CCR-2026-0048: new folder-only read/create public DAV share, delivered by exact ESO reader; no operator password in runtime; explicitly owner-approved provider-added re-sharing (permissions21)",
|
||||
"retention": "CCR-2026-0049: separate folder-only read/delete share and process; newest plus seven daily/two weekly and independently recovered pinned generation; unknown/unindexed points never deleted; explicitly owner-approved provider-added re-sharing (permissions25)",
|
||||
"schedule": "source prepared: daily 03:45 UTC plus up to five minutes jitter, hourly 25-hour freshness check; installation leaves timers disabled until independent archive recovery",
|
||||
"quota": "512 MiB ceiling, two GiB fresh provider headroom; initial 10 GiB account quota checked; unsupported public-share quota fails closed"
|
||||
},
|
||||
"admission_gates": [
|
||||
"Approve exact CCR-2026-0048/0049 grants and node runtime packet; three required owner reviews, confirmed native SA/namespace/audience boundaries before acceptance",
|
||||
"Exclude helper capture label from existing broad DNS allow policies; validate read-only helper manifests and cluster/PVC/source bindings before private capture",
|
||||
"Preserve every referenced credential encrypted with original keys; fresh native SQLite/PG snapshots and exact manifest, with documented omissions and no global atomicity claim",
|
||||
"Create fresh ciphertext and exact Storage Share object, read back ciphertext and receipt by strong ETag/hash, independently retrieve using operator custody",
|
||||
"Attended independent age-key proof: wrong-key denial, complete archive hashes, SQLite/RSA checks, disposable native PostgreSQL restore and verified cleanup before timer activation",
|
||||
"Full NK49 closure separately requires native directory/issuer/MFA authentication, retained token decryption, audit signature continuity, current callers and legacy credential dispositions; archive acceptance does not close those gates",
|
||||
"Host-local health cannot report a dead host; external outage monitoring and second-provider copy remain separate coverage gaps"
|
||||
],
|
||||
"runtime_host": {
|
||||
"ssh_alias": "railiance01",
|
||||
"hostname": "239.62.205.92.host.secureserver.net",
|
||||
"machine_id_sha256": "fe7367a60951922122af7ba18ed2cde65c7e0a232bbedfb045cb575de2094c2f"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Strict data-bearing identity archive format, native checks and private sealing.
|
||||
|
||||
No custody key is used to create a bundle. Its manifest and plaintext digests
|
||||
stay inside encryption. Public receipts expose only ciphertext provenance.
|
||||
"""
|
||||
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 sqlite3
|
||||
import stat
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
PROFILE=ROOT/'tools/bootstrap-recovery/identity-backup-profile.json'
|
||||
LIMIT=512*1024*1024
|
||||
FILE_LIMIT=256*1024*1024
|
||||
NAME=re.compile(r'[A-Za-z0-9_.-]{1,160}\Z')
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def profile():return json.loads(PROFILE.read_text())
|
||||
|
||||
|
||||
def private(path,directory=False,max_bytes=FILE_LIMIT):
|
||||
if any(p.is_symlink() for p in (path,*path.parents)):raise Refused()
|
||||
s=path.lstat()
|
||||
if s.st_uid!=os.getuid() or s.st_mode&0o077:raise Refused()
|
||||
if directory:
|
||||
if not stat.S_ISDIR(s.st_mode):raise Refused()
|
||||
elif not stat.S_ISREG(s.st_mode) or s.st_nlink!=1 or s.st_size>max_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 0<ciphertext.stat().st_size<=LIMIT:raise Refused()
|
||||
# Check source completeness after encryption too; changed/new files
|
||||
# refuse publication. This is not same-UID adversary isolation.
|
||||
if inventory(root)!=entries:raise Refused()
|
||||
with ciphertext.open('rb') as source:
|
||||
os.fsync(source.fileno());digest=hashlib.file_digest(source,'sha256').hexdigest()
|
||||
os.link(ciphertext,output)
|
||||
finally:
|
||||
if proc.poll() is None:proc.kill();proc.wait()
|
||||
return {'schema':'netkingdom.identity-capture.v1','status':'encrypted_bundle_created',
|
||||
'ciphertext_sha256':digest,'ciphertext_bytes':output.stat().st_size,'ciphertext_path':str(output),
|
||||
'profile_sha256':manifest['profile_sha256'],'file_count':len(entries),
|
||||
'checks':checked,'offsite_recovery_proven':False,'secret_values_exported':False}
|
||||
|
||||
|
||||
def restore(archive_path,root):
|
||||
private(root,True)
|
||||
with tarfile.open(archive_path,mode='r:') as archive:
|
||||
first=archive.next()
|
||||
if first is None or first.name!='manifest.json' or not first.isfile() or not 0<first.size<2*1024*1024:raise Refused()
|
||||
manifest=json.load(archive.extractfile(first))
|
||||
if manifest.get('schema')!='netkingdom.identity-archive.v1':raise Refused()
|
||||
captured=manifest['profile']
|
||||
if captured.get('schema')!='netkingdom.identity-backup-profile.v1':raise Refused()
|
||||
if len(captured['components'])!=4:raise Refused()
|
||||
if {(c['namespace'],c['deployment']) for c in captured['components']}!={('sso','lldap'),('sso','authelia'),('sso','keycape'),('mfa','privacyidea')}:raise Refused()
|
||||
for c in captured['components']:
|
||||
for key in ('secret_refs','configmap_refs'):
|
||||
if len(set(c[key]))!=len(c[key]) or any(not NAME.fullmatch(n) or n in {'.','..'} for n in c[key]):raise Refused()
|
||||
entries=manifest['files'];expected={e['path']:e for e in entries}
|
||||
if len(expected)!=len(entries) or len(entries)>4096: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()
|
||||
|
|
@ -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'<d:propfind xmlns:d="DAV:"><d:prop><d:quota-available-bytes/></d:prop></d:propfind>'
|
||||
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 0<size<=LIMIT:raise Refused()
|
||||
url=BASE+name;h=headers(token)
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:version=etag(response,url,size)
|
||||
h['If-Match']=version;count=0;sha=hashlib.sha256()
|
||||
with client.open(urllib.request.Request(url,headers=h),timeout=180) as response:
|
||||
if etag(response,url,size)!=version:raise Refused()
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:
|
||||
while block:=response.read(1024*1024):
|
||||
count+=len(block)
|
||||
if count>size: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 available<source.stat().st_size+HEADROOM:raise Refused()
|
||||
url=BASE+source.name;h=headers(token)
|
||||
try:
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30):raise Refused()
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code!=404:raise Refused() from None
|
||||
h.update({'If-None-Match':'*','Content-Type':'application/octet-stream','Content-Length':str(source.stat().st_size)})
|
||||
# An unknown upload outcome is never retried under this name automatically.
|
||||
with source.open('rb') as stream:
|
||||
with client.open(urllib.request.Request(url,data=stream,method='PUT',headers=h),timeout=600) as response:
|
||||
if response.status!=201 or response.url!=url:raise Refused()
|
||||
version=readback(client,token,source.name,source.stat().st_size,capture['ciphertext_sha256'],target)
|
||||
index={'schema':'netkingdom.identity-offsite-index.v1','status':'readback_verified',
|
||||
'artifact':source.name,'ciphertext_sha256':capture['ciphertext_sha256'],
|
||||
'ciphertext_bytes':source.stat().st_size,'captured_at':capture['finished_at']}
|
||||
raw=json.dumps(index,sort_keys=True).encode();index_name=source.name+'.receipt.json';index_url=BASE+index_name
|
||||
h=headers(token);h.update({'If-None-Match':'*','Content-Type':'application/json','Content-Length':str(len(raw))})
|
||||
with client.open(urllib.request.Request(index_url,data=raw,method='PUT',headers=h),timeout=30) as response:
|
||||
if response.status!=201 or response.url!=index_url:raise Refused()
|
||||
with tempfile.TemporaryDirectory(prefix='.identity-index-',dir=target.parent) as temporary:
|
||||
readback(client,token,index_name,len(raw),hashlib.sha256(raw).hexdigest(),Path(temporary)/'index.json')
|
||||
return {'schema':'netkingdom.identity-offsite.v1','status':'readback_verified',
|
||||
'observed_at':datetime.now(timezone.utc).isoformat(),'provider':'Storage Share',
|
||||
'folder':'Backup/railiance-backups/net-kingdom-identity','artifact':source.name,
|
||||
'ciphertext_sha256':capture['ciphertext_sha256'],'ciphertext_bytes':source.stat().st_size,
|
||||
'fetched_path':str(target),'etag':version,'quota_available_before':available,
|
||||
'new_object_created':True,'downloaded_digest_verified':True,
|
||||
'verified_index_published':True,'index_name':index_name,
|
||||
'age_private_key_used':False,'secret_values_exported':False,'retained_objects_deleted':False,
|
||||
'native_identity_recovery_proven':False}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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 0<record.get('ciphertext_bytes',0)<=offsite.LIMIT):raise offsite.Refused()
|
||||
identity,credentials=inventory.caller_credentials()
|
||||
auth=base64.b64encode((credentials['BACKUP_USERNAME']+':'+credentials['BACKUP_PASSWORD']).encode()).decode()
|
||||
url=offsite.HOST+'/remote.php/dav/files/Backup/railiance-backups/net-kingdom-identity/'+record['artifact']
|
||||
h={'Authorization':'Basic '+auth,'Accept-Encoding':'identity'}
|
||||
client=offsite.opener()
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:
|
||||
version=offsite.etag(response,url,record['ciphertext_bytes'])
|
||||
if version!=record['etag']:raise offsite.Refused()
|
||||
h['If-Match']=version
|
||||
directory=Path(tempfile.mkdtemp(prefix='nk49-identity-fetched-',dir='/tmp'))
|
||||
target=directory/record['artifact'];count=0;digest=hashlib.sha256()
|
||||
with client.open(urllib.request.Request(url,headers=h),timeout=180) as response:
|
||||
if offsite.etag(response,url,record['ciphertext_bytes'])!=version:raise offsite.Refused()
|
||||
with target.open('xb') 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)
|
||||
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())
|
||||
|
|
@ -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"
|
||||
|
|
@ -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())
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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())
|
||||
|
|
@ -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,'<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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue