From c2624700892590b42c2226af49a530fdf58ada28 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 15 Oct 2026 23:58:41 +0200 Subject: [PATCH] Inventory off-host recovery assets through existing bounded backup routes Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2 --- docs/bootstrap-recovery-ceremony.md | 28 +- .../caller-source-inventory.json | 14 +- .../offsite-discovery-summary.json | 37 ++ .../readiness.json | 11 +- .../scaleway-inventory-r1.json | 93 +++ .../storage-share-inventory-r1.json | 562 ++++++++++++++++++ tests/test_scaleway_backup_inventory.py | 37 ++ tests/test_storage_share_inventory.py | 43 ++ tools/bootstrap-recovery/README.md | 14 + .../bootstrap-recovery/inventory-scaleway.py | 104 ++++ .../inventory-storage-share.py | 109 ++++ ...-bootstrap-secret-free-recovery-handoff.md | 23 +- 12 files changed, 1069 insertions(+), 6 deletions(-) create mode 100644 docs/evidence/2026-10-15-bootstrap-recovery/offsite-discovery-summary.json create mode 100644 docs/evidence/2026-10-15-bootstrap-recovery/scaleway-inventory-r1.json create mode 100644 docs/evidence/2026-10-15-bootstrap-recovery/storage-share-inventory-r1.json create mode 100644 tests/test_scaleway_backup_inventory.py create mode 100644 tests/test_storage_share_inventory.py create mode 100644 tools/bootstrap-recovery/inventory-scaleway.py create mode 100644 tools/bootstrap-recovery/inventory-storage-share.py diff --git a/docs/bootstrap-recovery-ceremony.md b/docs/bootstrap-recovery-ceremony.md index d571c8c..6b89814 100644 --- a/docs/bootstrap-recovery-ceremony.md +++ b/docs/bootstrap-recovery-ceremony.md @@ -41,7 +41,11 @@ owner custody and pass the decryption key through attended temporary unlock. | Current consumer source | Reconcile the deployed revisions with the repaired caller/provider source. The October 14 reserved platform-group fix must be deployed and independently checked before claiming live enforcement. | | Legacy credential disposition | IAM owner identifies the exact historical personal-token/trigger principals, actual consumers and revocation method before any deletion. Deleting a Secret does not revoke an issued provider credential. | -The current independent key availability and exact artifact/manifest are pending. +The operator subsequently reported holding the `railiance-backup` age key. +Key material has not been received or read. Exact artifact/manifest selection +and verification that this independently held key matches the selected +ciphertext remain pending. The backup lane recipient and bootstrap +`keys/age.pub` must not be assumed to be the same. Do not draft a blanket restore or grant approval while these remain unknown. Once fixed, bind the source revisions, target identities, allowed operations, recovery/cleanup sequence and maintenance window to the reviewed live packet. @@ -116,3 +120,25 @@ Emergent Security 0.2.6, package digest start/root cards withheld, orientation only. Current SECURITY-COMPANION, Platform backup/custody procedures, owner runtime contracts and the workplan were reviewed as sources. No execution authority is derived from the cards. + + +## Off-host inventory return — 2026-10-15 + +Both bounded metadata inventories completed. Storage Share's inspected +`Backup/railiance-backups` scope includes the current privacyIDEA database +ciphertext and the October14 OpenBao snapshot. It did not reveal a matching +bootstrap/identity-file bundle in the inspected folders. Scaleway's inspected +`railiance-platform-pg-backup/platform-pg/` tree contains database backup trees +and an application archive subtree, with no identity-file bundle in this bounded +scope. Other accounts/buckets/prefixes are unassessed. See +[the discovery summary](evidence/2026-10-15-bootstrap-recovery/offsite-discovery-summary.json). +No object contents or age keys were read; the operator envelope exited0 and +self-revoked. Existing scoped backup credentials were reused without changes; +the Scaleway bootstrap API key was not used. + +The next human act is to run the existing custody helper in their own terminal +with `--expected-recipient` set to the public recipient in the discovery summary, +`--prompt --check-only`. Paste private material only into the hidden prompt. +This verifies the independently held key against the backup owner's declared +recipient and removes its temporary copy. It does not by itself prove that the +selected backup decrypts or includes the full matching identity recovery set. diff --git a/docs/evidence/2026-10-15-bootstrap-recovery/caller-source-inventory.json b/docs/evidence/2026-10-15-bootstrap-recovery/caller-source-inventory.json index 636ce45..825a132 100644 --- a/docs/evidence/2026-10-15-bootstrap-recovery/caller-source-inventory.json +++ b/docs/evidence/2026-10-15-bootstrap-recovery/caller-source-inventory.json @@ -1,8 +1,8 @@ { "schema": "netkingdom.bootstrap-caller-inventory.v1", "observed_at": "2026-10-15T20:54:09.856047+00:00", - "scope": "all current .sh/.py non-test files under sso-mfa/bootstrap and sso-mfa/k8s, Makefile, console, delivery copier and recovery transport tool", - "entry_count": 87, + "scope": "all current .sh/.py non-test files under sso-mfa/bootstrap and sso-mfa/k8s, Makefile, console, delivery copier and recovery transport tool plus two bounded provider metadata inventory helpers", + "entry_count": 89, "entries": [ { "path": "Makefile", @@ -424,6 +424,16 @@ "sha256": "7efe14c0d71f2c846434d19f8d05702d69d3c40a617a700b08fe13b3764d0650", "classification": "diagnostic; structural observations do not prove recovery or revoke credentials" }, + { + "path": "tools/bootstrap-recovery/inventory-scaleway.py", + "sha256": "a219df205708fa4ed44b763a418585d63c820c25434bc0fd14e805eb5c2f79d3", + "classification": "read-only bounded owner backup metadata; existing caller/custody only; no age key or backup content read" + }, + { + "path": "tools/bootstrap-recovery/inventory-storage-share.py", + "sha256": "fe56454e5ca740395e6d6a2a0f8185abdac1d8ca6d0eedb31273db409dee4954", + "classification": "read-only bounded owner backup metadata; existing caller/custody only; no age key or backup content read" + }, { "path": "tools/bootstrap-recovery/verify-bundle.py", "sha256": "885b05a2011e711228e42e316bda70bbe0d97d481383d61aa8469d0f10452370", diff --git a/docs/evidence/2026-10-15-bootstrap-recovery/offsite-discovery-summary.json b/docs/evidence/2026-10-15-bootstrap-recovery/offsite-discovery-summary.json new file mode 100644 index 0000000..3a4e8fd --- /dev/null +++ b/docs/evidence/2026-10-15-bootstrap-recovery/offsite-discovery-summary.json @@ -0,0 +1,37 @@ +{ + "observed_at": "2026-10-15T21:58:20.278564+00:00", + "operator_reports_holding_railiance_backup_age_key": true, + "private_key_received_or_read": false, + "candidate_key_match_verified": false, + "storage_share": { + "inventory": "storage-share-inventory-r1.json", + "status": "metadata_inventory_complete", + "attended_envelope_exit_code": 0, + "session_self_revocation_and_cleanup_confirmed": true, + "selected_candidate": "r01-net-kingdom-pg/net-kingdom-pg-privacyidea_db-20261015T023000Z.dump.age", + "candidate_bytes": 89129309, + "candidate_contents_downloaded": false, + "bootstrap_identity_file_bundle_found_in_inspected_scope": false + }, + "scaleway": { + "inventory": "scaleway-inventory-r1.json", + "status": "metadata_inventory_complete", + "bucket": "railiance-platform-pg-backup", + "scope": "platform-pg/ and at most three child levels; WAL/base object trees and other buckets/prefixes not enumerated", + "bootstrap_identity_file_bundle_found_in_inspected_scope": false, + "bootstrap_api_key_used": false + }, + "key_check": { + "source": "railiance-platform/lib/railiance-backup-common.sh", + "source_sha256": "09f349ae815339732db505d6896fb1ca384be7c66b40574750a3ba30d4d922e0", + "declared_default_public_recipient": "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz", + "operator_hidden_terminal_check_requested": true, + "scope": "A successful key match checks the declared public recipient, not actual ciphertext decryption or whole-stack recovery." + }, + "source_tests": { + "passed": 13, + "command": "python3 -m pytest -q tests/test_scaleway_backup_inventory.py tests/test_storage_share_inventory.py" + }, + "native_recovery_proven": false, + "production_mutations_performed": false +} diff --git a/docs/evidence/2026-10-15-bootstrap-recovery/readiness.json b/docs/evidence/2026-10-15-bootstrap-recovery/readiness.json index fc5495b..0bca3fb 100644 --- a/docs/evidence/2026-10-15-bootstrap-recovery/readiness.json +++ b/docs/evidence/2026-10-15-bootstrap-recovery/readiness.json @@ -18,9 +18,16 @@ "verification": "source-verification.json", "ceremony": "../../bootstrap-recovery-ceremony.md", "outstanding": [ - "Exact off-host artifact/version/digest and complete matching data/key/config coverage", + "Matching off-host identity data/key/config set is not located in inspected provider scopes; a recent PI database ciphertext is identified, not downloaded/decrypted", "Independent age key availability/custody confirmation", "Owner admitted isolated recovery target/procedure and native service continuity/cleanup evidence", "T02 current caller/provider source admission and exact legacy provider credential disposition; retain existing deferred-rotation boundary" - ] + ], + "operator_reports_holding_railiance_backup_age_key": true, + "operator_report_recorded_at": "2026-10-15T21:52:22.549430+00:00", + "key_material_received": false, + "key_matches_selected_ciphertext_verified": false, + "offsite_discovery": "offsite-discovery-summary.json", + "privacyidea_database_ciphertext_identified": true, + "required_full_asset_coverage_confirmed": false } diff --git a/docs/evidence/2026-10-15-bootstrap-recovery/scaleway-inventory-r1.json b/docs/evidence/2026-10-15-bootstrap-recovery/scaleway-inventory-r1.json new file mode 100644 index 0000000..7f89f4e --- /dev/null +++ b/docs/evidence/2026-10-15-bootstrap-recovery/scaleway-inventory-r1.json @@ -0,0 +1,93 @@ +{ + "schema": "netkingdom.scaleway-backup-inventory.v1", + "status": "metadata_inventory_complete", + "started_at": "2026-10-15T21:54:28.925951+00:00", + "read_only": true, + "provider": "Scaleway", + "bucket": "railiance-platform-pg-backup", + "prefix": "platform-pg/", + "source_sha256": "a219df205708fa4ed44b763a418585d63c820c25434bc0fd14e805eb5c2f79d3", + "bootstrap_api_key_used": false, + "age_keys_read": false, + "object_contents_downloaded": false, + "secret_values_exported": false, + "scope": "Bounded prefix metadata, at most three child levels; WAL/base trees not enumerated; other buckets/prefixes unassessed", + "prefixes": { + "platform-pg/": { + "directories": [ + "platform-pg/application-archives/", + "platform-pg/apps-pg/", + "platform-pg/forgejo-db/", + "platform-pg/platform-pg-2/", + "platform-pg/platform-pg/" + ], + "files": [] + }, + "platform-pg/application-archives/": { + "directories": [ + "platform-pg/application-archives/email-connect/" + ], + "files": [] + }, + "platform-pg/apps-pg/": { + "directories": [ + "platform-pg/apps-pg/apps-pg/" + ], + "files": [] + }, + "platform-pg/forgejo-db/": { + "directories": [ + "platform-pg/forgejo-db/forgejo-db/" + ], + "files": [] + }, + "platform-pg/platform-pg-2/": { + "directories": [ + "platform-pg/platform-pg-2/platform-pg-2/" + ], + "files": [] + }, + "platform-pg/platform-pg/": { + "directories": [ + "platform-pg/platform-pg/base/", + "platform-pg/platform-pg/wals/" + ], + "files": [] + }, + "platform-pg/application-archives/email-connect/": { + "directories": [ + "platform-pg/application-archives/email-connect/2026/" + ], + "files": [] + }, + "platform-pg/apps-pg/apps-pg/": { + "directories": [ + "platform-pg/apps-pg/apps-pg/base/", + "platform-pg/apps-pg/apps-pg/wals/" + ], + "files": [] + }, + "platform-pg/forgejo-db/forgejo-db/": { + "directories": [ + "platform-pg/forgejo-db/forgejo-db/base/", + "platform-pg/forgejo-db/forgejo-db/wals/" + ], + "files": [] + }, + "platform-pg/platform-pg-2/platform-pg-2/": { + "directories": [ + "platform-pg/platform-pg-2/platform-pg-2/base/", + "platform-pg/platform-pg-2/platform-pg-2/wals/" + ], + "files": [] + }, + "platform-pg/application-archives/email-connect/2026/": { + "directories": [ + "platform-pg/application-archives/email-connect/2026/10/" + ], + "files": [] + } + }, + "runtime_delivery_uid": "30c4f6fc-8eb7-4cdc-b692-0f74c4a5b3ce", + "finished_at": "2026-10-15T21:54:39.109262+00:00" +} diff --git a/docs/evidence/2026-10-15-bootstrap-recovery/storage-share-inventory-r1.json b/docs/evidence/2026-10-15-bootstrap-recovery/storage-share-inventory-r1.json new file mode 100644 index 0000000..7600a34 --- /dev/null +++ b/docs/evidence/2026-10-15-bootstrap-recovery/storage-share-inventory-r1.json @@ -0,0 +1,562 @@ +{ + "schema": "netkingdom.storage-share-inventory.v1", + "status": "metadata_inventory_complete", + "started_at": "2026-10-15T21:52:20.452797+00:00", + "read_only": true, + "age_keys_read": false, + "backup_contents_downloaded": false, + "secret_values_exported": false, + "source_sha256": "fe56454e5ca740395e6d6a2a0f8185abdac1d8ca6d0eedb31273db409dee4954", + "owner_sources": [ + { + "path": "/home/worsch/railiance-platform/scripts/state_hub_preflight_lane.py", + "sha256": "8bf63beda028de7c2375084afb7c22fb00a731c54ebf0b3ba369a699d920e0f5" + }, + { + "path": "/home/worsch/railiance-platform/scripts/migrate_nextcloud_backup_account.py", + "sha256": "d3285f403f3c947f75c3288d989152e59ab9a8f5542bcf901bd0a06b0f3e050c" + } + ], + "root": [ + { + "name": "core-apps-pg", + "directory": true, + "bytes": null + }, + { + "name": "core-gitea-db", + "directory": true, + "bytes": null + }, + { + "name": "core-net-kingdom-pg", + "directory": true, + "bytes": null + }, + { + "name": "core-state-hub-db", + "directory": true, + "bytes": null + }, + { + "name": "forgejo", + "directory": true, + "bytes": null + }, + { + "name": "openbao", + "directory": true, + "bytes": null + }, + { + "name": "r01-forgejo-db", + "directory": true, + "bytes": null + }, + { + "name": "r01-net-kingdom-pg", + "directory": true, + "bytes": null + }, + { + "name": "r01-platform-pg", + "directory": true, + "bytes": null + }, + { + "name": "r01-state-hub-db", + "directory": true, + "bytes": null + }, + { + "name": "r01-user-engine-pg", + "directory": true, + "bytes": null + } + ], + "folders": { + "core-apps-pg": [], + "core-gitea-db": [], + "core-net-kingdom-pg": [], + "core-state-hub-db": [], + "openbao": [ + { + "name": "openbao-raft-snapshot-RPF-WP-0015-T03-REBOOT-20261014-01-20261014T181053Z.snap.age", + "directory": false, + "bytes": 375618 + } + ], + "r01-forgejo-db": [ + { + "name": "forgejo-db-forgejo-20260906T023000Z.dump.age", + "directory": false, + "bytes": 7515593 + }, + { + "name": "forgejo-db-forgejo-20260907T023000Z.dump.age", + "directory": false, + "bytes": 7320543 + }, + { + "name": "forgejo-db-forgejo-20260908T023000Z.dump.age", + "directory": false, + "bytes": 7413040 + }, + { + "name": "forgejo-db-forgejo-20260909T023000Z.dump.age", + "directory": false, + "bytes": 7663338 + }, + { + "name": "forgejo-db-forgejo-20260910T023000Z.dump.age", + "directory": false, + "bytes": 8079045 + }, + { + "name": "forgejo-db-forgejo-20260911T023000Z.dump.age", + "directory": false, + "bytes": 8404583 + }, + { + "name": "forgejo-db-forgejo-20260912T023000Z.dump.age", + "directory": false, + "bytes": 8631188 + }, + { + "name": "forgejo-db-forgejo-20260913T023000Z.dump.age", + "directory": false, + "bytes": 8472109 + }, + { + "name": "forgejo-db-forgejo-20260914T023000Z.dump.age", + "directory": false, + "bytes": 8523610 + }, + { + "name": "forgejo-db-forgejo-20261005T023000Z.dump.age", + "directory": false, + "bytes": 11745885 + }, + { + "name": "forgejo-db-forgejo-20261006T023000Z.dump.age", + "directory": false, + "bytes": 11481436 + }, + { + "name": "forgejo-db-forgejo-20261007T023000Z.dump.age", + "directory": false, + "bytes": 11693800 + }, + { + "name": "forgejo-db-forgejo-20261008T023000Z.dump.age", + "directory": false, + "bytes": 11784032 + }, + { + "name": "forgejo-db-forgejo-20261009T023003Z.dump.age", + "directory": false, + "bytes": 12210534 + }, + { + "name": "forgejo-db-forgejo-20261010T023000Z.dump.age", + "directory": false, + "bytes": 12378726 + }, + { + "name": "forgejo-db-forgejo-20261011T023000Z.dump.age", + "directory": false, + "bytes": 12369466 + }, + { + "name": "forgejo-db-forgejo-20261012T023000Z.dump.age", + "directory": false, + "bytes": 12701177 + }, + { + "name": "forgejo-db-forgejo-20261013T023000Z.dump.age", + "directory": false, + "bytes": 12761857 + }, + { + "name": "forgejo-db-forgejo-20261014T023000Z.dump.age", + "directory": false, + "bytes": 13110850 + }, + { + "name": "forgejo-db-forgejo-20261015T023000Z.dump.age", + "directory": false, + "bytes": 13349242 + }, + { + "name": "forgejo-db-forgejo-weekly-20260906T023000Z.dump.age", + "directory": false, + "bytes": 7515593 + }, + { + "name": "forgejo-db-forgejo-weekly-20260913T023000Z.dump.age", + "directory": false, + "bytes": 8472109 + }, + { + "name": "forgejo-db-forgejo-weekly-20261011T023000Z.dump.age", + "directory": false, + "bytes": 12369466 + } + ], + "r01-net-kingdom-pg": [ + { + "name": "net-kingdom-pg-interhub-20260906T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20260907T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20260908T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20260909T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20260910T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20260911T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20260912T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20260913T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20260914T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20261005T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20261006T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20261007T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20261008T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20261009T023003Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20261010T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20261011T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20261012T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20261013T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20261014T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-20261015T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-weekly-20260906T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-weekly-20260913T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-interhub-weekly-20261011T023000Z.dump.age", + "directory": false, + "bytes": 215541 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20260906T023000Z.dump.age", + "directory": false, + "bytes": 271941 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20260907T023000Z.dump.age", + "directory": false, + "bytes": 272271 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20260908T023000Z.dump.age", + "directory": false, + "bytes": 272599 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20260909T023000Z.dump.age", + "directory": false, + "bytes": 274660 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20260910T023000Z.dump.age", + "directory": false, + "bytes": 274660 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20260911T023000Z.dump.age", + "directory": false, + "bytes": 274996 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20260912T023000Z.dump.age", + "directory": false, + "bytes": 276353 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20260913T023000Z.dump.age", + "directory": false, + "bytes": 277468 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20260914T023000Z.dump.age", + "directory": false, + "bytes": 1008342 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20261005T023000Z.dump.age", + "directory": false, + "bytes": 58987550 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20261006T023000Z.dump.age", + "directory": false, + "bytes": 61979707 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20261007T023000Z.dump.age", + "directory": false, + "bytes": 64976206 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20261008T023000Z.dump.age", + "directory": false, + "bytes": 68001818 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20261009T023003Z.dump.age", + "directory": false, + "bytes": 71016444 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20261010T023000Z.dump.age", + "directory": false, + "bytes": 74006618 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20261011T023000Z.dump.age", + "directory": false, + "bytes": 77020445 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20261012T023000Z.dump.age", + "directory": false, + "bytes": 80037505 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20261013T023000Z.dump.age", + "directory": false, + "bytes": 83066939 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20261014T023000Z.dump.age", + "directory": false, + "bytes": 86103429 + }, + { + "name": "net-kingdom-pg-privacyidea_db-20261015T023000Z.dump.age", + "directory": false, + "bytes": 89129309 + }, + { + "name": "net-kingdom-pg-privacyidea_db-weekly-20260906T023000Z.dump.age", + "directory": false, + "bytes": 271941 + }, + { + "name": "net-kingdom-pg-privacyidea_db-weekly-20260913T023000Z.dump.age", + "directory": false, + "bytes": 277468 + }, + { + "name": "net-kingdom-pg-privacyidea_db-weekly-20261011T023000Z.dump.age", + "directory": false, + "bytes": 77020445 + } + ], + "r01-platform-pg": [], + "r01-state-hub-db": [ + { + "name": "state-hub-db-state_hub-20260906T023000Z.dump.age", + "directory": false, + "bytes": 6924847 + }, + { + "name": "state-hub-db-state_hub-20260907T023000Z.dump.age", + "directory": false, + "bytes": 7141118 + }, + { + "name": "state-hub-db-state_hub-20260908T023000Z.dump.age", + "directory": false, + "bytes": 7180575 + }, + { + "name": "state-hub-db-state_hub-20260909T023000Z.dump.age", + "directory": false, + "bytes": 7390311 + }, + { + "name": "state-hub-db-state_hub-20260910T023000Z.dump.age", + "directory": false, + "bytes": 7542258 + }, + { + "name": "state-hub-db-state_hub-20260911T023000Z.dump.age", + "directory": false, + "bytes": 7687798 + }, + { + "name": "state-hub-db-state_hub-20260912T023000Z.dump.age", + "directory": false, + "bytes": 7748393 + }, + { + "name": "state-hub-db-state_hub-20260913T023000Z.dump.age", + "directory": false, + "bytes": 7765176 + }, + { + "name": "state-hub-db-state_hub-20260914T023000Z.dump.age", + "directory": false, + "bytes": 7839953 + }, + { + "name": "state-hub-db-state_hub-20261005T023000Z.dump.age", + "directory": false, + "bytes": 9407746 + }, + { + "name": "state-hub-db-state_hub-20261006T023000Z.dump.age", + "directory": false, + "bytes": 9464763 + }, + { + "name": "state-hub-db-state_hub-20261007T023000Z.dump.age", + "directory": false, + "bytes": 9549640 + }, + { + "name": "state-hub-db-state_hub-20261008T023000Z.dump.age", + "directory": false, + "bytes": 9602097 + }, + { + "name": "state-hub-db-state_hub-20261009T023003Z.dump.age", + "directory": false, + "bytes": 9693941 + }, + { + "name": "state-hub-db-state_hub-20261010T023000Z.dump.age", + "directory": false, + "bytes": 9766236 + }, + { + "name": "state-hub-db-state_hub-20261011T023000Z.dump.age", + "directory": false, + "bytes": 9864346 + }, + { + "name": "state-hub-db-state_hub-20261012T023000Z.dump.age", + "directory": false, + "bytes": 9973489 + }, + { + "name": "state-hub-db-state_hub-20261013T023000Z.dump.age", + "directory": false, + "bytes": 10036580 + }, + { + "name": "state-hub-db-state_hub-20261014T023000Z.dump.age", + "directory": false, + "bytes": 10265536 + }, + { + "name": "state-hub-db-state_hub-20261015T023000Z.dump.age", + "directory": false, + "bytes": 10388491 + }, + { + "name": "state-hub-db-state_hub-weekly-20260906T023000Z.dump.age", + "directory": false, + "bytes": 6924847 + }, + { + "name": "state-hub-db-state_hub-weekly-20260913T023000Z.dump.age", + "directory": false, + "bytes": 7765176 + }, + { + "name": "state-hub-db-state_hub-weekly-20261011T023000Z.dump.age", + "directory": false, + "bytes": 9864346 + } + ], + "r01-user-engine-pg": [] + }, + "caller_auth_path": "auth/netkingdom/oidc/callback", + "caller_entity_id": "4a1f663c-0621-0483-edd0-f852274f4b6b", + "scope": "Backup/railiance-backups root and bounded identity/database subfolders; other accounts/folders unassessed", + "finished_at": "2026-10-15T21:52:28.155972+00:00" +} diff --git a/tests/test_scaleway_backup_inventory.py b/tests/test_scaleway_backup_inventory.py new file mode 100644 index 0000000..15e2c1a --- /dev/null +++ b/tests/test_scaleway_backup_inventory.py @@ -0,0 +1,37 @@ +"""Scoped S3 list-only fixtures; no credentials, live cluster or cloud.""" +import importlib.util +from pathlib import Path +import pytest + +ROOT=Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location('s3_inventory',ROOT/'tools/bootstrap-recovery/inventory-scaleway.py') +module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + + +class Client: + def __init__(self,pages):self.pages=iter(pages);self.calls=[] + def list_objects_v2(self,**kw):self.calls.append(kw);return next(self.pages) + + +def test_scoped_list_metadata_follows_continuation_without_reading_contents(): + client=Client([{'IsTruncated':True,'NextContinuationToken':'next'}, + {'Contents':[{'Key':'platform-pg/identity/backup.tar.age','Size':256}]}]) + r=module.list_prefix(client,'platform-pg/identity/') + assert r['files']==[{'key':'platform-pg/identity/backup.tar.age','bytes':256}] + assert all(x['Bucket']==module.BUCKET and x['Prefix']=='platform-pg/identity/' for x in client.calls) + assert client.calls[1]['ContinuationToken']=='next' + + +def test_wal_and_base_presence_is_recorded_without_traversal(): + client=Client([{'CommonPrefixes':[{'Prefix':'platform-pg/base/'},{'Prefix':'platform-pg/wals/'}]}]) + r=module.inventory(client) + assert len(client.calls)==1 and len(r['platform-pg/']['directories'])==2 + + +@pytest.mark.parametrize('key',['elsewhere/backup.age','platform-pg/../backup.age','platform-pg/private\nmarker.age']) +def test_foreign_or_unsafe_metadata_refuses(key): + with pytest.raises(module.Refused):module.list_prefix(Client([{'Contents':[{'Key':key,'Size':1}]}]),module.PREFIX) + + +def test_missing_pagination_state_refuses_incomplete_inventory(): + with pytest.raises(module.Refused):module.list_prefix(Client([{'IsTruncated':True}]),module.PREFIX) diff --git a/tests/test_storage_share_inventory.py b/tests/test_storage_share_inventory.py new file mode 100644 index 0000000..7cd0367 --- /dev/null +++ b/tests/test_storage_share_inventory.py @@ -0,0 +1,43 @@ +"""Read-only DAV metadata parser tests; no provider or credential access.""" +import importlib.util +from pathlib import Path +import pytest + +ROOT=Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location('storage_inventory',ROOT/'tools/bootstrap-recovery/inventory-storage-share.py') +module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + + +def body(path, kind='file', size='16', origin=''): + collection='' if kind=='dir' else '' + return f'''{origin}{path} + HTTP/1.1 200 OK{collection} + {size}'''.encode() + + +def test_only_closed_safe_metadata_is_returned(): + raw=body(module.BASE+'bootstrap.tar.age') + assert module.parse_listing(raw,'')==[{'name':'bootstrap.tar.age','directory':False,'bytes':16}] + + +@pytest.mark.parametrize('path,origin',[(module.BASE+'%2e%2e/else.age',''), + ('/other/account/backup.age',''),(module.BASE+'backup.age','https://unapproved.invalid')]) +def test_foreign_and_escaping_references_refuse(path,origin): + with pytest.raises(module.Refused):module.parse_listing(body(path,origin=origin),'') + + +def test_non_numeric_size_and_oversized_response_refuse(): + with pytest.raises(module.Refused):module.parse_listing(body(module.BASE+'backup.age',size='not-a-size'),'') + with pytest.raises(module.Refused):module.parse_listing(b'X'*(module.LIMIT+1),'') + + +def test_unrecognized_plaintext_files_are_not_exported(): + assert module.parse_listing(body(module.BASE+'private-password.txt'),'')==[] + + +def test_unrelated_authenticated_role_cannot_read_retrieval_credential(monkeypatch): + calls=[] + monkeypatch.setattr(module,'bao',lambda args:calls.append(args) or object()) + monkeypatch.setattr(module,'data',lambda result:{'data':{'policies':['default'],'path':'auth/netkingdom/oidc/callback'}}) + with pytest.raises(module.Refused):module.inventory() + assert calls==[['token','lookup','-format=json']] diff --git a/tools/bootstrap-recovery/README.md b/tools/bootstrap-recovery/README.md index dc4a010..0f86602 100644 --- a/tools/bootstrap-recovery/README.md +++ b/tools/bootstrap-recovery/README.md @@ -21,3 +21,17 @@ archive fixtures, never production credentials: ```bash python3 -m pytest -q tests/test_bootstrap_recovery_transport.py ``` + + +`inventory-storage-share.py` is a silent read-only child of the existing +attended Platform Backup-account procedure. It checks the caller's OIDC/admin +scope, reuses the owner custody accessor, and exports only bounded DAV names and +sizes. Warden revokes and cleans up the session. It never reads an age key or +object content and contains provider errors/redirects/proxies. + +`inventory-scaleway.py` lists bounded metadata in the fixed backup bucket/prefix +using the existing scoped runtime delivery and pinned primary cluster. It uses +neither the bootstrap API key nor object GET/write/delete operations. Run through +the already admitted caller with cached boto3 1.40.18; it emits only its safe +receipt. Both inventories explicitly state their bounds and cannot prove asset +absence outside them, ciphertext integrity, decryption or native recovery. diff --git a/tools/bootstrap-recovery/inventory-scaleway.py b/tools/bootstrap-recovery/inventory-scaleway.py new file mode 100644 index 0000000..4f64cee --- /dev/null +++ b/tools/bootstrap-recovery/inventory-scaleway.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Read-only bounded S3 backup metadata through existing scoped runtime delivery. + +No bootstrap API key, new grant, object content read, age key, provider write or +retention action. The caller must already have admitted cluster read authority. +""" +import argparse +import base64 +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess + +BUCKET='railiance-platform-pg-backup' +ENDPOINT='https://s3.nl-ams.scw.cloud' +PREFIX='platform-pg/' +CLUSTER='a553c742-0115-43d4-99a4-a5ca56fe0786' + + +class Refused(Exception):pass + + +def kube(args): + p=subprocess.run(['kubectl',*args,'-o','json'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=30) + if p.returncode:raise Refused() + return json.loads(p.stdout) + + +def list_prefix(client,prefix): + if not prefix.startswith(PREFIX) or not re.fullmatch(r'[A-Za-z0-9._/-]+',prefix) or '..' in prefix.split('/'):raise Refused() + directories=[];files=[];continuation=None + for _ in range(20): + args={'Bucket':BUCKET,'Prefix':prefix,'Delimiter':'/','MaxKeys':1000} + if continuation:args['ContinuationToken']=continuation + page=client.list_objects_v2(**args) + for row in page.get('CommonPrefixes',[]): + value=row['Prefix'] + if not value.startswith(prefix) or not value.endswith('/') or not re.fullmatch(r'[A-Za-z0-9._/-]+',value) or '..' in value.split('/'):raise Refused() + directories.append(value) + for row in page.get('Contents',[]): + name=row['Key'] + if not name.startswith(prefix) or not re.fullmatch(r'[A-Za-z0-9._/-]+',name) or '..' in name.split('/'):raise Refused() + if name.endswith(('.age','.json','.yaml')): + size=row['Size'] + if type(size) is not int or size<0:raise Refused() + files.append({'key':name,'bytes':size}) + if not page.get('IsTruncated'):break + continuation=page.get('NextContinuationToken') + if not continuation:raise Refused() + else:raise Refused() + return {'directories':sorted(set(directories)),'files':sorted(files,key=lambda x:x['key'])} + + +def inventory(client): + pending=[(PREFIX,0)];result={} + while pending: + prefix,depth=pending.pop(0) + if prefix in result:raise Refused() + if len(result)>=64:raise Refused() + row=list_prefix(client,prefix);result[prefix]=row + if depth<3: + for child in row['directories']: + # Native database WAL/base trees are not application/key-file + # archives. Record their presence without enumerating every WAL. + if any(x in child.split('/') for x in ['wals','base']):continue + pending.append((child,depth+1)) + return result + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True) + a=p.parse_args();fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'schema':'netkingdom.scaleway-backup-inventory.v1','status':'failed', + 'started_at':datetime.now(timezone.utc).isoformat(),'read_only':True, + 'provider':'Scaleway','bucket':BUCKET,'prefix':PREFIX,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'bootstrap_api_key_used':False,'age_keys_read':False,'object_contents_downloaded':False, + 'secret_values_exported':False,'scope':'Bounded prefix metadata, at most three child levels; WAL/base trees not enumerated; other buckets/prefixes unassessed'} + try: + if kube(['get','namespace','kube-system'])['metadata']['uid']!=CLUSTER:raise Refused() + delivered=kube(['-n','databases','get','secret','platform-pg-backup-s3']) + if delivered['metadata']['namespace']!='databases' or delivered['metadata']['name']!='platform-pg-backup-s3':raise Refused() + values=delivered['data'] + # Same governed runtime delivery used by Platform's archive verifier; + # explicit credentials avoid SDK ambient/profile/metadata discovery. + import boto3 + from botocore.config import Config + client=boto3.client('s3',endpoint_url=ENDPOINT,region_name='nl-ams', + aws_access_key_id=base64.b64decode(values['ACCESS_KEY_ID'],validate=True).decode(), + aws_secret_access_key=base64.b64decode(values['ACCESS_SECRET_KEY'],validate=True).decode(), + config=Config(signature_version='s3v4',proxies={},connect_timeout=15,read_timeout=30, + retries={'max_attempts':1},s3={'addressing_style':'path'})) + r['prefixes']=inventory(client);r['status']='metadata_inventory_complete' + r['runtime_delivery_uid']=delivered['metadata']['uid'] + except Exception:r['error']='inventory_failed_diagnostics_suppressed' + finally: + r['finished_at']=datetime.now(timezone.utc).isoformat() + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + return int(r['status']=='failed') + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/tools/bootstrap-recovery/inventory-storage-share.py b/tools/bootstrap-recovery/inventory-storage-share.py new file mode 100644 index 0000000..adde0df --- /dev/null +++ b/tools/bootstrap-recovery/inventory-storage-share.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Silent read-only inventory in the existing Platform Backup-account procedure. + +Runs only inside the attended platform-admin envelope. Reuses Platform's owner +custody accessor and fixed provider/account contract; no age key is read and no +backup is downloaded, uploaded, deleted, rotated or granted. +""" +import argparse +import base64 +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import sys +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET + +ROOT=Path(__file__).resolve().parents[2] +OWNER=ROOT.parent/'railiance-platform/scripts' +sys.path.insert(0,str(OWNER)) +from state_hub_preflight_lane import bao, data +from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH +BASE='/remote.php/dav/files/Backup/railiance-backups/' +LIMIT=4*1024*1024 +NAME=re.compile(r'[A-Za-z0-9._-]{1,160}\Z') + + +class Refused(Exception):pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs):raise Refused() + + +def parse_listing(raw, relative): + if len(raw)>LIMIT:raise Refused() + rows=[] + for item in ET.fromstring(raw).findall('{DAV:}response'): + href=urllib.parse.urlsplit(item.findtext('{DAV:}href') or '') + if href.netloc and href.netloc!=urllib.parse.urlsplit(HOST).netloc:raise Refused() + path=urllib.parse.unquote(href.path) + if not path.startswith(BASE+relative):raise Refused() + name=path[len(BASE+relative):].rstrip('/') + if not name:continue + if not NAME.fullmatch(name) or name in {'.','..'}:raise Refused() + for row in item.findall('{DAV:}propstat'): + if ' 200 ' not in (row.findtext('{DAV:}status') or ''):continue + prop=row.find('{DAV:}prop') + if prop is None:continue + folder=prop.find('{DAV:}resourcetype/{DAV:}collection') is not None + size=prop.findtext('{DAV:}getcontentlength') + if size is not None and not size.isdigit():raise Refused() + # Export only known-safe operational names/size, not arbitrary DAV + # properties, URLs, account metadata or raw provider responses. + if folder or name.endswith(('.age','.json','.yaml')): + rows.append({'name':name,'directory':folder,'bytes':int(size) if size else None}) + if len(rows)>10000 or len({r['name'] for r in rows})!=len(rows):raise Refused() + return sorted(rows,key=lambda x:x['name']) + + +def listing(relative,credentials): + if relative and (not relative.endswith('/') or not NAME.fullmatch(relative[:-1])):raise Refused() + auth=base64.b64encode((credentials['BACKUP_USERNAME']+':'+credentials['BACKUP_PASSWORD']).encode()).decode() + req=urllib.request.Request(HOST+BASE+relative, + data=b'', + method='PROPFIND',headers={'Authorization':'Basic '+auth,'Depth':'1','Content-Type':'application/xml'}) + opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect()) + with opener.open(req,timeout=30) as response: + if response.status!=207 or response.url!=HOST+BASE+relative:raise Refused() + raw=response.read(LIMIT+1) + return parse_listing(raw,relative) + + +def inventory(): + # Lookup payload contains a token: retain only safe caller/role facts. + identity=data(bao(['token','lookup','-format=json']))['data'] + if 'platform-admin' not in identity.get('policies',[]) or 'root' in identity.get('policies',[]):raise Refused() + if not str(identity.get('path','')).startswith('auth/netkingdom/'):raise Refused() + credentials=data(bao(['read','-format=json',OPERATOR_PATH]))['data']['data'] + if credentials.get('BACKUP_USERNAME')!='Backup' or not isinstance(credentials.get('BACKUP_PASSWORD'),str) or not credentials['BACKUP_PASSWORD']:raise Refused() + root=listing('',credentials) + folders={} + for row in root: + if row['directory'] and re.match(r'^(?:r01-|core-|identity|sso|bootstrap|net-kingdom|openbao)',row['name']): + if len(folders)>=32:raise Refused() + folders[row['name']]=listing(row['name']+'/',credentials) + return {'root':root,'folders':folders,'caller_auth_path':identity['path'], + 'caller_entity_id':identity.get('entity_id'),'scope':'Backup/railiance-backups root and bounded identity/database subfolders; other accounts/folders unassessed'} + + +def main(): + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True) + a=p.parse_args();fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + r={'schema':'netkingdom.storage-share-inventory.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(), + 'read_only':True,'age_keys_read':False,'backup_contents_downloaded':False,'secret_values_exported':False, + 'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + 'owner_sources':[{'path':str(OWNER/name),'sha256':hashlib.sha256((OWNER/name).read_bytes()).hexdigest()} for name in ['state_hub_preflight_lane.py','migrate_nextcloud_backup_account.py']]} + try:r.update(inventory());r['status']='metadata_inventory_complete' + except Exception:r['error']='inventory_failed_diagnostics_suppressed' + finally: + r['finished_at']=datetime.now(timezone.utc).isoformat() + with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n') + return int(r['status']=='failed') + + +if __name__=='__main__':raise SystemExit(main()) diff --git a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md index 7b3903f..46e8df4 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -438,6 +438,27 @@ reviewed isolated target/procedure and actual owner/native acceptance. Evidence: `docs/evidence/2026-10-15-bootstrap-recovery/`; full verification results are recorded there. No workplan completion is asserted. +2026-10-15 off-host discovery follow-through: operator reports holding the +`railiance-backup` age key independently; no private key was received/read. +The read-only Storage Share inventory completed through the existing attended +Platform Backup-account custody procedure; Warden exited 0, confirming session +self-revocation/cleanup. The inspected account has a current privacyIDEA database +ciphertext (`r01-net-kingdom-pg/net-kingdom-pg-privacyidea_db-20261015T023000Z.dump.age`, +89,129,309 bytes) and the October14 OpenBao snapshot. The inspected folders do +not contain a bootstrap/identity-file bundle. Bounded Scaleway metadata inventory +under the existing scoped backup delivery also completed; it found database +base/WAL trees and an application archive subtree, with no identity-file bundle +in the inspected scope. No bootstrap API key, age key or object content was read +by these inventories; other accounts/buckets/prefixes remain unassessed. + +Thirteen fixture checks pass for contained caller scope, metadata/path bounds, +pagination and malformed/foreign responses. Requested the human's hidden-terminal +key check against the backup owner's declared public recipient. Matching that +recipient remains distinct from actual decryption and full recovery; the +bootstrap `keys/age.pub` is not assumed to be the same recipient. T02/T03 remain +open. Evidence: `offsite-discovery-summary.json` and the two exact inventory +receipts in the existing October15 evidence folder. + ## Verify owner-approved recovery and current native callers ```task @@ -445,7 +466,7 @@ id: NK-WP-0049-T03 status: wait priority: high depends_on: [NK-WP-0049-T02] -blocking_reason: "Exact off-host artifact/coverage and independently held matching key remain unconfirmed after Scaleway/Storage Share destination identification. Requires completed T02 owner/runtime and legacy-credential disposition, then the exact reviewed isolated recovery ceremony and native consumer acceptance. No live bootstrap/root retrieval or deferred exposure rotation is authorized by source tests." +blocking_reason: "A recent off-host PI database ciphertext is identified, but matching full identity data/key/config coverage and actual independent-key decryption remain unverified after bounded Storage Share/Scaleway inventory. Requires completed T02 owner/runtime and legacy-credential disposition, then the exact reviewed isolated recovery ceremony and native consumer acceptance. No live bootstrap/root retrieval or deferred exposure rotation is authorized by source tests." state_hub_task_id: "d68cd4d3-1ba3-530e-88f9-26ebb8dd6eac" ```