diff --git a/docs/evidence/2026-11-06-vergabe-upload-binding-failure-return.json b/docs/evidence/2026-11-06-vergabe-upload-binding-failure-return.json new file mode 100644 index 0000000..606c2fd --- /dev/null +++ b/docs/evidence/2026-11-06-vergabe-upload-binding-failure-return.json @@ -0,0 +1,47 @@ +{ + "schema": "railiance.vergabe-pilot-provider-recovery.v1", + "run_id": "20261105T233543Z-89bfbe04dece", + "status": "failed", + "provider_publication_verified": false, + "decryption_verified": false, + "restore_verified": false, + "application_changed": false, + "stage": "owner_custody", + "owner_handoff_checks": { + "routing_marker_present": true, + "operator_tunnel_matches": true, + "no_ambient_token_variables": true, + "isolated_home_shape": true, + "isolated_home_private": true + }, + "failure": "upload_lane_binding_changed", + "elapsed_seconds": 0.6, + "warden_exit_code": 11, + "receipt_file": "/home/worsch/.local/state/vergabe-pilot-recovery/20261105T233543Z-89bfbe04dece/provider-receipt-603935659e98.json", + "session_cleanup_confirmed": null, + "session_revocation_confirmed": null, + "session_storage_cleanup_confirmed": true, + "evidence_kind": "user_reported_terminal_output", + "reported_on": "2026-11-06", + "workplan": "RAPPS-WP-0014", + "task": "RAPPS-WP-0014-T03", + "source_revision": "473187c1141a87c8e05ca01a4cf3daae22a81b10", + "review": { + "login_and_handoff_passed": true, + "session_revoked_user_reported": true, + "owner_identity_and_two_custody_reads_succeeded_per_source_order": true, + "provider_calls_reached": false, + "obsolete_validator_accepted_only": "filesdrop", + "existing_owner_contract_routes": [ + "files", + "filesdrop" + ], + "actual_credential_bearing_url_not_requested_or_logged": true, + "actual_route_not_reported_in_original_failure": true, + "owner_sources": [ + "railiance-platform/scripts/root_custody_finish.py:roundtrip", + "railiance-platform/scripts/openbao-reboot-snapshot.py" + ], + "fix": "Accept only the two existing exact provider/share-token-bound route forms; retain provider, object, credential and no-overwrite scope." + } +} diff --git a/docs/proposals/2026-11-06-vergabe-provider-publication/README.md b/docs/proposals/2026-11-06-vergabe-provider-publication/README.md index 6009edb..b976147 100644 --- a/docs/proposals/2026-11-06-vergabe-provider-publication/README.md +++ b/docs/proposals/2026-11-06-vergabe-provider-publication/README.md @@ -14,6 +14,16 @@ offline test through the installed Warden envelope reproduces the inherited alias refusal and passes with the corrected preparation. Retry the same command below; the previous session was revoked. Provider scope and ciphertext are unchanged. +Second native attempt: [the founder return](../../evidence/2026-11-06-vergabe-upload-binding-failure-return.json) +passes all five handoff checks and the custody reads, then stops at +`upload_lane_binding_changed` before any provider call. The publisher had copied +the older migration helper's filesdrop-only assumption. Existing platform owner +recovery tools accept both `/public.php/dav/files/{share_token}` and the legacy +`/public.php/dav/filesdrop/{share_token}`. Revision 3 accepts exactly those two +forms on the approved provider with the same share token. Foreign origins, +different tokens, extra path segments, queries and fragments refuse. No raw +credential-bearing address was requested or logged. Retry the same command. + ## Founder workstation command ```bash @@ -121,7 +131,7 @@ No second application pause or recapture is needed for this point. ## Validation -Fifteen publisher checks cover real age encrypt/decrypt and complete packet/media/ +Seventeen publisher checks cover real age encrypt/decrypt and complete packet/media/ SQLite verification with mocked custody/provider; separate upload/readback credentials; identical-object retry; different-object preservation; wrong escrow, quota and tampered-download refusal; credential-safe silent failure; ambient @@ -129,5 +139,6 @@ token refusal; pinned owner helpers; foreign-origin denial and bounded readback. The additional cases test all token aliases, API/effective-policy handling, separate session outcomes and the installed Warden envelope using an offline fake Bao client. They issue no native session and inspect no real helper or -credential. All 45 pilot helper checks pass. Native provider publication and +credential. Both existing URL forms pass the synthetic publication/readback/ +decryption flow. All 47 pilot helper checks pass. Native provider publication and decryption remain unverified until a successful founder receipt returns. diff --git a/docs/proposals/2026-11-06-vergabe-provider-publication/plan.json b/docs/proposals/2026-11-06-vergabe-provider-publication/plan.json index 25773eb..ffb4896 100644 --- a/docs/proposals/2026-11-06-vergabe-provider-publication/plan.json +++ b/docs/proposals/2026-11-06-vergabe-provider-publication/plan.json @@ -6,7 +6,7 @@ "run_id": "20261105T233543Z-89bfbe04dece", "ciphertext_sha256": "4915288384e3ba8a87f1427b8e10106ee7241c04184adb97f439e57846bef76f", "ciphertext_bytes": 182521, - "publisher_source_sha256": "8815272ed09ffce91e2a1551c646285d8aea844f318fc188c986d15f4093f807", + "publisher_source_sha256": "4340f386e448984b2b34d13d59f318ca4dda35b218a2b28cd034dcd00881244e", "platform_helper_pins": { "openbao-attended-exec.py": "53fb5fb38ad17c4b06d7969a4d8968d976c8ebc8f1abfeb9a191aa43dc54a5c3", "migrate_nextcloud_backup_account.py": "d3285f403f3c947f75c3288d989152e59ab9a8f5542bcf901bd0a06b0f3e050c", @@ -53,18 +53,19 @@ "recovered_data": "Private provider-recovery-* directory inside the existing workstation kit, outside Git, retained for the isolated restore rehearsal", "failure_handling": "Safe receipt with phase flags; different object bytes refuse without overwrite. Retry reads the deterministic object name. Warden exit and cleanup confirmation are recorded separately from data verification.", "validation": { - "publisher_cases": 15, + "publisher_cases": 17, "synthetic_only": true, "actual_age_round_trip": true, "provider_and_custody_mocked": true, "native_provider_publication_executed": false, - "total_pilot_cases": 45, + "total_pilot_cases": 47, "real_installed_warden_offline_fixture": true, "synthetic_bao_only": true, - "native_login_used_by_tests": false + "native_login_used_by_tests": false, + "both_existing_routes_end_to_end_synthetic": true }, - "previous_publisher_source_sha256": "845294c574692333b35e627f5d3194e6334136f14f4f699e27a680f9cd4604db", - "revision": "r2-owner-handoff", + "previous_publisher_source_sha256": "8815272ed09ffce91e2a1551c646285d8aea844f318fc188c986d15f4093f807", + "revision": "r3-upload-route-contract", "handoff_fix": { "clear_all_token_aliases_before_login": [ "BAO_TOKEN", @@ -77,5 +78,19 @@ "effective_policies": "Union token and identity policies; root/agent boundary still refuse", "nonzero_child_exit_does_not_imply_revocation_failure": true, "same_provider_scope": true + }, + "upload_url_contract": { + "origin": "https://nx4069.your-storageshare.de", + "accepted_paths": [ + "/public.php/dav/files/{share_token}", + "/public.php/dav/filesdrop/{share_token}" + ], + "same_share_token_required": true, + "extra_paths_query_fragments_foreign_origins_refused": true, + "owner_sources": [ + "railiance-platform/scripts/root_custody_finish.py:roundtrip", + "railiance-platform/scripts/openbao-reboot-snapshot.py" + ], + "new_provider_scope": false } } diff --git a/tests/test_vergabe_pilot_publish.py b/tests/test_vergabe_pilot_publish.py index f358068..9faf412 100644 --- a/tests/test_vergabe_pilot_publish.py +++ b/tests/test_vergabe_pilot_publish.py @@ -97,21 +97,26 @@ class PublisherTests(unittest.TestCase): return Fake() def test_real_crypto_roundtrip_provider_readback_and_complete_packet_check(self): - provider = self.provider() - receipt = {"restore_verified": False} - publish.publish(self.directory, self.credentials, provider, receipt) - self.assertEqual(receipt["status"], "published_read_back_and_decrypted") - self.assertTrue(receipt["independent_readback_verified"]) - self.assertTrue(receipt["sqlite_link_verified"]) - self.assertTrue(receipt["packet_file_hashes_verified"]) - self.assertFalse(receipt["database_restored"]) - self.assertFalse(receipt["restore_verified"]) - self.assertEqual(len(provider.put_calls), 1) - self.assertEqual(len(provider.get_calls), 2) - self.assertNotIn("synthetic private row", json.dumps(receipt)) - recovered = Path(receipt["recovery_directory"]) - self.assertEqual(recovered.stat().st_mode & 0o777, 0o700) - self.assertEqual((recovered / "point/database.dump").stat().st_mode & 0o777, 0o600) + for kind in ("files", "filesdrop"): + with self.subTest(kind=kind): + provider = self.provider() + receipt = {"restore_verified": False} + credentials = {**self.credentials, "upload_root": publish.HOST + "/public.php/dav/" + kind + "/synthetic", + "upload_route_kind": kind} + publish.publish(self.directory, credentials, provider, receipt) + self.assertEqual(receipt["status"], "published_read_back_and_decrypted") + self.assertEqual(receipt["upload_route_kind"], kind) + self.assertTrue(receipt["independent_readback_verified"]) + self.assertTrue(receipt["sqlite_link_verified"]) + self.assertTrue(receipt["packet_file_hashes_verified"]) + self.assertFalse(receipt["database_restored"]) + self.assertFalse(receipt["restore_verified"]) + self.assertEqual(provider.put_calls, [credentials["upload_root"] + "/" + publish.PREFIX + "/" + publish.OBJECT]) + self.assertEqual(len(provider.get_calls), 2) + self.assertNotIn("synthetic private row", json.dumps(receipt)) + recovered = Path(receipt["recovery_directory"]) + self.assertEqual(recovered.stat().st_mode & 0o777, 0o700) + self.assertEqual((recovered / "point/database.dump").stat().st_mode & 0o777, 0o600) def test_identical_existing_object_is_recovered_without_upload(self): provider = self.provider(existing=self.content, available=0) @@ -160,6 +165,26 @@ class PublisherTests(unittest.TestCase): class BoundaryTests(unittest.TestCase): + def test_upload_binding_accepts_both_existing_owner_contract_routes(self): + token = "synthetic-share" + for kind in ("files", "filesdrop"): + url = publish.HOST + "/public.php/dav/" + kind + "/" + token + with self.subTest(kind=kind): + self.assertEqual(publish.upload_binding(token, url), (url, kind)) + self.assertEqual(publish.upload_binding(token, url + "/"), (url, kind)) + + def test_upload_binding_refuses_foreign_hosts_tokens_and_extra_paths(self): + token = "synthetic-share" + for url in ["https://foreign.invalid/public.php/dav/files/" + token, + publish.HOST + "/public.php/dav/files/other-share", + publish.HOST + "/public.php/dav/files/" + token + "/unexpected", + publish.HOST + "/public.php/dav/files/" + token + "?query=1", + publish.HOST + "/public.php/dav/files/" + token + "#fragment"]: + with self.subTest(url=url), self.assertRaisesRegex(stores.Refused, "upload_lane_binding_changed"): + publish.upload_binding(token, url) + with self.assertRaisesRegex(stores.Refused, "upload_token_shape_invalid"): + publish.upload_binding("unsafe?query", publish.HOST + "/public.php/dav/files/unsafe?query") + def test_owner_environment_discards_all_token_aliases_before_fresh_login(self): environment = {name: "synthetic" for name in publish.TOKEN_ENV} environment.update(HOME="/synthetic", PATH="/usr/bin", BAO_ADDR="wrong", VAULT_ADDR="wrong") @@ -204,7 +229,7 @@ class BoundaryTests(unittest.TestCase): if args[2] == publish.LANE: return {"data": {"metadata": {"version": 1}, "data": { "NC_WEBDAV_TOKEN": "synthetic-upload", - "NC_WEBDAV_URL": publish.HOST + "/public.php/dav/filesdrop/synthetic-upload", + "NC_WEBDAV_URL": publish.HOST + "/public.php/dav/files/synthetic-upload", "AGE_PRIVATE_KEY": "synthetic-not-a-key"}}} if args[2] == publish.OPERATOR: return {"data": {"data": {"BACKUP_USERNAME": "Backup", "BACKUP_PASSWORD": "synthetic"}}} @@ -218,6 +243,7 @@ class BoundaryTests(unittest.TestCase): "BAO_ADDR": publish.TUNNEL, "VAULT_ADDR": publish.TUNNEL}, clear=True): result = publish.custody(SimpleNamespace(bao=read, data=lambda value: value)) self.assertEqual(result["kv_version"], 1) + self.assertEqual(result["upload_route_kind"], "files") self.assertEqual([c[2] for c in calls], ["auth/token/lookup-self", publish.LANE, publish.OPERATOR]) def provider(self): diff --git a/tools/vergabe_pilot_publish.py b/tools/vergabe_pilot_publish.py index c971d4a..c6298ee 100644 --- a/tools/vergabe_pilot_publish.py +++ b/tools/vergabe_pilot_publish.py @@ -108,6 +108,17 @@ def require_owner_handoff(): require(checks[key], reason) +def upload_binding(token, url): + require(isinstance(token, str) and 0 < len(token) < 1024 + and not any(c in token for c in "\r\n\t/ \\?#"), "upload_token_shape_invalid") + require(isinstance(url, str), "upload_lane_binding_changed") + root = url.rstrip("/") + for kind in ("filesdrop", "files"): + if root == HOST + "/public.php/dav/" + kind + "/" + token: + return root, kind + raise Refused("upload_lane_binding_changed") + + def custody(helper): # Reuse the owner's contained OIDC session, never a token environment/file # handoff. Warden revokes and removes its private helper after the child. @@ -125,13 +136,10 @@ def custody(helper): owner = read(["read", "-format=json", OPERATOR], "backup_account_custody_read_failed")["data"]["data"] values = lane["data"] token, url = values["NC_WEBDAV_TOKEN"], values["NC_WEBDAV_URL"] - require(isinstance(token, str) and token and len(token) < 1024 - and not any(c in token for c in "\r\n/ ") - and url.rstrip("/") == HOST + "/public.php/dav/filesdrop/" + token, - "upload_lane_binding_changed") + upload_root, route_kind = upload_binding(token, url) require(owner["BACKUP_USERNAME"] == "Backup" and bool(owner["BACKUP_PASSWORD"]), "backup_account_required") - return {"upload_root": url.rstrip("/"), "upload_auth": (token, ""), + return {"upload_root": upload_root, "upload_route_kind": route_kind, "upload_auth": (token, ""), "owner_auth": ("Backup", owner["BACKUP_PASSWORD"]), "identity": values["AGE_PRIVATE_KEY"], "kv_version": lane["metadata"]["version"]} @@ -249,10 +257,13 @@ def packet_checks(plain, directory, expected_packet): def publish(directory, credentials, provider, receipt): + receipt["stage"] = "local_ciphertext_verification" encrypted = source(directory) content = encrypted.read_bytes() require(len(content) == CIPHER_BYTES and hashlib.sha256(content).hexdigest() == CIPHER_SHA256, "source_ciphertext_changed") + receipt.update(stage="escrow_recipient_verification", lane_kv_version=credentials["kv_version"], + upload_route_kind=credentials.get("upload_route_kind", "unspecified")) crypto_identity_matches(credentials["identity"]) receipt["escrow_recipient_verified"] = True auth = credentials["owner_auth"] diff --git a/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md b/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md index 4c73dd2..79e92d8 100644 --- a/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md +++ b/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md @@ -196,7 +196,7 @@ id: RAPPS-WP-0014-T03 status: wait needs_human: true decision_id: d06307e5-2737-461a-a2f4-a12c3b492a9a -intervention_note: "Capture/restart and encrypted workstation copy pass. First provider attempt logged in but the publisher handoff guard refused before credential/provider access; Warden reported revocation. Environment preparation and lifecycle reporting fixed and tested through the installed Warden envelope offline. Retry the same publisher --execute command with fresh founder login. Provider evidence, isolated restore, access/rollback and operating ownership remain open." +intervention_note: "Capture/restart and encrypted workstation copy pass. Second provider attempt passes handoff and custody but stops before provider calls on a stale filesdrop-only URL check. Aligned with both existing platform recovery routes (files and filesdrop) with exact provider/share-token binding; 47 checks pass. Retry same publisher --execute command. Provider evidence, isolated restore, access/rollback and operating ownership remain open." priority: high assignee: the-custodian blocking_reason: "Matched populated capture, restart and encrypted workstation copy pass with overview UI follow-up. Await provider publication/independent readback, escrow decryption, isolated restored workflow, access checks, compatible rollback and operating ownership." @@ -472,6 +472,24 @@ occurs in those tests. Retry the same reviewed provider command; the earlier go persists, while fresh login is needed because that session was revoked. No provider scope, role, policy, credential grant or live app change is introduced. +2026-11-06 second provider attempt: all handoff checks are true; owner identity +and both custody reads complete before `upload_lane_binding_changed` (0.6 +seconds, Warden exit 11). No provider call or app change occurs. Warden again +reports session revocation. Receipt: +`docs/evidence/2026-11-06-vergabe-upload-binding-failure-return.json`. +The publisher's URL validator accepted only filesdrop, copied from the older +migration helper; `root_custody_finish.py:roundtrip` and the platform reboot +snapshot helper already accept the repaired files route as well. The actual +credential-bearing URL is not exposed by this receipt or requested from the +founder. Revision 3 matches those two exact owner-contract forms with the same +provider/share token, retaining the object, credentials, create-only and +independent readback scope. Receipt metadata records only a route-kind label +and KV version. Seventeen publisher / 47 total pilot checks pass, including +both forms through synthetic publication/readback/decryption and rejection of +foreign origin, mismatched token or extra paths/query/fragment. Retry the same +reviewed command; the earlier go remains valid. Native provider and restore +acceptance remain open. + ## Validate synthetic sizing against the exact release and shared contention