From d2e13773545ba49da5197d4f016bf5ac17061a04 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 6 Nov 2026 01:02:59 +0100 Subject: [PATCH] Record populated pilot capture and prepare encrypted export Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1abf1-7e95-7141-87c9-ee78332bf790 --- ...1-06-vergabe-post-restart-user-return.json | 29 ++++ ...06-vergabe-three-store-capture-return.json | 44 +++++ .../README.md | 85 ++++++++++ .../plan.json | 49 ++++++ .../README.md | 8 + docs/vergabe-demo-company-acceptance.md | 24 ++- docs/vergabe-demo-company-binding.md | 8 + tests/test_vergabe_pilot_export.py | 156 +++++++++++++++++ tools/vergabe_pilot_export.py | 160 ++++++++++++++++++ tools/vergabe_pilot_export_remote.py | 125 ++++++++++++++ .../RAPPS-WP-0014-vergabe-invited-pilot.md | 33 +++- 11 files changed, 713 insertions(+), 8 deletions(-) create mode 100644 docs/evidence/2026-11-06-vergabe-post-restart-user-return.json create mode 100644 docs/evidence/2026-11-06-vergabe-three-store-capture-return.json create mode 100644 docs/proposals/2026-11-06-vergabe-recovery-export/README.md create mode 100644 docs/proposals/2026-11-06-vergabe-recovery-export/plan.json create mode 100644 tests/test_vergabe_pilot_export.py create mode 100644 tools/vergabe_pilot_export.py create mode 100644 tools/vergabe_pilot_export_remote.py diff --git a/docs/evidence/2026-11-06-vergabe-post-restart-user-return.json b/docs/evidence/2026-11-06-vergabe-post-restart-user-return.json new file mode 100644 index 0000000..9b46251 --- /dev/null +++ b/docs/evidence/2026-11-06-vergabe-post-restart-user-return.json @@ -0,0 +1,29 @@ +{ + "schema": "railiance.vergabe-pilot-user-return.v1", + "reported_on": "2026-11-06", + "workplan": "RAPPS-WP-0014", + "task": "RAPPS-WP-0014-T03", + "run_id": "20261105T233543Z-89bfbe04dece", + "evidence_kind": "user_reported", + "two_accounts_work_after_restart": true, + "user_report": "Both accounts work. The Ausschreibung is no longer listed on the \u00dcbersicht; everything else seems as before.", + "human_restart_check": "confirmed_with_overview_followup", + "overview_omission_reported": true, + "overview_followup": { + "deployed_source_revision": "c067993037f1cdeeb7b22d5600b51c649bc239db", + "source_file": "vergabe_teilnahme/apps/ausschreibungen/views.py", + "function": "dashboard", + "ongoing_list_filter": "status__range=(1, 9); latest ten by geaendert_am", + "final_statuses_omitted": [ + 10, + 11, + 12, + 13 + ], + "fixture_current_status": "not_reported; clarification requested", + "explanation": "A final status would explain exclusion; phase 8 alone is included. No live status or data-loss cause is inferred." + }, + "provider_publication_verified": false, + "restore_verified": false, + "authentication_policy_changed": false +} diff --git a/docs/evidence/2026-11-06-vergabe-three-store-capture-return.json b/docs/evidence/2026-11-06-vergabe-three-store-capture-return.json new file mode 100644 index 0000000..2533d69 --- /dev/null +++ b/docs/evidence/2026-11-06-vergabe-three-store-capture-return.json @@ -0,0 +1,44 @@ +{ + "schema": "railiance.vergabe-pilot-three-store-capture.v1", + "run_id": "20261105T233543Z-89bfbe04dece", + "status": "captured_and_resumed", + "database": "vergabe_demo_company", + "helm_revision": 4, + "app_resumed": true, + "off_host_verified": false, + "restore_verified": false, + "media_files": 1, + "media_bytes": 803, + "media_logical_sha256": "24fe90da88973ac03c8a475dff93c5866cebd797aeecdca2286989a1250779ed", + "fixture_document_sha256": "23a6850fa2391a58280c8515361ca29e9e92fa65fdadc36be7b44b552192e381", + "sqlite": { + "tables": 9, + "rows": 7, + "logical_sha256": "579dedb0e6a6b30f6cf3a5cec27ae423dbdaf0de959837cb9d697f6d5a789258", + "linked_issue_found": true, + "integrity_verified": true + }, + "stores_sha256": "6affa22da26badb4daceb87acf62d4eba57bfbfc2d60da0823b607c449596d66", + "stores_bytes": 3489, + "database_sha256": "8758ebd2e2b6c9e75742c42f2d32f71596522f5aade3a7212ed5a04c3cf3476c", + "database_bytes": 169232, + "database_tables": 39, + "database_rows": 247, + "database_logical_sha256": "33191c43c437f1b05da410a3f22989c86ce50f4fca8fd32d40fbb327b8b73765", + "writers_quiesced": true, + "anonymous_document_http_status": 302, + "anonymous_document_denied": true, + "helper_cleanup_confirmed": true, + "restart_fixture_verified": true, + "elapsed_seconds": 98.61, + "evidence_kind": "user_reported_terminal_output", + "reported_on": "2026-11-06", + "workplan": "RAPPS-WP-0014", + "task": "RAPPS-WP-0014-T03", + "command": "python3 tools/vergabe_pilot_snapshot_remote.py --execute", + "founder_executed_reviewed_change": true, + "elapsed_seconds_scope": "Whole helper operation after preflight; not separately measured outage duration", + "human_two_account_post_restart_walkthrough": "confirmed_with_overview_followup", + "production_data_restored": false, + "human_return_receipt": "2026-11-06-vergabe-post-restart-user-return.json" +} diff --git a/docs/proposals/2026-11-06-vergabe-recovery-export/README.md b/docs/proposals/2026-11-06-vergabe-recovery-export/README.md new file mode 100644 index 0000000..9f4d998 --- /dev/null +++ b/docs/proposals/2026-11-06-vergabe-recovery-export/README.md @@ -0,0 +1,85 @@ +# Export the populated Vergabe recovery point + +The founder's [native capture receipt](../../evidence/2026-11-06-vergabe-three-store-capture-return.json) +passes for run `20261105T233543Z-89bfbe04dece`. The app has resumed and both +accounts work, with the [overview UI follow-up](../../evidence/2026-11-06-vergabe-post-restart-user-return.json). +This step transfers the frozen point; it needs no further application pause. +[Exact scope, pins and validation](plan.json). + +## Founder workstation command + +```bash +git pull --ff-only +python3 tools/vergabe_pilot_export_remote.py --fetch +``` + +Default without `--fetch` reviews locally. `--fetch` uses the same existing +interactive SSH/sudo lane as inventory and capture. The agent does not execute +the founder's credential path. No remote checkout is needed. + +The node helper requires the successful capture receipt and the exact database +and stores sizes/hashes from the returned receipt. It packages only +`database.dump`, `stores.tar.gz`, `manifest.private.json`, `receipt.json` and a +generated packet manifest. Raw SQLite copies/work directories are excluded. + +Using the existing platform **public** age recipient, it encrypts on railiance01 +and retains the encrypted point plus its hash manifest in the root-private run +directory. A temporary plaintext package exists only inside that protected +directory and is removed after sealing. The original capture is preserved. +An interrupted, inconsistent seal stops for review rather than being overwritten. + +The helper copies ciphertext and safe transfer metadata to two 0600 files in +the existing founder SSH account's home (`tegwick`), named +`vergabe-pilot-20261105T233543Z-89bfbe04dece.{zip.age,transfer.json}`. +Only those encrypted handoff files are made readable by that account; the +private capture files remain root-only. Existing different handoff bytes refuse. + +The wrapper uses scp to fetch the pair into a private temporary directory, then +checks the source run/image/database, captured database/store hashes, encrypted +size and ciphertext hash. Only a verified pair becomes the final directory: + +```text +~/.local/state/vergabe-pilot-recovery/20261105T233543Z-89bfbe04dece/ +``` + +It refuses a destination inside a Git tree. Files are 0600 and the directory is +0700. An already verified pair can be reused on retry. Successful final JSON +has `status: encrypted_workstation_copy_verified` and includes the local path. +Return that JSON, without opening or pasting backup contents. + +No Kubernetes/database access, application change, OpenBao access, provider call +or recovery-key retrieval occurs. Age must already be available on the node at +one of the standard or existing platform vendor paths. Missing age fails before +sealing; this command does not install tools or alter the platform configuration. + +## What this establishes + +The encrypted bytes are now copied to the founder's workstation outside the +production node, with transport hashes verified. This is a separate milestone +from publication to the declared backup provider. The receipt keeps +`provider_publication_verified`, `decryption_verified` and `restore_verified` +false. Public-key encryption alone does not prove the escrow identity can recover +the point, and no recurring protection or accepted recovery objective is inferred. + +Credential routing confirms the existing `railiance-backup-offsite-lane` belongs +to railiance-platform. Its Nextcloud file-drop credential is create-only; +independent readback uses the Backup account, and age decryption uses recovery +escrow in a contained founder login. Reuse those owner helpers for publication +and readback; do not borrow another application's Scaleway publisher identity. +No new credential lane, role, provider folder, retention change or timer is +created by this export. The upload-only token is not readback authority. + +After provider readback, rehearse an isolated restore of this matched point with +outgoing integrations disabled. Verify every PostgreSQL table digest, documents +and local issue linkage, restored account behavior, source/image availability and +elapsed recovery time. Compatible rollback, access denial and named backup/support +ownership, cadence, retention and accepted RPO/RTO remain T03 requirements. + +## Validation + +`python3 -m unittest discover -s tests -p test_vergabe_pilot_export.py`: seven +synthetic checks pass. They include actual age encryption/decryption, complete +packet comparison, changed capture/ciphertext rejection, unsafe source refusal, +handoff preservation, verified transfer/retry and Git destination refusal. +No native backup or real key is read by those tests. Native export remains +founder-executed and unverified until its receipt returns. diff --git a/docs/proposals/2026-11-06-vergabe-recovery-export/plan.json b/docs/proposals/2026-11-06-vergabe-recovery-export/plan.json new file mode 100644 index 0000000..64151e2 --- /dev/null +++ b/docs/proposals/2026-11-06-vergabe-recovery-export/plan.json @@ -0,0 +1,49 @@ +{ + "schema": "railiance.vergabe-pilot-recovery-export-plan.v1", + "prepared_on": "2026-11-06", + "workplan": "RAPPS-WP-0014", + "task": "RAPPS-WP-0014-T03", + "run_id": "20261105T233543Z-89bfbe04dece", + "sources": { + "vergabe_pilot_recovery_store": "724a2dd9db8a0f57e1efa709ec989d8a47c681a86b1fca923bfdfea1698ae23c", + "vergabe_pilot_export": "b45733fa10ae59b56563045c43c53b5f824a74e1114f68fbed28404f14b43e3a" + }, + "execution": "Founder workstation --fetch: interactive SSH/sudo on railiance01, followed by scp of ciphertext and a safe manifest", + "source": "/var/lib/vergabe-pilot-recovery/20261105T233543Z-89bfbe04dece/", + "scope": [ + "Verify accepted capture receipt and pinned database/stores byte sizes and SHA256", + "Package only database.dump, stores.tar.gz, manifest.private.json, receipt.json and generated packet.json", + "Encrypt on the node to the existing platform public age recipient", + "Create mode-0600 encrypted handoff files for the existing founder SSH account", + "Copy and checksum-verify ciphertext and transfer metadata in a private workstation directory outside Git" + ], + "recipient": "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz", + "recipient_source": "railiance-platform/lib/railiance-backup-common.sh", + "node_ciphertext": "recovery-point.zip.age", + "node_public_manifest": "seal.json", + "founder_handoff_basename": "vergabe-pilot-20261105T233543Z-89bfbe04dece", + "workstation_directory": "~/.local/state/vergabe-pilot-recovery/20261105T233543Z-89bfbe04dece/", + "application_mutations": false, + "database_access": false, + "secret_reads": false, + "openbao_access": false, + "provider_access": false, + "provider_publication_verified": false, + "decryption_verified": false, + "restore_verified": false, + "next_provider_route": { + "id": "railiance-backup-offsite-lane", + "owner_repo": "railiance-platform", + "provider": "Existing Nextcloud create-only file-drop lane; independent Backup account for readback; age escrow for contained decrypt verification", + "route_only_not_publication_evidence": true + }, + "validation": { + "unit_cases_passed": 7, + "actual_age_synthetic_round_trip": true, + "tampered_capture_or_ciphertext_refused": true, + "existing_handoff_not_overwritten": true, + "transport_retry_uses_verified_pair": true, + "git_destination_refused": true, + "native_export_executed": false + } +} diff --git a/docs/proposals/2026-11-06-vergabe-three-store-capture/README.md b/docs/proposals/2026-11-06-vergabe-three-store-capture/README.md index c5aaa69..c7d7570 100644 --- a/docs/proposals/2026-11-06-vergabe-three-store-capture/README.md +++ b/docs/proposals/2026-11-06-vergabe-three-store-capture/README.md @@ -37,6 +37,14 @@ data. A failed check stops before any capture mutation. ## Prepared capture, after the founder's go +Executed by the founder: run `20261105T233543Z-89bfbe04dece` reports +[`captured_and_resumed`](../../evidence/2026-11-06-vergabe-three-store-capture-return.json). +The matched populated point and restart checks pass; the full helper took +98.61 seconds. Both accounts work after restart, with an overview-list UI +follow-up. Off-host publication and isolated restore remain open. The command +below is retained for reproducibility; **do not recapture this accepted point**. +Next: [encrypt and fetch the frozen point](../2026-11-06-vergabe-recovery-export/README.md). + Live production changes require the founder's go for this change under `the-custodian/docs/agent-environment-orientation.md` section 0. The following command is prepared for that later maintenance window: diff --git a/docs/vergabe-demo-company-acceptance.md b/docs/vergabe-demo-company-acceptance.md index d359b79..0e353e0 100644 --- a/docs/vergabe-demo-company-acceptance.md +++ b/docs/vergabe-demo-company-acceptance.md @@ -24,10 +24,21 @@ passes at Helm revision 4 with the expected healthy deployment/database and both Bound claims. The [November 6 capture check](evidence/2026-11-06-vergabe-capture-preflight-return.json) also passes: two active members and OIDC identities, fixture 1/1/1, matching file, valid local-state header, anonymous download denial, no pending migrations and -the reader Pod server dry-run. Next is the founder-approved pause/capture/resume -in the [three-store recovery packet](proposals/2026-11-06-vergabe-three-store-capture/README.md). +the reader Pod server dry-run. The founder-approved capture then executed using +the [three-store recovery packet](proposals/2026-11-06-vergabe-three-store-capture/README.md). Neither preflight nor synthetic validation satisfies the pilot recovery gate. +The [populated capture and resume](evidence/2026-11-06-vergabe-three-store-capture-return.json) +pass: 39 PostgreSQL tables / 247 rows, one PDF and SQLite integrity/link checks; +the helper was removed. Total operation time was 98.61 seconds, including +resume/postflight, rather than a separately measured outage duration. +The [human restart return](evidence/2026-11-06-vergabe-post-restart-user-return.json) +confirms both accounts work and everything else appears as before, except the +tender is absent from Übersicht. The deployed dashboard lists only status 1–9; +final statuses 10–13 would explain exclusion. The actual current status is not +yet reported, so this remains a UI follow-up. Off-host publication and isolated +restore remain unverified. + The other session does not update automatically, and a fresh login was needed after a short interval. The user wants a full working day without renewed login and would prefer no OTP at initial app sign-in, with refinement explicitly @@ -93,7 +104,8 @@ could not read native node, Deployment and PVC status. The founder's subsequent [capture preflight](evidence/2026-11-06-vergabe-capture-preflight-return.json) clear that blocker at Helm revision 4. They use the founder's own terminal and existing SSH/sudo lane; the agent receives only sanitized receipts. Native -capture and restart have not run. +capture and automated restart verification subsequently passed; see the capture +receipt above. No additional pause is needed to transfer that frozen point. After the populated workflow, prepare the exact attended recovery action from the existing [pilot recovery contract](vergabe-teilnahme-pilot.md#persistence-and-recovery) @@ -122,8 +134,8 @@ and [deployment binding](vergabe-demo-company-binding.md). Its receipt must cove Existing database-only backup/restore targets default to historical `vergabe_db` and omit both file stores. They cannot establish this three-store recovery claim. The two-account functional walkthrough is user-confirmed with UI follow-up. -The attended capture/restart action is prepared and its live preflight passes; -execution awaits the founder's go. Off-host publication, isolated native restore -and rollback remain outstanding. +The attended capture/restart action has executed successfully, with automated +and human restart checks (overview follow-up above). Off-host publication, +isolated restore and rollback remain outstanding. Logged-in account deactivation remains a separate owner-run access-control check from the pilot contract. No account is disabled as part of these walkthrough steps. diff --git a/docs/vergabe-demo-company-binding.md b/docs/vergabe-demo-company-binding.md index 12f1ca9..abc6314 100644 --- a/docs/vergabe-demo-company-binding.md +++ b/docs/vergabe-demo-company-binding.md @@ -3,6 +3,14 @@ Updated 2026-11-06 under RAPPS-WP-0014 and VERGABE-WP-0019. Release binding is complete (RAPPS-WP-0014-T02). Recovery and invited-recipient acceptance remain open. +November 6 recovery update: the founder's matched PostgreSQL/media/SQLite capture +and app resume pass at revision 4; the full operation took 98.61 seconds. Both +accounts work after restart, with an overview-list UI follow-up. See the +[capture receipt](evidence/2026-11-06-vergabe-three-store-capture-return.json). +Provider publication/readback, isolated restore, rollback/access and operating +ownership remain open. The accepted point can be transferred without another +pause using the [encrypted export packet](proposals/2026-11-06-vergabe-recovery-export/README.md). + | Item | Current binding | | --- | --- | | Tenant | tenant:trial:demo-company, active/version 1; three native memberships and one linked directory identity | diff --git a/tests/test_vergabe_pilot_export.py b/tests/test_vergabe_pilot_export.py new file mode 100644 index 0000000..29d546a --- /dev/null +++ b/tests/test_vergabe_pilot_export.py @@ -0,0 +1,156 @@ +"""Synthetic recovery export checks; no node, native backup or credential reads.""" + +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +import vergabe_pilot_export as export +import vergabe_pilot_export_remote as remote +from vergabe_pilot_recovery_store import Refused, digest + + +class ExportTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="vergabe-export-fixture-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.capture = self.root / "capture" + self.capture.mkdir(mode=0o700) + for name, content in {"database.dump": b"PGDMP synthetic data", + "stores.tar.gz": b"synthetic store data"}.items(): + (self.capture / name).write_bytes(content) + (self.capture / name).chmod(0o600) + expected = {name: ((self.capture / name).stat().st_size, digest(self.capture / name)) + for name in export.EXPECTED} + self.patch = patch.object(export, "EXPECTED", expected) + self.patch.start() + self.addCleanup(self.patch.stop) + receipt = {"run_id": export.RUN_ID, "status": "captured_and_resumed", "helm_revision": 4, + "database": "vergabe_demo_company", "app_resumed": True, "writers_quiesced": True, + "helper_cleanup_confirmed": True, "restart_fixture_verified": True} + for name, prefix in [("database.dump", "database"), ("stores.tar.gz", "stores")]: + receipt[prefix + "_bytes"], receipt[prefix + "_sha256"] = expected[name] + self.write("receipt.json", receipt) + self.write("manifest.private.json", {"image": export.IMAGE, "database": "vergabe_demo_company", + "private_fixture": "synthetic-private-sentinel"}) + + def write(self, name, value): + path = self.capture / name + path.write_text(json.dumps(value)) + path.chmod(0o600) + + def test_changed_capture_refuses_before_crypto(self): + (self.capture / "database.dump").write_bytes(b"different capture") + with patch.object(export.subprocess, "run") as crypto, self.assertRaises(Refused): + export.seal(self.capture, "age") + crypto.assert_not_called() + + def test_public_or_linked_source_is_refused(self): + path = self.capture / "manifest.private.json" + path.chmod(0o644) + with self.assertRaises(Refused): + export.capture_files(self.capture) + path.unlink() + path.symlink_to(self.capture / "receipt.json") + with self.assertRaises(Refused): + export.capture_files(self.capture) + + def test_handoff_never_overwrites_a_different_file(self): + home = self.root / "home" + home.mkdir(mode=0o700) + target = home / "packet.age" + target.write_bytes(b"already there") + target.chmod(0o600) + with self.assertRaises(Refused): + export.handoff(self.capture / "database.dump", home, target.name, os.geteuid(), os.getegid()) + self.assertEqual(target.read_bytes(), b"already there") + + @unittest.skipUnless(shutil.which("age") and shutil.which("age-keygen"), "requires age fixture tools") + def test_encrypt_decrypt_transfer_and_retry_preserve_the_whole_packet(self): + identity = self.root / "synthetic-identity" + generated = subprocess.run(["age-keygen"], capture_output=True, check=True) + identity.write_bytes(generated.stdout) + identity.chmod(0o600) + public = subprocess.run(["age-keygen", "-y", str(identity)], capture_output=True, check=True).stdout.decode().strip() + with patch.object(export, "RECIPIENT", public): + ciphertext, sealed = export.seal(self.capture, shutil.which("age")) + repeated, again = export.seal(self.capture, shutil.which("age")) + self.assertEqual(digest(repeated), digest(ciphertext)) + self.assertEqual(again, sealed) + decrypted = subprocess.run(["age", "-d", "-i", str(identity), str(ciphertext)], + capture_output=True, check=True) + with zipfile.ZipFile(io.BytesIO(decrypted.stdout)) as packet: + self.assertEqual(set(packet.namelist()), + {"database.dump", "stores.tar.gz", "manifest.private.json", "receipt.json", "packet.json"}) + metadata = json.loads(packet.read("packet.json")) + for name, row in metadata["files"].items(): + self.assertEqual(packet.read(name), (self.capture / name).read_bytes()) + self.assertEqual(row["sha256"], digest(self.capture / name)) + home = self.root / "handoff" + home.mkdir(mode=0o700) + export.handoff(ciphertext, home, export.NAME + ".zip.age", os.geteuid(), os.getegid()) + export.handoff(self.capture / "seal.json", home, export.NAME + ".transfer.json", os.geteuid(), os.getegid()) + result = remote.verify(home) + self.assertEqual(result["status"], "encrypted_workstation_copy_verified") + self.assertFalse(result["provider_publication_verified"]) + self.assertFalse(result["decryption_verified"]) + self.assertFalse(result["restore_verified"]) + self.assertNotIn("synthetic-private-sentinel", json.dumps(sealed)) + (home / (export.NAME + ".zip.age")).write_bytes(b"age-encryption.org/v1\ntampered") + with self.assertRaises(Refused): + remote.verify(home) + + def test_changed_source_pin_prevents_remote_execution(self): + with patch.dict(remote.PINS, {"vergabe_pilot_export": "0" * 64}), self.assertRaises(Refused): + remote.remote_command() + + def test_fetch_publishes_only_a_verified_pair_and_reuses_it_on_retry(self): + handoff = self.root / "remote-home" + handoff.mkdir(mode=0o700) + cipher = handoff / (export.NAME + ".zip.age") + cipher.write_bytes(b"age-encryption.org/v1\nsynthetic-ciphertext") + cipher.chmod(0o600) + metadata = {"schema": "railiance.vergabe-pilot-sealed-export.v1", "run_id": export.RUN_ID, + "recipient": export.RECIPIENT, "ciphertext_sha256": digest(cipher), + "ciphertext_bytes": cipher.stat().st_size, + "packet": {"run_id": export.RUN_ID, "image": export.IMAGE, + "database": "vergabe_demo_company", "files": export.capture_files(self.capture)}} + transfer = handoff / (export.NAME + ".transfer.json") + transfer.write_text(json.dumps(metadata)) + transfer.chmod(0o600) + def execute(args, **kwargs): + if args[0] == "scp": + source = args[2].split(":", 1)[1] + shutil.copy2(handoff / source, Path(args[3])) + return subprocess.CompletedProcess(args, 0) + with patch.object(remote, "remote_command", return_value=["fixture-ssh"]), \ + patch.object(remote.Path, "home", return_value=self.root / "workstation"), \ + patch.object(remote, "outside_git", return_value=True), \ + patch.object(remote.sys.stdin, "isatty", return_value=True), \ + patch.object(remote.sys, "argv", ["export", "--fetch"]), \ + patch.object(remote.subprocess, "run", side_effect=execute) as command, \ + redirect_stdout(io.StringIO()): + self.assertEqual(remote.main(), 0) + self.assertEqual(remote.main(), 0) + self.assertEqual(command.call_count, 3) + path = self.root / "workstation/.local/state/vergabe-pilot-recovery" / export.RUN_ID + self.assertTrue((path / "workstation-copy.json").exists()) + self.assertEqual({p.suffix for p in path.iterdir()}, {".age", ".json"}) + + def test_nested_backup_destination_inside_a_repository_is_refused(self): + (self.root / ".git").mkdir() + self.assertFalse(remote.outside_git(self.root / "private/backup")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/vergabe_pilot_export.py b/tools/vergabe_pilot_export.py new file mode 100644 index 0000000..df20dcf --- /dev/null +++ b/tools/vergabe_pilot_export.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Seal the accepted recovery point for founder-only workstation transfer. + +Run by the founder through the remote wrapper. Reads only this completed +capture; writes an age-encrypted packet and private founder handoff files. +No Kubernetes, database, OpenBao or provider access. +""" + +import json +import os +import pwd +import shutil +import stat +import subprocess +import tempfile +import zipfile +from pathlib import Path + +from vergabe_pilot_recovery_store import Refused, digest, private_directory, require + +RUN_ID = "20261105T233543Z-89bfbe04dece" +BASE = Path("/var/lib/vergabe-pilot-recovery") +NAME = "vergabe-pilot-" + RUN_ID +# Public recipient declared by railiance-platform/lib/railiance-backup-common.sh. +RECIPIENT = "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz" +EXPECTED = { + "database.dump": (169232, "8758ebd2e2b6c9e75742c42f2d32f71596522f5aade3a7212ed5a04c3cf3476c"), + "stores.tar.gz": (3489, "6affa22da26badb4daceb87acf62d4eba57bfbfc2d60da0823b607c449596d66"), +} +IMAGE = ("forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:" + "a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91") + + +def regular(path, owner): + info = path.lstat() + require(stat.S_ISREG(info.st_mode) and info.st_uid == owner + and not info.st_mode & 0o077, "private_regular_file_required") + return info + + +def capture_files(directory): + private_directory(directory) + files = {} + for name in [*EXPECTED, "manifest.private.json", "receipt.json"]: + path = directory / name + info = regular(path, os.geteuid()) + require(0 < info.st_size <= 1024 * 1024, "capture_file_bound") + files[name] = {"bytes": info.st_size, "sha256": digest(path)} + if name in EXPECTED: + require((info.st_size, files[name]["sha256"]) == EXPECTED[name], "capture_hash_mismatch") + receipt = json.loads((directory / "receipt.json").read_text()) + require(receipt["run_id"] == RUN_ID and receipt["status"] == "captured_and_resumed" + and receipt["helm_revision"] == 4 and receipt["database"] == "vergabe_demo_company" + and all(receipt.get(key) is True for key in ["app_resumed", "writers_quiesced", + "helper_cleanup_confirmed", "restart_fixture_verified"]), "capture_not_accepted") + for name, prefix in [("database.dump", "database"), ("stores.tar.gz", "stores")]: + require(receipt[prefix + "_sha256"] == files[name]["sha256"] + and receipt[prefix + "_bytes"] == files[name]["bytes"], "receipt_hash_mismatch") + manifest = json.loads((directory / "manifest.private.json").read_text()) + require(manifest["image"] == IMAGE and manifest["database"] == "vergabe_demo_company", + "capture_binding_mismatch") + return files + + +def seal(directory, age): + files = capture_files(directory) + metadata = {"schema": "railiance.vergabe-pilot-recovery-packet.v1", "run_id": RUN_ID, + "database": "vergabe_demo_company", "image": IMAGE, "files": files} + ciphertext = directory / "recovery-point.zip.age" + seal_receipt = directory / "seal.json" + if ciphertext.exists() or seal_receipt.exists(): + regular(ciphertext, os.geteuid()) + regular(seal_receipt, os.geteuid()) + result = json.loads(seal_receipt.read_text()) + require(result["packet"] == metadata and result["recipient"] == RECIPIENT + and result["ciphertext_sha256"] == digest(ciphertext) + and result["ciphertext_bytes"] == ciphertext.stat().st_size, + "existing_seal_mismatch") + return ciphertext, result + with tempfile.TemporaryDirectory(prefix="seal-", dir=directory) as temporary: + packet = Path(temporary) / "recovery-point.zip" + with zipfile.ZipFile(packet, "x", compression=zipfile.ZIP_STORED) as archive: + for name in files: + archive.write(directory / name, name) + archive.writestr("packet.json", json.dumps(metadata, sort_keys=True)) + packet.chmod(0o600) + # Recheck frozen source hashes after packing, before encryption/publication. + require(capture_files(directory) == files, "capture_changed_during_export") + encrypted = Path(temporary) / "packet.age" + fd = os.open(encrypted, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "wb") as output: + result = subprocess.run([age, "-r", RECIPIENT, str(packet)], stdout=output, + stderr=subprocess.DEVNULL, timeout=60) + require(result.returncode == 0, "encryption_failed") + output.flush() + os.fsync(output.fileno()) + require(encrypted.stat().st_size <= 4 * 1024 * 1024, "encrypted_packet_bound") + with encrypted.open("rb") as source: + require(source.read(22) == b"age-encryption.org/v1\n", "age_header_required") + result = {"schema": "railiance.vergabe-pilot-sealed-export.v1", "run_id": RUN_ID, + "recipient": RECIPIENT, "ciphertext_sha256": digest(encrypted), + "ciphertext_bytes": encrypted.stat().st_size, "packet": metadata, + "provider_publication_verified": False, "restore_verified": False} + fd = os.open(seal_receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w") as output: + json.dump(result, output, indent=2) + output.flush() + os.fsync(output.fileno()) + # No overwrite, including after an interrupted export. A partial pair + # requires review rather than silently replacing an earlier ciphertext. + os.link(encrypted, ciphertext) + return ciphertext, result + + +def handoff(source, directory, name, uid, gid): + target = directory / name + if target.exists() or target.is_symlink(): + regular(target, uid) + require(digest(target) == digest(source), "existing_handoff_mismatch") + return + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "wb") as output, source.open("rb") as stream: + shutil.copyfileobj(stream, output) + output.flush() + os.fsync(output.fileno()) + os.fchown(output.fileno(), uid, gid) + + +def main(): + require(os.geteuid() == 0, "founder_node_sudo_required") + account = pwd.getpwnam("tegwick") + require(os.environ.get("SUDO_USER") == account.pw_name + and os.environ.get("SUDO_UID") == str(account.pw_uid) and account.pw_uid > 0, + "founder_ssh_account_required") + home = Path(account.pw_dir) + info = home.lstat() + require(str(home) == "/home/tegwick" and stat.S_ISDIR(info.st_mode) + and info.st_uid == account.pw_uid and not info.st_mode & 0o022, + "founder_home_required") + age = next((str(p) for p in [Path("/usr/bin/age"), Path("/usr/local/bin/age"), + Path("/opt/railiance-platform/tools/vendor/bin/age"), + home / "railiance-platform/tools/vendor/bin/age"] if p.is_file() and os.access(p, os.X_OK)), None) + require(age, "age_unavailable") + private_directory(BASE) + encrypted, result = seal(BASE / RUN_ID, age) + handoff(encrypted, home, NAME + ".zip.age", account.pw_uid, account.pw_gid) + handoff(encrypted.parent / "seal.json", home, NAME + ".transfer.json", account.pw_uid, account.pw_gid) + print(json.dumps({"status": "encrypted_handoff_ready", "run_id": RUN_ID, + "ciphertext_sha256": result["ciphertext_sha256"], + "ciphertext_bytes": result["ciphertext_bytes"], "application_changed": False})) + + +if __name__ == "__main__": + os.umask(0o077) + try: + main() + except Exception as error: + print(json.dumps({"status": "export_failed", "failure": str(error) if isinstance(error, Refused) + else "export_command_failed", "details_suppressed": True})) + raise SystemExit(1) diff --git a/tools/vergabe_pilot_export_remote.py b/tools/vergabe_pilot_export_remote.py new file mode 100644 index 0000000..bafced9 --- /dev/null +++ b/tools/vergabe_pilot_export_remote.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Founder workstation: seal and fetch this completed pilot recovery point. + +Default reviews only. --fetch uses the existing interactive SSH/sudo lane to +encrypt on railiance01, then scp copies only ciphertext and a safe manifest. +No plaintext backup, credential or private recovery key leaves the node. +""" + +import argparse +import hashlib +import json +import os +import shlex +import subprocess +import sys +import tempfile +from pathlib import Path + +import vergabe_pilot_export as export +from vergabe_pilot_recovery_store import Refused, digest, private_directory, require + +PINS = { + "vergabe_pilot_recovery_store": "724a2dd9db8a0f57e1efa709ec989d8a47c681a86b1fca923bfdfea1698ae23c", + "vergabe_pilot_export": "b45733fa10ae59b56563045c43c53b5f824a74e1114f68fbed28404f14b43e3a", +} + + +def remote_command(): + sources = {} + for name, pin in PINS.items(): + raw = Path(__file__).with_name(name + ".py").read_bytes() + require(hashlib.sha256(raw).hexdigest() == pin, "export_source_pin_mismatch") + sources[name] = raw.decode() + bootstrap = ("import sys,types;m=types.ModuleType('vergabe_pilot_recovery_store');" + "sys.modules[m.__name__]=m;exec(compile(" + repr(sources["vergabe_pilot_recovery_store"]) + + ",'vergabe_pilot_recovery_store.py','exec'),m.__dict__);exec(compile(" + + repr(sources["vergabe_pilot_export"]) + ",'vergabe_pilot_export.py','exec')," + "{'__name__':'__main__'})") + return ["ssh", "-t", "railiance01", shlex.join(["sudo", "/usr/bin/python3", "-c", bootstrap])] + + +def verify(directory): + private_directory(directory) + ciphertext = directory / (export.NAME + ".zip.age") + transfer = directory / (export.NAME + ".transfer.json") + export.regular(ciphertext, os.geteuid()) + export.regular(transfer, os.geteuid()) + require(0 < transfer.stat().st_size <= 16384, "transfer_metadata_bound") + receipt = json.loads(transfer.read_text()) + require(receipt["schema"] == "railiance.vergabe-pilot-sealed-export.v1" + and receipt["run_id"] == export.RUN_ID and receipt["recipient"] == export.RECIPIENT + and receipt["packet"]["run_id"] == export.RUN_ID + and receipt["packet"]["image"] == export.IMAGE + and receipt["packet"]["database"] == "vergabe_demo_company", "wrong_recovery_point") + for name, (size, checksum) in export.EXPECTED.items(): + require(receipt["packet"]["files"][name] == {"bytes": size, "sha256": checksum}, + "source_hash_mismatch") + require(0 < ciphertext.stat().st_size <= 4 * 1024 * 1024 + and ciphertext.stat().st_size == receipt["ciphertext_bytes"] + and digest(ciphertext) == receipt["ciphertext_sha256"], "ciphertext_transfer_mismatch") + with ciphertext.open("rb") as stream: + require(stream.read(22) == b"age-encryption.org/v1\n", "age_header_required") + return {"schema": "railiance.vergabe-pilot-workstation-copy.v1", "run_id": export.RUN_ID, + "status": "encrypted_workstation_copy_verified", "ciphertext_sha256": receipt["ciphertext_sha256"], + "ciphertext_bytes": receipt["ciphertext_bytes"], "encrypted_on_source_node": True, + "off_host_copy_verified": True, "provider_publication_verified": False, + "decryption_verified": False, "restore_verified": False, "application_changed": False} + + +def outside_git(path): + return not any((p / ".git").exists() for p in [path, *path.parents]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fetch", action="store_true") + args = parser.parse_args() + command = remote_command() + if not args.fetch: + print(json.dumps({"status": "prepared", "run_id": export.RUN_ID, "ssh_target": "railiance01", + "application_changed": False, "next": "--fetch", "sources": PINS})) + return 0 + require(sys.stdin.isatty(), "founder_interactive_terminal_required") + destination = Path.home() / ".local/state/vergabe-pilot-recovery" / export.RUN_ID + require(outside_git(destination), "private_destination_outside_git_required") + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + private_directory(destination.parent) + if destination.exists() or destination.is_symlink(): + result = verify(destination) + else: + result = subprocess.run(command, check=False) + require(result.returncode == 0, "remote_seal_failed") + with tempfile.TemporaryDirectory(prefix="fetch-", dir=destination.parent) as temporary: + temporary = Path(temporary) + for suffix in (".zip.age", ".transfer.json"): + name = export.NAME + suffix + copied = subprocess.run(["scp", "-p", "railiance01:" + name, str(temporary / name)], + capture_output=True, timeout=120) + require(copied.returncode == 0, "encrypted_copy_failed") + result = verify(temporary) + # Keep a verified pair only; temporary failures never become success. + temporary.rename(destination) + result["local_directory"] = str(destination) + receipt = destination / "workstation-copy.json" + if receipt.exists() or receipt.is_symlink(): + export.regular(receipt, os.geteuid()) + require(json.loads(receipt.read_text()) == result, "existing_copy_receipt_mismatch") + else: + fd = os.open(receipt, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w") as output: + json.dump(result, output, indent=2) + output.flush() + os.fsync(output.fileno()) + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + os.umask(0o077) + try: + raise SystemExit(main()) + except Exception as error: + print(json.dumps({"status": "export_failed", "failure": str(error) if isinstance(error, Refused) + else "export_transport_failed", "details_suppressed": True})) + raise SystemExit(1) diff --git a/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md b/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md index bd01532..b10f5c5 100644 --- a/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md +++ b/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md @@ -196,10 +196,10 @@ id: RAPPS-WP-0014-T03 status: wait needs_human: true decision_id: d06307e5-2737-461a-a2f4-a12c3b492a9a -intervention_note: "Functional walkthrough is confirmed with UI follow-up. Latest founder inventory returned cluster_read unavailable_or_invalid_response; invoking host is unconfirmed, so no cluster fault is inferred. Next: run tools/vergabe_pilot_inventory_remote.py --run from the founder workstation terminal to target railiance01 explicitly. Agent privileges stay unchanged. Native inventory, populated recovery/access/rollback and ownership remain open." +intervention_note: "Native matched PostgreSQL/media/SQLite capture and resume pass at Helm revision 4 (98.61 seconds whole operation). Both accounts work after restart, with an Übersicht omission under UI review. Next: founder-run encrypted export --fetch, then provider publication/readback and isolated restore. Access/rollback and operating ownership remain open." priority: high assignee: the-custodian -blocking_reason: "Two-account functional walkthrough is confirmed with UI follow-up. Await attended native inventory, populated restart, coherent PostgreSQL/media/issue-state off-host recovery, isolated restored workflow, rollback/access evidence and operating ownership. September 25 proves database-only restore and restart." +blocking_reason: "Matched populated capture, automated restart checks and human two-account return pass with overview UI follow-up. Await encrypted off-host publication/readback, isolated restored workflow, access checks, compatible rollback and operating ownership." state_hub_task_id: "dd069c6d-fcc1-5bac-b233-976f2f0d5cd1" ``` @@ -402,6 +402,35 @@ and guarded resume. No capture or populated restart has executed. T03 remains waiting for that action and subsequent off-host/restore/rollback, access and operating-owner acceptance. +2026-11-06 founder execution return: run `20261105T233543Z-89bfbe04dece` +reports `captured_and_resumed` at Helm revision 4. PostgreSQL: 39 tables / 247 +rows, 169232-byte custom dump. Media: one 803-byte PDF matching the original +SHA256. SQLite: 9 tables / 7 rows, integrity and linked issue verified. Writers +were quiesced, helper cleanup confirmed and the app resumed with automated +document/issue checks passing. Whole operation: 98.61 seconds; this is not a +separately measured outage duration. Native receipt: +`docs/evidence/2026-11-06-vergabe-three-store-capture-return.json`. +The founder's execution is the go for the reviewed capture change. The app is +running; further capture pauses are unnecessary for this point. The human return +confirms both accounts work and everything else seems as before, with an +Übersicht omission reported. The deployed dashboard filters ongoing tenders to +status 1–9; final statuses would explain exclusion, but the current fixture +status is not yet confirmed. Receipt: +`docs/evidence/2026-11-06-vergabe-post-restart-user-return.json`. Keep that UI +follow-up separate from the confirmed restart checks. +Off-host publication/readback and isolated restore remain explicitly unverified; +rollback/access and operating ownership also remain open. T03 is not complete. + +Prepared `tools/vergabe_pilot_export_remote.py --fetch` and the review packet +`docs/proposals/2026-11-06-vergabe-recovery-export/README.md`: seal this exact +completed point on the node to the existing platform public age recipient, then +fetch only ciphertext/metadata to the founder's private workstation directory +and verify hashes. Seven synthetic export cases pass, including real age round +trip and transport/retry. This action does not pause the app or access credentials +or the provider. Native execution is pending. Warden routing reconfirms the +existing platform Nextcloud lane; an encrypted workstation copy is not provider +publication, independent escrow readback or an isolated restore receipt. + ## Validate synthetic sizing against the exact release and shared contention