From ad4628f948820f04559953a02cc43450c63b97aa Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Wed, 7 Oct 2026 10:59:23 -0300 Subject: [PATCH 01/30] feat(sync): negotiate encryption per peer (WIP) Clients advertise an encryption policy (optional or required) in SYFT_version.json. A pair encrypts when one side requires it, so clients of an enclave need no encryption setting. WIP: not ready for review. --- .../1. DO-benchmark-owner-dbe.ipynb | 2 +- .../2. DO-model-owner-dbe.ipynb | 2 +- .../1. DO-model-owner-gemma-shield.ipynb | 26 +- .../2. DO-benchmark-owner-gemma-shield.ipynb | 26 +- .../1. DO-model-owner-gemma-restrict.ipynb | 26 +- ...2. DO-benchmark-owner-gemma-restrict.ipynb | 26 +- .../notebooks/demo.ipynb | 21 +- .../scripts/run_demo.py | 15 +- packages/syft-enclave/README.md | 2 +- packages/syft-enclave/docs/dev.md | 2 +- packages/syft-enclave/docs/terraform_cs.md | 2 +- .../docs/tinfoil_troubleshooting.md | 2 +- .../syft-enclave/scripts/tinfoil_e2e_check.py | 2 +- .../syft-enclave/src/syft_enclaves/client.py | 23 +- .../syft-enclave/src/syft_enclaves/login.py | 21 +- .../syft-enclave/src/syft_enclaves/runner.py | 15 +- .../src/syft_enclaves/settings.py | 5 +- .../syft-enclave/tests/test_enclave_jobs.py | 16 +- .../syft-enclave/tests/test_encryption.py | 10 + packages/syft-enclave/tests/test_runner.py | 16 +- packages/syft-rds/src/syft_rds/login.py | 7 + syft/sync/connections/base_connection.py | 40 +- syft/sync/connections/connection_router.py | 202 ++- .../connections/drive/gdrive_transport.py | 116 +- syft/sync/encryption_policy.py | 112 ++ syft/sync/login.py | 8 + syft/sync/peers/peer.py | 26 +- syft/sync/peers/peer_store.py | 347 ++++- syft/sync/syftbox_manager.py | 65 +- .../sync/caches/datasite_watcher_cache.py | 18 +- syft/sync/sync/constants.py | 2 + syft/sync/sync/datasite_owner_syncer.py | 78 +- syft/sync/sync/datasite_watcher_syncer.py | 164 ++- syft/sync/sync/held_messages.py | 84 ++ syft/sync/version/peer_manager.py | 143 +- syft/sync/version/version_info.py | 13 +- .../unit/test_version_info_fields.py | 4 +- .../unit/test_version_info_serialization.py | 1 + tests/unit/test_encryption.py | 26 +- tests/unit/test_encryption_policy.py | 1155 +++++++++++++++++ tests/unit/test_peer_requests.py | 6 +- tests/unit/test_sync_manager.py | 12 + 42 files changed, 2630 insertions(+), 259 deletions(-) create mode 100644 syft/sync/encryption_policy.py create mode 100644 syft/sync/sync/held_messages.py create mode 100644 tests/unit/test_encryption_policy.py diff --git a/notebooks/enclave/double-blind-eval-w-receipts/1. DO-benchmark-owner-dbe.ipynb b/notebooks/enclave/double-blind-eval-w-receipts/1. DO-benchmark-owner-dbe.ipynb index a62cc6659ec..3149af1c9c6 100644 --- a/notebooks/enclave/double-blind-eval-w-receipts/1. DO-benchmark-owner-dbe.ipynb +++ b/notebooks/enclave/double-blind-eval-w-receipts/1. DO-benchmark-owner-dbe.ipynb @@ -107,7 +107,7 @@ "# from google.colab import files; files.upload()\n", "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", "\n", - "benchmark_owner = login_do(encryption=True, crypto_keys_path=CRYPTO_KEYS_PATH)\n", + "benchmark_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", "print(f\" Benchmark owner : {benchmark_owner.email}\")" ] }, diff --git a/notebooks/enclave/double-blind-eval-w-receipts/2. DO-model-owner-dbe.ipynb b/notebooks/enclave/double-blind-eval-w-receipts/2. DO-model-owner-dbe.ipynb index 9dcc0244ad4..0af1b6c707a 100644 --- a/notebooks/enclave/double-blind-eval-w-receipts/2. DO-model-owner-dbe.ipynb +++ b/notebooks/enclave/double-blind-eval-w-receipts/2. DO-model-owner-dbe.ipynb @@ -105,7 +105,7 @@ "# from google.colab import files; files.upload()\n", "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", "\n", - "model_owner = login_do(encryption=True, crypto_keys_path=CRYPTO_KEYS_PATH)\n", + "model_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", "print(f\" Model owner : {model_owner.email}\")" ] }, diff --git a/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb b/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb index d5940be0c86..8c86f71f45a 100644 --- a/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb +++ b/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb @@ -161,13 +161,20 @@ "# from google.colab import files; files.upload()\n", "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", "\n", - "model_owner = login_do(encryption=True, crypto_keys_path=CRYPTO_KEYS_PATH)\n", + "model_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", "print(f\" Model owner : {model_owner.email}\")\n", "\n", - "# First login: back up the encryption key — it lives only on this runtime's filesystem.\n", - "if CRYPTO_KEYS_PATH is None:\n", + "# Back up the encryption key: it lives only on this runtime's filesystem. The\n", + "# client makes it when it first syncs with an enclave that requires encryption.\n", + "def backup_key():\n", + " if CRYPTO_KEYS_PATH is not None:\n", + " return\n", + " key_file = model_owner.syftbox_folder / model_owner.email / \"private\" / \"crypto_keys.json\"\n", + " if not key_file.exists():\n", + " print(f\"No encryption key yet. It appears at {key_file} after the enclave accepts.\")\n", + " return\n", " from google.colab import files\n", - " files.download(str(model_owner.syftbox_folder / model_owner.email / \"private\" / \"crypto_keys.json\"))" + " files.download(str(key_file))" ] }, { @@ -256,6 +263,17 @@ " print(f\" Connected to {peer_email}\")" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "backup-key", + "metadata": {}, + "outputs": [], + "source": [ + "# The enclave accepted and the keys exist now: back up the key file.\n", + "backup_key()" + ] + }, { "cell_type": "markdown", "id": "18", diff --git a/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb b/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb index 04d449d4f66..2362c060c92 100644 --- a/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb +++ b/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb @@ -105,13 +105,20 @@ "# from google.colab import files; files.upload()\n", "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", "\n", - "benchmark_owner = login_do(encryption=True, crypto_keys_path=CRYPTO_KEYS_PATH)\n", + "benchmark_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", "print(f\" Benchmark owner : {benchmark_owner.email}\")\n", "\n", - "# First login: back up the encryption key — it lives only on this runtime's filesystem.\n", - "if CRYPTO_KEYS_PATH is None:\n", + "# Back up the encryption key: it lives only on this runtime's filesystem. The\n", + "# client makes it when it first syncs with an enclave that requires encryption.\n", + "def backup_key():\n", + " if CRYPTO_KEYS_PATH is not None:\n", + " return\n", + " key_file = benchmark_owner.syftbox_folder / benchmark_owner.email / \"private\" / \"crypto_keys.json\"\n", + " if not key_file.exists():\n", + " print(f\"No encryption key yet. It appears at {key_file} after the enclave accepts.\")\n", + " return\n", " from google.colab import files\n", - " files.download(str(benchmark_owner.syftbox_folder / benchmark_owner.email / \"private\" / \"crypto_keys.json\"))" + " files.download(str(key_file))" ] }, { @@ -198,6 +205,17 @@ " print(f\" Connected to {peer_email}\")" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "backup-key", + "metadata": {}, + "outputs": [], + "source": [ + "# The enclave accepted and the keys exist now: back up the key file.\n", + "backup_key()" + ] + }, { "cell_type": "markdown", "id": "15", diff --git a/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb b/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb index 7172f738261..9306122c5bd 100644 --- a/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb +++ b/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb @@ -113,13 +113,20 @@ "# from google.colab import files; files.upload()\n", "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", "\n", - "model_owner = login_do(encryption=True, crypto_keys_path=CRYPTO_KEYS_PATH)\n", + "model_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", "print(f\" Model owner : {model_owner.email}\")\n", "\n", - "# First login: back up the encryption key — it lives only on this runtime's filesystem.\n", - "if CRYPTO_KEYS_PATH is None:\n", + "# Back up the encryption key: it lives only on this runtime's filesystem. The\n", + "# client makes it when it first syncs with an enclave that requires encryption.\n", + "def backup_key():\n", + " if CRYPTO_KEYS_PATH is not None:\n", + " return\n", + " key_file = model_owner.syftbox_folder / model_owner.email / \"private\" / \"crypto_keys.json\"\n", + " if not key_file.exists():\n", + " print(f\"No encryption key yet. It appears at {key_file} after the enclave accepts.\")\n", + " return\n", " from google.colab import files\n", - " files.download(str(model_owner.syftbox_folder / model_owner.email / \"private\" / \"crypto_keys.json\"))" + " files.download(str(key_file))" ] }, { @@ -208,6 +215,17 @@ " print(f\" Connected to {peer_email}\")" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "backup-key", + "metadata": {}, + "outputs": [], + "source": [ + "# The enclave accepted and the keys exist now: back up the key file.\n", + "backup_key()" + ] + }, { "cell_type": "markdown", "id": "16", diff --git a/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb b/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb index 6ab0d7f817a..b473721f56e 100644 --- a/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb +++ b/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb @@ -94,13 +94,20 @@ "# from google.colab import files; files.upload()\n", "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", "\n", - "benchmark_owner = login_do(encryption=True, crypto_keys_path=CRYPTO_KEYS_PATH)\n", + "benchmark_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", "print(f\" Benchmark owner : {benchmark_owner.email}\")\n", "\n", - "# First login: back up the encryption key — it lives only on this runtime's filesystem.\n", - "if CRYPTO_KEYS_PATH is None:\n", + "# Back up the encryption key: it lives only on this runtime's filesystem. The\n", + "# client makes it when it first syncs with an enclave that requires encryption.\n", + "def backup_key():\n", + " if CRYPTO_KEYS_PATH is not None:\n", + " return\n", + " key_file = benchmark_owner.syftbox_folder / benchmark_owner.email / \"private\" / \"crypto_keys.json\"\n", + " if not key_file.exists():\n", + " print(f\"No encryption key yet. It appears at {key_file} after the enclave accepts.\")\n", + " return\n", " from google.colab import files\n", - " files.download(str(benchmark_owner.syftbox_folder / benchmark_owner.email / \"private\" / \"crypto_keys.json\"))" + " files.download(str(key_file))" ] }, { @@ -187,6 +194,17 @@ " print(f\" Connected to {peer_email}\")" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "backup-key", + "metadata": {}, + "outputs": [], + "source": [ + "# The enclave accepted and the keys exist now: back up the key file.\n", + "backup_key()" + ] + }, { "cell_type": "markdown", "id": "15", diff --git a/packages/enclave-model-api-example/notebooks/demo.ipynb b/packages/enclave-model-api-example/notebooks/demo.ipynb index a4a72d8d511..7ad565a81df 100644 --- a/packages/enclave-model-api-example/notebooks/demo.ipynb +++ b/packages/enclave-model-api-example/notebooks/demo.ipynb @@ -36,8 +36,8 @@ "import os\n", "from pathlib import Path\n", "\n", - "# Deployed PROD enclave (real Gemma model, encryption ON). Encryption is auto-detected\n", - "# from /model-status in the next cell, so no ENCLAVE_ENCRYPTION env var is needed.\n", + "# Deployed PROD enclave (real Gemma model, encryption ON). The data owners encrypt\n", + "# because the enclave policy requires it, so they need no encryption setting.\n", "os.environ[\"ENCLAVE_URL\"] = \"http://34.68.46.255:8080\"\n", "os.environ[\"GEMMA_WEIGHTS_DIR\"] = (\n", " f\"{Path.home().resolve()}/.cache/kagglehub/models/google/gemma-3/flax/gemma-3-270m-it/1\"\n", @@ -65,15 +65,6 @@ "# deployed VM, e.g. \"http://:8080\" — defaults to a local server if unset.\n", "ENCLAVE_URL = os.environ.get(\"ENCLAVE_URL\", \"http://localhost:8080\")\n", "\n", - "# Auto-match the enclave's encryption setting: data owners MUST use the same\n", - "# encryption mode as the enclave or Drive sync silently fails. The enclave\n", - "# reports it via /model-status (\"use_encryption\"); fall back to the\n", - "# ENCLAVE_ENCRYPTION env var if the endpoint doesn't expose it (older image).\n", - "_status = requests.get(f\"{ENCLAVE_URL}/model-status\").json()\n", - "ENCRYPTION = _status.get(\"use_encryption\")\n", - "if ENCRYPTION is None:\n", - " ENCRYPTION = os.environ.get(\"ENCLAVE_ENCRYPTION\", \"false\").lower() == \"true\"\n", - "\n", "# Local dir holding the real Gemma 3 270m flax weights that DO1 uploads. Expected layout:\n", "# /tokenizer.model and //\n", "WEIGHTS_DIR = Path(\n", @@ -82,7 +73,7 @@ " \"~/.cache/kagglehub/models/google/gemma-3/flax/gemma-3-270m-it/1\",\n", " )\n", ").expanduser()\n", - "ENCLAVE_URL, ENCRYPTION, WEIGHTS_DIR" + "ENCLAVE_URL, WEIGHTS_DIR" ] }, { @@ -111,10 +102,8 @@ "metadata": {}, "outputs": [], "source": [ - "do1 = login_do(DO1, CRED / \"token_do.json\", encryption=ENCRYPTION) # model + log owner\n", - "do2 = login_do(\n", - " DO2, CRED / \"token_ds.json\", encryption=ENCRYPTION\n", - ") # submits the analysis job" + "do1 = login_do(DO1, CRED / \"token_do.json\") # model + log owner\n", + "do2 = login_do(DO2, CRED / \"token_ds.json\") # submits the analysis job" ] }, { diff --git a/packages/enclave-model-api-example/scripts/run_demo.py b/packages/enclave-model-api-example/scripts/run_demo.py index 71602baedb0..9e47be3b7d5 100644 --- a/packages/enclave-model-api-example/scripts/run_demo.py +++ b/packages/enclave-model-api-example/scripts/run_demo.py @@ -62,10 +62,6 @@ ) ).expanduser() EXPECT_MODEL = os.environ.get("DEMO_EXPECT_MODEL", "auto").lower() # auto|real|mock -# Encrypt all Drive communication. MUST match the enclave: the prod `inference-start` -# recipe forces SYFT_ENCLAVE_USE_ENCRYPTION=true, so set DEMO_ENCRYPTION=true when -# running against a prod enclave (debug VMs default to encryption off). -ENCRYPTION = os.environ.get("DEMO_ENCRYPTION", "false").lower() in ("1", "true", "yes") ENCLAVE = "beach.do.008@gmail.com" DO1 = "koenlennartvanderveen@gmail.com" # model + log owner @@ -201,15 +197,10 @@ def main(): "DEMO_EXPECT_MODEL=mock but the enclave is serving the REAL backend." ) print(f"enclave backend: {'REAL Gemma' if server_real else 'mock'}") - # Match the enclave's encryption setting (reported by /model-status). Fall - # back to DEMO_ENCRYPTION if the endpoint doesn't expose it (older image). - server_enc = status.get("use_encryption") - encryption = ENCRYPTION if server_enc is None else bool(server_enc) - + # The data owners encrypt with the enclave when its policy requires it. banner("log in the two data owners") - print(f"encryption: {encryption}") - do1 = login_do(DO1, CRED / "token_do.json", encryption=encryption) - do2 = login_do(DO2, CRED / "token_ds.json", encryption=encryption) + do1 = login_do(DO1, CRED / "token_do.json") + do2 = login_do(DO2, CRED / "token_ds.json") banner("peer with the enclave, wait until connected") do1.add_peer(ENCLAVE) diff --git a/packages/syft-enclave/README.md b/packages/syft-enclave/README.md index 45f0c1cc568..e0993ee9ca1 100644 --- a/packages/syft-enclave/README.md +++ b/packages/syft-enclave/README.md @@ -69,7 +69,7 @@ just start-debug EMAIL my-vm n2d-standard-4 # override name / machine type just stop [name] # Teardown: Deletes the VM. ``` -Debug enclaves run with encryption off — data owner clients must match: `login_do(encryption=False)`. +Debug enclaves run with encryption off. Clients select encryption from the enclave policy, so they need no setting. ## GPU deployments diff --git a/packages/syft-enclave/docs/dev.md b/packages/syft-enclave/docs/dev.md index 467ec261be5..b7e759a04df 100644 --- a/packages/syft-enclave/docs/dev.md +++ b/packages/syft-enclave/docs/dev.md @@ -25,7 +25,7 @@ just start-debug # cpu (default) just hardware=gpu start-debug # gpu (a3-highgpu-1g, 1x H100) — see docs/terraform_cs.md (Confidential Spaces) "GPU deployments" ``` -Debug enclaves run with encryption off — data owner clients must match: `login_do(encryption=False)`. +Debug enclaves run with encryption off. Clients select encryption from the enclave policy, so they need no setting. ## Building & pushing a new Docker Image diff --git a/packages/syft-enclave/docs/terraform_cs.md b/packages/syft-enclave/docs/terraform_cs.md index 503954a287b..0a97d2431b8 100644 --- a/packages/syft-enclave/docs/terraform_cs.md +++ b/packages/syft-enclave/docs/terraform_cs.md @@ -101,7 +101,7 @@ Deletes the VM, secret, service account, and IAM bindings. The enabled APIs are Debug image — SSH enabled, container logs redirected to serial output, encryption off (override with `use_encryption = true` in tfvars), restart policy `Never` (a crashed container stays down for post-mortem; `gcloud compute instances reset` to relaunch). -> Clients must match the enclave's encryption setting: against a debug enclave, data owners log in with `login_do(encryption=False)`. +> Clients select encryption from the policy the enclave advertises, so they need no encryption setting. ```bash just tf-apply-dev diff --git a/packages/syft-enclave/docs/tinfoil_troubleshooting.md b/packages/syft-enclave/docs/tinfoil_troubleshooting.md index 78a912ab9e4..9d39723873d 100644 --- a/packages/syft-enclave/docs/tinfoil_troubleshooting.md +++ b/packages/syft-enclave/docs/tinfoil_troubleshooting.md @@ -18,7 +18,7 @@ for what the attestation does and does not prove, see | The enclave hangs at "Building SyftEnclaveClient" | OAuth token refresh cannot reach `oauth2.googleapis.com`. See the egress note in `tinfoil-config.yml`: `allowlist` resolves hostnames to IPs once at boot and Google rotates them, so use `egress: open`. | | `curl: (60) SSL certificate problem: self signed certificate` | expected. The enclave's TLS key is generated inside it and the report commits to that key, so there is no CA. Use `-k` (as `just tinfoil-attest` does) and get your trust from `just tinfoil-verify`. | | `The server had an error while processing your request.` from the domain | the shim is up but your container is not serving on `upstream-port`. It has probably crashed — `just tinfoil-debug ` then `just tinfoil-logs`. | -| `ValueError: Serialization error: sender fingerprint mismatch` on a data owner's login | with `encryption=True` the keypair is persisted at `//private/crypto_keys.json`. A fresh `SYFTBOX_FOLDER` mints a new identity that cannot verify that account's own history on Drive. Point at the account's existing folder, or wipe its Drive state first. | +| `ValueError: Serialization error: sender fingerprint mismatch` on a data owner's login | once a client encrypts, the keypair is persisted at `//private/crypto_keys.json`. A fresh `SYFTBOX_FOLDER` mints a new identity that cannot verify that account's own history on Drive. Point at the account's existing folder, or wipe its Drive state first. | | `upstream port is not set` | the config's `shim.upstream-port` is missing; it is required | | Config rejected: image must be a digest | `image:` uses a tag; it must be `repo@sha256:...` | | A path 404s | it is not in `shim.paths`. `/.well-known/tinfoil-attestation` is exempt | diff --git a/packages/syft-enclave/scripts/tinfoil_e2e_check.py b/packages/syft-enclave/scripts/tinfoil_e2e_check.py index 7d7da637724..987ef4bb495 100644 --- a/packages/syft-enclave/scripts/tinfoil_e2e_check.py +++ b/packages/syft-enclave/scripts/tinfoil_e2e_check.py @@ -97,7 +97,7 @@ def main() -> int: from syft_enclaves.attestation.tinfoil import TinfoilAppraisalPolicy print("=== 1. login as the data owner ===", flush=True) - client = login_do(email=args.do_email, token_path=args.token, encryption=True) + client = login_do(email=args.do_email, token_path=args.token) print("=== 2. peer with the enclave ===", flush=True) client.add_peer(args.enclave_email) diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index 8cbbe109a66..f8615ffdf07 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -15,6 +15,7 @@ from syft_rds import SyftRDSClient, SyftRDSClientConfig from syft_rds.waiting import JobCheck, JobStatusArg, wait_for_job +from syft.sync.encryption_policy import EncryptionPolicy from syft.sync.peers.peer import Peer from syft.sync.peers.peer_list import PeerList from syft.sync.utils.waiting import ( @@ -789,7 +790,7 @@ def from_config( """Build a SyftEnclaveClient from an RDS config with a wrapped job_client. Encryption rides along on ``config.sync`` via - ``peer_manager_config.use_encryption``; ``SyftboxManager.from_config`` + ``peer_manager_config.encryption_policy``; ``SyftboxManager.from_config`` (invoked inside ``SyftRDSClient.from_config``) loads/generates this datasite's keys from its per-datasite key file, so no extra step is needed. """ @@ -810,7 +811,7 @@ def for_enclave( email: str, token_path: Path | str | None = None, data_owners: list[str] | None = None, - encryption: bool = False, + encryption: bool | str = False, persist_owner_state: bool = False, ) -> "SyftEnclaveClient": """Build an enclave client backed by a real Google Drive connection. @@ -818,7 +819,8 @@ def for_enclave( email: The enclave datasite's email address. token_path: Path to a pre-authorized Google Drive OAuth token. data_owners: Emails whose approval gates every job on this enclave. - encryption: Enable end-to-end drive encryption. + encryption: Require end-to-end drive encryption with every peer. + Without it, the enclave encrypts with each peer that requires it. persist_owner_state: Keep the owner-only state used to restore this datasite later (event log, rolling state, checkpoints). Off by default - an enclave gets ephemeral keys and wipes its @@ -846,8 +848,9 @@ def quad_with_mock_drive_service_connection( do2_email: str | None = None, ds_email: str | None = None, use_in_memory_cache: bool = True, - encryption: bool = False, + encryption: bool | str = False, enclave_persist_owner_state: bool = False, + enclave_encryption_policy: EncryptionPolicy | None = None, ) -> tuple[ "SyftEnclaveClient", "SyftEnclaveClient", @@ -863,9 +866,11 @@ def quad_with_mock_drive_service_connection( - DS: peers with DO1, DO2, Enclave Args: - encryption: Enable end-to-end drive encryption on all four clients. + encryption: Require end-to-end drive encryption on all four clients. enclave_persist_owner_state: Whether the enclave keeps restorable owner state. Off by default, as in production. + enclave_encryption_policy: Policy of the enclave; overrides + ``encryption`` for the enclave only. Returns: Tuple of (enclave, do1, do2, ds) """ @@ -892,9 +897,11 @@ def quad_with_mock_drive_service_connection( # Initialize encryption AFTER connections/versions but BEFORE peering, # so the bundle exchange in wire_peers() picks up each client's keys # (mirrors SyftboxManager.pair_with_mock_drive_service_connection). - if encryption: - for manager in managers: - manager._init_encrypted_peer_store() + policies = [EncryptionPolicy.coerce(encryption)] * len(managers) + if enclave_encryption_policy is not None: + policies[0] = enclave_encryption_policy + for manager, policy in zip(managers, policies): + manager._apply_encryption_policy_for_testing(policy) wire_peers(managers) return clients diff --git a/packages/syft-enclave/src/syft_enclaves/login.py b/packages/syft-enclave/src/syft_enclaves/login.py index 0a51738f022..85fd9eb7ccc 100644 --- a/packages/syft-enclave/src/syft_enclaves/login.py +++ b/packages/syft-enclave/src/syft_enclaves/login.py @@ -20,7 +20,7 @@ def _login( skip_peer_on_patch_version_diff: bool | None, has_do_role: bool, has_ds_role: bool, - encryption: bool = False, + encryption: bool | str = False, crypto_keys_path: str | Path | None = None, ) -> SyftEnclaveClient: """Shared login for enclave-flow participants. @@ -29,9 +29,10 @@ def _login( run the login-time version-mismatch check, then build the enclave client for Colab or Jupyter — always wrapping the job_client with EnclaveJobClient. - When ``encryption`` is set, keys are persisted per-datasite to + Keys are persisted per-datasite to ``//private/crypto_keys.json`` (a stable identity - across sessions; never synced to Drive). + across sessions; never synced to Drive). The client makes them when + ``encryption`` is set, or when a peer first requires encryption. """ env = check_env() email, token_path = _resolve_login_params(email, token_path) @@ -79,12 +80,15 @@ def login_do( sync: bool = True, load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, - encryption: bool = False, + encryption: bool | str = False, crypto_keys_path: str | Path | None = None, ) -> SyftEnclaveClient: """Log in a data owner for an enclave computation. - Set ``encryption=True`` to encrypt all drive communication. + Set ``encryption=True`` to require encryption with every peer. Without + it, the client encrypts with each peer that requires it, such as an + enclave. Set ``encryption="off"`` to never encrypt, e.g. against a debug + enclave; a peer that requires encryption is then skipped. """ return _login( email=email, @@ -105,12 +109,15 @@ def login_ds( sync: bool = True, load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, - encryption: bool = False, + encryption: bool | str = False, crypto_keys_path: str | Path | None = None, ) -> SyftEnclaveClient: """Log in a data scientist for an enclave computation. - Set ``encryption=True`` to encrypt all drive communication. + Set ``encryption=True`` to require encryption with every peer. Without + it, the client encrypts with each peer that requires it, such as an + enclave. Set ``encryption="off"`` to never encrypt, e.g. against a debug + enclave; a peer that requires encryption is then skipped. """ return _login( email=email, diff --git a/packages/syft-enclave/src/syft_enclaves/runner.py b/packages/syft-enclave/src/syft_enclaves/runner.py index 8281e83408c..09cd3a71e6a 100644 --- a/packages/syft-enclave/src/syft_enclaves/runner.py +++ b/packages/syft-enclave/src/syft_enclaves/runner.py @@ -144,6 +144,9 @@ def _on_attesting(self) -> None: logger.info( "TEE detected (%s) — collecting attestation evidence", provider.kind.value ) + # An optional enclave makes keys too: a peer that requires encryption + # later uses them, and only keys that exist now are bound to the report. + self.client._rds.peer_manager.peer_store.ensure_keys() # The key bundle and claims first: on a target that commits to them in # the token, the digest has to exist before minting. claims = self._build_claims() @@ -175,11 +178,7 @@ def _build_claims(self) -> Optional[dict]: report already binds and serves it over the pinned connection. """ peer_store = self.client._rds.peer_manager.peer_store - bundle = ( - peer_store.get_public_bundle() - if peer_store.use_encryption and peer_store.has_my_keys() - else None - ) + bundle = peer_store.get_public_bundle() if peer_store.has_my_keys() else None claims = build_claims( email=self.client.email, data_owners=self.client.data_owners, @@ -199,11 +198,11 @@ def _publish_key_bundle(self, claims: Optional[dict] = None) -> None: A peer fetching the report over a pinned connection gets the bundle from the same channel, which binds it to the hardware report. Skipped - when encryption is off, since then there is no bundle. + when there are no keys, since then there is no bundle. """ peer_store = self.client._rds.peer_manager.peer_store - if not peer_store.use_encryption or not peer_store.has_my_keys(): - logger.info("Encryption disabled — no key bundle to publish") + if not peer_store.has_my_keys(): + logger.info("No keys — no key bundle to publish") return peer_manager = self.client._rds.peer_manager # The per-datasite default location; the enclave never overrides diff --git a/packages/syft-enclave/src/syft_enclaves/settings.py b/packages/syft-enclave/src/syft_enclaves/settings.py index 6f772190108..8ef7f0ddcde 100644 --- a/packages/syft-enclave/src/syft_enclaves/settings.py +++ b/packages/syft-enclave/src/syft_enclaves/settings.py @@ -153,8 +153,9 @@ def _split_data_owners(cls, v: object) -> object: use_encryption: bool = Field( default=True, description=( - "End-to-end drive encryption for all enclave peer communication. " - "Enabled by default; set false to disable." + "Require end-to-end drive encryption with every peer. Enabled by " + "default. When false, the enclave encrypts with each peer that " + "requires it. Clients read this policy and need no setting." ), ) persist_owner_state: bool = Field( diff --git a/packages/syft-enclave/tests/test_enclave_jobs.py b/packages/syft-enclave/tests/test_enclave_jobs.py index 0ade9b35625..c72e710d07d 100644 --- a/packages/syft-enclave/tests/test_enclave_jobs.py +++ b/packages/syft-enclave/tests/test_enclave_jobs.py @@ -7,6 +7,7 @@ from pathlib import Path import pytest +from syft.sync.encryption_policy import EncryptionPolicy os.environ["PRE_SYNC"] = "false" @@ -73,12 +74,21 @@ def create_tmp_code_file(code: str): return str(code_path) -@pytest.mark.parametrize("encryption", [False, True]) -def test_enclave_job_distribution(encryption): +@pytest.mark.parametrize( + "encryption_kwargs", + [ + {"encryption": False}, + {"encryption": True}, + # Only the enclave requires encryption; the other clients comply. + {"enclave_encryption_policy": EncryptionPolicy.REQUIRED}, + ], + ids=["plaintext", "encrypted", "negotiated"], +) +def test_enclave_job_distribution(encryption_kwargs): """Test full flow: DS submits job to enclave, enclave distributes to DOs.""" enclave, do1, do2, ds = SyftEnclaveClient.quad_with_mock_drive_service_connection( use_in_memory_cache=False, - encryption=encryption, + **encryption_kwargs, ) # DO1 creates dataset1 diff --git a/packages/syft-enclave/tests/test_encryption.py b/packages/syft-enclave/tests/test_encryption.py index 226d78cf039..c8736ffc47b 100644 --- a/packages/syft-enclave/tests/test_encryption.py +++ b/packages/syft-enclave/tests/test_encryption.py @@ -68,3 +68,13 @@ def test_encrypted_message_to_enclave_roundtrips(): enclave.sync() assert "simple_job" in [j.name for j in enclave.jobs] + + +def test_quad_builder_applies_off_to_the_enclave(): + from syft.sync.encryption_policy import EncryptionPolicy + + enclave, *_ = SyftEnclaveClient.quad_with_mock_drive_service_connection( + enclave_encryption_policy=EncryptionPolicy.OFF + ) + store = enclave._rds.peer_manager.peer_store + assert store.encryption_policy == EncryptionPolicy.OFF diff --git a/packages/syft-enclave/tests/test_runner.py b/packages/syft-enclave/tests/test_runner.py index 2586d0e1228..f302a2c3bef 100644 --- a/packages/syft-enclave/tests/test_runner.py +++ b/packages/syft-enclave/tests/test_runner.py @@ -144,7 +144,12 @@ def test_the_key_bundle_is_published_for_the_attestation_endpoint( # moments earlier, so the file the endpoint signs with is gone. store.save_keys.assert_called_once_with(written["keys_path"]) - def test_no_key_bundle_is_published_without_encryption(self, monkeypatch): + def test_optional_enclave_binds_keys_it_makes_at_boot(self, monkeypatch): + """A peer can require encryption from an optional enclave later. + + The keys it then uses must be bound to the report too, so the enclave + makes them before it attests. + """ provider = MagicMock() provider.kind = AttestationKind.TINFOIL provider.collect.return_value = tinfoil_evidence(TINFOIL_DOC) @@ -159,11 +164,16 @@ def test_no_key_bundle_is_published_without_encryption(self, monkeypatch): client = _make_client() client._rds.peer_manager.syftbox_folder = Path("/tmp/SyftBox_enclave") - client._rds.peer_manager.peer_store.use_encryption = False + store = client._rds.peer_manager.peer_store + store.email = "enclave@openmined.org" + store.use_encryption = False + store.has_my_keys.return_value = True + store.get_public_bundle.return_value = {"identity": "enclave@openmined.org"} EnclaveRunner(client=client, require_tee=True).init() - assert calls == [] + store.ensure_keys.assert_called_once() + assert calls == [{"identity": "enclave@openmined.org"}] def test_provider_gets_the_settings_object(self, monkeypatch): seen = {} diff --git a/packages/syft-rds/src/syft_rds/login.py b/packages/syft-rds/src/syft_rds/login.py index 68285dbca6d..0614daf70d6 100644 --- a/packages/syft-rds/src/syft_rds/login.py +++ b/packages/syft-rds/src/syft_rds/login.py @@ -31,6 +31,7 @@ def _login( skip_peer_on_patch_version_diff: bool | None, has_do_role: bool, has_ds_role: bool, + encryption: bool | str = False, ) -> SyftRDSClient: """Shared RDS login. @@ -47,6 +48,7 @@ def _login( has_do_role=has_do_role, has_ds_role=has_ds_role, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, + encryption=encryption, ) else: config = SyftRDSClientConfig.for_jupyter( @@ -55,6 +57,7 @@ def _login( has_ds_role=has_ds_role, token_path=Path(token_path) if token_path is not None else None, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, + encryption=encryption, ) client = SyftRDSClient.from_config(config) @@ -74,6 +77,7 @@ def login_do( sync: bool = True, load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, + encryption: bool | str = False, ) -> SyftRDSClient: """Log in as a Data Owner and return a self-contained RDS client.""" return _login( @@ -82,6 +86,7 @@ def login_do( sync=sync, load_peers=load_peers, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, + encryption=encryption, has_do_role=True, has_ds_role=False, ) @@ -94,6 +99,7 @@ def login_ds( sync: bool = True, load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, + encryption: bool | str = False, ) -> SyftRDSClient: """Log in as a Data Scientist and return a self-contained RDS client.""" return _login( @@ -102,6 +108,7 @@ def login_ds( sync=sync, load_peers=load_peers, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, + encryption=encryption, has_do_role=False, has_ds_role=True, ) diff --git a/syft/sync/connections/base_connection.py b/syft/sync/connections/base_connection.py index da97faf303a..346d0b7924d 100644 --- a/syft/sync/connections/base_connection.py +++ b/syft/sync/connections/base_connection.py @@ -1,8 +1,18 @@ -from typing import ClassVar, Type +from typing import ClassVar, NamedTuple, Type from pydantic import BaseModel from syft.sync.messages.proposed_filechange import ProposedFileChangesMessage +class RejectedMessage(NamedTuple): + """A message file that the recipient renamed with the rejected suffix.""" + + file_id: str + # The file name before the suffix. + filename: str + # True when a file with the original name is in the folder again. + resent: bool + + class FileCollection(BaseModel): """A collection of files that can be shared and synced.""" @@ -108,6 +118,26 @@ def owner_download_next_raw_proposed_message_from_inbox( """Download next message from inbox as raw bytes. Returns (data, file_id) or None.""" raise NotImplementedError() + def owner_get_inbox_message_ids(self, sender_email: str) -> list[str]: + """The ids of the messages in the inbox of ``sender_email``, oldest first.""" + raise NotImplementedError() + + def mark_message_rejected(self, file_id: str) -> None: + """Mark an inbox or outbox message as rejected, so the sender resends it.""" + raise NotImplementedError() + + def watcher_get_rejected_inbox_messages( + self, recipient: str + ) -> list[RejectedMessage]: + """The inbox messages ``recipient`` rejected.""" + raise NotImplementedError() + + def owner_get_rejected_outbox_messages( + self, recipient: str + ) -> list[RejectedMessage]: + """The outbox messages ``recipient`` rejected.""" + raise NotImplementedError() + def owner_write_raw_bytes_to_outbox( self, recipient: str, filename: str, data: bytes ) -> None: @@ -128,6 +158,14 @@ def owner_write_raw_bytes_to_syftbox(self, filename: str, data: bytes) -> str: def owner_download_raw_bytes_by_id(self, file_id: str) -> bytes: raise NotImplementedError() + def owner_overwrite_raw_bytes_by_id(self, file_id: str, data: bytes) -> None: + """Replace the content of file ``file_id``, keeping its name and place.""" + raise NotImplementedError() + + def owner_get_checkpoint_and_state_file_ids(self) -> list[str]: + """The ids of all checkpoint and rolling state files.""" + raise NotImplementedError() + def owner_download_all_raw_events_from_syftbox(self) -> list[bytes]: raise NotImplementedError() diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index e45fc214476..9ca1f590680 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -1,5 +1,5 @@ import logging -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Callable, Dict, List, Optional from pydantic import BaseModel, PrivateAttr from syft_migration import MigratableObject, ProtocolSchema @@ -14,8 +14,14 @@ from syft.sync.connections.base_connection import ( ConnectionConfig, FileCollection, + RejectedMessage, SyftboxPlatformConnection, ) +from syft.sync.encryption_policy import ( + EncryptionPolicyConflictError, + PeerEncryptionPendingError, + UnencryptedMessageError, +) from syft.sync.connections.drive.gdrive_transport import ( PEERS_META_KEY, GDriveConnection, @@ -140,6 +146,31 @@ def _downgrade_for_peer( target = client_registry.schema_for_protocol_version(protocol_version) return client_migration_service.migrate_to_schema(message, target) + def _encrypt_for_peer(self, peer_email: str, data: bytes) -> bytes: + """Encrypt ``data`` for ``peer_email`` when the pair encrypts. + + When the peer policy is not known, the peer version file is read once + more first, at most once per peer load: the peer can have accepted + since the last load. + + Raises: + PeerEncryptionPolicyUnknownError: when the policy is still not known. + """ + if not self.peer_store.peer_policy_known(peer_email): + peer = self.peer_store.get_cached_peer(peer_email) + if peer is not None and self.peer_store.take_policy_reread(peer_email): + peer.version = self.read_peer_version_file(peer_email) + self.resolve_legacy_policy(peer) + return self.peer_store.encrypt_if_needed(peer_email, data) + + def resolve_legacy_policy(self, peer: Peer) -> None: + """Set the policy of ``peer`` when its version file does not say it.""" + if peer.needs_legacy_policy: + policy = self.peer_store.legacy_policy( + peer.email, lambda: bool(self.read_peer_encryption_bundle(peer.email)) + ) + peer.set_legacy_policy(policy) + def watcher_send_proposed_file_changes_message( self, recipient: str, proposed_file_changes_message: ProposedFileChangesMessage ): @@ -147,7 +178,7 @@ def watcher_send_proposed_file_changes_message( proposed_file_changes_message, recipient ) data = proposed_file_changes_message.as_compressed_data() - data = self.peer_store.encrypt_if_needed(recipient, data) + data = self._encrypt_for_peer(recipient, data) filename = proposed_file_changes_message.message_filename.as_string() self.connection_for_send_message().watcher_send_raw_bytes_to_inbox( recipient, filename, data @@ -163,17 +194,90 @@ def owner_get_next_proposed_filechange_message( if result is None: return None raw_data, file_id = result - raw_data = self.peer_store.decrypt_and_verify_if_needed(sender_email, raw_data) + raw_data = self._decrypt_or_reject( + connection, + sender_email, + raw_data, + file_id, + on_reject=lambda: self._reject_plaintext_inbox_messages( + connection, sender_email + ), + ) + if raw_data is None: + return None msg = ProposedFileChangesMessage.from_compressed_data(raw_data) msg.platform_id = file_id return msg + def _reject_plaintext_inbox_messages( + self, connection: SyftboxPlatformConnection, sender_email: str + ) -> None: + """Reject every other plaintext message from ``sender_email`` at once. + + The sender then resends them all on one sync, not one per round. It + encrypts all messages after the first envelope, so the scan stops there. + """ + for file_id in connection.owner_get_inbox_message_ids(sender_email): + if self.peer_store.is_envelope(connection.download_file(file_id)): + break + connection.mark_message_rejected(file_id) + + def owner_resend_rejected_outbox_messages(self, recipient: str) -> int: + """Resend, encrypted, the plaintext outbox messages ``recipient`` rejected. + + Returns the number of messages resent. + """ + connection = self.connection_for_outbox() + return self._resend_rejected( + recipient, + connection, + connection.owner_get_rejected_outbox_messages, + connection.owner_write_raw_bytes_to_outbox, + ) + + def watcher_resend_rejected_inbox_messages(self, recipient: str) -> int: + """Resend, encrypted, the plaintext messages ``recipient`` rejected. + + Returns the number of messages resent. + """ + connection = self.connection_for_send_message() + return self._resend_rejected( + recipient, + connection, + connection.watcher_get_rejected_inbox_messages, + connection.watcher_send_raw_bytes_to_inbox, + ) + + def _resend_rejected( + self, + recipient: str, + connection: SyftboxPlatformConnection, + list_rejected: Callable[[str], list[RejectedMessage]], + write: Callable[[str, str, bytes], None], + ) -> int: + # Only a pair that encrypts rejects plaintext; skip the listing otherwise. + if not self.peer_store.peer_uses_encryption(recipient): + return 0 + if not self.peer_store.encryption_ready(recipient): + logger.info(f"Resend to {recipient} waits for the keys and the pin") + return 0 + rejected = list_rejected(recipient) + for file_id, filename, resent in rejected: + # A resend whose delete failed is not uploaded a second time. + if not resent: + plaintext = connection.download_file(file_id) + write( + recipient, filename, self.peer_store.encrypt(recipient, plaintext) + ) + connection.delete_file_by_id(file_id, raise_on_error=True) + return len(rejected) + def owner_write_event_messages_to_outbox( self, recipient_email: str, events_message: FileChangeEventsMessage ): events_message = self._downgrade_for_peer(events_message, recipient_email) data = events_message.as_compressed_data() - data = self.peer_store.encrypt_if_needed(recipient_email, data) + data = self._encrypt_for_peer(recipient_email, data) fname = events_message.message_filepath.as_string() self.connection_for_outbox().owner_write_raw_bytes_to_outbox( recipient_email, fname, data @@ -183,15 +287,62 @@ def watcher_get_events_messages( self, peer_email: str, since_timestamp: float | None ) -> List[FileChangeEventsMessage]: connection = self.connection_for_datasite_watcher() - raw_list = connection.watcher_download_raw_events_from_outbox( + messages = [] + waiting = False + for meta in connection.watcher_get_outbox_file_metadatas( peer_email, since_timestamp - ) - return [ - FileChangeEventsMessage.from_compressed_data( - self.peer_store.decrypt_and_verify_if_needed(peer_email, raw) + ): + # After one message that cannot be read, later ones are not applied, + # but each is still read, so all plaintext ones are rejected at once. + message = self.watcher_read_events_message( + connection, peer_email, meta["file_id"] ) - for raw in raw_list - ] + waiting = waiting or message is None + if not waiting: + messages.append(message) + return messages + + def watcher_read_events_message( + self, connection: SyftboxPlatformConnection, peer_email: str, file_id: str + ) -> FileChangeEventsMessage | None: + """Download, verify and decrypt one outbox events message. + + Returns None when the message cannot be read yet (see + ``_decrypt_or_reject``). + """ + raw = self._decrypt_or_reject( + connection, peer_email, connection.download_file(file_id), file_id + ) + if raw is None: + return None + return FileChangeEventsMessage.from_compressed_data(raw) + + def _decrypt_or_reject( + self, + connection: SyftboxPlatformConnection, + peer_email: str, + raw: bytes, + file_id: str, + on_reject: Callable[[], None] | None = None, + ) -> bytes | None: + """Verify and decrypt an inbox or outbox message from ``peer_email``. + + Returns None when the message cannot be read yet; the messages of this + peer then wait, so they keep their order. A plaintext message to a pair + that encrypts is also marked rejected, and the sender resends it + encrypted on its next sync. + """ + try: + return self.peer_store.decrypt_and_verify_if_needed(peer_email, raw) + except UnencryptedMessageError as e: + logger.warning(f"Rejected message file {file_id}: {e}") + connection.mark_message_rejected(file_id) + if on_reject is not None: + on_reject() + except (PeerEncryptionPendingError, EncryptionPolicyConflictError) as e: + # A conflict found here is new since the peer filter of this sync. + logger.info(f"Messages from {peer_email} wait: {e}") + return None def watcher_get_outbox_file_metadatas( self, peer_email: str, since_timestamp: float | None @@ -225,6 +376,27 @@ def owner_get_all_accepted_events_messages(self) -> List[FileChangeEventsMessage for raw in raw_list ] + def owner_encrypt_files_at_rest(self) -> None: + """Encrypt the plaintext files at rest once, after a switch to required. + + Files written while this client was optional (event log, checkpoints, + rolling state) are plaintext. Each one is encrypted in place, so its + name and id stay, and reads verify them as usual. + """ + connection = self.connection_for_eventlog() + + def encrypt_files() -> None: + file_ids = connection.owner_get_all_accepted_event_file_ids() + file_ids += connection.owner_get_checkpoint_and_state_file_ids() + for file_id in file_ids: + raw = connection.owner_download_raw_bytes_by_id(file_id) + if not self.peer_store.is_envelope(raw): + connection.owner_overwrite_raw_bytes_by_id( + file_id, self.peer_store.encrypt_for_self(raw) + ) + + self.peer_store.encrypt_files_at_rest_once(encrypt_files) + def download_events_message_by_id( self, events_message_id: str ) -> FileChangeEventsMessage: @@ -367,7 +539,7 @@ def owner_upload_collection_files( """Upload collection files, encrypting each file if encryption is enabled.""" if recipient_email and self.peer_store: files = { - name: self.peer_store.encrypt_if_needed(recipient_email, data) + name: self._encrypt_for_peer(recipient_email, data) for name, data in files.items() } connection = self.connection_for_send_message() @@ -430,10 +602,12 @@ def read_peer_version_file(self, peer_email: str) -> Optional["VersionInfo"]: connection = self.connection_for_datasite_watcher() return connection.read_peer_version_file(peer_email) - def share_version_file_with_peer(self, peer_email: str) -> None: + def share_version_file_with_peer( + self, peer_email: str, version_info: Optional["VersionInfo"] = None + ) -> None: """Share version file with a peer so they can read it.""" connection = self.connection_for_own_syftbox() - connection.share_version_file_with_peer(peer_email) + connection.share_version_file_with_peer(peer_email, version_info) def watcher_get_collection_file_metadatas( self, prefix: str, tag: str, content_hash: str, owner_email: str diff --git a/syft/sync/connections/drive/gdrive_transport.py b/syft/sync/connections/drive/gdrive_transport.py index 30b22fd878a..42d49942ed5 100644 --- a/syft/sync/connections/drive/gdrive_transport.py +++ b/syft/sync/connections/drive/gdrive_transport.py @@ -28,6 +28,7 @@ ) from syft.sync.connections.base_connection import ( FileCollection, + RejectedMessage, SyftboxPlatformConnection, ) from syft.sync.connections.drive.gdrive_retry import ( @@ -67,6 +68,18 @@ logging.getLogger("google_auth_httplib2").setLevel(logging.ERROR) +logger = logging.getLogger(__name__) + + +def _waits_for_rejected_message(file_metadatas: list[dict], folder: str) -> bool: + """Whether a message folder holds a rejected message the sender must resend. + + Later messages can depend on the rejected one, so they wait until then. + """ + if not any(m["name"].endswith(REJECTED_MESSAGE_SUFFIX) for m in file_metadatas): + return False + logger.warning(f"The {folder} waits for the sender to resend a rejected message.") + return True def build_drive_service( @@ -94,6 +107,10 @@ def build_drive_service( LEGACY_GDRIVE_OUTBOX_INBOX_FOLDER_PREFIX = "syft_outbox_inbox" # legacy prefix +# Suffix the recipient adds to a plaintext inbox or outbox message when the +# pair encrypts. The sender then resends the message encrypted, under the +# original name. +REJECTED_MESSAGE_SUFFIX = ".rejected" GDRIVE_P2P_FOLDER_DATASITE_PREFIX = "syft_datasite" SYFT_PEERS_FILE = "SYFT_peers.json" @@ -715,6 +732,8 @@ def watcher_get_outbox_file_metadatas( file_metadatas = self.get_file_metadatas_from_folder( folder_id, since_timestamp=since_timestamp ) + if _waits_for_rejected_message(file_metadatas, f"outbox of {peer_email}"): + return [] valid_fname_objs = self._get_valid_events_from_file_metadatas(file_metadatas) name_to_id = {f["name"]: f["id"] for f in file_metadatas} @@ -754,6 +773,24 @@ def owner_download_raw_bytes_by_id(self, file_id: str) -> bytes: """Download raw bytes by file ID.""" return self.download_file(file_id) + def owner_overwrite_raw_bytes_by_id(self, file_id: str, data: bytes) -> None: + payload, _ = self.create_file_payload(data) + execute_with_retries( + self.drive_service.files().update(fileId=file_id, media_body=payload) + ) + + def owner_get_checkpoint_and_state_file_ids(self) -> list[str]: + folder_ids = [ + self._get_checkpoints_folder_id(), + self._get_rolling_state_folder_id(), + ] + return [ + m["id"] + for folder_id in folder_ids + if folder_id is not None + for m in self.get_file_metadatas_from_folder(folder_id) + ] + def owner_get_all_accepted_event_file_ids( self, since_timestamp: float | None = None ) -> list[str]: @@ -1086,6 +1123,8 @@ def _get_valid_events_from_file_metadatas( res = [] for file_metadata in file_metadatas: fname = file_metadata["name"] + if fname.endswith(REJECTED_MESSAGE_SUFFIX): + continue try: message_filename = FileChangeEventsMessageFileName.from_string(fname) except ValueError: @@ -1100,6 +1139,8 @@ def _get_valid_messages_from_file_metadatas( ) -> list[MessageFileName]: res = [] for file_metadata in file_metadatas: + if file_metadata["name"].endswith(REJECTED_MESSAGE_SUFFIX): + continue try: message_filename = MessageFileName.from_string(file_metadata["name"]) res.append(message_filename) @@ -1114,6 +1155,8 @@ def owner_download_next_raw_proposed_message_from_inbox( if inbox_folder_id is None: raise ValueError(f"Inbox folder for {sender_email} not found") file_metadatas = self.get_file_metadatas_from_folder(inbox_folder_id) + if _waits_for_rejected_message(file_metadatas, f"inbox of {sender_email}"): + return None valid_file_names = self._get_valid_messages_from_file_metadatas(file_metadatas) if len(valid_file_names) == 0: return None @@ -1126,6 +1169,66 @@ def owner_download_next_raw_proposed_message_from_inbox( raw_data = self.download_file(first_file_id) return raw_data, first_file_id + def owner_get_inbox_message_ids(self, sender_email: str) -> list[str]: + inbox_folder_id = self._get_own_datasite_inbox_id(sender_email) + if inbox_folder_id is None: + return [] + file_metadatas = self.get_file_metadatas_from_folder(inbox_folder_id) + name_to_id = {m["name"]: m["id"] for m in file_metadatas} + valid_file_names = self._get_valid_messages_from_file_metadatas(file_metadatas) + return [ + name_to_id[f.as_string()] + for f in sorted(valid_file_names, key=lambda x: x.submitted_timestamp) + ] + + def mark_message_rejected(self, file_id: str) -> None: + """Rename message file ``file_id`` with the rejected suffix, in place. + + Two readers on one datasite (a notebook and syft-bg) can reject the + same file; the second finds the suffix and leaves the name. + """ + file_info = execute_with_retries( + self.drive_service.files().get(fileId=file_id, fields="name") + ) + if file_info["name"].endswith(REJECTED_MESSAGE_SUFFIX): + return + execute_with_retries( + self.drive_service.files().update( + fileId=file_id, + body={"name": file_info["name"] + REJECTED_MESSAGE_SUFFIX}, + fields="id, name", + supportsAllDrives=True, + ) + ) + + def watcher_get_rejected_inbox_messages( + self, recipient: str + ) -> list[RejectedMessage]: + return self._rejected_messages_in_folder( + self._get_peer_datasite_inbox_id(recipient) + ) + + def owner_get_rejected_outbox_messages( + self, recipient: str + ) -> list[RejectedMessage]: + return self._rejected_messages_in_folder( + self._get_own_datasite_outbox_id(recipient) + ) + + def _rejected_messages_in_folder( + self, folder_id: str | None + ) -> list[RejectedMessage]: + if folder_id is None: + return [] + metadatas = self.get_file_metadatas_from_folder(folder_id) + names = {m["name"] for m in metadatas} + res = [] + for m in metadatas: + if m["name"].endswith(REJECTED_MESSAGE_SUFFIX): + original = m["name"].removesuffix(REJECTED_MESSAGE_SUFFIX) + res.append(RejectedMessage(m["id"], original, original in names)) + return res + def owner_get_next_proposed_filechange_message( self, sender_email: str ) -> ProposedFileChangesMessage | None: @@ -1939,14 +2042,21 @@ def read_peer_version_file(self, peer_email: str) -> Optional["VersionInfo"]: print(f"Warning: could not read the version file of {peer_email}: {e}") return None - def share_version_file_with_peer(self, peer_email: str) -> None: - """Share the version file with a peer so they can read it.""" + def share_version_file_with_peer( + self, peer_email: str, version_info: Optional["VersionInfo"] = None + ) -> None: + """Share the version file with a peer so they can read it. + + A missing file is created from ``version_info``, the version of this + client. Without it, the file says only what any client of this release + says, and not the encryption policy of this client. + """ file_id = self._get_version_file_id() if file_id is None: # Version file doesn't exist yet, create it first from syft.sync.version.version_info import VersionInfo - self.write_version_file(VersionInfo.current()) + self.write_version_file(version_info or VersionInfo.current()) file_id = self._get_version_file_id() if file_id: diff --git a/syft/sync/encryption_policy.py b/syft/sync/encryption_policy.py new file mode 100644 index 00000000000..c099c5e5cf8 --- /dev/null +++ b/syft/sync/encryption_policy.py @@ -0,0 +1,112 @@ +"""Encryption policy that a client advertises to its peers.""" + +from enum import Enum +from typing import Any + + +class EncryptionPolicy(str, Enum): + """What a client asks of the encryption between it and a peer. + + - ``OFF``: never encrypt. + - ``OPTIONAL``: encrypt only with a peer that requires it. + - ``REQUIRED``: encrypt with every peer, and refuse plaintext. + + A pair encrypts when one or both sides require it. An ``OFF`` and a + ``REQUIRED`` client conflict, and do not exchange messages. + """ + + OFF = "off" + OPTIONAL = "optional" + REQUIRED = "required" + + @classmethod + def coerce(cls, value: "bool | str | EncryptionPolicy") -> "EncryptionPolicy": + """The policy for an ``encryption`` setting. + + ``True`` is ``REQUIRED`` and ``False`` is ``OPTIONAL``. A policy or + its name (``"off"``) selects that policy. + """ + if isinstance(value, bool): + return cls.REQUIRED if value else cls.OPTIONAL + return cls(value) + + +def policy_from_use_encryption(data: Any) -> Any: + """Model input with the earlier ``use_encryption`` bool as ``encryption_policy``. + + For a ``model_validator(mode="before")`` of a model that took the bool. + """ + if isinstance(data, dict) and "use_encryption" in data: + data = dict(data) + data["encryption_policy"] = EncryptionPolicy.coerce(data.pop("use_encryption")) + return data + + +class PeerEncryptionPendingError(ValueError): + """A message to a peer cannot go yet. A later sync sends it.""" + + +class PeerEncryptionPolicyUnknownError(PeerEncryptionPendingError): + """The policy of a peer is not known, so the client cannot select a format.""" + + def __init__(self, peer_email: str): + super().__init__( + f"The encryption policy of {peer_email} is not known. The peer has " + "not shared its version file yet. Wait until the peer accepts the " + "peer request, then send again." + ) + self.peer_email = peer_email + + +class PeerKeysNotReadyError(PeerEncryptionPendingError): + """The pair encrypts, but our keys or the pinned peer bundle are missing. + + ``load_peers`` makes the keys and pins the bundle. + """ + + def __init__(self, peer_email: str): + super().__init__( + f"Encryption with {peer_email} is not set up yet. The next sync " + "sets it up and sends the message." + ) + self.peer_email = peer_email + + +class EncryptionPolicyConflictError(ValueError): + """One side of the pair has encryption off and the other requires it.""" + + def __init__(self, peer_email: str): + super().__init__( + f"Encryption with {peer_email} is off on one side and required on " + "the other, so this pair cannot exchange messages. Set the same " + "policy on both sides, or use optional on one of them." + ) + self.peer_email = peer_email + + +class UnencryptedMessageError(ValueError): + """A client that requires encryption received plaintext.""" + + def __init__(self, peer_email: str): + super().__init__( + f"A message from {peer_email} is not encrypted, and the pair " + "requires encryption." + ) + self.peer_email = peer_email + + +def pair_uses_encryption( + mine: EncryptionPolicy, theirs: EncryptionPolicy | None +) -> bool: + """Whether a pair with these policies encrypts. ``theirs`` is None when unknown. + + A conflicting pair (see ``pair_conflicts``) does not encrypt. + """ + if EncryptionPolicy.OFF in (mine, theirs): + return False + return EncryptionPolicy.REQUIRED in (mine, theirs) + + +def pair_conflicts(mine: EncryptionPolicy, theirs: EncryptionPolicy | None) -> bool: + """Whether one side has encryption off and the other requires it.""" + return {mine, theirs} == {EncryptionPolicy.OFF, EncryptionPolicy.REQUIRED} diff --git a/syft/sync/login.py b/syft/sync/login.py index e7a43d12363..94828413f70 100644 --- a/syft/sync/login.py +++ b/syft/sync/login.py @@ -100,6 +100,7 @@ def login( token_path: str | Path | None = None, skip_peer_on_patch_version_diff: bool | None = None, # None: value is determined by the role + encryption: bool | str = False, # True: required; "off": never encrypt ): return login_ds( email, @@ -107,6 +108,7 @@ def login( load_peers, token_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, + encryption=encryption, ) @@ -117,6 +119,7 @@ def login_ds( token_path: str | Path | None = None, skip_peer_on_patch_version_diff: bool | None = None, # None: value is determined by the role + encryption: bool | str = False, # True: required; "off": never encrypt ): env = check_env() email, token_path = _resolve_login_params(email, token_path) @@ -128,6 +131,7 @@ def login_ds( email=email, has_ds_role=True, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, + encryption=encryption, ) else: client = SyftboxManager.for_jupyter( @@ -135,6 +139,7 @@ def login_ds( has_ds_role=True, token_path=token_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, + encryption=encryption, ) return _init_client_login(client, sync, load_peers, repair_pending) @@ -147,6 +152,7 @@ def login_do( token_path: str | Path | None = None, skip_peer_on_patch_version_diff: bool | None = None, # None: value is determined by the role + encryption: bool | str = False, # True: required; "off": never encrypt ): env = check_env() email, token_path = _resolve_login_params(email, token_path) @@ -158,6 +164,7 @@ def login_do( email=email, has_do_role=True, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, + encryption=encryption, ) else: client = SyftboxManager.for_jupyter( @@ -165,6 +172,7 @@ def login_do( has_do_role=True, token_path=token_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, + encryption=encryption, ) return _init_client_login(client, sync, load_peers, repair_pending) diff --git a/syft/sync/peers/peer.py b/syft/sync/peers/peer.py index 82201a9c3e8..29caac23503 100644 --- a/syft/sync/peers/peer.py +++ b/syft/sync/peers/peer.py @@ -1,6 +1,7 @@ from enum import Enum from typing import Any, List, Optional from pydantic import BaseModel, PrivateAttr +from syft.sync.encryption_policy import EncryptionPolicy from syft.sync.peers.key_bundle import bundle_fingerprint from syft.sync.platforms.base_platform import BasePlatform from syft.sync.version.version_info import VersionInfo @@ -27,10 +28,33 @@ class Peer(BaseModel): state: PeerState = PeerState.ACCEPTED # Default for backward compatibility version: Optional[VersionInfo] = None public_encryption_bundle: Optional[dict] = None - use_encryption: bool = False # Set by SyftboxManager.peers when this Peer is handed out. _manager: Any = PrivateAttr(default=None) + # The policy of an earlier client, whose version file does not say. + _legacy_policy: Optional[EncryptionPolicy] = PrivateAttr(default=None) + + @property + def encryption_policy(self) -> Optional[EncryptionPolicy]: + """The policy of the peer, or None while it is not known. + + It is not known before the version file is read, and for an earlier + client until ``set_legacy_policy``. + """ + if self.version is None: + return None + if self.version.encryption_policy is not None: + return self.version.encryption_policy + return self._legacy_policy + + @property + def needs_legacy_policy(self) -> bool: + """Whether the version file is read and does not say the policy.""" + return self.version is not None and self.version.encryption_policy is None + + def set_legacy_policy(self, policy: EncryptionPolicy) -> None: + """Set the policy of an earlier client, whose version file does not say.""" + self._legacy_policy = policy @property def fingerprint(self) -> Optional[str]: diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index eef769016e7..8b045655da5 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -1,6 +1,6 @@ """PeerStore: merged peer list + encryption key management. -Holds peers, encryption keys, and a use_encryption flag. +Holds peers, encryption keys, and the encryption policy of this client. Shared between PeerManager and all ConnectionRouter instances. """ @@ -11,11 +11,22 @@ import warnings from contextlib import contextmanager from pathlib import Path -from typing import Iterator, List, Optional +from typing import Any, Callable, Iterator, List, Optional import portalocker import syft_crypto_python as syc -from pydantic import BaseModel, PrivateAttr +from pydantic import BaseModel, PrivateAttr, model_validator + +from syft.sync.encryption_policy import ( + EncryptionPolicy, + EncryptionPolicyConflictError, + PeerEncryptionPolicyUnknownError, + PeerKeysNotReadyError, + UnencryptedMessageError, + pair_conflicts, + pair_uses_encryption, + policy_from_use_encryption, +) from syft.sync.peers.key_bundle import ( InvalidPeerBundleError, @@ -42,6 +53,15 @@ CRYPTO_KEYS_VERSION = 1 # Key in the crypto key file under which pinned peer bundles are stored. PEER_BUNDLES_KEY = "peer_bundles" +# Key in the crypto key file that records the policy. Every process that +# loads the file (a notebook and syft-bg) then uses the same policy. A file +# without it was written when only required clients had keys. +ENCRYPTION_POLICY_KEY = "encryption_policy" +# Key in the crypto key file that says the files of this datasite at rest +# (event log, checkpoints, rolling state) are encrypted. A required client +# encrypts them once and sets it: an optional client, or an earlier client +# without encryption, can have written plaintext there. +AT_REST_ENCRYPTED_KEY = "at_rest_encrypted" def datasite_crypto_keys_path(syftbox_folder: Path | str, email: str) -> Path: @@ -63,13 +83,34 @@ class PeerStore(BaseModel): model_config = {"arbitrary_types_allowed": True} email: str - use_encryption: bool = False + encryption_policy: EncryptionPolicy = EncryptionPolicy.OPTIONAL _private_keys: syc.SyftPrivateKeys | None = PrivateAttr(default=None) _peers: List[Peer] = PrivateAttr(default_factory=list) # Where pinned peer bundles persist (the private key file). None when the # store was built in memory, e.g. in tests; pins then live only in memory. _keys_path: Path | None = PrivateAttr(default=None) + # Peers whose version file was read again since the last ``set_peers``. + _policy_rereads: set[str] = PrivateAttr(default_factory=set) + # Policies of earlier clients, by email. Their version file does not say, + # so the policy comes from a Drive read, once per process. + _legacy_policies: dict[str, EncryptionPolicy] = PrivateAttr(default_factory=dict) + # Whether the files at rest are known to be encrypted. + _at_rest_encrypted: bool = PrivateAttr(default=False) + + @model_validator(mode="before") + @classmethod + def _policy_from_use_encryption(cls, data: Any) -> Any: + return policy_from_use_encryption(data) + + @property + def use_encryption(self) -> bool: + """True when this client requires encryption with every peer and at rest.""" + return self.encryption_policy == EncryptionPolicy.REQUIRED + + @use_encryption.setter + def use_encryption(self, value: bool) -> None: + self.encryption_policy = EncryptionPolicy.coerce(value) # ========== Peer list methods ========== @@ -100,17 +141,53 @@ def get_cached_peer(self, email: str) -> Optional[Peer]: return None def encrypt_if_needed(self, email: str, data: bytes) -> bytes: - if self.peer_uses_encryption(email): - return self.encrypt(email, data) - return data + """Encrypt ``data`` for ``email`` when the pair encrypts. + + Raises: + PeerEncryptionPolicyUnknownError: when this client is optional and + the policy of the peer is not known. + PeerKeysNotReadyError: when the pair encrypts, but our keys or the + pinned peer bundle are missing. + EncryptionPolicyConflictError: when the policies of the pair conflict. + """ + if not self.peer_policy_known(email): + raise PeerEncryptionPolicyUnknownError(email) + if self.peer_conflicts(email): + raise EncryptionPolicyConflictError(email) + if not self.peer_uses_encryption(email): + return data + if not self.encryption_ready(email): + raise PeerKeysNotReadyError(email) + return self.encrypt(email, data) def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: - if self.peer_uses_encryption(email): - self.verify_message(email, data) - return self.decrypt(email, data) - return data + """Verify and decrypt an envelope from ``email``; pass plaintext through. + + When the pair encrypts, plaintext is refused, so that a peer or the + storage provider cannot downgrade the pair. When the pair does not + encrypt, an envelope is still accepted: the peer can have started to + require encryption after the last load. - def _is_syc_envelope(self, data: bytes) -> bool: + Raises: + UnencryptedMessageError: when the pair encrypts and ``data`` is + plaintext. + PeerKeysNotReadyError: when ``data`` is an envelope, but our keys + or the pinned peer bundle are missing. + EncryptionPolicyConflictError: when the policies of the pair conflict. + """ + if self.peer_conflicts(email): + raise EncryptionPolicyConflictError(email) + if not self.is_envelope(data): + if self.peer_uses_encryption(email): + raise UnencryptedMessageError(email) + return data + if not self.encryption_ready(email): + raise PeerKeysNotReadyError(email) + self.verify_message(email, data) + return self.decrypt(email, data) + + @staticmethod + def is_envelope(data: bytes) -> bool: """True if `data` is an SYC encryption envelope (vs plaintext).""" try: syc.parse_envelope(data) @@ -125,19 +202,36 @@ def decrypt_dataset_if_needed(self, owner_email: str, data: bytes) -> bytes: plaintext bytes are passed through instead of raising. Bytes that are an SYC envelope are still signature-verified and decrypted as usual. """ - if not self.peer_uses_encryption(owner_email): - return data - if not self._is_syc_envelope(data): + if not self.is_envelope(data): return data self.verify_message(owner_email, data) return self.decrypt(owner_email, data) - def peer_uses_encryption(self, email: str) -> bool: + def peer_policy(self, email: str) -> Optional[EncryptionPolicy]: + """The policy ``email`` advertises, or None when not known.""" peer = self.get_cached_peer(email) - return peer is not None and peer.use_encryption + return peer.encryption_policy if peer is not None else None + + def peer_policy_known(self, email: str) -> bool: + """Whether this client can select the format for ``email``. + + A required client encrypts with every peer, so it needs no peer policy. + """ + return self.use_encryption or self.peer_policy(email) is not None + + def encryption_ready(self, email: str) -> bool: + """Whether our keys and the pinned bundle of ``email`` exist.""" + return self.has_my_keys() and self.has_peer_bundle(email) + + def peer_conflicts(self, email: str) -> bool: + """Whether this client has encryption off and ``email`` requires it, or the reverse.""" + return pair_conflicts(self.encryption_policy, self.peer_policy(email)) + + def peer_uses_encryption(self, email: str) -> bool: + """Whether the pair with ``email`` encrypts. False when the peer policy is not known.""" + return pair_uses_encryption(self.encryption_policy, self.peer_policy(email)) def set_peer(self, peer: Peer) -> None: - peer.use_encryption = self.use_encryption self._keep_pinned_bundle(peer) for i, p in enumerate(self._peers): if p.email == peer.email: @@ -146,15 +240,37 @@ def set_peer(self, peer: Peer) -> None: self._peers.append(peer) def add_peer(self, peer: Peer) -> None: - peer.use_encryption = self.use_encryption self._keep_pinned_bundle(peer) self._peers.append(peer) def set_peers(self, peers: List[Peer]) -> None: for p in peers: - p.use_encryption = self.use_encryption self._keep_pinned_bundle(p) self._peers = peers + self._policy_rereads.clear() + + def legacy_policy( + self, email: str, published_bundle: Callable[[], bool] + ) -> EncryptionPolicy: + """The policy of the earlier client ``email``, read once per process. + + Such a client publishes a bundle for us only when it encrypts. One + that does not has no keys and cannot decrypt, so it is off. + """ + if email not in self._legacy_policies: + self._legacy_policies[email] = ( + EncryptionPolicy.REQUIRED + if published_bundle() + else EncryptionPolicy.OFF + ) + return self._legacy_policies[email] + + def take_policy_reread(self, email: str) -> bool: + """True once per peer load: the caller may read the version file again.""" + if email in self._policy_rereads: + return False + self._policy_rereads.add(email) + return True def _keep_pinned_bundle(self, incoming: Peer) -> None: """Make ``incoming`` carry the pinned bundle for its email, if any. @@ -163,8 +279,16 @@ def _keep_pinned_bundle(self, incoming: Peer) -> None: copy holds. A pin always wins over that copy: a differing Drive bundle is reported and dropped. Without a pin, a valid Drive bundle becomes the pin (trust on first use) and an invalid one is dropped. + + An optional client without keys has no key file to pin in. It keeps + only a bundle it set itself (``set_peer_bundle``) and drops the Drive + copy; ``load_peers`` pins the published bundle once it has keys. """ - if not self.use_encryption: + if not self.use_encryption and not self.has_my_keys(): + cached = self.get_cached_peer(incoming.email) + incoming.public_encryption_bundle = ( + cached.public_encryption_bundle if cached is not None else None + ) return pinned = self._current_pin(incoming.email) candidate = incoming.public_encryption_bundle @@ -254,6 +378,49 @@ def _ensure_peer_bundle(self, email: str) -> dict: def generate_keys(self) -> None: self._private_keys = syc.SyftRecoveryKey.generate().derive_keys() + def ensure_keys(self) -> None: + """Make sure this client has keys: keep them, load the key file, or generate. + + An optional client has no keys until a peer requires encryption. New + keys are saved to the key file when the store has one. The lock covers + the check and the write, so two processes on one datasite (a notebook + and syft-bg) do not each make a key pair. + """ + if self.has_my_keys(): + return + if self._keys_path is None: + self.generate_keys() + return + with _locked_key_file(self._keys_path): + if self._keys_path.exists(): + self._adopt_key_file(self.load_keys(self._keys_path)) + return + self.generate_keys() + _write_key_file(self._keys_path, self._key_file_data()) + + def _adopt_key_file(self, loaded: "PeerStore") -> None: + """Take the keys, pins and recorded policy of ``loaded``, a store read from our key file.""" + if loaded.email != self.email: + raise ValueError( + f"Encryption key file {self._keys_path} belongs to " + f"{loaded.email!r}, not {self.email!r}" + ) + self._private_keys = loaded._private_keys + self._at_rest_encrypted = loaded._at_rest_encrypted + if loaded.use_encryption: + self.encryption_policy = EncryptionPolicy.REQUIRED + if not self._peers: + self._peers = loaded._peers + return + stored = {p.email: p.public_encryption_bundle for p in loaded._peers} + for peer in self._peers: + if peer.email in stored: + # The pin in the key file wins. + peer.public_encryption_bundle = stored[peer.email] + elif peer.public_encryption_bundle is not None: + # Set in memory before there were keys, e.g. from attestation. + self._persist_peer_bundle(peer.email, peer.public_encryption_bundle) + def has_my_keys(self) -> bool: return self._private_keys is not None @@ -394,8 +561,64 @@ def _key_file_data(self) -> dict: "email": self.email, "keys_jwk": keys.to_jwks(), PEER_BUNDLES_KEY: self._pinned_bundles(), + ENCRYPTION_POLICY_KEY: self.encryption_policy.value, + AT_REST_ENCRYPTED_KEY: self._at_rest_encrypted, } + def reload_policy_from_key_file(self) -> None: + """Take a required policy that another process recorded in the key file. + + A notebook that started optional must not write plaintext at rest once + syft-bg made the datasite required. + + Raises: + ValueError: when this client has encryption off. + """ + if self.use_encryption or self._keys_path is None: + return + if not self._keys_path.exists(): + return + loaded = self.load_keys(self._keys_path) + if not loaded.use_encryption: + return + if self.encryption_policy == EncryptionPolicy.OFF: + raise _off_refused_error(self._keys_path) + self._adopt_key_file(loaded) + + def needs_at_rest_encryption(self) -> bool: + """Whether this client is required, but its files at rest can be plaintext.""" + return self.use_encryption and not self._at_rest_encrypted + + def encrypt_files_at_rest_once(self, encrypt_files: Callable[[], None]) -> None: + """Run ``encrypt_files`` unless a process already did, then record it. + + The key file lock covers the check, the run and the record, so two + processes on one datasite do not both encrypt the files. + """ + self.ensure_keys() + if self._keys_path is None: + encrypt_files() + else: + with _locked_key_file(self._keys_path): + data = self._read_key_file() or self._key_file_data() + if not data.get(AT_REST_ENCRYPTED_KEY): + encrypt_files() + data = {**data, AT_REST_ENCRYPTED_KEY: True} + _write_key_file(self._keys_path, data) + self._at_rest_encrypted = True + + def _record_required_policy(self) -> None: + """Record a required policy in the key file, keeping what else it holds.""" + if self._keys_path is None or not self.use_encryption: + return + with _locked_key_file(self._keys_path): + data = self._read_key_file() or self._key_file_data() + if data.get(ENCRYPTION_POLICY_KEY) == EncryptionPolicy.REQUIRED.value: + return + data = {**data, ENCRYPTION_POLICY_KEY: EncryptionPolicy.REQUIRED.value} + _write_key_file(self._keys_path, data) + _warn_key_file_holds_data(self._keys_path) + def save_keys(self, path: Path) -> None: data = self._key_file_data() path = Path(path) @@ -461,7 +684,9 @@ def load_keys(cls, path: Path) -> "PeerStore": f"client reads up to version {CRYPTO_KEYS_VERSION}. Install a " "newer syft to use these keys." ) - store = cls(email=data["email"], use_encryption=True) + policy = data.get(ENCRYPTION_POLICY_KEY, EncryptionPolicy.REQUIRED.value) + store = cls(email=data["email"], encryption_policy=EncryptionPolicy(policy)) + store._at_rest_encrypted = bool(data.get(AT_REST_ENCRYPTED_KEY)) store._private_keys = syc.SyftPrivateKeys.from_jwks(data["keys_jwk"]) store._keys_path = Path(path) store._peers = _peers_from_stored_pins(data.get(PEER_BUNDLES_KEY, {})) @@ -471,44 +696,68 @@ def load_keys(cls, path: Path) -> "PeerStore": def create( cls, email: str, - use_encryption: bool = False, + encryption_policy: EncryptionPolicy = EncryptionPolicy.OPTIONAL, keys_path: Path | str | None = None, + use_encryption: bool | None = None, ) -> "PeerStore": - """Build a PeerStore, loading or generating encryption keys when enabled. + """Build a PeerStore for ``email`` with its per-datasite key file. + + ``keys_path`` is the participant's own key file + (``//private/crypto_keys.json``, see + :func:`datasite_crypto_keys_path`). When the file exists, the store + loads its keys and pins. A required policy in the file wins over an + optional ``encryption_policy``, so all processes on a datasite agree. - - encryption off: return a plain store (no keys). - - encryption on: ``keys_path`` is required — the participant's own - per-datasite key file (``//private/crypto_keys.json``, - see :func:`datasite_crypto_keys_path`). Load it when present, else - generate a fresh key pair and persist it there. + Without a file, a required store generates keys and saves them. An + optional store has no keys until :meth:`ensure_keys`. + + ``use_encryption`` is the earlier bool form of ``encryption_policy``. Scoping keys to a per-datasite file lets several identities run on one machine (e.g. two data owners in the same notebook) without colliding, and ties key lifetime to the datasite folder. Raises: - ValueError: if encryption is on but ``keys_path`` is missing, or the - existing key file belongs to a different identity. + ValueError: if the policy is required but ``keys_path`` is missing, + or the existing key file belongs to a different identity. """ - if not use_encryption: - return cls(email=email, use_encryption=False) + if use_encryption is not None: + encryption_policy = EncryptionPolicy.coerce(use_encryption) + store = cls(email=email, encryption_policy=encryption_policy) if keys_path is None: - raise ValueError("keys_path is required when use_encryption is True") - path = Path(keys_path) - if path.exists(): - store = cls.load_keys(path) - if store.email != email: - raise ValueError( - f"Encryption key file {path} belongs to {store.email!r}, " - f"not {email!r}" - ) + if store.use_encryption: + raise ValueError("keys_path is required when encryption is required") return store - else: - # write keys to passed path - store = cls(email=email, use_encryption=True) + store._keys_path = Path(keys_path) + if store._keys_path.exists(): + loaded = cls.load_keys(store._keys_path) + if encryption_policy == EncryptionPolicy.OFF and loaded.use_encryption: + raise _off_refused_error(store._keys_path) + store._adopt_key_file(loaded) + store._record_required_policy() + elif store.use_encryption: store.generate_keys() - store.save_keys(path) - return store + store.save_keys(store._keys_path) + _warn_key_file_holds_data(store._keys_path) + return store + + +def _off_refused_error(keys_path: Path) -> ValueError: + return ValueError( + f"The key file {keys_path} requires encryption: the files of this " + "datasite at rest are encrypted. Encryption cannot be off for it. Use a " + "new SyftBox folder for a client with encryption off." + ) + + +def _warn_key_file_holds_data(keys_path: Path) -> None: + """Warn once, when a datasite starts to require encryption.""" + warnings.warn( + "This datasite now requires encryption, and every later session keeps " + "that setting. Its files at rest are encrypted with the key in " + f"{keys_path}. Without that file, they cannot be read again: keep a " + "copy in a safe place." + ) @contextmanager @@ -570,7 +819,5 @@ def _peers_from_stored_pins(bundles: dict[str, dict]) -> List[Peer]: except InvalidPeerBundleError as e: logger.warning(f"Dropping stored key pin for {email}: {e}") continue - peers.append( - Peer(email=email, public_encryption_bundle=bundle, use_encryption=True) - ) + peers.append(Peer(email=email, public_encryption_bundle=bundle)) return peers diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index 8daacb29c31..0e5a76446e8 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -26,6 +26,7 @@ from syft.sync.job_file_change_handler import JobFileChangeHandler from syft.sync.peers.peer import Peer, PeerNotReadyError from syft.sync.peers.peer_list import PeerList +from syft.sync.encryption_policy import EncryptionPolicy from syft.sync.peers.peer_store import PeerStore from syft.sync.platforms.base_platform import BasePlatform from syft.sync.sync.caches.datasite_owner_cache import ( @@ -134,7 +135,7 @@ def for_colab( email: str, has_ds_role: bool = False, has_do_role: bool = False, - encryption: bool = False, + encryption: bool | str = False, persist_owner_state: bool = True, crypto_keys_path: Path | None = None, skip_peer_on_patch_version_diff: Optional[ @@ -216,7 +217,7 @@ def for_jupyter( has_ds_role: bool = False, has_do_role: bool = False, token_path: Path | None = None, - encryption: bool = False, + encryption: bool | str = False, persist_owner_state: bool = True, crypto_keys_path: Path | None = None, skip_peer_on_patch_version_diff: Optional[ @@ -563,11 +564,10 @@ def from_config(cls, config: SyftboxManagerConfig): config=config, ) - # PeerManager.from_config built the (possibly key-bearing) peer store from - # peer_manager_config; share that single store across every syncer's router - # so encryption is consistent on all sync paths. - if peer_manager.peer_store.use_encryption: - manager_res._set_peer_store(peer_manager.peer_store) + # PeerManager.from_config built the peer store from peer_manager_config; + # share that single store across every syncer's router so all sync paths + # see the same peers, peer policies and keys. + manager_res._set_peer_store(peer_manager.peer_store) # Every router that sends peer-directed messages downgrades them to the # peer's negotiated syft protocol, so each one gets the live map. @@ -609,7 +609,7 @@ def for_colab( email: str, has_ds_role: bool = False, has_do_role: bool = False, - encryption: bool = False, + encryption: bool | str = False, persist_owner_state: bool = True, crypto_keys_path: Path | None = None, skip_peer_on_patch_version_diff: Optional[ @@ -638,7 +638,7 @@ def for_jupyter( has_ds_role: bool = False, has_do_role: bool = False, token_path: Path | None = None, - encryption: bool = False, + encryption: bool | str = False, persist_owner_state: bool = True, crypto_keys_path: Path | None = None, skip_peer_on_patch_version_diff: Optional[ @@ -764,8 +764,10 @@ def pair_with_mock_drive_service_connection( use_in_memory_cache: bool = True, persist_owner_state: bool = True, check_versions: bool = False, - encryption: bool = False, + encryption: bool | str = False, collection_specs: list["CollectionSyncSpec"] | None = None, + ds_encryption_policy: EncryptionPolicy | None = None, + do_encryption_policy: EncryptionPolicy | None = None, ): """Create a pair of managers using mock Google Drive services for testing. @@ -783,6 +785,9 @@ def pair_with_mock_drive_service_connection( use_in_memory_cache: Whether to use in-memory caches persist_owner_state: Whether the DO keeps restorable owner state check_versions: Whether to check protocol/client versions + encryption: Require encryption on both sides + ds_encryption_policy: Policy of the DS; overrides ``encryption`` + do_encryption_policy: Policy of the DO; overrides ``encryption`` Returns: Tuple of (ds_manager, do_manager) @@ -839,10 +844,12 @@ def pair_with_mock_drive_service_connection( ds_manager.peer_manager.write_own_version() do_manager.peer_manager.write_own_version() - # Initialize encryption if requested - if encryption: - ds_manager._init_encrypted_peer_store() - do_manager._init_encrypted_peer_store() + default_policy = EncryptionPolicy.coerce(encryption) + for manager, policy in ( + (ds_manager, ds_encryption_policy or default_policy), + (do_manager, do_encryption_policy or default_policy), + ): + manager._apply_encryption_policy_for_testing(policy) if add_peers: # DS creates peer request @@ -856,11 +863,25 @@ def pair_with_mock_drive_service_connection( return ds_manager, do_manager + def _apply_encryption_policy_for_testing(self, policy: EncryptionPolicy) -> None: + """Give a mock-Drive client ``policy``, before it peers.""" + if policy == EncryptionPolicy.REQUIRED: + self._init_encrypted_peer_store() + elif policy == EncryptionPolicy.OFF: + self.peer_manager.peer_store.encryption_policy = policy + self.peer_manager.write_own_version() + def _init_encrypted_peer_store(self) -> None: - """Initialize the encrypted peer store.""" - peer_store = PeerStore(email=self.email, use_encryption=True) + """Replace the peer store with an in-memory one that requires encryption. + + The version file is written again, so that peers read the new policy. + """ + peer_store = PeerStore( + email=self.email, encryption_policy=EncryptionPolicy.REQUIRED + ) peer_store.generate_keys() self._set_peer_store(peer_store) + self.peer_manager.write_own_version() def add_peer( self, @@ -983,6 +1004,7 @@ def sync( separate process may have updated the file. """ with self.sync_file_lock(): + self.peer_manager.reload_policy() self.load_peers(force_download=force_download_peer_state) if self.has_do_role: peer_emails = [peer.email for peer in self.peer_manager.approved_peers] @@ -992,6 +1014,9 @@ def sync( ignore_peer_version=ignore_peer_version, ) ) + compatible_emails = self.peer_manager.without_encryption_conflicts( + compatible_emails + ) self.datasite_owner_syncer.sync(compatible_emails) if auto_compact: for peer_email in compatible_emails: @@ -1004,6 +1029,11 @@ def sync( if self.has_ds_role: peer_emails = [peer.email for peer in self.peer_manager.syncable_peers] self.peer_manager.warn_if_all_peers_incompatible(peer_emails) + peer_emails = self.peer_manager.without_encryption_conflicts( + peer_emails + ) + self.datasite_watcher_syncer.resend_rejected_messages(peer_emails) + self.datasite_watcher_syncer.send_pending_messages() self.datasite_watcher_syncer.sync_down(peer_emails) def load_peers(self, force_download: bool = False): @@ -1099,7 +1129,8 @@ def wait_until_peered( def encryption_fingerprint(self) -> str | None: """Fingerprint of our own encryption key, to give peers out of band. - None when encryption is off. + None until this client has keys: it makes them when it requires + encryption, or when a peer first requires it. """ return self.peer_manager.my_fingerprint() diff --git a/syft/sync/sync/caches/datasite_watcher_cache.py b/syft/sync/sync/caches/datasite_watcher_cache.py index 1d2112d3482..425a34c29a4 100644 --- a/syft/sync/sync/caches/datasite_watcher_cache.py +++ b/syft/sync/sync/caches/datasite_watcher_cache.py @@ -241,9 +241,14 @@ def sync_down_parallel( self, peer_email: str, executor: ThreadPoolExecutor, - download_fn: Callable[[str], FileChangeEventsMessage], + download_fn: Callable[[str], FileChangeEventsMessage | None], ) -> int: - """Sync with parallel file downloads. Returns the number of events applied.""" + """Sync with parallel file downloads. Returns the number of events applied. + + ``download_fn`` returns None for a message that was rejected or cannot + be read yet. Messages from its timestamp on are not applied, and the + cursor stays before it, so they apply in order when it can be read. + """ peer_timestamp = self.last_event_timestamp_per_peer.get(peer_email) # Get file metadata (no download yet) @@ -261,9 +266,14 @@ def sync_down_parallel( file_ids = [m["file_id"] for m in file_metadatas] downloaded_messages = list(executor.map(download_fn, file_ids)) - # Apply in timestamp order + # Apply in timestamp order, up to the first rejected message event_count = 0 - for event_message in sorted(downloaded_messages, key=lambda x: x.timestamp): + by_timestamp = sorted( + zip(file_metadatas, downloaded_messages), key=lambda x: x[0]["timestamp"] + ) + for _, event_message in by_timestamp: + if event_message is None: + break self.apply_event_message(event_message, peer_email) self.last_event_timestamp_per_peer[peer_email] = event_message.timestamp event_count += len(event_message.events) diff --git a/syft/sync/sync/constants.py b/syft/sync/sync/constants.py index 2e8508efb4f..c73793f8ef6 100644 --- a/syft/sync/sync/constants.py +++ b/syft/sync/sync/constants.py @@ -2,3 +2,5 @@ OWNER_FILE_HASHES_FILENAME = "owner_file_hashes.json" ROLLING_STATE_FILENAME = "rolling_state.json" +HELD_OUTBOX_FILENAME = "held_outbox.json" +HELD_PROPOSALS_FILENAME = "held_proposals.json" diff --git a/syft/sync/sync/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py index 4ca8204a878..1d9f1178960 100644 --- a/syft/sync/sync/datasite_owner_syncer.py +++ b/syft/sync/sync/datasite_owner_syncer.py @@ -20,6 +20,10 @@ DataSiteOwnerEventCacheConfig, ) from syft.sync.connections.connection_router import ConnectionRouter +from syft.sync.encryption_policy import ( + EncryptionPolicyConflictError, + PeerEncryptionPendingError, +) from syft.sync.sync.caches.datasite_owner_cache import DataSiteOwnerEventCache from syft.sync.sync.collection_spec import CollectionSyncSpec from syft.sync.callback_mixin import BaseModelCallbackMixin @@ -37,7 +41,12 @@ raise_for_later_version, ) from syft_perms import SyftPermContext -from syft.sync.sync.constants import CACHE_DIR, ROLLING_STATE_FILENAME +from syft.sync.sync.constants import ( + CACHE_DIR, + HELD_OUTBOX_FILENAME, + ROLLING_STATE_FILENAME, +) +from syft.sync.sync.held_messages import HeldMessages logger = logging.getLogger(__name__) @@ -192,12 +201,18 @@ def _save_rolling_state(self) -> None: tmp.rename(path) def sync(self, peer_emails: list[str], recompute_hashes: bool = True): + if self.connection_router.peer_store.needs_at_rest_encryption(): + self.connection_router.owner_encrypt_files_at_rest() if self.persist_owner_state: self._load_rolling_state() try: if not self.initial_sync_done: self.pull_initial_state() + # Rejected messages go first: the reader waits for them. + for peer_email in peer_emails: + self.connection_router.owner_resend_rejected_outbox_messages(peer_email) + if recompute_hashes: self.process_local_changes(recipients=peer_emails) @@ -796,11 +811,53 @@ def process_syftbox_events_queue(self): self.connection_router.owner_write_events_message_to_syftbox( file_change_events_message ) - while not self.outbox_queue.empty(): - recipient, file_change_events_message = self.outbox_queue.get() - self.connection_router.owner_write_event_messages_to_outbox( - recipient, file_change_events_message - ) + # The event is already in the event log, so a held outbox message is + # kept on disk until encryption with its recipient is set up. + with self._held_outbox.edit() as held: + pending = [ + (r, FileChangeEventsMessage.from_compressed_data(d)) for r, d in held + ] + held.clear() + while not self.outbox_queue.empty(): + pending.append(self.outbox_queue.get()) + for recipient, file_change_events_message in pending: + if any(r == recipient for r, _ in held): + # Keep the order of the messages to one recipient. + held.append( + (recipient, file_change_events_message.as_compressed_data()) + ) + continue + try: + self.connection_router.owner_write_event_messages_to_outbox( + recipient, file_change_events_message + ) + except PeerEncryptionPendingError: + if ( + self.connection_router.peer_store.get_cached_peer(recipient) + is None + ): + # Not a peer any more: it never gets a policy. + logger.warning(f"Dropped an outbox message for {recipient}") + continue + held.append( + (recipient, file_change_events_message.as_compressed_data()) + ) + except EncryptionPolicyConflictError as e: + logger.warning(f"Dropped an outbox message: {e}") + if held: + logger.info( + f"Held {len(held)} outbox message(s) for peers whose " + "encryption is not set up yet" + ) + + @property + def _held_outbox(self) -> HeldMessages: + return HeldMessages(self.syftbox_folder / CACHE_DIR / HELD_OUTBOX_FILENAME) + + @property + def held_message_count(self) -> int: + """The number of outbox messages held until encryption is set up.""" + return len(self._held_outbox) def _download_outbox_message_with_new_connection( self, file_id: str @@ -821,10 +878,13 @@ def compact_outbox_if_needed( deletes the originals. Returns the number of source messages compacted (0 if below threshold, - if no outbox folder, or if peer encryption is on — DO can't read - peer-encrypted bytes back to merge). + if no outbox folder, or if peer encryption is on or the peer policy is + not known — DO can't read peer-encrypted bytes back to merge). """ - if self.connection_router.peer_store.peer_uses_encryption(recipient_email): + peer_store = self.connection_router.peer_store + if not peer_store.peer_policy_known( + recipient_email + ) or peer_store.peer_uses_encryption(recipient_email): logger.debug( f"outbox compaction for {recipient_email} skipped — " f"peer encryption is not supported yet" diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index 912f06c7d81..b9897c6e993 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -1,23 +1,32 @@ +import logging from pathlib import Path from concurrent.futures import ThreadPoolExecutor from queue import Queue -from typing import List +from typing import Dict, List from pydantic import BaseModel, Field, PrivateAttr from syft.sync.connections.base_connection import ConnectionConfig from syft.sync.connections.connection_router import ConnectionRouter from syft.sync.callback_mixin import BaseModelCallbackMixin +from syft.sync.encryption_policy import ( + EncryptionPolicyConflictError, + PeerEncryptionPendingError, +) from syft.sync.events.file_change_event import FileChangeEventsMessage from syft.sync.messages.proposed_filechange import ( ProposedFileChange, ProposedFileChangesMessage, ) +from syft.sync.sync.constants import CACHE_DIR, HELD_PROPOSALS_FILENAME +from syft.sync.sync.held_messages import HeldMessages from syft.sync.sync.caches.datasite_watcher_cache import ( DataSiteWatcherCache, DataSiteWatcherCacheConfig, ) +logger = logging.getLogger(__name__) + class DatasiteWatcherSyncerConfig(BaseModel): syftbox_folder: Path | None = None @@ -65,7 +74,13 @@ def get_proposed_file_change_object( if datasite_email is None: datasite_email = self.email cache_key = Path(datasite_email) / relative_path - old_hash = self.datasite_watcher_cache.current_hash_for_file(cache_key) + # The cache holds only sent changes. A held change to the same file + # gives the next change its old hash. + held_hashes = self._held_hashes() + if cache_key in held_hashes: + old_hash = held_hashes[cache_key] + else: + old_hash = self.datasite_watcher_cache.current_hash_for_file(cache_key) return ProposedFileChange( datasite_email=datasite_email, path_in_datasite=relative_path, @@ -74,7 +89,8 @@ def get_proposed_file_change_object( ) def process_file_changes_queue(self): - file_changes = [] + # One message per datasite: each change goes to the owner of its path. + changes_by_recipient: Dict[str, List[ProposedFileChange]] = {} while not self.queue.empty(): relative_path, content = self.queue.get() @@ -97,22 +113,128 @@ def process_file_changes_queue(self): file_change = self.get_proposed_file_change_object( path_in_datasite, content, datasite_email=recipient ) - file_changes.append(file_change) + changes_by_recipient.setdefault(recipient, []).append(file_change) - message = ProposedFileChangesMessage( - sender_email=self.email, proposed_file_changes=file_changes - ) - self.connection_router.watcher_send_proposed_file_changes_message( - recipient, message - ) + conflict = None + for recipient, file_changes in changes_by_recipient.items(): + message = ProposedFileChangesMessage( + sender_email=self.email, proposed_file_changes=file_changes + ) + try: + sent = self._send_or_hold(recipient, message) + except EncryptionPolicyConflictError as e: + # The other recipients still get their changes. + conflict = conflict or e + continue + if sent: + self._record_sent_hashes(message) + if conflict is not None: + raise conflict - # Update local cache with new hashes - for fc in file_changes: + def _record_sent_hashes(self, message: ProposedFileChangesMessage) -> None: + hashes = self.datasite_watcher_cache.file_hashes + for fc in message.proposed_file_changes: path_key = Path(fc.datasite_email) / fc.path_in_datasite if fc.is_deleted: - self.datasite_watcher_cache.file_hashes.pop(path_key, None) + hashes.pop(path_key, None) else: - self.datasite_watcher_cache.file_hashes[path_key] = fc.new_hash + hashes[path_key] = fc.new_hash + + @property + def _held_messages(self) -> HeldMessages: + return HeldMessages(self.syftbox_folder / CACHE_DIR / HELD_PROPOSALS_FILENAME) + + def _held_hashes(self) -> Dict[Path, int | None]: + """The new hash of each file that a held change touches.""" + with self._held_messages.edit() as held: + messages = [ + ProposedFileChangesMessage.from_compressed_data(d) for _, d in held + ] + hashes: Dict[Path, int | None] = {} + for message in messages: + for fc in message.proposed_file_changes: + path_key = Path(fc.datasite_email) / fc.path_in_datasite + hashes[path_key] = None if fc.is_deleted else fc.new_hash + return hashes + + def _send_or_hold( + self, recipient: str, message: ProposedFileChangesMessage + ) -> bool: + """Send ``message``, or hold it while encryption with the recipient is pending. + + Encryption is pending while the recipient policy is unknown, or while + the pair encrypts and the keys or the peer bundle are missing. A held + message is kept on disk. Messages to one recipient keep their order. + Returns True when sent. + + Raises: + PeerEncryptionPolicyUnknownError: when ``recipient`` is not a peer. + It never gets a policy, so a held message would never go. + EncryptionPolicyConflictError: when the policies of the pair conflict. + """ + with self._held_messages.edit() as held: + if not any(r == recipient for r, _ in held): + try: + self.connection_router.watcher_send_proposed_file_changes_message( + recipient, message + ) + return True + except PeerEncryptionPendingError as e: + peer_store = self.connection_router.peer_store + if peer_store.get_cached_peer(recipient) is None: + raise + print(f"Held a message for {recipient}: {e}") + held.append((recipient, message.as_compressed_data())) + return False + + @property + def pending_message_count(self) -> int: + return len(self._held_messages) + + def send_pending_messages(self) -> None: + """Send the held messages whose recipient policy is now known. + + Messages for a recipient that is no longer a peer (the peer request + was rejected), or whose policy conflicts, are dropped with a warning. + """ + peer_store = self.connection_router.peer_store + with self._held_messages.edit() as held: + pending = list(held) + held.clear() + for recipient, data in pending: + if any(r == recipient for r, _ in held): + held.append((recipient, data)) + continue + if peer_store.get_cached_peer(recipient) is None: + logger.warning( + f"Dropped a held message for {recipient}: it is not a peer." + ) + continue + message = ProposedFileChangesMessage.from_compressed_data(data) + try: + self.connection_router.watcher_send_proposed_file_changes_message( + recipient, message + ) + except PeerEncryptionPendingError: + held.append((recipient, data)) + continue + except EncryptionPolicyConflictError as e: + print(f"Dropped a held message for {recipient}: {e}") + continue + self._record_sent_hashes(message) + + def resend_rejected_messages(self, peer_emails: list[str]) -> None: + """Resend, encrypted, the plaintext messages that peers rejected. + + A peer rejects plaintext when it requires encryption and this client + sent with an outdated view of its policy. + """ + for peer_email in peer_emails: + count = self.connection_router.watcher_resend_rejected_inbox_messages( + peer_email + ) + if count: + logger.info(f"Resent {count} rejected message(s) to {peer_email}") def on_file_change( self, relative_path: Path | str, content: str | None = None, process_now=True @@ -126,14 +248,16 @@ def on_file_change( def download_events_message_with_new_connection( self, file_id: str, peer_email: str - ) -> FileChangeEventsMessage: - """Download from outbox using a new connection (thread-safe).""" + ) -> FileChangeEventsMessage | None: + """Download from outbox using a new connection (thread-safe). + + Returns None when the message was rejected, or when it cannot be + read yet (see ``ConnectionRouter.watcher_read_events_message``). + """ connection = self.connection_router.connection_for_parallel_download() - raw = connection.download_file(file_id) - raw = self.connection_router.peer_store.decrypt_and_verify_if_needed( - peer_email, raw + return self.connection_router.watcher_read_events_message( + connection, peer_email, file_id ) - return FileChangeEventsMessage.from_compressed_data(raw) def download_collection_file_with_new_connection( self, file_id: str, owner_email: str diff --git a/syft/sync/sync/held_messages.py b/syft/sync/sync/held_messages.py new file mode 100644 index 00000000000..cee380a4409 --- /dev/null +++ b/syft/sync/sync/held_messages.py @@ -0,0 +1,84 @@ +"""Messages held until encryption with their recipient is set up. + +The list persists to a JSON file in the cache folder of the datasite, so a +held message survives a restart. The file is the only copy: a process takes +the cross-process lock, sends what it can, and writes back the rest, so two +processes on one datasite (a notebook and syft-bg) never send one message +twice. +""" + +import base64 +import json +import os +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +import portalocker + +# Format of the file: {"version": N, "messages": [...]}. Raise it when the +# layout of a message entry changes. +HELD_MESSAGES_VERSION = 1 + +HeldMessage = tuple[str, bytes] # (recipient email, compressed message) + + +class HeldMessages: + """A list of held messages, persisted to ``path``.""" + + def __init__(self, path: Path): + self._path = Path(path) + + @contextmanager + def edit(self) -> Iterator[list[HeldMessage]]: + """Hold the lock, yield the list, and save the changes made to it. + + When the block raises, the file stays as it was. + """ + self._path.parent.mkdir(parents=True, exist_ok=True) + with open(self._path.with_suffix(".lock"), "a") as lock_file: + portalocker.lock(lock_file, portalocker.LOCK_EX) + try: + messages = self._read() + yield messages + self._write(messages) + finally: + portalocker.unlock(lock_file) + + def __len__(self) -> int: + with self.edit() as messages: + return len(messages) + + def _read(self) -> list[HeldMessage]: + if not self._path.exists(): + return [] + data = json.loads(self._path.read_text()) + version = data.get("version") + if version != HELD_MESSAGES_VERSION: + # Held messages are user data, so a file this client cannot read + # is refused, not dropped. + raise ValueError( + f"The held messages in {self._path} have version {version}, and " + f"this client reads version {HELD_MESSAGES_VERSION}." + ) + return [(m["recipient"], base64.b64decode(m["data"])) for m in data["messages"]] + + def _write(self, messages: list[HeldMessage]) -> None: + if not messages: + self._path.unlink(missing_ok=True) + return + data = { + "version": HELD_MESSAGES_VERSION, + "messages": [ + {"recipient": r, "data": base64.b64encode(d).decode("ascii")} + for r, d in messages + ], + } + fd, tmp = tempfile.mkstemp(dir=self._path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "w") as f: + f.write(json.dumps(data)) + os.replace(tmp, self._path) + finally: + Path(tmp).unlink(missing_ok=True) diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index beb478857bc..8c8b840f2bd 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -8,7 +8,7 @@ from concurrent.futures import ThreadPoolExecutor from enum import Enum from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional, Set from googleapiclient.errors import HttpError from pydantic import BaseModel, ConfigDict, PrivateAttr, model_validator @@ -17,6 +17,12 @@ from syft.sync.connections.base_connection import ConnectionConfig from syft.sync.connections.connection_router import ConnectionRouter from syft.sync.connections.drive.gdrive_retry import RETRYABLE_TRANSPORT_ERRORS +from syft.sync.encryption_policy import ( + EncryptionPolicy, + pair_conflicts, + pair_uses_encryption, + policy_from_use_encryption, +) from syft.sync.peers.key_bundle import ( InvalidPeerBundleError, PeerFingerprintMismatchError, @@ -56,6 +62,14 @@ class CompatAction(str, Enum): EXECUTE = "execute" +def _conflict_skip_message(peer_email: str) -> str: + return ( + f"{peer_email} is skipped: encryption is off on one side and required " + "on the other. Set the same policy on both sides, or use optional on " + "one of them." + ) + + class PeerCompatibilityResult(BaseModel): """Outcome of a per-peer version compatibility check. @@ -120,7 +134,7 @@ class PeerManagerConfig(BaseModel): n_threads: int = 10 has_do_role: bool = False has_ds_role: bool = False - use_encryption: bool = False + encryption_policy: EncryptionPolicy = EncryptionPolicy.OPTIONAL # Explicit override for the encryption key file. When None, falls back to # ``default_crypto_keys_path`` (the per-datasite location). crypto_keys_path: Path | None = None @@ -128,6 +142,16 @@ class PeerManagerConfig(BaseModel): None # None: value is determined by the role (resolves to has_do_role) ) + @model_validator(mode="before") + @classmethod + def _policy_from_use_encryption(cls, data: Any) -> Any: + return policy_from_use_encryption(data) + + @property + def use_encryption(self) -> bool: + """True when the policy is required.""" + return self.encryption_policy == EncryptionPolicy.REQUIRED + @property def default_crypto_keys_path(self) -> Path: """Default per-datasite encryption key file for this participant @@ -177,6 +201,10 @@ class PeerManager(BaseModel): # Peer email -> state at the first load_peers(), for accepted peers and # waiting requests. None until that load. _states_at_start: Optional[Dict[str, PeerState]] = PrivateAttr(default=None) + # Peers this process published its encryption bundle for. + _bundle_published_for: Set[str] = PrivateAttr(default_factory=set) + # The policy in the version file this process last wrote. + _advertised_policy: Optional[EncryptionPolicy] = PrivateAttr(default=None) # ========== Peer List Properties ========== @@ -214,10 +242,8 @@ def from_config(cls, config: PeerManagerConfig, email: str = "") -> "PeerManager resolved_email = config.email or email peer_store = PeerStore.create( email=resolved_email, - use_encryption=config.use_encryption, - keys_path=config.resolved_crypto_keys_path - if config.use_encryption - else None, + encryption_policy=config.encryption_policy, + keys_path=config.resolved_crypto_keys_path, ) connection_router = ConnectionRouter.from_configs( resolved_email, config.connection_configs @@ -292,6 +318,9 @@ def get_own_version(self) -> VersionInfo: """Get current client's version info.""" if self._own_version is None: self._own_version = VersionInfo.current() + # The store can change after the first call: tests and the enclave + # replace it, and a key file can make it required. + self._own_version.encryption_policy = self.peer_store.encryption_policy return self._own_version def write_own_version(self) -> None: @@ -301,10 +330,27 @@ def write_own_version(self) -> None: version_info = self.get_own_version() self.connection_router.write_version_file(version_info) write_local_version(self.syftbox_folder) + self._advertised_policy = version_info.encryption_policy + + def reload_policy(self) -> None: + """Take a policy another process recorded in the key file, and advertise it.""" + self.peer_store.reload_policy_from_key_file() + self._republish_version_if_policy_changed() + + def _republish_version_if_policy_changed(self) -> None: + """Write the version file again when the policy changed in memory. + + Loading a key file marked required makes this client required. Peers + must see that, or they send plaintext that this client rejects. + """ + if self._advertised_policy not in (None, self.peer_store.encryption_policy): + self.write_own_version() def share_version_with_peer(self, peer_email: str) -> None: """Share version file with a peer so they can read it.""" - self.connection_router.share_version_file_with_peer(peer_email) + self.connection_router.share_version_file_with_peer( + peer_email, self.get_own_version() + ) def get_cached_peer(self, email: str) -> Optional[Peer]: """Get a peer by email, or None if not found.""" @@ -316,6 +362,7 @@ def load_peer_version(self, peer_email: str) -> Optional[VersionInfo]: cached_peer = self.get_cached_peer(peer_email) if cached_peer: cached_peer.version = version_info + self.connection_router.resolve_legacy_policy(cached_peer) self._update_peer_schemas(peer_email, version_info) return version_info @@ -351,6 +398,7 @@ def load_peer_versions_parallel( peer = self.get_cached_peer(email) if peer: peer.version = version + self.connection_router.resolve_legacy_policy(peer) self._update_peer_schemas(email, version) return {email: version for email, version in results} @@ -492,6 +540,20 @@ def get_compatible_peer_emails_for_syncing( compatible.append(email) return compatible + def without_encryption_conflicts(self, peer_emails: List[str]) -> List[str]: + """``peer_emails`` without the peers whose encryption policy conflicts. + + Such a pair cannot exchange messages, so it is skipped with a warning. + ``ignore_peer_version`` does not apply: it is not a version question. + """ + result = [] + for email in peer_emails: + if self.peer_store.peer_conflicts(email): + warnings.warn(_conflict_skip_message(email)) + else: + result.append(email) + return result + def warn_if_all_peers_incompatible(self, peer_emails: List[str]) -> None: """Warn if all connected peers are incompatible (used by DS during sync).""" if self.suppress_version_warnings: @@ -564,9 +626,21 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): if existing_peer_obj: new_peer_obj = existing_peer_obj - # Exchange encryption bundles if key_manager is set - if self.peer_store.use_encryption: + # The peer policy selects encryption for the pair. Before the peer + # accepts, its version file is not shared and the policy is unknown; + # load_peers publishes and pins when the policy becomes known. + version_info = self.connection_router.read_peer_version_file(peer_email) + new_peer_obj.version = version_info + self.connection_router.resolve_legacy_policy(new_peer_obj) + encrypted = pair_uses_encryption( + self.peer_store.encryption_policy, new_peer_obj.encryption_policy + ) + if encrypted: self._write_encryption_bundle_for_peer(peer_email) + if pair_conflicts( + self.peer_store.encryption_policy, new_peer_obj.encryption_policy + ): + warnings.warn(_conflict_skip_message(peer_email)) # Accepting a request or re-adding an accepted peer with force is an # explicit user decision, so a changed key is adopted with a warning. @@ -576,7 +650,7 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): and existing_peer_obj.state == PeerState.ACCEPTED ) peer_bundle = None - if self.peer_store.use_encryption and (is_accepting or re_adding_accepted): + if encrypted and (is_accepting or re_adding_accepted): peer_bundle = self._pin_published_bundle( peer_email, new_peer_obj, explicit=True ) @@ -585,10 +659,8 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): peer_email, new_state.value, public_encryption_bundle=peer_bundle ) self.share_version_with_peer(peer_email) - version_info = self.connection_router.read_peer_version_file(peer_email) self._update_peer_schemas(peer_email, version_info) - new_peer_obj.version = version_info if peer_bundle is not None: new_peer_obj.public_encryption_bundle = peer_bundle new_peer_obj.state = new_state @@ -605,15 +677,13 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): # ========== Peer key pinning ========== def my_fingerprint(self) -> Optional[str]: - """Fingerprint of our own encryption identity key (None when off).""" - if not self.peer_store.use_encryption or not self.peer_store.has_my_keys(): + """Fingerprint of our own encryption identity key (None without keys).""" + if not self.peer_store.has_my_keys(): return None return self.peer_store.my_fingerprint def peer_fingerprint(self, peer_email: str) -> Optional[str]: """Fingerprint of the pinned encryption key of ``peer_email``, or None.""" - if not self.peer_store.use_encryption: - return None return self.peer_store.peer_fingerprint(peer_email) def _pin_published_bundle( @@ -680,10 +750,10 @@ def refresh_peer_bundle( InvalidPeerBundleError: ``expected_fingerprint`` is given and the published bundle does not parse. """ - if not self.peer_store.use_encryption: - raise ValueError("Encryption is not enabled") if self.peer_store.get_cached_peer(peer_email) is None: raise ValueError(f"Unknown peer {peer_email}; run client.load_peers()") + if not self.peer_store.peer_uses_encryption(peer_email): + raise ValueError(f"Encryption is not in use with {peer_email}") raw_bundle = self._read_peer_encryption_bundle(peer_email) if raw_bundle is None: warnings.warn(f"{peer_email} has not published an encryption key for us") @@ -771,13 +841,14 @@ def _warn_if_key_rotated(self, peer_email: str, published_bundle: dict) -> None: ) def _write_encryption_bundle_for_peer(self, peer_email: str) -> dict | None: - """Write own encryption bundle for a peer if encryption is enabled.""" - if not self.peer_store.use_encryption: - raise ValueError("Encryption is not enabled") + """Write own encryption bundle for a peer, making keys first when needed.""" + self.peer_store.ensure_keys() + self._republish_version_if_policy_changed() bundle = self.peer_store.get_public_bundle() bundle_json = json.dumps({BUNDLE_FILE_KEY: bundle}) self.connection_router.write_encryption_bundle(peer_email, bundle_json) self.connection_router.share_encryption_bundles_folder(peer_email) + self._bundle_published_for.add(peer_email) return bundle def _read_peer_encryption_bundle( @@ -857,17 +928,29 @@ def load_peers(self, force_download: bool = False): # set_peers keeps the locally pinned bundle over whatever the Drive # copy of SYFT_peers.json holds, and validates any bundle it adopts. self.peer_store.set_peers(peers) + self.load_peer_versions_parallel([peer.email for peer in peers]) - if self.peer_store.use_encryption: - active = [ - p - for p in peers - if p.state in (PeerState.ACCEPTED, PeerState.REQUESTED_BY_ME) - ] - self._pin_missing_peer_bundles(active) - self._check_peer_key_rotations([p.email for p in active]) + # Peer policies are known only after the versions load. + encrypted = [ + p + for p in peers + if p.state in (PeerState.ACCEPTED, PeerState.REQUESTED_BY_ME) + and self.peer_store.peer_uses_encryption(p.email) + ] + self._publish_bundle_to_new_encrypted_peers(encrypted) + self._pin_missing_peer_bundles(encrypted) + self._check_peer_key_rotations([p.email for p in encrypted]) - self.load_peer_versions_parallel([peer.email for peer in peers]) + def _publish_bundle_to_new_encrypted_peers(self, peers: List[Peer]) -> None: + """Publish our bundle to each peer the pair encrypts with, once per process. + + ``add_peer`` publishes when the pair encrypts at peering. A pair can + start to encrypt later: an optional client learns the peer policy, or + this client becomes required after peering. + """ + for peer in peers: + if peer.email not in self._bundle_published_for: + self._write_encryption_bundle_for_peer(peer.email) def _pin_missing_peer_bundles(self, peers: List[Peer]) -> None: """Pin the published bundle of every peer we hold none for (trust on first use). diff --git a/syft/sync/version/version_info.py b/syft/sync/version/version_info.py index 1273ba2e670..e69879ff5ae 100644 --- a/syft/sync/version/version_info.py +++ b/syft/sync/version/version_info.py @@ -14,6 +14,7 @@ from syft_migration import MigratableObject, ProtocolSchema from syft.migrations import client_registry, load_as_latest +from syft.sync.encryption_policy import EncryptionPolicy from syft.version import ( MIN_SUPPORTED_PROTOCOL_VERSION, MIN_SUPPORTED_SYFT_VERSION, @@ -214,6 +215,10 @@ class VersionInfoV2(VersionInfoV1): # pydantic's own ``extra`` config, which governs unknown *fields*. extra: dict[str, Any] = Field(default_factory=dict) + # Peers read it to select encryption for the pair. None in a file from an + # earlier client, which does not say; see ``Peer.encryption_policy``. + encryption_policy: Optional[EncryptionPolicy] = None + @classmethod def current(cls) -> "VersionInfo": info = super().current() @@ -234,7 +239,13 @@ def _version_info_v1_to_v2(obj: VersionInfoV1) -> VersionInfoV2: def _version_info_v2_to_v1(obj: VersionInfoV2) -> VersionInfoV1: return VersionInfoV1.model_validate( obj.model_dump( - exclude={"canonical_name", "version", "protocol_schemas", "extra"} + exclude={ + "canonical_name", + "version", + "protocol_schemas", + "extra", + "encryption_policy", + } ) ) diff --git a/tests/migrations/unit/test_version_info_fields.py b/tests/migrations/unit/test_version_info_fields.py index de914d1950f..275883bd343 100644 --- a/tests/migrations/unit/test_version_info_fields.py +++ b/tests/migrations/unit/test_version_info_fields.py @@ -29,7 +29,9 @@ "attestation_token", } -V2_ADDS = {"protocol_schemas", "extra"} +# encryption_policy is read by syft itself, so it is a field and not a key in +# the ``extra`` bag that other packages use. +V2_ADDS = {"protocol_schemas", "extra", "encryption_policy"} def test_v1_fields_are_frozen(): diff --git a/tests/migrations/unit/test_version_info_serialization.py b/tests/migrations/unit/test_version_info_serialization.py index be0b7dd7fc2..a1d4f8fc5f2 100644 --- a/tests/migrations/unit/test_version_info_serialization.py +++ b/tests/migrations/unit/test_version_info_serialization.py @@ -57,6 +57,7 @@ def test_legacy_reader_tolerates_identity_fields(): data.pop("version") data.pop("protocol_schemas") data.pop("extra") + data.pop("encryption_policy") # Additive-only invariant: current output minus the added fields is # exactly the legacy shape a 0.1.117 reader expects. assert set(data) == set(json.loads(LEGACY_FILE.read_text())) diff --git a/tests/unit/test_encryption.py b/tests/unit/test_encryption.py index cfc839d871f..96476330404 100644 --- a/tests/unit/test_encryption.py +++ b/tests/unit/test_encryption.py @@ -115,28 +115,6 @@ def test_save_and_load_keys(): path.unlink() -def test_peer_use_encryption_inherited(): - """Peers added to PeerStore inherit its use_encryption flag.""" - ps = PeerStore(email="alice@example.com", use_encryption=True) - peer = Peer(email="bob@example.com") - assert not peer.use_encryption - ps.add_peer(peer) - assert peer.use_encryption - - ps2 = PeerStore(email="alice@example.com", use_encryption=False) - peer2 = Peer(email="bob@example.com", use_encryption=True) - ps2.add_peer(peer2) - assert not peer2.use_encryption - - -def test_set_peers_inherits_encryption(): - """set_peers propagates use_encryption to all peers.""" - ps = PeerStore(email="alice@example.com", use_encryption=True) - peers = [Peer(email="bob@example.com"), Peer(email="carol@example.com")] - ps.set_peers(peers) - assert all(p.use_encryption for p in peers) - - # ========================================================================= # Full DS→DO→DS flow with encryption via mock drive # ========================================================================= @@ -214,8 +192,8 @@ def test_no_encryption_backward_compat(): grant_job_inbox_access(do_manager, ds_manager.email) - assert ds_manager._peer_store is None - assert do_manager._peer_store is None + assert not ds_manager._peer_store.has_my_keys() + assert not do_manager._peer_store.has_my_keys() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "hello unencrypted") diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py new file mode 100644 index 00000000000..e3b4329b3fb --- /dev/null +++ b/tests/unit/test_encryption_policy.py @@ -0,0 +1,1155 @@ +"""Per-peer encryption negotiated from each side's advertised policy. + +A client is ``optional`` (encrypt only when the peer requires it) or +``required``. A pair encrypts when either side requires it. +""" + +import json +import threading +import warnings +from pathlib import Path + +import pytest + +from syft.sync.connections.connection_router import ConnectionRouter +from syft.sync.connections.drive import mock_drive_service +from syft.sync.connections.drive.gdrive_transport import GDriveConnection +from syft.sync.encryption_policy import ( + EncryptionPolicy, + EncryptionPolicyConflictError, + PeerEncryptionPolicyUnknownError, + PeerKeysNotReadyError, +) +from syft.sync.events.file_change_event import FileChangeEventsMessage +from syft.sync.peers.peer import Peer, PeerState +from syft.sync.peers.peer_store import PeerStore +from syft.sync.sync.datasite_watcher_syncer import DatasiteWatcherSyncer +from syft.sync.syftbox_manager import SyftboxManager +from syft.sync.version.version_info import VersionInfo +from tests.unit.test_sync_manager import path_for_job +from tests.unit.utils import grant_job_inbox_access + +OFF = EncryptionPolicy.OFF +OPTIONAL = EncryptionPolicy.OPTIONAL +REQUIRED = EncryptionPolicy.REQUIRED + + +def _version_with_policy(policy: EncryptionPolicy | None) -> VersionInfo: + version = VersionInfo.current() + if policy is not None: + version.encryption_policy = policy + return version + + +def _store_with_peer( + my_policy: EncryptionPolicy, peer_policy: EncryptionPolicy | None +) -> PeerStore: + store = PeerStore(email="alice@example.com", encryption_policy=my_policy) + store.add_peer( + Peer(email="bob@example.com", version=_version_with_policy(peer_policy)) + ) + return store + + +def _keyed_pair( + alice_policy: EncryptionPolicy, bob_policy: EncryptionPolicy +) -> tuple[PeerStore, PeerStore]: + """Two stores that know each other's policy and hold each other's bundle.""" + alice = PeerStore(email="alice@example.com", encryption_policy=alice_policy) + bob = PeerStore(email="bob@example.com", encryption_policy=bob_policy) + alice.generate_keys() + bob.generate_keys() + alice.add_peer( + Peer(email="bob@example.com", version=_version_with_policy(bob_policy)) + ) + bob.add_peer( + Peer(email="alice@example.com", version=_version_with_policy(alice_policy)) + ) + alice.set_peer_bundle("bob@example.com", bob.get_public_bundle()) + bob.set_peer_bundle("alice@example.com", alice.get_public_bundle()) + return alice, bob + + +def _raw_inbox_message(do_manager, sender_email: str) -> bytes: + connection = do_manager.datasite_owner_syncer.connection_router + raw, _ = ( + connection.connection_for_receive_message().owner_download_next_raw_proposed_message_from_inbox( + sender_email + ) + ) + return raw + + +def _is_envelope(data: bytes) -> bool: + return PeerStore.is_envelope(data) + + +def _accepted_contents(do_manager) -> list[str]: + return [ + str(e.content) + for msg in do_manager._get_all_accepted_events_do() + for e in msg.events + ] + + +def _ds_cached_contents(ds_manager) -> list[str]: + cache = ds_manager.datasite_watcher_syncer.datasite_watcher_cache + return [str(e.content) for e in cache.get_cached_events()] + + +def _pair_with_stale_sender_view(): + """A required DO and an optional DS whose cached DO policy is outdated.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=OPTIONAL, + do_encryption_policy=REQUIRED, + ) + grant_job_inbox_access(do_manager, ds_manager.email) + ds_manager.load_peers() + do_manager.load_peers() + stale = ds_manager.peer_manager.get_cached_peer(do_manager.email) + stale.version = _version_with_policy(OPTIONAL) + return ds_manager, do_manager + + +def _required_ds_pair(): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=REQUIRED, + do_encryption_policy=OPTIONAL, + ) + grant_job_inbox_access(do_manager, ds_manager.email) + do_manager.load_peers() + return ds_manager, do_manager + + +def _owner_sends_with_stale_view(ds_manager, do_manager, content: str) -> None: + """The owner handles a proposal and answers while it sees the DS as optional.""" + stale = do_manager.peer_manager.get_cached_peer(ds_manager.email) + stale.version = _version_with_policy(OPTIONAL) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, content) + do_manager.datasite_owner_syncer.sync([ds_manager.email]) + + +# ========================================================================= +# Policy model and advertisement +# ========================================================================= + + +def test_policy_defaults_to_optional(): + assert PeerStore(email="alice@example.com").encryption_policy == OPTIONAL + + +@pytest.mark.parametrize( + "use_encryption, expected", [(True, REQUIRED), (False, OPTIONAL)] +) +def test_use_encryption_bool_maps_to_policy(use_encryption, expected): + store = PeerStore(email="alice@example.com", use_encryption=use_encryption) + assert store.encryption_policy == expected + + +@pytest.mark.parametrize( + "value, expected", + [(True, REQUIRED), (False, OPTIONAL), ("off", OFF), (OFF, OFF)], +) +def test_encryption_setting_is_coerced_to_policy(value, expected): + assert EncryptionPolicy.coerce(value) == expected + + +def test_version_info_without_policy_field_has_no_policy(): + """A version file from a client that predates the field says nothing.""" + data = VersionInfo.current().model_dump(mode="json") + data.pop("encryption_policy") + assert VersionInfo.model_validate(data).encryption_policy is None + + +def _advertise_as_legacy_client(manager) -> None: + """Write the version file of ``manager`` without the policy field.""" + legacy = manager.peer_manager.get_own_version().model_copy() + legacy.encryption_policy = None + manager.peer_manager.connection_router.write_version_file(legacy) + + +@pytest.mark.parametrize("do_policy, expected", [(REQUIRED, REQUIRED), (OPTIONAL, OFF)]) +def test_legacy_peer_policy_follows_its_published_bundle(do_policy, expected): + """An earlier client publishes a bundle for us only when it encrypts. + + One that does not has no keys and cannot decrypt, so it is off. + """ + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=do_policy, + ) + _advertise_as_legacy_client(do_manager) + ds_manager.load_peers() + assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) == expected + + +def test_own_version_advertises_policy(): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=OPTIONAL, + do_encryption_policy=REQUIRED, + ) + assert do_manager.peer_manager.get_own_version().encryption_policy == REQUIRED + assert ds_manager.peer_manager.get_own_version().encryption_policy == OPTIONAL + advertised = ds_manager.peer_manager.get_peer_version(do_manager.email) + assert advertised.encryption_policy == REQUIRED + + +def test_missing_version_file_is_created_with_own_policy(): + a_conn, b_conn = mock_drive_service.pair_with_mock_service( + "a@example.com", "b@example.com" + ) + a_conn.share_version_file_with_peer("b@example.com", _version_with_policy(REQUIRED)) + assert b_conn.read_peer_version_file("a@example.com").encryption_policy == REQUIRED + + +def test_policy_change_in_memory_is_republished(): + """A key file marked required makes this client required: peers must see it.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + do_encryption_policy=REQUIRED, + ) + ds_manager.add_peer(do_manager.email, sync=False) + do_manager.load_peers() + do_manager.approve_peer_request(ds_manager.email) + + # Another process on the DS datasite wrote a required key file. + keys_path = ds_manager.peer_manager.peer_store._keys_path + PeerStore.create( + email=ds_manager.email, encryption_policy=REQUIRED, keys_path=keys_path + ) + ds_manager.load_peers() + + assert ds_manager.peer_manager.peer_store.encryption_policy == REQUIRED + advertised = do_manager.peer_manager.load_peer_version(ds_manager.email) + assert advertised.encryption_policy == REQUIRED + + +def test_legacy_policy_is_read_once_per_peer(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + _advertise_as_legacy_client(do_manager) + reads = [] + original = ConnectionRouter.read_peer_encryption_bundle + + def spy(self, email): + reads.append(email) + return original(self, email) + + monkeypatch.setattr(ConnectionRouter, "read_peer_encryption_bundle", spy) + ds_manager.load_peers() + ds_manager.load_peers() + assert reads == [do_manager.email] + + +# ========================================================================= +# Per-peer resolution +# ========================================================================= + + +@pytest.mark.parametrize( + "mine, theirs, encrypted", + [ + (OPTIONAL, OPTIONAL, False), + (OPTIONAL, REQUIRED, True), + (REQUIRED, OPTIONAL, True), + (REQUIRED, REQUIRED, True), + (OFF, OFF, False), + (OFF, OPTIONAL, False), + (OPTIONAL, OFF, False), + ], +) +def test_peer_encryption_is_resolved_from_both_policies(mine, theirs, encrypted): + store = _store_with_peer(mine, theirs) + assert store.peer_uses_encryption("bob@example.com") is encrypted + + +@pytest.mark.parametrize( + "mine, theirs", [(OFF, REQUIRED), (REQUIRED, OFF)], ids=["off", "required"] +) +def test_off_and_required_conflict(mine, theirs): + """Neither side gives way, so the pair cannot exchange messages.""" + alice, bob = _keyed_pair(mine, theirs) + assert alice.peer_conflicts("bob@example.com") + with pytest.raises(EncryptionPolicyConflictError): + alice.encrypt_if_needed("bob@example.com", b"data") + with pytest.raises(EncryptionPolicyConflictError): + alice.decrypt_and_verify_if_needed("bob@example.com", b"plain") + + +def test_resolution_follows_a_changed_peer_policy(): + """An enclave that reboots with another setting is picked up on the next load.""" + store = _store_with_peer(OPTIONAL, OPTIONAL) + assert not store.peer_uses_encryption("bob@example.com") + + store.get_cached_peer("bob@example.com").version = _version_with_policy(REQUIRED) + assert store.peer_uses_encryption("bob@example.com") + + store.get_cached_peer("bob@example.com").version = _version_with_policy(OPTIONAL) + assert not store.peer_uses_encryption("bob@example.com") + + +def test_optional_sender_refuses_peer_with_unknown_policy(): + store = PeerStore(email="alice@example.com", encryption_policy=OPTIONAL) + store.add_peer(Peer(email="bob@example.com")) + with pytest.raises(PeerEncryptionPolicyUnknownError): + store.encrypt_if_needed("bob@example.com", b"data") + + +def test_required_sender_needs_no_peer_policy(): + alice, _ = _keyed_pair(REQUIRED, OPTIONAL) + alice.get_cached_peer("bob@example.com").version = None + assert _is_envelope(alice.encrypt_if_needed("bob@example.com", b"data")) + + +@pytest.mark.parametrize( + "mine, theirs, with_keys", + [(OPTIONAL, REQUIRED, False), (REQUIRED, OPTIONAL, True)], + ids=["no own keys", "no peer bundle"], +) +def test_encrypting_pair_without_keys_or_pin_is_not_ready(mine, theirs, with_keys): + store = _store_with_peer(mine, theirs) + if with_keys: + store.generate_keys() + with pytest.raises(PeerKeysNotReadyError): + store.encrypt_if_needed("bob@example.com", b"data") + + +# ========================================================================= +# Keys and pins +# ========================================================================= + + +def test_optional_store_creates_no_key_file(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + store = PeerStore.create( + email="alice@example.com", encryption_policy=OPTIONAL, keys_path=keys_path + ) + assert not store.has_my_keys() + assert not keys_path.exists() + + +def test_off_store_creates_no_key_file(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + store = PeerStore.create( + email="alice@example.com", encryption_policy=OFF, keys_path=keys_path + ) + assert not store.has_my_keys() + assert not keys_path.exists() + + +def test_off_is_refused_when_key_file_requires_encryption(tmp_path): + """The files at rest of such a datasite are encrypted; off cannot read them.""" + keys_path = tmp_path / "crypto_keys.json" + with pytest.warns(UserWarning): + PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + with pytest.raises(ValueError, match="new SyftBox folder"): + PeerStore.create( + email="alice@example.com", encryption_policy=OFF, keys_path=keys_path + ) + + +def test_off_keeps_its_policy_over_optional_key_file(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() + store = PeerStore.create( + email="alice@example.com", encryption_policy=OFF, keys_path=keys_path + ) + assert store.encryption_policy == OFF + + +def test_optional_store_creates_keys_when_needed(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + store = PeerStore.create( + email="alice@example.com", encryption_policy=OPTIONAL, keys_path=keys_path + ) + store.ensure_keys() + assert store.has_my_keys() + + reloaded = PeerStore.create(email="alice@example.com", keys_path=keys_path) + assert reloaded.has_my_keys() + assert reloaded.encryption_policy == OPTIONAL + + +def test_optional_store_loads_existing_keys_when_needed(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + first = PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + second = PeerStore.create( + email="alice@example.com", encryption_policy=OPTIONAL, keys_path=keys_path + ) + second.ensure_keys() + assert second.get_public_bundle() == first.get_public_bundle() + + +def test_required_policy_in_key_file_wins(tmp_path): + """syft-bg next to a required notebook loads the same key file and requires too.""" + keys_path = tmp_path / "crypto_keys.json" + PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + daemon = PeerStore.create( + email="alice@example.com", encryption_policy=OPTIONAL, keys_path=keys_path + ) + assert daemon.encryption_policy == REQUIRED + + +def test_key_file_without_policy_reads_as_required(tmp_path): + """Before the field, only required clients wrote a key file.""" + keys_path = tmp_path / "crypto_keys.json" + PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + data = json.loads(keys_path.read_text()) + del data["encryption_policy"] + keys_path.write_text(json.dumps(data)) + + store = PeerStore.create(email="alice@example.com", keys_path=keys_path) + assert store.encryption_policy == REQUIRED + + +def test_required_client_records_policy_in_existing_key_file(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() + PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + store = PeerStore.create(email="alice@example.com", keys_path=keys_path) + assert store.encryption_policy == REQUIRED + + +def test_new_required_key_file_checks_files_at_rest_once(tmp_path): + """An optional client before it wrote plaintext at rest without a key file.""" + keys_path = tmp_path / "crypto_keys.json" + PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + store = PeerStore.create(email="alice@example.com", keys_path=keys_path) + assert store.needs_at_rest_encryption() + + +def test_switch_to_required_needs_at_rest_encryption_until_marked(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() + store = PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + assert store.needs_at_rest_encryption() + + store.encrypt_files_at_rest_once(lambda: None) + reloaded = PeerStore.create(email="alice@example.com", keys_path=keys_path) + assert not reloaded.needs_at_rest_encryption() + + +def test_legacy_key_file_checks_files_at_rest_once(tmp_path): + """An earlier client without encryption can have written plaintext at rest.""" + keys_path = tmp_path / "crypto_keys.json" + PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + data = json.loads(keys_path.read_text()) + del data["encryption_policy"] + data.pop("at_rest_encrypted", None) + keys_path.write_text(json.dumps(data)) + + store = PeerStore.create(email="alice@example.com", keys_path=keys_path) + assert store.needs_at_rest_encryption() + + +def test_first_required_login_warns_about_the_key_file(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + with pytest.warns(UserWarning, match="keep a copy"): + PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + + +def test_switch_to_required_warns_about_the_key_file(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() + with pytest.warns(UserWarning, match="keep a copy"): + PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + + +@pytest.mark.parametrize("policy", [REQUIRED, OPTIONAL]) +def test_later_logins_do_not_warn(tmp_path, policy): + keys_path = tmp_path / "crypto_keys.json" + with pytest.warns(UserWarning): + PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + with warnings.catch_warnings(): + warnings.simplefilter("error") + PeerStore.create( + email="alice@example.com", encryption_policy=policy, keys_path=keys_path + ) + + +def test_optional_keys_do_not_warn(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + with warnings.catch_warnings(): + warnings.simplefilter("error") + PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() + + +def test_concurrent_processes_make_one_key_pair(tmp_path): + """A notebook and syft-bg that need keys at once must end with the same keys.""" + keys_path = tmp_path / "crypto_keys.json" + stores = [ + PeerStore.create(email="alice@example.com", keys_path=keys_path) + for _ in range(8) + ] + barrier = threading.Barrier(len(stores)) + + def make_keys(store: PeerStore) -> None: + barrier.wait() + store.ensure_keys() + + threads = [threading.Thread(target=make_keys, args=(s,)) for s in stores] + for t in threads: + t.start() + for t in threads: + t.join() + + on_disk = PeerStore.load_keys(keys_path).get_public_bundle() + assert all(store.get_public_bundle() == on_disk for store in stores) + + +def test_optional_store_without_keys_ignores_drive_bundle(): + """Without keys there is no pin, so a bundle from the Drive copy is not trusted.""" + bob = PeerStore(email="bob@example.com", encryption_policy=REQUIRED) + bob.generate_keys() + store = PeerStore(email="alice@example.com") + store.set_peers( + [ + Peer( + email="bob@example.com", + public_encryption_bundle=bob.get_public_bundle(), + ) + ] + ) + assert not store.has_peer_bundle("bob@example.com") + + +def test_optional_client_pins_published_bundle_over_drive_copy(): + """A swapped bundle in SYFT_peers.json does not become the key we encrypt to.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + do_encryption_policy=REQUIRED, + ) + ds_manager.add_peer(do_manager.email, sync=False) + do_manager.load_peers() + do_manager.approve_peer_request(ds_manager.email) + + attacker = PeerStore(email=do_manager.email, encryption_policy=REQUIRED) + attacker.generate_keys() + ds_manager.peer_manager.connection_router.update_peer_state( + do_manager.email, + PeerState.REQUESTED_BY_ME.value, + public_encryption_bundle=attacker.get_public_bundle(), + ) + ds_manager.load_peers() + + ds_store = ds_manager.peer_manager.peer_store + real = do_manager.peer_manager.peer_store.my_fingerprint + assert ds_store.peer_fingerprint(do_manager.email) == real + + +# ========================================================================= +# Receive path +# ========================================================================= + + +@pytest.mark.parametrize( + "alice_policy, bob_policy", + [(REQUIRED, OPTIONAL), (OPTIONAL, REQUIRED)], + ids=["optional receiver", "required receiver"], +) +def test_receiver_rejects_plaintext_when_pair_encrypts(alice_policy, bob_policy): + """A pair that encrypts never sends plaintext, so plaintext is a downgrade.""" + _, bob = _keyed_pair(alice_policy, bob_policy) + with pytest.raises(ValueError, match="not encrypted"): + bob.decrypt_and_verify_if_needed("alice@example.com", b"plain") + + +def test_receiver_accepts_plaintext_in_plaintext_pair(): + _, bob = _keyed_pair(OPTIONAL, OPTIONAL) + assert bob.decrypt_and_verify_if_needed("alice@example.com", b"plain") == b"plain" + + +def test_receiver_accepts_envelope_from_plaintext_peer(): + alice, bob = _keyed_pair(REQUIRED, OPTIONAL) + envelope = alice.encrypt("bob@example.com", b"secret") + # Bob now sees Alice as optional, e.g. after she restarted without encryption. + bob.get_cached_peer("alice@example.com").version = _version_with_policy(OPTIONAL) + assert bob.decrypt_and_verify_if_needed("alice@example.com", envelope) == b"secret" + + +def _unpinned_receiver() -> PeerStore: + _, bob = _keyed_pair(REQUIRED, OPTIONAL) + bob.get_cached_peer("alice@example.com").public_encryption_bundle = None + return bob + + +def _keyless_receiver() -> PeerStore: + bob = PeerStore(email="bob@example.com") + bob.add_peer( + Peer(email="alice@example.com", version=_version_with_policy(REQUIRED)) + ) + return bob + + +@pytest.mark.parametrize( + "make_receiver", + [_unpinned_receiver, _keyless_receiver], + ids=["no peer bundle", "no own keys"], +) +def test_envelope_before_keys_and_pin_is_not_ready(make_receiver): + alice, _ = _keyed_pair(REQUIRED, OPTIONAL) + envelope = alice.encrypt("bob@example.com", b"secret") + bob = make_receiver() + with pytest.raises(PeerKeysNotReadyError): + bob.decrypt_and_verify_if_needed("alice@example.com", envelope) + + +# ========================================================================= +# End to end over the mock drive +# ========================================================================= + + +@pytest.mark.parametrize( + "ds_policy, do_policy", + [(OPTIONAL, REQUIRED), (REQUIRED, OPTIONAL), (REQUIRED, REQUIRED)], +) +def test_negotiated_encryption_flow(ds_policy, do_policy): + """The optional side complies with no manual configuration.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=ds_policy, + do_encryption_policy=do_policy, + ) + grant_job_inbox_access(do_manager, ds_manager.email) + # One load_peers round each lets both sides see the policy and pin bundles. + ds_manager.load_peers() + do_manager.load_peers() + + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "negotiated content") + assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) + + do_manager.sync() + assert any("negotiated content" in c for c in _accepted_contents(do_manager)) + + ds_manager.sync() + assert len(_ds_cached_contents(ds_manager)) > 0 + + +def test_client_that_becomes_required_after_peering_publishes_its_bundle(): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + grant_job_inbox_access(do_manager, ds_manager.email) + + # A later login of the DS requires encryption. + ds_manager.peer_manager.peer_store.encryption_policy = REQUIRED + ds_manager.peer_manager.write_own_version() + ds_manager.load_peers() + do_manager.load_peers() + ds_manager.load_peers() + + do_store = do_manager.peer_manager.peer_store + assert do_store.encryption_ready(ds_manager.email) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "after switch") + do_manager.sync() + assert any("after switch" in c for c in _accepted_contents(do_manager)) + + +def test_policy_recorded_by_another_process_is_taken_at_next_sync(): + """syft-bg starts required next to a notebook that started optional.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + grant_job_inbox_access(do_manager, ds_manager.email) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "written while optional") + do_manager.sync() + + keys_path = do_manager.peer_manager.peer_store._keys_path + with pytest.warns(UserWarning): + PeerStore.create( + email=do_manager.email, encryption_policy=REQUIRED, keys_path=keys_path + ) + do_manager.sync() + + do_store = do_manager.peer_manager.peer_store + assert do_store.encryption_policy == REQUIRED + assert all(_is_envelope(raw) for raw in _raw_event_log(do_manager)) + assert any("written while optional" in c for c in _accepted_contents(do_manager)) + advertised = ds_manager.peer_manager.load_peer_version(do_manager.email) + assert advertised.encryption_policy == REQUIRED + + +def _raw_event_log(do_manager) -> list[bytes]: + connection = ( + do_manager.datasite_owner_syncer.connection_router.connection_for_eventlog() + ) + return connection.owner_download_all_raw_events_from_syftbox() + + +def test_owner_that_becomes_required_encrypts_files_at_rest(): + """Plaintext written while optional is encrypted once, then read as usual.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + grant_job_inbox_access(do_manager, ds_manager.email) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "before switch") + do_manager.sync() + do_manager.create_checkpoint() + assert not any(_is_envelope(raw) for raw in _raw_event_log(do_manager)) + + do_manager.peer_manager.peer_store.encryption_policy = REQUIRED + do_manager.peer_manager.peer_store.ensure_keys() + do_manager.sync() + + assert all(_is_envelope(raw) for raw in _raw_event_log(do_manager)) + assert any("before switch" in c for c in _accepted_contents(do_manager)) + router = do_manager.datasite_owner_syncer.connection_router + assert router.get_latest_checkpoint() is not None + + +def test_off_client_works_in_plaintext_with_optional_peer(): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=OFF, + do_encryption_policy=OPTIONAL, + ) + grant_job_inbox_access(do_manager, ds_manager.email) + assert ds_manager.peer_manager.get_own_version().encryption_policy == OFF + + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "plain content") + assert not _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) + do_manager.sync() + assert any("plain content" in c for c in _accepted_contents(do_manager)) + assert not ds_manager.peer_manager.peer_store.has_my_keys() + + +def test_off_and_required_peers_skip_each_other(): + """Both syncs go on without the other side, and say why.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=OFF, + do_encryption_policy=REQUIRED, + ) + grant_job_inbox_access(do_manager, ds_manager.email) + ds_manager.load_peers() + + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + with pytest.raises(EncryptionPolicyConflictError): + ds_manager._send_file_change(file_path, "content") + with pytest.warns(UserWarning, match="skipped"): + do_manager.sync() + with pytest.warns(UserWarning, match="skipped"): + ds_manager.sync() + + +def test_owner_drops_outbox_message_for_conflicting_peer(): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=OFF, + do_encryption_policy=REQUIRED, + ) + syncer = do_manager.datasite_owner_syncer + syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) + syncer.process_syftbox_events_queue() + assert syncer.held_message_count == 0 + + +def test_conflict_found_on_read_makes_the_peer_wait(): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=OFF, + do_encryption_policy=OPTIONAL, + ) + grant_job_inbox_access(do_manager, ds_manager.email) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "content") + + # The owner now requires encryption, after the message was sent. + do_manager.peer_manager.peer_store.encryption_policy = REQUIRED + syncer = do_manager.datasite_owner_syncer + assert ( + syncer.pull_and_process_next_proposed_filechange( + ds_manager.email, raise_on_none=False + ) + is None + ) + + +def test_owner_waits_on_envelope_from_unpinned_peer(): + """The sync goes on; the message stays in the inbox until the pin exists.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=REQUIRED, + do_encryption_policy=OPTIONAL, + ) + grant_job_inbox_access(do_manager, ds_manager.email) + do_manager.load_peers() + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "pinned later") + + peer = do_manager.peer_manager.peer_store.get_cached_peer(ds_manager.email) + peer.public_encryption_bundle = None + do_manager.datasite_owner_syncer.sync([ds_manager.email]) + assert not any("pinned later" in c for c in _accepted_contents(do_manager)) + + do_manager.sync() + assert any("pinned later" in c for c in _accepted_contents(do_manager)) + + +# ========================================================================= +# Held sends +# ========================================================================= + + +def test_send_rereads_unknown_peer_policy(): + """The peer can have accepted since the last load; one re-read finds its policy.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=OPTIONAL, + do_encryption_policy=REQUIRED, + ) + grant_job_inbox_access(do_manager, ds_manager.email) + ds_manager.load_peers() + ds_manager.peer_manager.get_cached_peer(do_manager.email).version = None + + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "content") + assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) + + +def test_send_before_peer_accepts_is_held_until_sync(capsys): + """Before the owner accepts, its policy is unknown, so the send waits.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + do_encryption_policy=REQUIRED, + ) + ds_manager.add_peer(do_manager.email, sync=False) + + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "held content") + watcher = ds_manager.datasite_watcher_syncer + assert watcher.pending_message_count == 1 + assert "Held a message" in capsys.readouterr().out + # The held message is on disk, so a restart keeps it. + assert (ds_manager.syftbox_folder / ".cache" / "held_proposals.json").exists() + + do_manager.load_peers() + do_manager.approve_peer_request(ds_manager.email) + grant_job_inbox_access(do_manager, ds_manager.email) + ds_manager.sync() + assert watcher.pending_message_count == 0 + assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) + + do_manager.sync() + assert any("held content" in c for c in _accepted_contents(do_manager)) + + +def test_send_after_accept_before_peer_load_is_held(): + """The owner accepted, but the scientist has no keys or pin yet: hold, then send.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + do_encryption_policy=REQUIRED, + ) + ds_manager.add_peer(do_manager.email, sync=False) + do_manager.load_peers() + do_manager.approve_peer_request(ds_manager.email) + grant_job_inbox_access(do_manager, ds_manager.email) + + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "after accept") + assert ds_manager.datasite_watcher_syncer.pending_message_count == 1 + + ds_manager.sync() + do_manager.sync() + assert any("after accept" in c for c in _accepted_contents(do_manager)) + + +def test_held_change_does_not_update_hash_cache(): + """The cache records what the owner accepted, so a restart re-proposes correctly.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + ) + ds_manager.add_peer(do_manager.email, sync=False) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "held content") + + cache = ds_manager.datasite_watcher_syncer.datasite_watcher_cache + assert Path(file_path) not in cache.file_hashes + + +def test_two_held_changes_to_one_file_apply_in_order(): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + ) + ds_manager.add_peer(do_manager.email, sync=False) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "first") + ds_manager._send_file_change(file_path, "second") + assert ds_manager.datasite_watcher_syncer.pending_message_count == 2 + + do_manager.load_peers() + do_manager.approve_peer_request(ds_manager.email) + grant_job_inbox_access(do_manager, ds_manager.email) + ds_manager.sync() + do_manager.sync() + assert any("second" in c for c in _accepted_contents(do_manager)) + + +def test_send_to_non_peer_raises(): + """A recipient that is not a peer never gets a policy, so holding would be forever.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + ) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + with pytest.raises(PeerEncryptionPolicyUnknownError): + ds_manager._send_file_change(file_path, "content") + assert ds_manager.datasite_watcher_syncer.pending_message_count == 0 + + +def test_changes_for_two_datasites_go_to_each(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + sent = [] + monkeypatch.setattr( + DatasiteWatcherSyncer, + "_send_or_hold", + lambda self, recipient, message: sent.append( + (recipient, {fc.datasite_email for fc in message.proposed_file_changes}) + ) + or True, + ) + watcher = ds_manager.datasite_watcher_syncer + watcher.on_file_change(f"{do_manager.email}/a.txt", "a", process_now=False) + watcher.on_file_change("other@example.com/b.txt", "b", process_now=False) + watcher.process_file_changes_queue() + assert sorted(sent) == sorted( + [ + (do_manager.email, {do_manager.email}), + ("other@example.com", {"other@example.com"}), + ] + ) + + +def test_conflict_with_one_datasite_still_sends_to_the_other(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + sent = [] + + def send_or_hold(self, recipient, message): + if recipient == "conflict@example.com": + raise EncryptionPolicyConflictError(recipient) + sent.append(recipient) + return True + + monkeypatch.setattr(DatasiteWatcherSyncer, "_send_or_hold", send_or_hold) + watcher = ds_manager.datasite_watcher_syncer + watcher.on_file_change("conflict@example.com/a.txt", "a", process_now=False) + watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) + with pytest.raises(EncryptionPolicyConflictError): + watcher.process_file_changes_queue() + assert sent == [do_manager.email] + + +def test_owner_drops_outbox_message_for_former_peer(): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + syncer = do_manager.datasite_owner_syncer + syncer.outbox_queue.put(("former@example.com", FileChangeEventsMessage(events=[]))) + syncer.process_syftbox_events_queue() + assert syncer.held_message_count == 0 + + +def test_policy_is_reread_once_per_peer_load(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + syncer = do_manager.datasite_owner_syncer + do_manager.peer_manager.get_cached_peer(ds_manager.email).version = None + reads = [] + monkeypatch.setattr( + ConnectionRouter, + "read_peer_version_file", + lambda self, email: reads.append(email), + ) + for _ in range(3): + syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) + syncer.process_syftbox_events_queue() + syncer.process_syftbox_events_queue() + assert reads == [ds_manager.email] + + +def test_compaction_skips_peer_with_unknown_policy(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + grant_job_inbox_access(do_manager, ds_manager.email) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + for content in ("one", "two"): + ds_manager._send_file_change(file_path, content) + do_manager.sync() + + do_manager.peer_manager.get_cached_peer(ds_manager.email).version = None + monkeypatch.setattr( + ConnectionRouter, "read_peer_version_file", lambda self, email: None + ) + syncer = do_manager.datasite_owner_syncer + assert syncer.compact_outbox_if_needed(ds_manager.email, min_messages=1) == 0 + + +def _unknown_policy(ds_manager, do_manager, monkeypatch): + """Make the DS policy unknown to the DO; return a function that undoes it.""" + peer = do_manager.peer_manager.get_cached_peer(ds_manager.email) + version = peer.version + peer.version = None + monkeypatch.setattr( + ConnectionRouter, "read_peer_version_file", lambda self, email: None + ) + + def restore(): + monkeypatch.undo() + peer.version = version + + return restore + + +def _unpinned_bundle(ds_manager, do_manager, monkeypatch): + """Drop the DO pin of the DS bundle; return a function that undoes it.""" + peer = do_manager.peer_manager.get_cached_peer(ds_manager.email) + bundle = peer.public_encryption_bundle + peer.public_encryption_bundle = None + + def restore(): + peer.public_encryption_bundle = bundle + + return restore + + +@pytest.mark.parametrize( + "make_pending", [_unknown_policy, _unpinned_bundle], ids=["policy", "pin"] +) +def test_owner_holds_outbox_message_until_encryption_is_set_up( + make_pending, monkeypatch +): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=REQUIRED, + ) + syncer = do_manager.datasite_owner_syncer + restore = make_pending(ds_manager, do_manager, monkeypatch) + + syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) + syncer.process_syftbox_events_queue() + assert syncer.held_message_count == 1 + # The held message is on disk, so a restart keeps it. + assert (do_manager.syftbox_folder / ".cache" / "held_outbox.json").exists() + + restore() + syncer.process_syftbox_events_queue() + assert syncer.held_message_count == 0 + + +# ========================================================================= +# Rejected messages +# ========================================================================= + + +def test_rejected_inbox_message_is_resent_in_order(): + """A newer encrypted change waits until the rejected one is resent encrypted.""" + ds_manager, do_manager = _pair_with_stale_sender_view() + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "first") + do_manager.sync() + + # The sender learns the policy and sends a newer change before it resends. + ds_manager.load_peers() + ds_manager._send_file_change(file_path, "second") + do_manager.sync() + assert not any("second" in c for c in _accepted_contents(do_manager)) + + ds_manager.sync() + assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) + do_manager.sync() + contents = _accepted_contents(do_manager) + assert any("first" in c for c in contents) + assert any("second" in c for c in contents) + + +def test_rejected_outbox_message_is_resent_in_order(monkeypatch): + ds_manager, do_manager = _required_ds_pair() + _owner_sends_with_stale_view(ds_manager, do_manager, "first") + ds_manager.sync() + assert not any("first" in c for c in _ds_cached_contents(ds_manager)) + + # The owner refreshes its view and answers a newer proposal, but has not + # resent the rejected message yet. + do_manager.load_peers() + with monkeypatch.context() as m: + m.setattr( + ConnectionRouter, + "owner_resend_rejected_outbox_messages", + lambda self, recipient: 0, + ) + file_path = path_for_job(do_manager.email, ds_manager.email, "other.job") + ds_manager._send_file_change(file_path, "second") + do_manager.datasite_owner_syncer.sync([ds_manager.email]) + ds_manager.sync() + assert not any("second" in c for c in _ds_cached_contents(ds_manager)) + + do_manager.sync() + ds_manager.sync() + contents = _ds_cached_contents(ds_manager) + assert any("first" in c for c in contents) + assert any("second" in c for c in contents) + + +def test_all_plaintext_inbox_messages_are_rejected_in_one_pass(): + ds_manager, do_manager = _pair_with_stale_sender_view() + for name in ("a.job", "b.job", "c.job"): + file_path = path_for_job(do_manager.email, ds_manager.email, name) + ds_manager._send_file_change(file_path, f"content {name}") + do_manager.sync() + + ds_manager.sync() + do_manager.sync() + contents = _accepted_contents(do_manager) + for name in ("a.job", "b.job", "c.job"): + assert any(f"content {name}" in c for c in contents) + + +def test_message_rejected_twice_keeps_one_suffix(): + """A notebook and syft-bg can both reject one message.""" + ds_manager, do_manager = _pair_with_stale_sender_view() + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "rejected twice") + do_manager.sync() + + ds_conn = ds_manager.datasite_watcher_syncer.connection_router.connection_for_send_message() + [(file_id, _, _)] = ds_conn.watcher_get_rejected_inbox_messages(do_manager.email) + do_conn = do_manager.datasite_owner_syncer.connection_router.connection_for_receive_message() + do_conn.mark_message_rejected(file_id) + + ds_manager.sync() + do_manager.sync() + assert any("rejected twice" in c for c in _accepted_contents(do_manager)) + + +def test_optional_pair_does_not_look_for_rejected_messages(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + calls = [] + original = GDriveConnection.watcher_get_rejected_inbox_messages + + def spy(self, recipient): + calls.append(recipient) + return original(self, recipient) + + monkeypatch.setattr(GDriveConnection, "watcher_get_rejected_inbox_messages", spy) + ds_manager.sync() + assert calls == [] + + +def test_resend_waits_while_peer_bundle_is_not_pinned(): + ds_manager, do_manager = _pair_with_stale_sender_view() + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "plain content") + do_manager.sync() + + peer = ds_manager.peer_manager.get_cached_peer(do_manager.email) + peer.version = _version_with_policy(REQUIRED) + peer.public_encryption_bundle = None + router = ds_manager.datasite_watcher_syncer.connection_router + assert router.watcher_resend_rejected_inbox_messages(do_manager.email) == 0 diff --git a/tests/unit/test_peer_requests.py b/tests/unit/test_peer_requests.py index 3d74b428381..70dbc02a2ff 100644 --- a/tests/unit/test_peer_requests.py +++ b/tests/unit/test_peer_requests.py @@ -14,7 +14,7 @@ def test_peer_request_blocks_sync_until_approved(): 2. DS submits a job 3. DO syncs - nothing should sync (peer not approved) 4. DO approves peer request - 5. DO syncs - job should now sync + 5. DS syncs (sends the held job), DO syncs - job should now sync """ # Create managers with mock drive service connection, no auto-add peers # Note: email1 becomes DO, email2 becomes DS @@ -54,7 +54,9 @@ def test_peer_request_blocks_sync_until_approved(): assert len(do_manager.peer_manager.approved_peers) == 1 assert do_manager.peer_manager.approved_peers[0].email == ds_manager.email - # Step 5: DO syncs again - now it should work + # Step 5: DS syncs, which sends the job it held while the DO policy was + # unknown. DO syncs again - now it should work + ds_manager.sync() do_manager.sync() # Verify: File synced and in cache diff --git a/tests/unit/test_sync_manager.py b/tests/unit/test_sync_manager.py index 1227b26fb8f..f9b63a62052 100644 --- a/tests/unit/test_sync_manager.py +++ b/tests/unit/test_sync_manager.py @@ -5,10 +5,13 @@ from syft.sync.connections.connection_router import ConnectionRouter from syft.sync.connections.drive.gdrive_transport import GDriveConnection +from syft.sync.encryption_policy import EncryptionPolicy +from syft.sync.peers.peer import Peer from syft.sync.peers.peer_store import PeerStore from syft.sync.messages.proposed_filechange import ProposedFileChangesMessage from syft.sync.messages.proposed_filechange import ProposedFileChange from syft.sync.syftbox_manager import SyftboxManager +from syft.sync.version.version_info import VersionInfo from syft.sync.sync.caches.datasite_owner_cache import ( ProposedEventFileOutdatedException, ) @@ -434,6 +437,13 @@ def test_collection_files_excluded_from_outbox_sync(): ) +def _add_known_peer(do_manager, email: str) -> None: + """Make ``email`` a peer of the DO with a known version, as peering does.""" + version = VersionInfo.current() + version.encryption_policy = EncryptionPolicy.OPTIONAL + do_manager.peer_manager.peer_store.add_peer(Peer(email=email, version=version)) + + def test_job_files_sync_to_submitter_only(): """Test that job files only sync to the peer who submitted the job. @@ -487,6 +497,7 @@ def test_job_files_sync_to_submitter_only(): submitter_email, ds_manager._connection_router.connections[0].drive_service ) submitter_conn.add_peer(do_manager.email) + _add_known_peer(do_manager, submitter_email) submitter_router = ConnectionRouter( connections=[submitter_conn], peer_store=PeerStore(email=submitter_email), @@ -689,6 +700,7 @@ def test_permission_change_triggers_resend(): peer_b_email, ds_manager._connection_router.connections[0].drive_service ) peer_b_conn.add_peer(do_manager.email) + _add_known_peer(do_manager, peer_b_email) peer_b_router = ConnectionRouter( connections=[peer_b_conn], peer_store=PeerStore(email=peer_b_email), From 644ea0015fdfb5a373bbffaa082b4486dd0951bc Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Wed, 7 Oct 2026 19:39:07 -0300 Subject: [PATCH 02/30] fix(sync): address review findings in encryption negotiation - Load the key file without a nested lock in ensure_keys (deadlock). - Keep held messages on disk; save after each send. - Read an earlier peer without a bundle as unknown for one load, then off. - Advertise a switch to required also from a process that never wrote the version file. - Hold sends before the first peer load. - Re-read an unknown peer through PeerManager, before the message is converted. - Skip a bundle that is already published; warn on a Drive error. --- syft/sync/connections/connection_router.py | 49 ++-- .../connections/drive/gdrive_transport.py | 13 + syft/sync/peers/peer.py | 7 +- syft/sync/peers/peer_store.py | 77 ++++-- syft/sync/syftbox_manager.py | 12 + syft/sync/sync/datasite_owner_syncer.py | 39 ++- syft/sync/sync/datasite_watcher_syncer.py | 89 ++++--- syft/sync/sync/held_messages.py | 21 +- syft/sync/version/peer_manager.py | 37 ++- tests/unit/test_encryption_policy.py | 249 +++++++++++++++++- 10 files changed, 492 insertions(+), 101 deletions(-) diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index 9ca1f590680..9f1e746e12b 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -54,6 +54,13 @@ class ConnectionRouter(BaseModel): # live map here (updated in place as peer version files load), so outgoing # messages downgrade to what each peer reads. _peer_schemas: Dict[str, ProtocolSchema] = PrivateAttr(default_factory=dict) + # Reads the version file of a peer and updates what depends on it (the + # peer, its policy, the protocol map). Set by SyftboxManager to + # ``PeerManager.load_peer_version``. + _version_loader: Optional[Callable[[str], object]] = PrivateAttr(default=None) + + def set_version_loader(self, version_loader: Callable[[str], object]) -> None: + self._version_loader = version_loader def set_peer_schemas(self, peer_schemas: Dict[str, ProtocolSchema]) -> None: """Adopt the live {peer email -> syft ProtocolSchema} map.""" @@ -146,22 +153,22 @@ def _downgrade_for_peer( target = client_registry.schema_for_protocol_version(protocol_version) return client_migration_service.migrate_to_schema(message, target) - def _encrypt_for_peer(self, peer_email: str, data: bytes) -> bytes: - """Encrypt ``data`` for ``peer_email`` when the pair encrypts. - - When the peer policy is not known, the peer version file is read once - more first, at most once per peer load: the peer can have accepted - since the last load. + def _refresh_unknown_peer(self, peer_email: str) -> None: + """Read the version file of ``peer_email`` again when its policy is not known. - Raises: - PeerEncryptionPolicyUnknownError: when the policy is still not known. + The peer can have accepted since the last load. It runs at most once + per peer load, and before a message is converted for the peer. """ - if not self.peer_store.peer_policy_known(peer_email): - peer = self.peer_store.get_cached_peer(peer_email) - if peer is not None and self.peer_store.take_policy_reread(peer_email): - peer.version = self.read_peer_version_file(peer_email) - self.resolve_legacy_policy(peer) - return self.peer_store.encrypt_if_needed(peer_email, data) + if self.peer_store.peer_policy_known(peer_email): + return + peer = self.peer_store.get_cached_peer(peer_email) + if peer is None or not self.peer_store.take_policy_reread(peer_email): + return + if self._version_loader is not None: + self._version_loader(peer_email) + else: + peer.version = self.read_peer_version_file(peer_email) + self.resolve_legacy_policy(peer) def resolve_legacy_policy(self, peer: Peer) -> None: """Set the policy of ``peer`` when its version file does not say it.""" @@ -174,11 +181,12 @@ def resolve_legacy_policy(self, peer: Peer) -> None: def watcher_send_proposed_file_changes_message( self, recipient: str, proposed_file_changes_message: ProposedFileChangesMessage ): + self._refresh_unknown_peer(recipient) proposed_file_changes_message = self._downgrade_for_peer( proposed_file_changes_message, recipient ) data = proposed_file_changes_message.as_compressed_data() - data = self._encrypt_for_peer(recipient, data) + data = self.peer_store.encrypt_if_needed(recipient, data) filename = proposed_file_changes_message.message_filename.as_string() self.connection_for_send_message().watcher_send_raw_bytes_to_inbox( recipient, filename, data @@ -275,9 +283,10 @@ def _resend_rejected( def owner_write_event_messages_to_outbox( self, recipient_email: str, events_message: FileChangeEventsMessage ): + self._refresh_unknown_peer(recipient_email) events_message = self._downgrade_for_peer(events_message, recipient_email) data = events_message.as_compressed_data() - data = self._encrypt_for_peer(recipient_email, data) + data = self.peer_store.encrypt_if_needed(recipient_email, data) fname = events_message.message_filepath.as_string() self.connection_for_outbox().owner_write_raw_bytes_to_outbox( recipient_email, fname, data @@ -489,6 +498,11 @@ def owner_remove_proposed_filechange_from_inbox( # ENCRYPTION BUNDLE EXCHANGE # ========================================================================= + def read_own_encryption_bundle(self, peer_email: str) -> str | None: + """Read the bundle this client published for ``peer_email``.""" + connection = self.connection_for_own_syftbox() + return connection.read_own_encryption_bundle(peer_email) + def write_encryption_bundle(self, peer_email: str, bundle_json: str) -> None: """Write own encryption bundle for a peer.""" connection = self.connection_for_own_syftbox() @@ -538,8 +552,9 @@ def owner_upload_collection_files( ) -> None: """Upload collection files, encrypting each file if encryption is enabled.""" if recipient_email and self.peer_store: + self._refresh_unknown_peer(recipient_email) files = { - name: self._encrypt_for_peer(recipient_email, data) + name: self.peer_store.encrypt_if_needed(recipient_email, data) for name, data in files.items() } connection = self.connection_for_send_message() diff --git a/syft/sync/connections/drive/gdrive_transport.py b/syft/sync/connections/drive/gdrive_transport.py index 42d49942ed5..57ce8a46df1 100644 --- a/syft/sync/connections/drive/gdrive_transport.py +++ b/syft/sync/connections/drive/gdrive_transport.py @@ -2560,6 +2560,19 @@ def share_encryption_bundles_folder(self, peer_email: str) -> None: folder_id = self._get_or_create_encryption_bundles_folder_id() self.add_permission(folder_id, peer_email, write=False) + def read_own_encryption_bundle(self, peer_email: str) -> str | None: + """Read the bundle this client published for ``peer_email``, or None.""" + folder_id = self._get_or_create_encryption_bundles_folder_id() + filename = self._encryption_bundle_filename(self.email, peer_email) + query = f"name='{filename}' and '{folder_id}' in parents and trashed=false" + results = execute_with_retries( + self.drive_service.files().list(q=query, fields="files(id)") + ) + items = results.get("files", []) + if not items: + return None + return self.download_file(items[0]["id"]).decode("utf-8") + def read_peer_encryption_bundle(self, peer_email: str) -> str | None: """Read encryption bundle that peer wrote for us. diff --git a/syft/sync/peers/peer.py b/syft/sync/peers/peer.py index 29caac23503..bc55020ba56 100644 --- a/syft/sync/peers/peer.py +++ b/syft/sync/peers/peer.py @@ -52,8 +52,11 @@ def needs_legacy_policy(self) -> bool: """Whether the version file is read and does not say the policy.""" return self.version is not None and self.version.encryption_policy is None - def set_legacy_policy(self, policy: EncryptionPolicy) -> None: - """Set the policy of an earlier client, whose version file does not say.""" + def set_legacy_policy(self, policy: Optional[EncryptionPolicy]) -> None: + """Set the policy of an earlier client, whose version file does not say. + + None keeps it unknown. + """ self._legacy_policy = policy @property diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 8b045655da5..583744b248e 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -92,9 +92,13 @@ class PeerStore(BaseModel): _keys_path: Path | None = PrivateAttr(default=None) # Peers whose version file was read again since the last ``set_peers``. _policy_rereads: set[str] = PrivateAttr(default_factory=set) - # Policies of earlier clients, by email. Their version file does not say, - # so the policy comes from a Drive read, once per process. + # Earlier clients known to encrypt. Their version file does not say, so + # the policy comes from a Drive read; a found bundle is read once. _legacy_policies: dict[str, EncryptionPolicy] = PrivateAttr(default_factory=dict) + # Number of peer loads (``set_peers``) so far. + _load_count: int = PrivateAttr(default=0) + # Earlier clients without a found bundle: the load at which none was found. + _bundle_missing_since: dict[str, int] = PrivateAttr(default_factory=dict) # Whether the files at rest are known to be encrypted. _at_rest_encrypted: bool = PrivateAttr(default=False) @@ -248,22 +252,32 @@ def set_peers(self, peers: List[Peer]) -> None: self._keep_pinned_bundle(p) self._peers = peers self._policy_rereads.clear() + self._load_count += 1 + + @property + def peers_loaded(self) -> bool: + """Whether a peer load ran. Before it, no recipient is known to be a peer.""" + return self._load_count > 0 def legacy_policy( self, email: str, published_bundle: Callable[[], bool] - ) -> EncryptionPolicy: - """The policy of the earlier client ``email``, read once per process. + ) -> Optional[EncryptionPolicy]: + """The policy of the earlier client ``email``, or None while not known. Such a client publishes a bundle for us only when it encrypts. One - that does not has no keys and cannot decrypt, so it is off. + that does not has no keys and cannot decrypt, so it is off. A bundle + can be not yet visible on Drive, so a client without one is unknown + during the peer load that first misses it, and off from the next. + A found bundle is kept for the process. """ - if email not in self._legacy_policies: - self._legacy_policies[email] = ( - EncryptionPolicy.REQUIRED - if published_bundle() - else EncryptionPolicy.OFF - ) - return self._legacy_policies[email] + if email in self._legacy_policies: + return self._legacy_policies[email] + if published_bundle(): + self._legacy_policies[email] = EncryptionPolicy.REQUIRED + self._bundle_missing_since.pop(email, None) + return EncryptionPolicy.REQUIRED + first_missed = self._bundle_missing_since.setdefault(email, self._load_count) + return EncryptionPolicy.OFF if self._load_count > first_missed else None def take_policy_reread(self, email: str) -> bool: """True once per peer load: the caller may read the version file again.""" @@ -391,15 +405,28 @@ def ensure_keys(self) -> None: if self._keys_path is None: self.generate_keys() return + unsaved_pins: list[tuple[str, dict]] = [] with _locked_key_file(self._keys_path): if self._keys_path.exists(): - self._adopt_key_file(self.load_keys(self._keys_path)) - return - self.generate_keys() - _write_key_file(self._keys_path, self._key_file_data()) + unsaved_pins = self._take_key_file(self.load_keys(self._keys_path)) + else: + self.generate_keys() + _write_key_file(self._keys_path, self._key_file_data()) + # Saving a pin takes the lock again, so it waits until here. + for email, bundle in unsaved_pins: + self._persist_peer_bundle(email, bundle) def _adopt_key_file(self, loaded: "PeerStore") -> None: """Take the keys, pins and recorded policy of ``loaded``, a store read from our key file.""" + for email, bundle in self._take_key_file(loaded): + self._persist_peer_bundle(email, bundle) + + def _take_key_file(self, loaded: "PeerStore") -> list[tuple[str, dict]]: + """Like ``_adopt_key_file``, but return the pins to save, without file I/O. + + These are pins set in memory before there were keys, e.g. from + attestation, that the key file does not hold yet. + """ if loaded.email != self.email: raise ValueError( f"Encryption key file {self._keys_path} belongs to " @@ -411,15 +438,16 @@ def _adopt_key_file(self, loaded: "PeerStore") -> None: self.encryption_policy = EncryptionPolicy.REQUIRED if not self._peers: self._peers = loaded._peers - return + return [] stored = {p.email: p.public_encryption_bundle for p in loaded._peers} + unsaved_pins = [] for peer in self._peers: if peer.email in stored: # The pin in the key file wins. peer.public_encryption_bundle = stored[peer.email] elif peer.public_encryption_bundle is not None: - # Set in memory before there were keys, e.g. from attestation. - self._persist_peer_bundle(peer.email, peer.public_encryption_bundle) + unsaved_pins.append((peer.email, peer.public_encryption_bundle)) + return unsaved_pins def has_my_keys(self) -> bool: return self._private_keys is not None @@ -576,11 +604,16 @@ def reload_policy_from_key_file(self) -> None: """ if self.use_encryption or self._keys_path is None: return - if not self._keys_path.exists(): + # Most syncs find no change: read the policy before the keys. + data = self._read_key_file() + recorded = ( + data.get(ENCRYPTION_POLICY_KEY, EncryptionPolicy.REQUIRED.value) + if data + else None + ) + if recorded != EncryptionPolicy.REQUIRED.value: return loaded = self.load_keys(self._keys_path) - if not loaded.use_encryption: - return if self.encryption_policy == EncryptionPolicy.OFF: raise _off_refused_error(self._keys_path) self._adopt_key_file(loaded) diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index 0e5a76446e8..1becf7b0730 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -572,6 +572,7 @@ def from_config(cls, config: SyftboxManagerConfig): # Every router that sends peer-directed messages downgrades them to the # peer's negotiated syft protocol, so each one gets the live map. manager_res._set_peer_schemas(peer_manager.live_peer_schemas("syft")) + manager_res._set_version_loader(peer_manager.load_peer_version) return manager_res @@ -587,6 +588,17 @@ def _set_peer_schemas(self, peer_schemas) -> None: peer_schemas ) + def _set_version_loader(self, version_loader) -> None: + """Wire PeerManager's version reload into all routers that send.""" + if self.datasite_owner_syncer: + self.datasite_owner_syncer.connection_router.set_version_loader( + version_loader + ) + if self.datasite_watcher_syncer: + self.datasite_watcher_syncer.connection_router.set_version_loader( + version_loader + ) + def _set_peer_store(self, peer_store) -> None: """Wire shared peer_store into all connection routers.""" from syft.sync.peers.peer_store import PeerStore diff --git a/syft/sync/sync/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py index 1d9f1178960..414654c7be3 100644 --- a/syft/sync/sync/datasite_owner_syncer.py +++ b/syft/sync/sync/datasite_owner_syncer.py @@ -813,37 +813,36 @@ def process_syftbox_events_queue(self): ) # The event is already in the event log, so a held outbox message is # kept on disk until encryption with its recipient is set up. - with self._held_outbox.edit() as held: - pending = [ - (r, FileChangeEventsMessage.from_compressed_data(d)) for r, d in held - ] - held.clear() + store = self._held_outbox + with store.edit() as held: + # On disk before any send, so an error part way loses nothing. while not self.outbox_queue.empty(): - pending.append(self.outbox_queue.get()) - for recipient, file_change_events_message in pending: - if any(r == recipient for r, _ in held): + recipient, message = self.outbox_queue.get() + held.append((recipient, message.as_compressed_data())) + store.save(held) + waiting: set[str] = set() + for item in list(held): + recipient, data = item + if recipient in waiting: # Keep the order of the messages to one recipient. - held.append( - (recipient, file_change_events_message.as_compressed_data()) - ) continue try: self.connection_router.owner_write_event_messages_to_outbox( - recipient, file_change_events_message + recipient, FileChangeEventsMessage.from_compressed_data(data) ) except PeerEncryptionPendingError: - if ( - self.connection_router.peer_store.get_cached_peer(recipient) - is None + peer_store = self.connection_router.peer_store + if not peer_store.peers_loaded or peer_store.get_cached_peer( + recipient ): - # Not a peer any more: it never gets a policy. - logger.warning(f"Dropped an outbox message for {recipient}") + waiting.add(recipient) continue - held.append( - (recipient, file_change_events_message.as_compressed_data()) - ) + # Not a peer any more: it never gets a policy. + logger.warning(f"Dropped an outbox message for {recipient}") except EncryptionPolicyConflictError as e: logger.warning(f"Dropped an outbox message: {e}") + held.remove(item) + store.save(held) if held: logger.info( f"Held {len(held)} outbox message(s) for peers whose " diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index b9897c6e993..f63dee46ca4 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -12,6 +12,7 @@ from syft.sync.encryption_policy import ( EncryptionPolicyConflictError, PeerEncryptionPendingError, + PeerEncryptionPolicyUnknownError, ) from syft.sync.events.file_change_event import FileChangeEventsMessage from syft.sync.messages.proposed_filechange import ( @@ -69,14 +70,19 @@ def from_config(cls, config: DatasiteWatcherSyncerConfig): # --- Pushing proposed file changes --- def get_proposed_file_change_object( - self, relative_path: Path, content: str, datasite_email: str | None = None + self, + relative_path: Path, + content: str, + datasite_email: str | None = None, + held_hashes: Dict[Path, int | None] | None = None, ) -> ProposedFileChange: if datasite_email is None: datasite_email = self.email cache_key = Path(datasite_email) / relative_path # The cache holds only sent changes. A held change to the same file # gives the next change its old hash. - held_hashes = self._held_hashes() + if held_hashes is None: + held_hashes = self._held_hashes() if cache_key in held_hashes: old_hash = held_hashes[cache_key] else: @@ -91,6 +97,7 @@ def get_proposed_file_change_object( def process_file_changes_queue(self): # One message per datasite: each change goes to the owner of its path. changes_by_recipient: Dict[str, List[ProposedFileChange]] = {} + held_hashes: Dict[Path, int | None] | None = None while not self.queue.empty(): relative_path, content = self.queue.get() @@ -110,26 +117,34 @@ def process_file_changes_queue(self): recipient = datasite_email path_in_datasite = path_in_datasite + if held_hashes is None: + held_hashes = self._held_hashes() file_change = self.get_proposed_file_change_object( - path_in_datasite, content, datasite_email=recipient + path_in_datasite, + content, + datasite_email=recipient, + held_hashes=held_hashes, ) changes_by_recipient.setdefault(recipient, []).append(file_change) - conflict = None + error = None for recipient, file_changes in changes_by_recipient.items(): message = ProposedFileChangesMessage( sender_email=self.email, proposed_file_changes=file_changes ) try: sent = self._send_or_hold(recipient, message) - except EncryptionPolicyConflictError as e: + except ( + EncryptionPolicyConflictError, + PeerEncryptionPolicyUnknownError, + ) as e: # The other recipients still get their changes. - conflict = conflict or e + error = error or e continue if sent: self._record_sent_hashes(message) - if conflict is not None: - raise conflict + if error is not None: + raise error def _record_sent_hashes(self, message: ProposedFileChangesMessage) -> None: hashes = self.datasite_watcher_cache.file_hashes @@ -146,10 +161,10 @@ def _held_messages(self) -> HeldMessages: def _held_hashes(self) -> Dict[Path, int | None]: """The new hash of each file that a held change touches.""" - with self._held_messages.edit() as held: - messages = [ - ProposedFileChangesMessage.from_compressed_data(d) for _, d in held - ] + messages = [ + ProposedFileChangesMessage.from_compressed_data(d) + for _, d in self._held_messages.read() + ] hashes: Dict[Path, int | None] = {} for message in messages: for fc in message.proposed_file_changes: @@ -181,7 +196,9 @@ def _send_or_hold( return True except PeerEncryptionPendingError as e: peer_store = self.connection_router.peer_store - if peer_store.get_cached_peer(recipient) is None: + if peer_store.peers_loaded and not peer_store.get_cached_peer( + recipient + ): raise print(f"Held a message for {recipient}: {e}") held.append((recipient, message.as_compressed_data())) @@ -198,30 +215,38 @@ def send_pending_messages(self) -> None: was rejected), or whose policy conflicts, are dropped with a warning. """ peer_store = self.connection_router.peer_store - with self._held_messages.edit() as held: - pending = list(held) - held.clear() - for recipient, data in pending: - if any(r == recipient for r, _ in held): - held.append((recipient, data)) + store = self._held_messages + with store.edit() as held: + waiting: set[str] = set() + for item in list(held): + recipient, data = item + if recipient in waiting: + # Keep the order of the messages to one recipient. + continue + if not peer_store.peers_loaded: + waiting.add(recipient) continue if peer_store.get_cached_peer(recipient) is None: logger.warning( f"Dropped a held message for {recipient}: it is not a peer." ) - continue - message = ProposedFileChangesMessage.from_compressed_data(data) - try: - self.connection_router.watcher_send_proposed_file_changes_message( - recipient, message - ) - except PeerEncryptionPendingError: - held.append((recipient, data)) - continue - except EncryptionPolicyConflictError as e: - print(f"Dropped a held message for {recipient}: {e}") - continue - self._record_sent_hashes(message) + else: + message = ProposedFileChangesMessage.from_compressed_data(data) + try: + self.connection_router.watcher_send_proposed_file_changes_message( + recipient, message + ) + except PeerEncryptionPendingError: + waiting.add(recipient) + continue + except EncryptionPolicyConflictError as e: + print(f"Dropped a held message for {recipient}: {e}") + else: + self._record_sent_hashes(message) + # Saved after each message, so an error part way leaves + # exactly the messages that were not sent. + held.remove(item) + store.save(held) def resend_rejected_messages(self, peer_emails: list[str]) -> None: """Resend, encrypted, the plaintext messages that peers rejected. diff --git a/syft/sync/sync/held_messages.py b/syft/sync/sync/held_messages.py index cee380a4409..80e7600f417 100644 --- a/syft/sync/sync/held_messages.py +++ b/syft/sync/sync/held_messages.py @@ -46,9 +46,26 @@ def edit(self) -> Iterator[list[HeldMessage]]: finally: portalocker.unlock(lock_file) + def read(self) -> list[HeldMessage]: + """The held messages, read under the lock, without a write.""" + self._path.parent.mkdir(parents=True, exist_ok=True) + with open(self._path.with_suffix(".lock"), "a") as lock_file: + portalocker.lock(lock_file, portalocker.LOCK_EX) + try: + return self._read() + finally: + portalocker.unlock(lock_file) + + def save(self, messages: list[HeldMessage]) -> None: + """Write ``messages`` now. Call it only inside ``edit``. + + A flush saves after each message it sends, so an error part way + leaves exactly the messages that were not sent. + """ + self._write(messages) + def __len__(self) -> int: - with self.edit() as messages: - return len(messages) + return len(self.read()) def _read(self) -> list[HeldMessage]: if not self._path.exists(): diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 8c8b840f2bd..91bb55aa32a 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -341,9 +341,19 @@ def _republish_version_if_policy_changed(self) -> None: """Write the version file again when the policy changed in memory. Loading a key file marked required makes this client required. Peers - must see that, or they send plaintext that this client rejects. + must see that, or they send plaintext that this client rejects. A + process that never wrote the version file (syft-bg) compares with the + file on Drive, once. """ - if self._advertised_policy not in (None, self.peer_store.encryption_policy): + if self._advertised_policy is None: + published = self.connection_router.read_own_version_file() + if published is None or ( + published.encryption_policy != self.peer_store.encryption_policy + ): + self.write_own_version() + else: + self._advertised_policy = published.encryption_policy + elif self._advertised_policy != self.peer_store.encryption_policy: self.write_own_version() def share_version_with_peer(self, peer_email: str) -> None: @@ -949,8 +959,27 @@ def _publish_bundle_to_new_encrypted_peers(self, peers: List[Peer]) -> None: this client becomes required after peering. """ for peer in peers: - if peer.email not in self._bundle_published_for: - self._write_encryption_bundle_for_peer(peer.email) + if peer.email in self._bundle_published_for: + continue + try: + if self._own_bundle_is_published(peer.email): + self._bundle_published_for.add(peer.email) + else: + self._write_encryption_bundle_for_peer(peer.email) + except (HttpError, *RETRYABLE_TRANSPORT_ERRORS) as e: + warnings.warn( + f"Could not publish the encryption key for {peer.email}: {e}. " + "The next peer load tries again." + ) + + def _own_bundle_is_published(self, peer_email: str) -> bool: + """Whether the bundle published for ``peer_email`` is our current one.""" + self.peer_store.ensure_keys() + published = self.connection_router.read_own_encryption_bundle(peer_email) + if published is None: + return False + bundle = json.loads(published).get(BUNDLE_FILE_KEY) + return bundle == self.peer_store.get_public_bundle() def _pin_missing_peer_bundles(self, peers: List[Peer]) -> None: """Pin the published bundle of every peer we hold none for (trust on first use). diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index e3b4329b3fb..08eeccdb503 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -180,9 +180,21 @@ def test_legacy_peer_policy_follows_its_published_bundle(do_policy, expected): ) _advertise_as_legacy_client(do_manager) ds_manager.load_peers() + ds_manager.load_peers() assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) == expected +def test_legacy_peer_without_bundle_is_unknown_for_one_load(): + """Its bundle can be not yet visible, so the first load holds sends.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + _advertise_as_legacy_client(do_manager) + ds_manager.load_peers() + store = ds_manager.peer_manager.peer_store + assert store.peer_policy(do_manager.email) is None + ds_manager.load_peers() + assert store.peer_policy(do_manager.email) == OFF + + def test_own_version_advertises_policy(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=OPTIONAL, @@ -224,8 +236,10 @@ def test_policy_change_in_memory_is_republished(): assert advertised.encryption_policy == REQUIRED -def test_legacy_policy_is_read_once_per_peer(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() +def test_legacy_required_policy_is_read_once_per_peer(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=REQUIRED, + ) _advertise_as_legacy_client(do_manager) reads = [] original = ConnectionRouter.read_peer_encryption_bundle @@ -240,6 +254,27 @@ def spy(self, email): assert reads == [do_manager.email] +def test_legacy_peer_without_bundle_is_checked_again(monkeypatch): + """A bundle that was not found once, e.g. not yet visible on Drive, is read again.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=REQUIRED, + ) + _advertise_as_legacy_client(do_manager) + original = ConnectionRouter.read_peer_encryption_bundle + calls = [] + + def first_read_finds_nothing(self, email): + calls.append(email) + return None if len(calls) == 1 else original(self, email) + + monkeypatch.setattr( + ConnectionRouter, "read_peer_encryption_bundle", first_read_finds_nothing + ) + ds_manager.load_peers() + ds_manager.load_peers() + assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) == REQUIRED + + # ========================================================================= # Per-peer resolution # ========================================================================= @@ -495,6 +530,41 @@ def test_optional_keys_do_not_warn(tmp_path): PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() +def test_keys_from_another_process_keep_a_pin_held_in_memory(tmp_path): + """A pin set before there were keys is saved once the key file exists.""" + keys_path = tmp_path / "crypto_keys.json" + bob = PeerStore(email="bob@example.com", encryption_policy=REQUIRED) + bob.generate_keys() + alice = PeerStore.create(email="alice@example.com", keys_path=keys_path) + alice.add_peer( + Peer(email="bob@example.com", version=_version_with_policy(REQUIRED)) + ) + alice.set_peer_bundle("bob@example.com", bob.get_public_bundle()) + # Another process on the datasite makes the key file. + PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() + + thread = threading.Thread(target=alice.ensure_keys, daemon=True) + thread.start() + thread.join(timeout=10) + assert not thread.is_alive(), "ensure_keys did not finish" + assert PeerStore.load_keys(keys_path).has_peer_bundle("bob@example.com") + + +def test_reload_reads_keys_only_for_a_required_key_file(tmp_path, monkeypatch): + keys_path = tmp_path / "crypto_keys.json" + store = PeerStore.create(email="alice@example.com", keys_path=keys_path) + store.ensure_keys() + loads = [] + original = PeerStore.load_keys.__func__ + monkeypatch.setattr( + PeerStore, + "load_keys", + classmethod(lambda cls, path: loads.append(path) or original(cls, path)), + ) + store.reload_policy_from_key_file() + assert loads == [] + + def test_concurrent_processes_make_one_key_pair(tmp_path): """A notebook and syft-bg that need keys at once must end with the same keys.""" keys_path = tmp_path / "crypto_keys.json" @@ -688,6 +758,21 @@ def test_policy_recorded_by_another_process_is_taken_at_next_sync(): assert advertised.encryption_policy == REQUIRED +def test_switch_is_advertised_by_process_that_never_wrote_version(): + """syft-bg does not write the version file, but must advertise required.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + do_manager.peer_manager._advertised_policy = None # as a fresh syft-bg + + keys_path = do_manager.peer_manager.peer_store._keys_path + with pytest.warns(UserWarning): + PeerStore.create( + email=do_manager.email, encryption_policy=REQUIRED, keys_path=keys_path + ) + do_manager.sync() + advertised = ds_manager.peer_manager.load_peer_version(do_manager.email) + assert advertised.encryption_policy == REQUIRED + + def _raw_event_log(do_manager) -> list[bytes]: connection = ( do_manager.datasite_owner_syncer.connection_router.connection_for_eventlog() @@ -780,6 +865,41 @@ def test_conflict_found_on_read_makes_the_peer_wait(): ) +def test_current_published_bundle_is_not_written_again(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=REQUIRED, + ) + ds_manager.load_peers() + writes = [] + monkeypatch.setattr( + ConnectionRouter, + "write_encryption_bundle", + lambda self, peer_email, bundle_json: writes.append(peer_email), + ) + ds_manager.peer_manager._bundle_published_for.clear() # as a new process + ds_manager.load_peers() + assert writes == [] + + +def test_drive_error_while_publishing_bundle_does_not_stop_sync(monkeypatch): + from googleapiclient.errors import HttpError + + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=REQUIRED, + ) + + def fail(self, peer_email): + raise HttpError( + resp=type("R", (), {"status": 503, "reason": ""})(), content=b"" + ) + + monkeypatch.setattr(ConnectionRouter, "read_own_encryption_bundle", fail) + ds_manager.peer_manager._bundle_published_for.clear() # as a new process + with pytest.warns(UserWarning, match="publish"): + ds_manager.load_peers() + assert do_manager.email not in ds_manager.peer_manager._bundle_published_for + + def test_owner_waits_on_envelope_from_unpinned_peer(): """The sync goes on; the message stays in the inbox until the pin exists.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( @@ -805,6 +925,24 @@ def test_owner_waits_on_envelope_from_unpinned_peer(): # ========================================================================= +def test_send_reread_updates_peer_manager(): + """The re-read also refreshes what PeerManager derives from the version.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=OPTIONAL, + do_encryption_policy=REQUIRED, + ) + grant_job_inbox_access(do_manager, ds_manager.email) + ds_manager.load_peers() + peer_manager = ds_manager.peer_manager + peer_manager.get_cached_peer(do_manager.email).version = None + peer_manager._loaded_peer_versions[do_manager.email] = None + + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "content") + assert peer_manager._loaded_peer_versions[do_manager.email] is not None + assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) + + def test_send_rereads_unknown_peer_policy(): """The peer can have accepted since the last load; one re-read finds its policy.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( @@ -898,11 +1036,40 @@ def test_two_held_changes_to_one_file_apply_in_order(): assert any("second" in c for c in _accepted_contents(do_manager)) +def test_held_hashes_are_read_once_per_queue_flush(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + reads = [] + original = DatasiteWatcherSyncer._held_hashes + monkeypatch.setattr( + DatasiteWatcherSyncer, + "_held_hashes", + lambda self: reads.append(1) or original(self), + ) + grant_job_inbox_access(do_manager, ds_manager.email) + watcher = ds_manager.datasite_watcher_syncer + for name in ("a.job", "b.job", "c.job"): + file_path = path_for_job(do_manager.email, ds_manager.email, name) + watcher.on_file_change(file_path, name, process_now=False) + watcher.process_file_changes_queue() + assert len(reads) == 1 + + +def test_send_before_any_peer_load_is_held(): + """With load_peers=False no recipient is a peer yet, so the send waits.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + ) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "content") + assert ds_manager.datasite_watcher_syncer.pending_message_count == 1 + + def test_send_to_non_peer_raises(): """A recipient that is not a peer never gets a policy, so holding would be forever.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, ) + ds_manager.load_peers() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") with pytest.raises(PeerEncryptionPolicyUnknownError): ds_manager._send_file_change(file_path, "content") @@ -951,6 +1118,25 @@ def send_or_hold(self, recipient, message): assert sent == [do_manager.email] +def test_non_peer_recipient_still_sends_to_the_other(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + sent = [] + + def send_or_hold(self, recipient, message): + if recipient == "stranger@example.com": + raise PeerEncryptionPolicyUnknownError(recipient) + sent.append(recipient) + return True + + monkeypatch.setattr(DatasiteWatcherSyncer, "_send_or_hold", send_or_hold) + watcher = ds_manager.datasite_watcher_syncer + watcher.on_file_change("stranger@example.com/a.txt", "a", process_now=False) + watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) + with pytest.raises(PeerEncryptionPolicyUnknownError): + watcher.process_file_changes_queue() + assert sent == [do_manager.email] + + def test_owner_drops_outbox_message_for_former_peer(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() syncer = do_manager.datasite_owner_syncer @@ -992,6 +1178,65 @@ def test_compaction_skips_peer_with_unknown_policy(monkeypatch): assert syncer.compact_outbox_if_needed(ds_manager.email, min_messages=1) == 0 +def test_owner_flush_keeps_only_unsent_messages_on_error(monkeypatch): + """A Drive error mid-flush neither loses queued messages nor resends sent ones.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + syncer = do_manager.datasite_owner_syncer + sent = [] + + def write_once_then_fail(self, recipient, message): + if sent: + raise RuntimeError("Drive error") + sent.append(recipient) + + monkeypatch.setattr( + ConnectionRouter, "owner_write_event_messages_to_outbox", write_once_then_fail + ) + for _ in range(2): + syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) + with pytest.raises(RuntimeError): + syncer.process_syftbox_events_queue() + assert sent == [ds_manager.email] + assert syncer.held_message_count == 1 + + monkeypatch.undo() + syncer.process_syftbox_events_queue() + assert syncer.held_message_count == 0 + + +def test_held_send_flush_keeps_only_unsent_messages_on_error(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + ) + ds_manager.add_peer(do_manager.email, sync=False) + for name in ("a.job", "b.job"): + file_path = path_for_job(do_manager.email, ds_manager.email, name) + ds_manager._send_file_change(file_path, name) + watcher = ds_manager.datasite_watcher_syncer + assert watcher.pending_message_count == 2 + + do_manager.load_peers() + do_manager.approve_peer_request(ds_manager.email) + ds_manager.load_peers() + original = ConnectionRouter.watcher_send_proposed_file_changes_message + sent = [] + + def send_once_then_fail(self, recipient, message): + if sent: + raise RuntimeError("Drive error") + sent.append(recipient) + return original(self, recipient, message) + + monkeypatch.setattr( + ConnectionRouter, + "watcher_send_proposed_file_changes_message", + send_once_then_fail, + ) + with pytest.raises(RuntimeError): + watcher.send_pending_messages() + assert watcher.pending_message_count == 1 + + def _unknown_policy(ds_manager, do_manager, monkeypatch): """Make the DS policy unknown to the DO; return a function that undoes it.""" peer = do_manager.peer_manager.get_cached_peer(ds_manager.email) From b478e7657859f6c97cd955c397f1e2516cfe49c6 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Wed, 7 Oct 2026 20:15:20 -0300 Subject: [PATCH 03/30] fix(sync): hold only pending messages and reload policy on event writes - Send queued outbox messages directly; keep only pending ones on disk, with one write per flush and no loss on errors. - Reload the key file policy before direct event writes. - Catch every pending error per recipient when sending changes. - Read earlier peers' bundles in the parallel version load. - Decode held hashes again only when the held file changes. - Document the encryption policy and the earlier-client limit. --- docs/connections.md | 18 +++++ syft/sync/peers/peer_store.py | 4 ++ syft/sync/sync/datasite_owner_syncer.py | 78 +++++++++++--------- syft/sync/sync/datasite_watcher_syncer.py | 77 +++++++++----------- syft/sync/sync/held_messages.py | 47 ++++++++++-- syft/sync/version/peer_manager.py | 28 ++++++-- tests/unit/test_encryption_policy.py | 88 +++++++++++++++++++++-- 7 files changed, 244 insertions(+), 96 deletions(-) diff --git a/docs/connections.md b/docs/connections.md index b6c0d482d09..05f23be9b8b 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -48,6 +48,24 @@ Syft uses Google Drive as its file-based transport layer. All peer-to-peer commu --- +## Encryption Policy + +Each client advertises an encryption policy in `SYFT_version.json`: + +| Policy | Set with | Effect | +| ---------- | ------------------------------ | -------------------------------------------- | +| `optional` | default, `encryption=False` | encrypt only with a peer that requires it | +| `required` | `encryption=True` | encrypt with every peer; refuse plaintext | +| `off` | `encryption="off"` | never encrypt | + +A pair encrypts when one side is `required`. An `off` and a `required` client conflict: each side skips the other with a warning. + +A pair that encrypts refuses plaintext. The receiver renames a plaintext message to `.rejected`, and waits for that peer. The sender resends the message encrypted on its next sync. + +An earlier client writes no policy in its version file. It is `required` when it published a bundle for us, else `off`. + +**Known limit.** An earlier client that encrypted once and then turned encryption off still has its old bundle on Drive, so it reads as `required`. Its plaintext is rejected, and an earlier client does not resend rejected messages, so the pair stops syncing. To fix it, upgrade that client, or delete its file `encryption_bundle_{owner}_for_{peer}.json` in `syft_encryption_bundles#{owner}/`. + ## Peer Requests DS creates inbox/outbox folders on their own drive and grants the DO write access. The DO discovers these folders by searching for `syft_datasite#{my_email}#` folders they don't own. diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 583744b248e..516ecf98bf4 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -279,6 +279,10 @@ def legacy_policy( first_missed = self._bundle_missing_since.setdefault(email, self._load_count) return EncryptionPolicy.OFF if self._load_count > first_missed else None + def legacy_policy_found(self, email: str) -> bool: + """Whether the earlier client ``email`` is known to encrypt, without a read.""" + return email in self._legacy_policies + def take_policy_reread(self, email: str) -> bool: """True once per peer load: the caller may read the version file again.""" if email in self._policy_rereads: diff --git a/syft/sync/sync/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py index 414654c7be3..b4b69e46aa7 100644 --- a/syft/sync/sync/datasite_owner_syncer.py +++ b/syft/sync/sync/datasite_owner_syncer.py @@ -50,6 +50,11 @@ logger = logging.getLogger(__name__) + +def _compressed(message: "FileChangeEventsMessage | bytes") -> bytes: + return message if isinstance(message, bytes) else message.as_compressed_data() + + # Default threshold for creating incremental checkpoint from rolling state DEFAULT_CHECKPOINT_EVENT_THRESHOLD = 50 @@ -805,6 +810,10 @@ def queue_event_for_syftbox( self.outbox_queue.put((recipient, file_change_events_message)) def process_syftbox_events_queue(self): + # syft-rds and the enclave client call this outside sync(). Another + # process can have made the datasite required, and its files at rest + # encrypted, since this process last read the key file. + self.connection_router.peer_store.reload_policy_from_key_file() # TODO: make this atomic while not self.syftbox_events_queue.empty(): file_change_events_message = self.syftbox_events_queue.get() @@ -813,41 +822,40 @@ def process_syftbox_events_queue(self): ) # The event is already in the event log, so a held outbox message is # kept on disk until encryption with its recipient is set up. - store = self._held_outbox - with store.edit() as held: - # On disk before any send, so an error part way loses nothing. - while not self.outbox_queue.empty(): - recipient, message = self.outbox_queue.get() - held.append((recipient, message.as_compressed_data())) - store.save(held) - waiting: set[str] = set() - for item in list(held): - recipient, data = item - if recipient in waiting: - # Keep the order of the messages to one recipient. - continue - try: - self.connection_router.owner_write_event_messages_to_outbox( - recipient, FileChangeEventsMessage.from_compressed_data(data) - ) - except PeerEncryptionPendingError: - peer_store = self.connection_router.peer_store - if not peer_store.peers_loaded or peer_store.get_cached_peer( - recipient - ): - waiting.add(recipient) - continue - # Not a peer any more: it never gets a policy. - logger.warning(f"Dropped an outbox message for {recipient}") - except EncryptionPolicyConflictError as e: - logger.warning(f"Dropped an outbox message: {e}") - held.remove(item) - store.save(held) - if held: - logger.info( - f"Held {len(held)} outbox message(s) for peers whose " - "encryption is not set up yet" - ) + queued = [] + while not self.outbox_queue.empty(): + queued.append(self.outbox_queue.get()) + # A message waits in the held file only while encryption with its + # recipient is pending. The event is already in the event log, so the + # held file keeps it over a restart. + held_count = self._held_outbox.send_all( + self._send_outbox_message, queued, encode=_compressed + ) + if held_count: + logger.info( + f"Held {held_count} outbox message(s) for peers whose encryption " + "is not set up yet" + ) + + def _send_outbox_message( + self, recipient: str, message: "FileChangeEventsMessage | bytes" + ) -> bool: + """Write one outbox message. False while encryption is pending.""" + if isinstance(message, bytes): + message = FileChangeEventsMessage.from_compressed_data(message) + try: + self.connection_router.owner_write_event_messages_to_outbox( + recipient, message + ) + except PeerEncryptionPendingError: + peer_store = self.connection_router.peer_store + if not peer_store.peers_loaded or peer_store.get_cached_peer(recipient): + return False + # Not a peer any more: it never gets a policy. + logger.warning(f"Dropped an outbox message for {recipient}") + except EncryptionPolicyConflictError as e: + logger.warning(f"Dropped an outbox message: {e}") + return True @property def _held_outbox(self) -> HeldMessages: diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index f63dee46ca4..e2324b0b8fa 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -12,7 +12,6 @@ from syft.sync.encryption_policy import ( EncryptionPolicyConflictError, PeerEncryptionPendingError, - PeerEncryptionPolicyUnknownError, ) from syft.sync.events.file_change_event import FileChangeEventsMessage from syft.sync.messages.proposed_filechange import ( @@ -53,6 +52,10 @@ class Config: _executor: ThreadPoolExecutor = PrivateAttr( default_factory=lambda: ThreadPoolExecutor(max_workers=10) ) + # (held file stamp, held hashes): decoded again only when the file changes. + _held_hashes_cache: tuple[object, Dict[Path, int | None]] | None = PrivateAttr( + default=None + ) @classmethod def from_config(cls, config: DatasiteWatcherSyncerConfig): @@ -134,10 +137,7 @@ def process_file_changes_queue(self): ) try: sent = self._send_or_hold(recipient, message) - except ( - EncryptionPolicyConflictError, - PeerEncryptionPolicyUnknownError, - ) as e: + except (EncryptionPolicyConflictError, PeerEncryptionPendingError) as e: # The other recipients still get their changes. error = error or e continue @@ -161,15 +161,19 @@ def _held_messages(self) -> HeldMessages: def _held_hashes(self) -> Dict[Path, int | None]: """The new hash of each file that a held change touches.""" - messages = [ - ProposedFileChangesMessage.from_compressed_data(d) - for _, d in self._held_messages.read() - ] + store = self._held_messages + stamp = store.stamp() + if stamp is None: + return {} + if self._held_hashes_cache is not None and self._held_hashes_cache[0] == stamp: + return self._held_hashes_cache[1] hashes: Dict[Path, int | None] = {} - for message in messages: + for _, data in store.read(): + message = ProposedFileChangesMessage.from_compressed_data(data) for fc in message.proposed_file_changes: path_key = Path(fc.datasite_email) / fc.path_in_datasite hashes[path_key] = None if fc.is_deleted else fc.new_hash + self._held_hashes_cache = (stamp, hashes) return hashes def _send_or_hold( @@ -214,39 +218,28 @@ def send_pending_messages(self) -> None: Messages for a recipient that is no longer a peer (the peer request was rejected), or whose policy conflicts, are dropped with a warning. """ + self._held_messages.send_all(self._send_held_message) + + def _send_held_message(self, recipient: str, data: bytes) -> bool: + """Send one held message. False while encryption is pending.""" peer_store = self.connection_router.peer_store - store = self._held_messages - with store.edit() as held: - waiting: set[str] = set() - for item in list(held): - recipient, data = item - if recipient in waiting: - # Keep the order of the messages to one recipient. - continue - if not peer_store.peers_loaded: - waiting.add(recipient) - continue - if peer_store.get_cached_peer(recipient) is None: - logger.warning( - f"Dropped a held message for {recipient}: it is not a peer." - ) - else: - message = ProposedFileChangesMessage.from_compressed_data(data) - try: - self.connection_router.watcher_send_proposed_file_changes_message( - recipient, message - ) - except PeerEncryptionPendingError: - waiting.add(recipient) - continue - except EncryptionPolicyConflictError as e: - print(f"Dropped a held message for {recipient}: {e}") - else: - self._record_sent_hashes(message) - # Saved after each message, so an error part way leaves - # exactly the messages that were not sent. - held.remove(item) - store.save(held) + if not peer_store.peers_loaded: + return False + if peer_store.get_cached_peer(recipient) is None: + logger.warning(f"Dropped a held message for {recipient}: it is not a peer.") + return True + message = ProposedFileChangesMessage.from_compressed_data(data) + try: + self.connection_router.watcher_send_proposed_file_changes_message( + recipient, message + ) + except PeerEncryptionPendingError: + return False + except EncryptionPolicyConflictError as e: + print(f"Dropped a held message for {recipient}: {e}") + return True + self._record_sent_hashes(message) + return True def resend_rejected_messages(self, peer_emails: list[str]) -> None: """Resend, encrypted, the plaintext messages that peers rejected. diff --git a/syft/sync/sync/held_messages.py b/syft/sync/sync/held_messages.py index 80e7600f417..62ba3ebf43e 100644 --- a/syft/sync/sync/held_messages.py +++ b/syft/sync/sync/held_messages.py @@ -13,7 +13,7 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import Iterator +from typing import Any, Callable, Iterable, Iterator import portalocker @@ -56,13 +56,46 @@ def read(self) -> list[HeldMessage]: finally: portalocker.unlock(lock_file) - def save(self, messages: list[HeldMessage]) -> None: - """Write ``messages`` now. Call it only inside ``edit``. - - A flush saves after each message it sends, so an error part way - leaves exactly the messages that were not sent. + def send_all( + self, + send: Callable[[str, Any], bool], + queued: Iterable[tuple[str, Any]] = (), + encode: Callable[[Any], bytes] = lambda message: message, + ) -> int: + """Send the held messages, then ``queued``, in order for each recipient. + + ``send(recipient, message)`` gets the held bytes, or a queued message. + It returns False while encryption with the recipient is pending: that + message, and later ones to the same recipient, stay held (``encode`` + gives the bytes of a queued message). True means sent, or dropped on + purpose. When ``send`` raises, the messages not handled yet stay held, + so none is lost and none is sent twice. + + Returns the number of messages held after the call. """ - self._write(messages) + with self.edit() as held: + items = list(held) + list(queued) + remaining: list[tuple[str, Any]] = [] + waiting: set[str] = set() + done = 0 + try: + for recipient, message in items: + if recipient in waiting or not send(recipient, message): + waiting.add(recipient) + remaining.append((recipient, message)) + done += 1 + finally: + held[:] = [(r, encode(m)) for r, m in remaining + items[done:]] + self._write(held) + return len(held) + + def stamp(self) -> tuple[int, int] | None: + """Changes when the file changes: (mtime in ns, size), or None without a file.""" + try: + stat = self._path.stat() + except FileNotFoundError: + return None + return (stat.st_mtime_ns, stat.st_size) def __len__(self) -> int: return len(self.read()) diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 91bb55aa32a..a183743858a 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -378,19 +378,30 @@ def load_peer_version(self, peer_email: str) -> Optional[VersionInfo]: def _load_single_peer_version( self, peer_email: str - ) -> tuple[str, Optional[VersionInfo]]: - """Load version for a single peer. Used by parallel loader.""" + ) -> tuple[str, Optional[VersionInfo], bool]: + """Load version for a single peer. Used by parallel loader. + + For an earlier client, whose version file does not say its policy, it + also reads whether the peer published a bundle for us, so that these + Drive reads run in parallel too. + """ try: # Create new connection for thread safety connection = self.connection_router.connection_for_version_read( create_new=True ) version_info = connection.read_peer_version_file(peer_email) - return (peer_email, version_info) + published = ( + version_info is not None + and version_info.encryption_policy is None + and not self.peer_store.legacy_policy_found(peer_email) + and bool(connection.read_peer_encryption_bundle(peer_email)) + ) + return (peer_email, version_info, published) except Exception as e: if not self.suppress_version_warnings: warnings.warn(f"Failed to load version for {peer_email}: {e}") - return (peer_email, None) + return (peer_email, None, False) def load_peer_versions_parallel( self, peer_emails: List[str], force: bool = False @@ -404,14 +415,17 @@ def load_peer_versions_parallel( results = list(self._executor.map(self._load_single_peer_version, peer_emails)) # Update peer version fields and return - for email, version in results: + for email, version, published in results: peer = self.get_cached_peer(email) if peer: peer.version = version - self.connection_router.resolve_legacy_policy(peer) + if peer.needs_legacy_policy: + peer.set_legacy_policy( + self.peer_store.legacy_policy(email, lambda p=published: p) + ) self._update_peer_schemas(email, version) - return {email: version for email, version in results} + return {email: version for email, version, _ in results} def get_peer_version(self, peer_email: str) -> Optional[VersionInfo]: """Get cached version for peer, None if not loaded or not available.""" diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 08eeccdb503..fa77f40d1cd 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -4,6 +4,7 @@ ``required``. A pair encrypts when either side requires it. """ +import inspect import json import threading import warnings @@ -21,9 +22,11 @@ PeerKeysNotReadyError, ) from syft.sync.events.file_change_event import FileChangeEventsMessage +from syft.sync.messages.proposed_filechange import ProposedFileChangesMessage from syft.sync.peers.peer import Peer, PeerState from syft.sync.peers.peer_store import PeerStore from syft.sync.sync.datasite_watcher_syncer import DatasiteWatcherSyncer +from syft.sync.sync.held_messages import HeldMessages from syft.sync.syftbox_manager import SyftboxManager from syft.sync.version.version_info import VersionInfo from tests.unit.test_sync_manager import path_for_job @@ -242,13 +245,16 @@ def test_legacy_required_policy_is_read_once_per_peer(monkeypatch): ) _advertise_as_legacy_client(do_manager) reads = [] - original = ConnectionRouter.read_peer_encryption_bundle + original = GDriveConnection.read_peer_encryption_bundle def spy(self, email): - reads.append(email) + # Count the reads of the version load; pinning and the key rotation + # check read the bundle too. + if any(f.function == "_load_single_peer_version" for f in inspect.stack()): + reads.append(email) return original(self, email) - monkeypatch.setattr(ConnectionRouter, "read_peer_encryption_bundle", spy) + monkeypatch.setattr(GDriveConnection, "read_peer_encryption_bundle", spy) ds_manager.load_peers() ds_manager.load_peers() assert reads == [do_manager.email] @@ -260,7 +266,7 @@ def test_legacy_peer_without_bundle_is_checked_again(monkeypatch): do_encryption_policy=REQUIRED, ) _advertise_as_legacy_client(do_manager) - original = ConnectionRouter.read_peer_encryption_bundle + original = GDriveConnection.read_peer_encryption_bundle calls = [] def first_read_finds_nothing(self, email): @@ -268,7 +274,7 @@ def first_read_finds_nothing(self, email): return None if len(calls) == 1 else original(self, email) monkeypatch.setattr( - ConnectionRouter, "read_peer_encryption_bundle", first_read_finds_nothing + GDriveConnection, "read_peer_encryption_bundle", first_read_finds_nothing ) ds_manager.load_peers() ds_manager.load_peers() @@ -773,6 +779,20 @@ def test_switch_is_advertised_by_process_that_never_wrote_version(): assert advertised.encryption_policy == REQUIRED +def test_direct_event_write_takes_policy_recorded_by_another_process(): + """syft-rds and the enclave client write events outside sync().""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + keys_path = do_manager.peer_manager.peer_store._keys_path + with pytest.warns(UserWarning): + PeerStore.create( + email=do_manager.email, encryption_policy=REQUIRED, keys_path=keys_path + ) + syncer = do_manager.datasite_owner_syncer + syncer.queue_event_for_syftbox([], FileChangeEventsMessage(events=[])) + syncer.process_syftbox_events_queue() + assert all(_is_envelope(raw) for raw in _raw_event_log(do_manager)) + + def _raw_event_log(do_manager) -> list[bytes]: connection = ( do_manager.datasite_owner_syncer.connection_router.connection_for_eventlog() @@ -1137,6 +1157,64 @@ def send_or_hold(self, recipient, message): assert sent == [do_manager.email] +def test_keys_not_ready_for_one_recipient_still_sends_to_the_other(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + sent = [] + + def send_or_hold(self, recipient, message): + if recipient == "stranger@example.com": + raise PeerKeysNotReadyError(recipient) + sent.append(recipient) + return True + + monkeypatch.setattr(DatasiteWatcherSyncer, "_send_or_hold", send_or_hold) + watcher = ds_manager.datasite_watcher_syncer + watcher.on_file_change("stranger@example.com/a.txt", "a", process_now=False) + watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) + with pytest.raises(PeerKeysNotReadyError): + watcher.process_file_changes_queue() + assert sent == [do_manager.email] + + +def test_owner_flush_writes_no_held_file_when_nothing_waits(monkeypatch): + """Messages that go out at once do not pass through the held file.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + written = [] + original = HeldMessages._write + monkeypatch.setattr( + HeldMessages, + "_write", + lambda self, messages: written.append(len(messages)) + or original(self, messages), + ) + syncer = do_manager.datasite_owner_syncer + for _ in range(3): + syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) + syncer.process_syftbox_events_queue() + assert all(n == 0 for n in written) + + +def test_held_hashes_are_decoded_again_only_after_a_change(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + ) + ds_manager.add_peer(do_manager.email, sync=False) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "held") + + decodes = [] + original = ProposedFileChangesMessage.from_compressed_data.__func__ + monkeypatch.setattr( + ProposedFileChangesMessage, + "from_compressed_data", + classmethod(lambda cls, data: decodes.append(1) or original(cls, data)), + ) + watcher = ds_manager.datasite_watcher_syncer + watcher._held_hashes() + watcher._held_hashes() + assert len(decodes) == 1 + + def test_owner_drops_outbox_message_for_former_peer(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() syncer = do_manager.datasite_owner_syncer From 5e7f55c7c5e921a3d69504dda6dfa63eb533b979 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Wed, 7 Oct 2026 21:29:52 -0300 Subject: [PATCH 04/30] fix(sync): keep required peers required and encrypt at rest before writes - Remember peers seen as required in the key file; ignore an advertised downgrade until the user trusts a new key for the peer. - Encrypt plaintext files at rest before direct event writes too. - Run the at-rest pass under its own lock, with parallel downloads. - Key the held-hash cache on file content. - Read earlier peers without a bundle again only every tenth load. - Download in parallel batches when rejecting plaintext inbox messages. --- docs/connections.md | 2 + syft/sync/connections/connection_router.py | 30 ++++- syft/sync/peers/peer_store.py | 87 +++++++++++-- syft/sync/sync/datasite_owner_syncer.py | 15 ++- syft/sync/sync/held_messages.py | 11 +- syft/sync/version/peer_manager.py | 30 ++++- tests/unit/test_encryption_policy.py | 141 +++++++++++++++++++++ 7 files changed, 287 insertions(+), 29 deletions(-) diff --git a/docs/connections.md b/docs/connections.md index 05f23be9b8b..12d33c43373 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -60,6 +60,8 @@ Each client advertises an encryption policy in `SYFT_version.json`: A pair encrypts when one side is `required`. An `off` and a `required` client conflict: each side skips the other with a warning. +The version file is not signed. So a client keeps a peer that it saw as `required` as `required` (in its key file), and warns when the peer seems to advertise less. Trusting a new key for that peer (`client.trust_peer_key`) clears this. + A pair that encrypts refuses plaintext. The receiver renames a plaintext message to `.rejected`, and waits for that peer. The sender resends the message encrypted on its next sync. An earlier client writes no policy in its version file. It is `required` when it published a bundle for us, else `off`. diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index 9f1e746e12b..fac1714e60e 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -1,4 +1,5 @@ import logging +from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Callable, Dict, List, Optional from pydantic import BaseModel, PrivateAttr @@ -44,6 +45,9 @@ logger = logging.getLogger(__name__) +# Downloads in flight at once when the router reads many files. +PARALLEL_DOWNLOADS = 10 + class ConnectionRouter(BaseModel): connections: List[SyftboxPlatformConnection] @@ -223,12 +227,25 @@ def _reject_plaintext_inbox_messages( """Reject every other plaintext message from ``sender_email`` at once. The sender then resends them all on one sync, not one per round. It - encrypts all messages after the first envelope, so the scan stops there. + encrypts all messages after the first envelope, so the scan stops + there. Files download in parallel batches. """ - for file_id in connection.owner_get_inbox_message_ids(sender_email): - if self.peer_store.is_envelope(connection.download_file(file_id)): - break - connection.mark_message_rejected(file_id) + file_ids = connection.owner_get_inbox_message_ids(sender_email) + for start in range(0, len(file_ids), PARALLEL_DOWNLOADS): + batch = file_ids[start : start + PARALLEL_DOWNLOADS] + for file_id, raw in zip(batch, self._download_parallel(batch)): + if self.peer_store.is_envelope(raw): + return + connection.mark_message_rejected(file_id) + + def _download_parallel(self, file_ids: List[str]) -> List[bytes]: + """Download ``file_ids`` in parallel, each on its own connection.""" + + def download(file_id: str) -> bytes: + return self.connection_for_parallel_download().download_file(file_id) + + with ThreadPoolExecutor(max_workers=PARALLEL_DOWNLOADS) as executor: + return list(executor.map(download, file_ids)) def owner_resend_rejected_outbox_messages(self, recipient: str) -> int: """Resend, encrypted, the plaintext outbox messages ``recipient`` rejected. @@ -397,8 +414,7 @@ def owner_encrypt_files_at_rest(self) -> None: def encrypt_files() -> None: file_ids = connection.owner_get_all_accepted_event_file_ids() file_ids += connection.owner_get_checkpoint_and_state_file_ids() - for file_id in file_ids: - raw = connection.owner_download_raw_bytes_by_id(file_id) + for file_id, raw in zip(file_ids, self._download_parallel(file_ids)): if not self.peer_store.is_envelope(raw): connection.owner_overwrite_raw_bytes_by_id( file_id, self.peer_store.encrypt_for_self(raw) diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 516ecf98bf4..4b301788bf9 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -62,6 +62,13 @@ # encrypts them once and sets it: an optional client, or an earlier client # without encryption, can have written plaintext there. AT_REST_ENCRYPTED_KEY = "at_rest_encrypted" +# Key in the crypto key file that lists the peers seen to require encryption. +# The version file that advertises a policy is not signed, so such a peer +# stays required here until the user trusts a new key for it. +REQUIRED_PEERS_KEY = "required_peers" +# An earlier client without a bundle for us is read again on the load after +# the first miss, then once every this many peer loads. +LEGACY_BUNDLE_RECHECK_LOADS = 10 def datasite_crypto_keys_path(syftbox_folder: Path | str, email: str) -> Path: @@ -99,6 +106,8 @@ class PeerStore(BaseModel): _load_count: int = PrivateAttr(default=0) # Earlier clients without a found bundle: the load at which none was found. _bundle_missing_since: dict[str, int] = PrivateAttr(default_factory=dict) + # Peers seen to require encryption (see REQUIRED_PEERS_KEY). + _required_peers: set[str] = PrivateAttr(default_factory=set) # Whether the files at rest are known to be encrypted. _at_rest_encrypted: bool = PrivateAttr(default=False) @@ -212,9 +221,43 @@ def decrypt_dataset_if_needed(self, owner_email: str, data: bytes) -> bytes: return self.decrypt(owner_email, data) def peer_policy(self, email: str) -> Optional[EncryptionPolicy]: - """The policy ``email`` advertises, or None when not known.""" + """The policy of ``email``, or None when not known. + + A peer seen to require encryption stays required, whatever its + version file says now (see ``remember_required_peer``). + """ peer = self.get_cached_peer(email) - return peer.encryption_policy if peer is not None else None + if peer is None: + return None + if email in self._required_peers: + return EncryptionPolicy.REQUIRED + return peer.encryption_policy + + def is_remembered_required(self, email: str) -> bool: + return email in self._required_peers + + def remember_required_peer(self, email: str) -> None: + """Keep ``email`` required, in memory and in the key file.""" + if email not in self._required_peers: + self._required_peers.add(email) + self._persist_required_peers() + + def forget_required_peer(self, email: str) -> None: + """Stop keeping ``email`` required. For a trusted key change only.""" + if email in self._required_peers: + self._required_peers.discard(email) + self._persist_required_peers(removed=email) + + def _persist_required_peers(self, removed: str | None = None) -> None: + """Record the remembered peers in the key file, with those of other processes.""" + if self._keys_path is None or self._private_keys is None: + return + with _locked_key_file(self._keys_path): + data = self._read_key_file() or self._key_file_data() + stored = set(data.get(REQUIRED_PEERS_KEY, [])) - {removed} + self._required_peers |= stored + data = {**data, REQUIRED_PEERS_KEY: sorted(self._required_peers)} + _write_key_file(self._keys_path, data) def peer_policy_known(self, email: str) -> bool: """Whether this client can select the format for ``email``. @@ -279,9 +322,19 @@ def legacy_policy( first_missed = self._bundle_missing_since.setdefault(email, self._load_count) return EncryptionPolicy.OFF if self._load_count > first_missed else None - def legacy_policy_found(self, email: str) -> bool: - """Whether the earlier client ``email`` is known to encrypt, without a read.""" - return email in self._legacy_policies + def legacy_bundle_needs_read(self, email: str) -> bool: + """Whether to read again if the earlier client ``email`` published a bundle. + + Not once one was found. After a miss: on the next load, then once + every LEGACY_BUNDLE_RECHECK_LOADS loads. + """ + if email in self._legacy_policies: + return False + first_missed = self._bundle_missing_since.get(email) + if first_missed is None: + return True + loads = self._load_count - first_missed + return loads <= 1 or loads % LEGACY_BUNDLE_RECHECK_LOADS == 0 def take_policy_reread(self, email: str) -> bool: """True once per peer load: the caller may read the version file again.""" @@ -419,11 +472,13 @@ def ensure_keys(self) -> None: # Saving a pin takes the lock again, so it waits until here. for email, bundle in unsaved_pins: self._persist_peer_bundle(email, bundle) + self._persist_required_peers() def _adopt_key_file(self, loaded: "PeerStore") -> None: """Take the keys, pins and recorded policy of ``loaded``, a store read from our key file.""" for email, bundle in self._take_key_file(loaded): self._persist_peer_bundle(email, bundle) + self._persist_required_peers() def _take_key_file(self, loaded: "PeerStore") -> list[tuple[str, dict]]: """Like ``_adopt_key_file``, but return the pins to save, without file I/O. @@ -438,6 +493,7 @@ def _take_key_file(self, loaded: "PeerStore") -> list[tuple[str, dict]]: ) self._private_keys = loaded._private_keys self._at_rest_encrypted = loaded._at_rest_encrypted + self._required_peers |= loaded._required_peers if loaded.use_encryption: self.encryption_policy = EncryptionPolicy.REQUIRED if not self._peers: @@ -508,6 +564,9 @@ def set_peer_bundle( new_fp = parsed.identity_fingerprint() if old_fp != new_fp and not allow_key_change: raise PeerKeyChangedError(peer_email, old_fp, new_fp) + if old_fp != new_fp: + # A peer drops required only with a new key file, so a new key. + self.forget_required_peer(peer_email) if pinned == bundle: peer.public_encryption_bundle = bundle return @@ -595,6 +654,7 @@ def _key_file_data(self) -> dict: PEER_BUNDLES_KEY: self._pinned_bundles(), ENCRYPTION_POLICY_KEY: self.encryption_policy.value, AT_REST_ENCRYPTED_KEY: self._at_rest_encrypted, + REQUIRED_PEERS_KEY: sorted(self._required_peers), } def reload_policy_from_key_file(self) -> None: @@ -629,19 +689,23 @@ def needs_at_rest_encryption(self) -> bool: def encrypt_files_at_rest_once(self, encrypt_files: Callable[[], None]) -> None: """Run ``encrypt_files`` unless a process already did, then record it. - The key file lock covers the check, the run and the record, so two - processes on one datasite do not both encrypt the files. + A lock of its own covers the check, the run and the record, so two + processes on one datasite do not both encrypt the files. The key file + lock is taken only to record, so the run does not block key work. """ self.ensure_keys() if self._keys_path is None: encrypt_files() else: - with _locked_key_file(self._keys_path): - data = self._read_key_file() or self._key_file_data() + at_rest_lock = self._keys_path.with_name(self._keys_path.name + ".at_rest") + with _locked_key_file(at_rest_lock): + data = self._read_key_file() or {} if not data.get(AT_REST_ENCRYPTED_KEY): encrypt_files() - data = {**data, AT_REST_ENCRYPTED_KEY: True} - _write_key_file(self._keys_path, data) + with _locked_key_file(self._keys_path): + data = self._read_key_file() or self._key_file_data() + data = {**data, AT_REST_ENCRYPTED_KEY: True} + _write_key_file(self._keys_path, data) self._at_rest_encrypted = True def _record_required_policy(self) -> None: @@ -724,6 +788,7 @@ def load_keys(cls, path: Path) -> "PeerStore": policy = data.get(ENCRYPTION_POLICY_KEY, EncryptionPolicy.REQUIRED.value) store = cls(email=data["email"], encryption_policy=EncryptionPolicy(policy)) store._at_rest_encrypted = bool(data.get(AT_REST_ENCRYPTED_KEY)) + store._required_peers = set(data.get(REQUIRED_PEERS_KEY, [])) store._private_keys = syc.SyftPrivateKeys.from_jwks(data["keys_jwk"]) store._keys_path = Path(path) store._peers = _peers_from_stored_pins(data.get(PEER_BUNDLES_KEY, {})) diff --git a/syft/sync/sync/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py index b4b69e46aa7..31bf5778357 100644 --- a/syft/sync/sync/datasite_owner_syncer.py +++ b/syft/sync/sync/datasite_owner_syncer.py @@ -206,8 +206,7 @@ def _save_rolling_state(self) -> None: tmp.rename(path) def sync(self, peer_emails: list[str], recompute_hashes: bool = True): - if self.connection_router.peer_store.needs_at_rest_encryption(): - self.connection_router.owner_encrypt_files_at_rest() + self._encrypt_files_at_rest_if_needed() if self.persist_owner_state: self._load_rolling_state() try: @@ -811,17 +810,16 @@ def queue_event_for_syftbox( def process_syftbox_events_queue(self): # syft-rds and the enclave client call this outside sync(). Another - # process can have made the datasite required, and its files at rest - # encrypted, since this process last read the key file. + # process can have made the datasite required since this process last + # read the key file. self.connection_router.peer_store.reload_policy_from_key_file() + self._encrypt_files_at_rest_if_needed() # TODO: make this atomic while not self.syftbox_events_queue.empty(): file_change_events_message = self.syftbox_events_queue.get() self.connection_router.owner_write_events_message_to_syftbox( file_change_events_message ) - # The event is already in the event log, so a held outbox message is - # kept on disk until encryption with its recipient is set up. queued = [] while not self.outbox_queue.empty(): queued.append(self.outbox_queue.get()) @@ -837,6 +835,11 @@ def process_syftbox_events_queue(self): "is not set up yet" ) + def _encrypt_files_at_rest_if_needed(self) -> None: + """Encrypt plaintext files at rest once, before they are read or written.""" + if self.connection_router.peer_store.needs_at_rest_encryption(): + self.connection_router.owner_encrypt_files_at_rest() + def _send_outbox_message( self, recipient: str, message: "FileChangeEventsMessage | bytes" ) -> bool: diff --git a/syft/sync/sync/held_messages.py b/syft/sync/sync/held_messages.py index 62ba3ebf43e..76aa5de4298 100644 --- a/syft/sync/sync/held_messages.py +++ b/syft/sync/sync/held_messages.py @@ -8,6 +8,7 @@ """ import base64 +import hashlib import json import os import tempfile @@ -89,13 +90,15 @@ def send_all( self._write(held) return len(held) - def stamp(self) -> tuple[int, int] | None: - """Changes when the file changes: (mtime in ns, size), or None without a file.""" + def stamp(self) -> str | None: + """Changes when the content changes: a hash of the file, or None without one. + + Not the modification time: some mounts keep it only to the second. + """ try: - stat = self._path.stat() + return hashlib.sha256(self._path.read_bytes()).hexdigest() except FileNotFoundError: return None - return (stat.st_mtime_ns, stat.st_size) def __len__(self) -> int: return len(self.read()) diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index a183743858a..aec1f433240 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -205,6 +205,8 @@ class PeerManager(BaseModel): _bundle_published_for: Set[str] = PrivateAttr(default_factory=set) # The policy in the version file this process last wrote. _advertised_policy: Optional[EncryptionPolicy] = PrivateAttr(default=None) + # Peers already warned about an advertised downgrade, in this process. + _downgrade_warned: Set[str] = PrivateAttr(default_factory=set) # ========== Peer List Properties ========== @@ -373,6 +375,7 @@ def load_peer_version(self, peer_email: str) -> Optional[VersionInfo]: if cached_peer: cached_peer.version = version_info self.connection_router.resolve_legacy_policy(cached_peer) + self._note_peer_policy(cached_peer) self._update_peer_schemas(peer_email, version_info) return version_info @@ -394,7 +397,7 @@ def _load_single_peer_version( published = ( version_info is not None and version_info.encryption_policy is None - and not self.peer_store.legacy_policy_found(peer_email) + and self.peer_store.legacy_bundle_needs_read(peer_email) and bool(connection.read_peer_encryption_bundle(peer_email)) ) return (peer_email, version_info, published) @@ -423,10 +426,34 @@ def load_peer_versions_parallel( peer.set_legacy_policy( self.peer_store.legacy_policy(email, lambda p=published: p) ) + self._note_peer_policy(peer) self._update_peer_schemas(email, version) return {email: version for email, version, _ in results} + def _note_peer_policy(self, peer: Peer) -> None: + """Remember a peer that requires encryption; warn when it seems to stop. + + The version file is not signed, so the storage provider could write a + downgrade into it. The peer stays required until the user trusts a new + key for it. + """ + advertised = peer.encryption_policy + if advertised == EncryptionPolicy.REQUIRED: + self.peer_store.remember_required_peer(peer.email) + elif ( + advertised is not None + and self.peer_store.is_remembered_required(peer.email) + and peer.email not in self._downgrade_warned + ): + self._downgrade_warned.add(peer.email) + warnings.warn( + f"{peer.email} advertises encryption {advertised.value}, but it " + "required encryption before, so encryption stays on. If the peer " + "reset its keys, compare its new key fingerprint with it and run " + f"client.trust_peer_key({peer.email!r}, fingerprint)." + ) + def get_peer_version(self, peer_email: str) -> Optional[VersionInfo]: """Get cached version for peer, None if not loaded or not available.""" peer = self.get_cached_peer(peer_email) @@ -656,6 +683,7 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): version_info = self.connection_router.read_peer_version_file(peer_email) new_peer_obj.version = version_info self.connection_router.resolve_legacy_policy(new_peer_obj) + self._note_peer_policy(new_peer_obj) encrypted = pair_uses_encryption( self.peer_store.encryption_policy, new_peer_obj.encryption_policy ) diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index fa77f40d1cd..e0933b0bbf3 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -6,6 +6,7 @@ import inspect import json +import os import threading import warnings from pathlib import Path @@ -111,6 +112,8 @@ def _pair_with_stale_sender_view(): do_manager.load_peers() stale = ds_manager.peer_manager.get_cached_peer(do_manager.email) stale.version = _version_with_policy(OPTIONAL) + # A client that never saw the owner as required. + ds_manager.peer_manager.peer_store.forget_required_peer(do_manager.email) return ds_manager, do_manager @@ -128,6 +131,7 @@ def _owner_sends_with_stale_view(ds_manager, do_manager, content: str) -> None: """The owner handles a proposal and answers while it sees the DS as optional.""" stale = do_manager.peer_manager.get_cached_peer(ds_manager.email) stale.version = _version_with_policy(OPTIONAL) + do_manager.peer_manager.peer_store.forget_required_peer(ds_manager.email) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, content) do_manager.datasite_owner_syncer.sync([ds_manager.email]) @@ -187,6 +191,24 @@ def test_legacy_peer_policy_follows_its_published_bundle(do_policy, expected): assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) == expected +def test_legacy_peer_without_bundle_is_read_again_only_now_and_then(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + _advertise_as_legacy_client(do_manager) + reads = [] + original = GDriveConnection.read_peer_encryption_bundle + + def spy(self, email): + if any(f.function == "_load_single_peer_version" for f in inspect.stack()): + reads.append(email) + return original(self, email) + + monkeypatch.setattr(GDriveConnection, "read_peer_encryption_bundle", spy) + for _ in range(5): + ds_manager.load_peers() + # The load that first misses it, the next one, and then every tenth. + assert len(reads) == 2 + + def test_legacy_peer_without_bundle_is_unknown_for_one_load(): """Its bundle can be not yet visible, so the first load holds sends.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() @@ -281,6 +303,65 @@ def first_read_finds_nothing(self, email): assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) == REQUIRED +def _advertise_policy(manager, policy) -> None: + """Write the version file of ``manager`` with ``policy``, as a tamperer could.""" + version = manager.peer_manager.get_own_version().model_copy() + version.encryption_policy = policy + manager.peer_manager.connection_router.write_version_file(version) + + +def test_advertised_downgrade_of_required_peer_is_ignored(): + """The version file is not signed, so a required peer stays required.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=OPTIONAL, + do_encryption_policy=REQUIRED, + ) + grant_job_inbox_access(do_manager, ds_manager.email) + ds_manager.load_peers() + do_manager.load_peers() + + _advertise_policy(do_manager, OPTIONAL) + with pytest.warns(UserWarning, match="required encryption before"): + ds_manager.load_peers() + ds_store = ds_manager.peer_manager.peer_store + assert ds_store.peer_policy(do_manager.email) == REQUIRED + + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "content") + assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) + + +def test_required_peer_is_remembered_in_the_key_file(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + store = PeerStore.create(email="alice@example.com", keys_path=keys_path) + store.ensure_keys() + store.add_peer( + Peer(email="bob@example.com", version=_version_with_policy(REQUIRED)) + ) + store.remember_required_peer("bob@example.com") + + later = PeerStore.create(email="alice@example.com", keys_path=keys_path) + later.add_peer( + Peer(email="bob@example.com", version=_version_with_policy(OPTIONAL)) + ) + assert later.peer_policy("bob@example.com") == REQUIRED + + +def test_trusted_key_change_clears_a_remembered_required_peer(): + """A peer can drop required only with a new key file, so a new identity key.""" + alice, bob = _keyed_pair(OPTIONAL, REQUIRED) + alice.remember_required_peer("bob@example.com") + alice.get_cached_peer("bob@example.com").version = _version_with_policy(OPTIONAL) + assert alice.peer_policy("bob@example.com") == REQUIRED + + reset_bob = PeerStore(email="bob@example.com") + reset_bob.generate_keys() + alice.set_peer_bundle( + "bob@example.com", reset_bob.get_public_bundle(), allow_key_change=True + ) + assert alice.peer_policy("bob@example.com") == OPTIONAL + + # ========================================================================= # Per-peer resolution # ========================================================================= @@ -571,6 +652,29 @@ def test_reload_reads_keys_only_for_a_required_key_file(tmp_path, monkeypatch): assert loads == [] +def test_at_rest_pass_leaves_the_key_file_lock_free(tmp_path): + """A notebook can pin keys while syft-bg encrypts the files at rest.""" + from syft.sync.peers.peer_store import _locked_key_file + + keys_path = tmp_path / "crypto_keys.json" + store = PeerStore.create(email="alice@example.com", keys_path=keys_path) + store.ensure_keys() + store.encryption_policy = REQUIRED + took_lock = [] + + def encrypt_files(): + def other_process(): + with _locked_key_file(keys_path): + took_lock.append(True) + + thread = threading.Thread(target=other_process, daemon=True) + thread.start() + thread.join(timeout=5) + + store.encrypt_files_at_rest_once(encrypt_files) + assert took_lock == [True] + + def test_concurrent_processes_make_one_key_pair(tmp_path): """A notebook and syft-bg that need keys at once must end with the same keys.""" keys_path = tmp_path / "crypto_keys.json" @@ -793,6 +897,25 @@ def test_direct_event_write_takes_policy_recorded_by_another_process(): assert all(_is_envelope(raw) for raw in _raw_event_log(do_manager)) +def test_direct_event_write_encrypts_earlier_files_at_rest(): + """Files written while optional are encrypted before a direct write reads them.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + grant_job_inbox_access(do_manager, ds_manager.email) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "written while optional") + do_manager.sync() + + keys_path = do_manager.peer_manager.peer_store._keys_path + with pytest.warns(UserWarning): + PeerStore.create( + email=do_manager.email, encryption_policy=REQUIRED, keys_path=keys_path + ) + syncer = do_manager.datasite_owner_syncer + syncer.queue_event_for_syftbox([], FileChangeEventsMessage(events=[])) + syncer.process_syftbox_events_queue() + assert all(_is_envelope(raw) for raw in _raw_event_log(do_manager)) + + def _raw_event_log(do_manager) -> list[bytes]: connection = ( do_manager.datasite_owner_syncer.connection_router.connection_for_eventlog() @@ -955,6 +1078,7 @@ def test_send_reread_updates_peer_manager(): ds_manager.load_peers() peer_manager = ds_manager.peer_manager peer_manager.get_cached_peer(do_manager.email).version = None + peer_manager.peer_store.forget_required_peer(do_manager.email) peer_manager._loaded_peer_versions[do_manager.email] = None file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") @@ -1074,6 +1198,22 @@ def test_held_hashes_are_read_once_per_queue_flush(monkeypatch): assert len(reads) == 1 +def test_held_file_stamp_follows_content_with_same_time_and_size(tmp_path): + """Coarse time stamps on some mounts must not keep a stale hash cache.""" + path = tmp_path / "held.json" + store = HeldMessages(path) + with store.edit() as held: + held.append(("a@example.com", b"AAAA")) + stat = path.stat() + first = store.stamp() + + with store.edit() as held: + held[0] = ("a@example.com", b"BBBB") + os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns)) + assert path.stat().st_size == stat.st_size + assert store.stamp() != first + + def test_send_before_any_peer_load_is_held(): """With load_peers=False no recipient is a peer yet, so the send waits.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( @@ -1320,6 +1460,7 @@ def _unknown_policy(ds_manager, do_manager, monkeypatch): peer = do_manager.peer_manager.get_cached_peer(ds_manager.email) version = peer.version peer.version = None + do_manager.peer_manager.peer_store.forget_required_peer(ds_manager.email) monkeypatch.setattr( ConnectionRouter, "read_peer_version_file", lambda self, email: None ) From 107b39862ef04d72bd22981cca7c073bef6ea39d Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Thu, 8 Oct 2026 08:33:05 -0300 Subject: [PATCH 05/30] fix(sync): harden legacy policy checks, unknown peers and bundle reads - Take an earlier peer as required only for a valid bundle; a failed check or a junk file keeps the policy unknown and is checked again. - Record a missing bundle only in a peer load, so add_peer keeps the grace. - Wait on plaintext while the peer policy is unknown; keep the last known policy when the version file disappears. - Hold unsent proposals when a send fails. - Keep a version when only the bundle check fails. - Treat an unreadable own bundle as not published. - Name test SyftBox folders with uuid4, so tests do not share caches. --- syft/sync/connections/connection_router.py | 44 +++- .../connections/drive/gdrive_transport.py | 9 + syft/sync/peers/key_bundle.py | 4 + syft/sync/peers/peer_store.py | 48 ++++- syft/sync/sync/datasite_watcher_syncer.py | 40 ++-- syft/sync/utils/syftbox_utils.py | 5 +- syft/sync/version/peer_manager.py | 53 +++-- tests/unit/test_encryption_policy.py | 197 ++++++++++++++++-- tests/unit/test_sync_manager.py | 13 +- 9 files changed, 348 insertions(+), 65 deletions(-) diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index fac1714e60e..65714bd6292 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -1,4 +1,8 @@ +import json import logging +import warnings + +from googleapiclient.errors import HttpError from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Callable, Dict, List, Optional @@ -18,6 +22,12 @@ RejectedMessage, SyftboxPlatformConnection, ) +from syft.sync.connections.drive.gdrive_retry import RETRYABLE_TRANSPORT_ERRORS +from syft.sync.peers.key_bundle import ( + BUNDLE_FILE_KEY, + InvalidPeerBundleError, + parse_and_validate_bundle, +) from syft.sync.encryption_policy import ( EncryptionPolicyConflictError, PeerEncryptionPendingError, @@ -178,10 +188,42 @@ def resolve_legacy_policy(self, peer: Peer) -> None: """Set the policy of ``peer`` when its version file does not say it.""" if peer.needs_legacy_policy: policy = self.peer_store.legacy_policy( - peer.email, lambda: bool(self.read_peer_encryption_bundle(peer.email)) + peer.email, lambda: self.legacy_bundle_published(peer.email) ) peer.set_legacy_policy(policy) + def legacy_bundle_published( + self, + peer_email: str, + connection: SyftboxPlatformConnection | None = None, + ) -> Optional[bool]: + """Whether the earlier client ``peer_email`` published a valid bundle for us. + + None when the check failed, or the file there is not a valid bundle: + neither shows that the peer does not encrypt, so the policy stays + unknown and the next load checks again. + """ + if connection is None: + connection = self.connection_for_datasite_watcher() + try: + if not connection.peer_encryption_bundle_exists(peer_email): + return False + raw = connection.read_peer_encryption_bundle(peer_email) + except (HttpError, *RETRYABLE_TRANSPORT_ERRORS) as e: + logger.warning(f"Could not check the bundle of {peer_email}: {e}") + return None + if raw is None: + return None + try: + payload = json.loads(raw) + if not isinstance(payload, dict): + raise InvalidPeerBundleError(f"not a JSON object: {raw[:40]!r}") + parse_and_validate_bundle(peer_email, payload.get(BUNDLE_FILE_KEY)) + except ValueError as e: + warnings.warn(f"Ignoring the encryption bundle of {peer_email}: {e}") + return None + return True + def watcher_send_proposed_file_changes_message( self, recipient: str, proposed_file_changes_message: ProposedFileChangesMessage ): diff --git a/syft/sync/connections/drive/gdrive_transport.py b/syft/sync/connections/drive/gdrive_transport.py index 57ce8a46df1..87903866d0c 100644 --- a/syft/sync/connections/drive/gdrive_transport.py +++ b/syft/sync/connections/drive/gdrive_transport.py @@ -2573,6 +2573,15 @@ def read_own_encryption_bundle(self, peer_email: str) -> str | None: return None return self.download_file(items[0]["id"]).decode("utf-8") + def peer_encryption_bundle_exists(self, peer_email: str) -> bool: + """Whether ``peer_email`` published a bundle for us. Errors propagate.""" + filename = self._encryption_bundle_filename(peer_email, self.email) + query = f"name='{filename}' and trashed=false and '{peer_email}' in owners" + results = execute_with_retries( + self.drive_service.files().list(q=query, fields="files(id)") + ) + return bool(results.get("files", [])) + def read_peer_encryption_bundle(self, peer_email: str) -> str | None: """Read encryption bundle that peer wrote for us. diff --git a/syft/sync/peers/key_bundle.py b/syft/sync/peers/key_bundle.py index f6415cba8a7..ac0f0f2d64d 100644 --- a/syft/sync/peers/key_bundle.py +++ b/syft/sync/peers/key_bundle.py @@ -20,6 +20,10 @@ DID_PREFIX = "did:syft:" +# Key wrapping the DID document inside a published encryption bundle file. +BUNDLE_FILE_KEY = "public_encryption_bundle" + + class InvalidPeerBundleError(ValueError): """The bundle does not parse, its signatures fail, or it names another identity.""" diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 4b301788bf9..73972428ffa 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -108,6 +108,10 @@ class PeerStore(BaseModel): _bundle_missing_since: dict[str, int] = PrivateAttr(default_factory=dict) # Peers seen to require encryption (see REQUIRED_PEERS_KEY). _required_peers: set[str] = PrivateAttr(default_factory=set) + # The last policy known of each peer in this process (see peer_policy). + _last_known_policies: dict[str, EncryptionPolicy] = PrivateAttr( + default_factory=dict + ) # Whether the files at rest are known to be encrypted. _at_rest_encrypted: bool = PrivateAttr(default=False) @@ -177,11 +181,14 @@ def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: """Verify and decrypt an envelope from ``email``; pass plaintext through. When the pair encrypts, plaintext is refused, so that a peer or the - storage provider cannot downgrade the pair. When the pair does not + storage provider cannot downgrade the pair. While the peer policy is + not known, plaintext waits, as a send does. When the pair does not encrypt, an envelope is still accepted: the peer can have started to require encryption after the last load. Raises: + PeerEncryptionPolicyUnknownError: when ``data`` is plaintext and + the peer policy is not known. UnencryptedMessageError: when the pair encrypts and ``data`` is plaintext. PeerKeysNotReadyError: when ``data`` is an envelope, but our keys @@ -191,6 +198,8 @@ def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: if self.peer_conflicts(email): raise EncryptionPolicyConflictError(email) if not self.is_envelope(data): + if not self.peer_policy_known(email): + raise PeerEncryptionPolicyUnknownError(email) if self.peer_uses_encryption(email): raise UnencryptedMessageError(email) return data @@ -224,14 +233,20 @@ def peer_policy(self, email: str) -> Optional[EncryptionPolicy]: """The policy of ``email``, or None when not known. A peer seen to require encryption stays required, whatever its - version file says now (see ``remember_required_peer``). + version file says now (see ``remember_required_peer``). When the + version file can no longer be read (the peer deleted its SyftBox, or + a read failed), the last policy known in this process applies. """ peer = self.get_cached_peer(email) if peer is None: return None if email in self._required_peers: return EncryptionPolicy.REQUIRED - return peer.encryption_policy + policy = peer.encryption_policy + if policy is None: + return self._last_known_policies.get(email) + self._last_known_policies[email] = policy + return policy def is_remembered_required(self, email: str) -> bool: return email in self._required_peers @@ -303,24 +318,39 @@ def peers_loaded(self) -> bool: return self._load_count > 0 def legacy_policy( - self, email: str, published_bundle: Callable[[], bool] + self, + email: str, + published_bundle: Callable[[], Optional[bool]], + record_miss: bool = False, ) -> Optional[EncryptionPolicy]: """The policy of the earlier client ``email``, or None while not known. Such a client publishes a bundle for us only when it encrypts. One that does not has no keys and cannot decrypt, so it is off. A bundle can be not yet visible on Drive, so a client without one is unknown - during the peer load that first misses it, and off from the next. - A found bundle is kept for the process. + during the first peer load that misses it, and off from the next. + ``published_bundle`` returns None when the check failed: the policy + then stays unknown. A found bundle is kept for the process. + + Only a peer load records a miss (``record_miss``): a check outside one + (add_peer, a re-read on send) must not start the grace, or the load + that follows would already make the peer off. """ if email in self._legacy_policies: return self._legacy_policies[email] - if published_bundle(): + published = published_bundle() + if published is None: + return None + if published: self._legacy_policies[email] = EncryptionPolicy.REQUIRED self._bundle_missing_since.pop(email, None) return EncryptionPolicy.REQUIRED - first_missed = self._bundle_missing_since.setdefault(email, self._load_count) - return EncryptionPolicy.OFF if self._load_count > first_missed else None + if record_miss: + self._bundle_missing_since.setdefault(email, self._load_count) + first_missed = self._bundle_missing_since.get(email) + if first_missed is None or self._load_count <= first_missed: + return None + return EncryptionPolicy.OFF def legacy_bundle_needs_read(self, email: str) -> bool: """Whether to read again if the earlier client ``email`` published a bundle. diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index e2324b0b8fa..8f87299bb93 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -130,19 +130,35 @@ def process_file_changes_queue(self): ) changes_by_recipient.setdefault(recipient, []).append(file_change) - error = None - for recipient, file_changes in changes_by_recipient.items(): - message = ProposedFileChangesMessage( - sender_email=self.email, proposed_file_changes=file_changes + messages = [ + ( + r, + ProposedFileChangesMessage( + sender_email=self.email, proposed_file_changes=c + ), ) - try: - sent = self._send_or_hold(recipient, message) - except (EncryptionPolicyConflictError, PeerEncryptionPendingError) as e: - # The other recipients still get their changes. - error = error or e - continue - if sent: - self._record_sent_hashes(message) + for r, c in changes_by_recipient.items() + ] + error = None + done = 0 + try: + for recipient, message in messages: + try: + sent = self._send_or_hold(recipient, message) + except (EncryptionPolicyConflictError, PeerEncryptionPendingError) as e: + # The other recipients still get their changes. + error = error or e + done += 1 + continue + if sent: + self._record_sent_hashes(message) + done += 1 + finally: + # The queue is already empty: on another error, the messages not + # sent go to the held file, and the next sync sends them. + if done < len(messages): + with self._held_messages.edit() as held: + held.extend((r, m.as_compressed_data()) for r, m in messages[done:]) if error is not None: raise error diff --git a/syft/sync/utils/syftbox_utils.py b/syft/sync/utils/syftbox_utils.py index 8b7cbe1fd8a..66ecde1f86f 100644 --- a/syft/sync/utils/syftbox_utils.py +++ b/syft/sync/utils/syftbox_utils.py @@ -1,4 +1,5 @@ import random +import uuid import io import shutil import tarfile @@ -56,7 +57,9 @@ def random_email(): def random_syftbox_folder_for_testing(): - return Path(f"/tmp/sb_folder_testing-{random.randint(1, 1000000)}") + # Not ``random``: pytest-randomly reseeds it for each test, so two tests + # in one worker would share a folder, and its on-disk caches. + return Path(f"/tmp/sb_folder_testing-{uuid.uuid4().hex}") def compress_data(data: bytes) -> bytes: diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index aec1f433240..0ac19581dfd 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -24,6 +24,7 @@ policy_from_use_encryption, ) from syft.sync.peers.key_bundle import ( + BUNDLE_FILE_KEY, InvalidPeerBundleError, PeerFingerprintMismatchError, PeerKeyChangedError, @@ -50,10 +51,6 @@ logger = logging.getLogger(__name__) -# Key wrapping the DID document inside a published encryption bundle file. -BUNDLE_FILE_KEY = "public_encryption_bundle" - - class CompatAction(str, Enum): """What the caller is doing — used to phrase warning messages.""" @@ -381,12 +378,12 @@ def load_peer_version(self, peer_email: str) -> Optional[VersionInfo]: def _load_single_peer_version( self, peer_email: str - ) -> tuple[str, Optional[VersionInfo], bool]: + ) -> tuple[str, Optional[VersionInfo], Optional[bool]]: """Load version for a single peer. Used by parallel loader. For an earlier client, whose version file does not say its policy, it - also reads whether the peer published a bundle for us, so that these - Drive reads run in parallel too. + also checks whether the peer published a bundle for us, so that these + Drive calls run in parallel too: None when that check failed. """ try: # Create new connection for thread safety @@ -394,17 +391,21 @@ def _load_single_peer_version( create_new=True ) version_info = connection.read_peer_version_file(peer_email) - published = ( - version_info is not None - and version_info.encryption_policy is None - and self.peer_store.legacy_bundle_needs_read(peer_email) - and bool(connection.read_peer_encryption_bundle(peer_email)) - ) - return (peer_email, version_info, published) except Exception as e: if not self.suppress_version_warnings: warnings.warn(f"Failed to load version for {peer_email}: {e}") return (peer_email, None, False) + published: Optional[bool] = False + if ( + version_info is not None + and version_info.encryption_policy is None + and self.peer_store.legacy_bundle_needs_read(peer_email) + ): + # Its own check: a failure here keeps the version that was read. + published = self.connection_router.legacy_bundle_published( + peer_email, connection + ) + return (peer_email, version_info, published) def load_peer_versions_parallel( self, peer_emails: List[str], force: bool = False @@ -424,7 +425,9 @@ def load_peer_versions_parallel( peer.version = version if peer.needs_legacy_policy: peer.set_legacy_policy( - self.peer_store.legacy_policy(email, lambda p=published: p) + self.peer_store.legacy_policy( + email, lambda p=published: p, record_miss=True + ) ) self._note_peer_policy(peer) self._update_peer_schemas(email, version) @@ -749,7 +752,11 @@ def _pin_published_bundle( # The peer must be in the store for the pin to be recorded. if self.peer_store.get_cached_peer(peer_email) is None: self.peer_store.add_peer(peer) - raw_bundle = self._read_peer_encryption_bundle(peer_email) + try: + raw_bundle = self._read_peer_encryption_bundle(peer_email) + except ValueError as e: + warnings.warn(f"Refusing the encryption key of {peer_email}: {e}") + return None if raw_bundle is None: return None return self._adopt_peer_bundle(peer_email, raw_bundle, explicit=explicit) @@ -1017,11 +1024,17 @@ def _publish_bundle_to_new_encrypted_peers(self, peers: List[Peer]) -> None: def _own_bundle_is_published(self, peer_email: str) -> bool: """Whether the bundle published for ``peer_email`` is our current one.""" self.peer_store.ensure_keys() - published = self.connection_router.read_own_encryption_bundle(peer_email) - if published is None: + try: + published = self.connection_router.read_own_encryption_bundle(peer_email) + if published is None: + return False + payload = json.loads(published) + except ValueError: + # Not valid UTF-8 or JSON, so not ours as written: write it again. + return False + if not isinstance(payload, dict): return False - bundle = json.loads(published).get(BUNDLE_FILE_KEY) - return bundle == self.peer_store.get_public_bundle() + return payload.get(BUNDLE_FILE_KEY) == self.peer_store.get_public_bundle() def _pin_missing_peer_bundles(self, peers: List[Peer]) -> None: """Pin the published bundle of every peer we hold none for (trust on first use). diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index e0933b0bbf3..17ee3294315 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -74,6 +74,12 @@ def _keyed_pair( return alice, bob +def _forget(store: PeerStore, email: str) -> None: + """Make ``store`` forget what it knew of ``email``'s policy, as a new client.""" + store.forget_required_peer(email) + store._last_known_policies.pop(email, None) + + def _raw_inbox_message(do_manager, sender_email: str) -> bytes: connection = do_manager.datasite_owner_syncer.connection_router raw, _ = ( @@ -113,7 +119,7 @@ def _pair_with_stale_sender_view(): stale = ds_manager.peer_manager.get_cached_peer(do_manager.email) stale.version = _version_with_policy(OPTIONAL) # A client that never saw the owner as required. - ds_manager.peer_manager.peer_store.forget_required_peer(do_manager.email) + _forget(ds_manager.peer_manager.peer_store, do_manager.email) return ds_manager, do_manager @@ -131,7 +137,7 @@ def _owner_sends_with_stale_view(ds_manager, do_manager, content: str) -> None: """The owner handles a proposal and answers while it sees the DS as optional.""" stale = do_manager.peer_manager.get_cached_peer(ds_manager.email) stale.version = _version_with_policy(OPTIONAL) - do_manager.peer_manager.peer_store.forget_required_peer(ds_manager.email) + _forget(do_manager.peer_manager.peer_store, ds_manager.email) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, content) do_manager.datasite_owner_syncer.sync([ds_manager.email]) @@ -169,11 +175,16 @@ def test_version_info_without_policy_field_has_no_policy(): assert VersionInfo.model_validate(data).encryption_policy is None -def _advertise_as_legacy_client(manager) -> None: - """Write the version file of ``manager`` without the policy field.""" +def _advertise_as_legacy_client(manager, observer) -> None: + """Write the version file of ``manager`` without the policy field. + + ``observer`` is the other client: it forgets what it remembered of + ``manager``, as a client that only ever saw an earlier client. + """ legacy = manager.peer_manager.get_own_version().model_copy() legacy.encryption_policy = None manager.peer_manager.connection_router.write_version_file(legacy) + _forget(observer.peer_manager.peer_store, manager.email) @pytest.mark.parametrize("do_policy, expected", [(REQUIRED, REQUIRED), (OPTIONAL, OFF)]) @@ -185,7 +196,7 @@ def test_legacy_peer_policy_follows_its_published_bundle(do_policy, expected): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( do_encryption_policy=do_policy, ) - _advertise_as_legacy_client(do_manager) + _advertise_as_legacy_client(do_manager, ds_manager) ds_manager.load_peers() ds_manager.load_peers() assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) == expected @@ -193,16 +204,15 @@ def test_legacy_peer_policy_follows_its_published_bundle(do_policy, expected): def test_legacy_peer_without_bundle_is_read_again_only_now_and_then(monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - _advertise_as_legacy_client(do_manager) + _advertise_as_legacy_client(do_manager, ds_manager) reads = [] - original = GDriveConnection.read_peer_encryption_bundle + original = GDriveConnection.peer_encryption_bundle_exists def spy(self, email): - if any(f.function == "_load_single_peer_version" for f in inspect.stack()): - reads.append(email) + reads.append(email) return original(self, email) - monkeypatch.setattr(GDriveConnection, "read_peer_encryption_bundle", spy) + monkeypatch.setattr(GDriveConnection, "peer_encryption_bundle_exists", spy) for _ in range(5): ds_manager.load_peers() # The load that first misses it, the next one, and then every tenth. @@ -212,7 +222,7 @@ def spy(self, email): def test_legacy_peer_without_bundle_is_unknown_for_one_load(): """Its bundle can be not yet visible, so the first load holds sends.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - _advertise_as_legacy_client(do_manager) + _advertise_as_legacy_client(do_manager, ds_manager) ds_manager.load_peers() store = ds_manager.peer_manager.peer_store assert store.peer_policy(do_manager.email) is None @@ -265,9 +275,9 @@ def test_legacy_required_policy_is_read_once_per_peer(monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( do_encryption_policy=REQUIRED, ) - _advertise_as_legacy_client(do_manager) + _advertise_as_legacy_client(do_manager, ds_manager) reads = [] - original = GDriveConnection.read_peer_encryption_bundle + original = GDriveConnection.peer_encryption_bundle_exists def spy(self, email): # Count the reads of the version load; pinning and the key rotation @@ -276,7 +286,7 @@ def spy(self, email): reads.append(email) return original(self, email) - monkeypatch.setattr(GDriveConnection, "read_peer_encryption_bundle", spy) + monkeypatch.setattr(GDriveConnection, "peer_encryption_bundle_exists", spy) ds_manager.load_peers() ds_manager.load_peers() assert reads == [do_manager.email] @@ -287,16 +297,16 @@ def test_legacy_peer_without_bundle_is_checked_again(monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( do_encryption_policy=REQUIRED, ) - _advertise_as_legacy_client(do_manager) - original = GDriveConnection.read_peer_encryption_bundle + _advertise_as_legacy_client(do_manager, ds_manager) + original = GDriveConnection.peer_encryption_bundle_exists calls = [] def first_read_finds_nothing(self, email): calls.append(email) - return None if len(calls) == 1 else original(self, email) + return False if len(calls) == 1 else original(self, email) monkeypatch.setattr( - GDriveConnection, "read_peer_encryption_bundle", first_read_finds_nothing + GDriveConnection, "peer_encryption_bundle_exists", first_read_finds_nothing ) ds_manager.load_peers() ds_manager.load_peers() @@ -362,6 +372,118 @@ def test_trusted_key_change_clears_a_remembered_required_peer(): assert alice.peer_policy("bob@example.com") == OPTIONAL +def _drive_error(): + from googleapiclient.errors import HttpError + + return HttpError(resp=type("R", (), {"status": 503, "reason": ""})(), content=b"") + + +def test_failed_legacy_bundle_check_keeps_the_policy_unknown(monkeypatch): + """A Drive error is not a missing bundle, so it never makes the peer off.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=REQUIRED, + ) + _advertise_as_legacy_client(do_manager, ds_manager) + original = GDriveConnection.peer_encryption_bundle_exists + calls = [] + + def fail_twice(self, email): + calls.append(email) + if len(calls) <= 2: + raise _drive_error() + return original(self, email) + + monkeypatch.setattr(GDriveConnection, "peer_encryption_bundle_exists", fail_twice) + store = ds_manager.peer_manager.peer_store + ds_manager.load_peers() + ds_manager.load_peers() + assert store.peer_policy(do_manager.email) is None + # The version itself is kept when only the bundle check fails. + assert ds_manager.peer_manager.get_peer_version(do_manager.email) is not None + ds_manager.load_peers() + assert store.peer_policy(do_manager.email) == REQUIRED + + +def test_legacy_miss_before_first_load_still_gives_a_load_of_grace(): + """add_peer can run before any peer load; the grace still covers one sync.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + ) + _advertise_as_legacy_client(do_manager, ds_manager) + do_manager.add_peer(ds_manager.email, sync=False) + ds_manager.add_peer(do_manager.email, sync=False) + ds_manager.load_peers() + assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) is None + + +def test_legacy_miss_in_add_peer_after_a_load_still_gives_a_load_of_grace(): + """add_peer then syncs: that load must not already make the peer off.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + ) + ds_manager.load_peers() + _advertise_as_legacy_client(do_manager, ds_manager) + do_manager.add_peer(ds_manager.email, sync=False) + ds_manager.add_peer(do_manager.email, sync=False) + ds_manager.load_peers() + assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) is None + + +def test_unreadable_own_bundle_does_not_stop_peer_load(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=REQUIRED, + ) + + def undecodable(self, peer_email): + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + + monkeypatch.setattr(ConnectionRouter, "read_own_encryption_bundle", undecodable) + ds_manager.peer_manager._bundle_published_for.clear() # as a new process + ds_manager.load_peers() + assert do_manager.email in ds_manager.peer_manager._bundle_published_for + + +def test_junk_legacy_bundle_keeps_the_policy_unknown_until_valid(): + """A bundle that does not validate is not taken as required, and is read again.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=REQUIRED, + ) + _advertise_as_legacy_client(do_manager, ds_manager) + router = do_manager.peer_manager.connection_router + router.write_encryption_bundle(ds_manager.email, "not json") + store = ds_manager.peer_manager.peer_store + with pytest.warns(UserWarning): + ds_manager.load_peers() + ds_manager.load_peers() + assert store.peer_policy(do_manager.email) is None + + do_manager.peer_manager._write_encryption_bundle_for_peer(ds_manager.email) + ds_manager.load_peers() + assert store.peer_policy(do_manager.email) == REQUIRED + + +def test_malformed_bundle_files_do_not_stop_peer_load(): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + add_peers=False, + do_encryption_policy=REQUIRED, + ) + ds_manager.add_peer(do_manager.email, sync=False) + do_manager.load_peers() + do_manager.approve_peer_request(ds_manager.email) + # The owner's bundle for the DS, and the DS's own published bundle, are garbage. + do_manager.peer_manager.connection_router.write_encryption_bundle( + ds_manager.email, "not json" + ) + ds_manager.load_peers() + ds_manager.peer_manager.connection_router.write_encryption_bundle( + do_manager.email, "not json" + ) + ds_manager.peer_manager._bundle_published_for.clear() # as a new process + with pytest.warns(UserWarning): + ds_manager.load_peers() + assert ds_manager.peer_manager._own_bundle_is_published(do_manager.email) + + # ========================================================================= # Per-peer resolution # ========================================================================= @@ -755,6 +877,22 @@ def test_receiver_rejects_plaintext_when_pair_encrypts(alice_policy, bob_policy) bob.decrypt_and_verify_if_needed("alice@example.com", b"plain") +def test_plaintext_from_peer_with_unknown_policy_waits(): + """The receive path waits like the send path while the policy is unknown.""" + _, bob = _keyed_pair(OPTIONAL, OPTIONAL) + bob.get_cached_peer("alice@example.com").version = None + with pytest.raises(PeerEncryptionPolicyUnknownError): + bob.decrypt_and_verify_if_needed("alice@example.com", b"plain") + + +def test_last_known_policy_is_kept_when_the_version_vanishes(): + """E.g. after the owner's delete_syftbox(): its plaintext deletes still apply.""" + _, bob = _keyed_pair(OPTIONAL, OPTIONAL) + assert bob.peer_policy("alice@example.com") == OPTIONAL + bob.get_cached_peer("alice@example.com").version = None + assert bob.decrypt_and_verify_if_needed("alice@example.com", b"plain") == b"plain" + + def test_receiver_accepts_plaintext_in_plaintext_pair(): _, bob = _keyed_pair(OPTIONAL, OPTIONAL) assert bob.decrypt_and_verify_if_needed("alice@example.com", b"plain") == b"plain" @@ -1078,7 +1216,7 @@ def test_send_reread_updates_peer_manager(): ds_manager.load_peers() peer_manager = ds_manager.peer_manager peer_manager.get_cached_peer(do_manager.email).version = None - peer_manager.peer_store.forget_required_peer(do_manager.email) + _forget(peer_manager.peer_store, do_manager.email) peer_manager._loaded_peer_versions[do_manager.email] = None file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") @@ -1096,6 +1234,7 @@ def test_send_rereads_unknown_peer_policy(): grant_job_inbox_access(do_manager, ds_manager.email) ds_manager.load_peers() ds_manager.peer_manager.get_cached_peer(do_manager.email).version = None + _forget(ds_manager.peer_manager.peer_store, do_manager.email) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "content") @@ -1355,6 +1494,22 @@ def test_held_hashes_are_decoded_again_only_after_a_change(monkeypatch): assert len(decodes) == 1 +def test_drive_error_while_sending_changes_holds_the_unsent(monkeypatch): + """The queue is already empty, so unsent changes go to the held file.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + + def fail(self, recipient, message): + raise RuntimeError("Drive error") + + monkeypatch.setattr(DatasiteWatcherSyncer, "_send_or_hold", fail) + watcher = ds_manager.datasite_watcher_syncer + watcher.on_file_change(f"{do_manager.email}/a.txt", "a", process_now=False) + watcher.on_file_change("other@example.com/b.txt", "b", process_now=False) + with pytest.raises(RuntimeError): + watcher.process_file_changes_queue() + assert watcher.pending_message_count == 2 + + def test_owner_drops_outbox_message_for_former_peer(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() syncer = do_manager.datasite_owner_syncer @@ -1367,6 +1522,7 @@ def test_policy_is_reread_once_per_peer_load(monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() syncer = do_manager.datasite_owner_syncer do_manager.peer_manager.get_cached_peer(ds_manager.email).version = None + _forget(do_manager.peer_manager.peer_store, ds_manager.email) reads = [] monkeypatch.setattr( ConnectionRouter, @@ -1389,6 +1545,7 @@ def test_compaction_skips_peer_with_unknown_policy(monkeypatch): do_manager.sync() do_manager.peer_manager.get_cached_peer(ds_manager.email).version = None + _forget(do_manager.peer_manager.peer_store, ds_manager.email) monkeypatch.setattr( ConnectionRouter, "read_peer_version_file", lambda self, email: None ) @@ -1460,7 +1617,7 @@ def _unknown_policy(ds_manager, do_manager, monkeypatch): peer = do_manager.peer_manager.get_cached_peer(ds_manager.email) version = peer.version peer.version = None - do_manager.peer_manager.peer_store.forget_required_peer(ds_manager.email) + _forget(do_manager.peer_manager.peer_store, ds_manager.email) monkeypatch.setattr( ConnectionRouter, "read_peer_version_file", lambda self, email: None ) diff --git a/tests/unit/test_sync_manager.py b/tests/unit/test_sync_manager.py index f9b63a62052..f97b2b8a22d 100644 --- a/tests/unit/test_sync_manager.py +++ b/tests/unit/test_sync_manager.py @@ -437,6 +437,15 @@ def test_collection_files_excluded_from_outbox_sync(): ) +def _store_knowing(email: str, peer_email: str) -> PeerStore: + """A store for ``email`` that knows ``peer_email`` as an optional peer.""" + version = VersionInfo.current() + version.encryption_policy = EncryptionPolicy.OPTIONAL + store = PeerStore(email=email) + store.add_peer(Peer(email=peer_email, version=version)) + return store + + def _add_known_peer(do_manager, email: str) -> None: """Make ``email`` a peer of the DO with a known version, as peering does.""" version = VersionInfo.current() @@ -500,7 +509,7 @@ def test_job_files_sync_to_submitter_only(): _add_known_peer(do_manager, submitter_email) submitter_router = ConnectionRouter( connections=[submitter_conn], - peer_store=PeerStore(email=submitter_email), + peer_store=_store_knowing(submitter_email, do_manager.email), ) do_manager.datasite_owner_syncer.process_local_changes(recipients) @@ -703,7 +712,7 @@ def test_permission_change_triggers_resend(): _add_known_peer(do_manager, peer_b_email) peer_b_router = ConnectionRouter( connections=[peer_b_conn], - peer_store=PeerStore(email=peer_b_email), + peer_store=_store_knowing(peer_b_email, do_manager.email), ) # Grant only peer A read access to project/ From efc097be88ebb48011384fe8df2e5d34ace22ee8 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Thu, 8 Oct 2026 10:41:30 -0300 Subject: [PATCH 06/30] refactor(sync): drop legacy-peer handling and trim encryption code - Treat a peer without an encryption policy as outdated: skip it with a warning to upgrade, and raise on a send to it. - Remove the use_encryption keyword shim; pass encryption_policy. - Share the hold-or-drop rule through PeerStore.should_hold. - Remove the held-hash cache, the parallel downloads and the holding of sends before the first peer load. - Merge redundant tests and rename tests without filler words. --- docs/connections.md | 4 +- .../syft-enclave/tests/test_encryption.py | 2 +- syft/sync/connections/connection_router.py | 79 +-- .../connections/drive/gdrive_transport.py | 9 - syft/sync/encryption_policy.py | 24 +- syft/sync/peers/key_bundle.py | 4 - syft/sync/peers/peer.py | 23 +- syft/sync/peers/peer_store.py | 110 +--- syft/sync/syftbox_manager.py | 4 +- syft/sync/sync/datasite_owner_syncer.py | 11 +- syft/sync/sync/datasite_watcher_syncer.py | 27 +- syft/sync/sync/held_messages.py | 11 - syft/sync/version/peer_manager.py | 66 +-- syft/sync/version/version_info.py | 4 +- tests/unit/test_crypto_keys_version.py | 5 +- tests/unit/test_encryption.py | 77 ++- tests/unit/test_encryption_policy.py | 560 +++++------------- tests/unit/test_peer_key_pinning.py | 61 +- 18 files changed, 339 insertions(+), 742 deletions(-) diff --git a/docs/connections.md b/docs/connections.md index 12d33c43373..cb1121d4b6a 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -64,9 +64,7 @@ The version file is not signed. So a client keeps a peer that it saw as `require A pair that encrypts refuses plaintext. The receiver renames a plaintext message to `.rejected`, and waits for that peer. The sender resends the message encrypted on its next sync. -An earlier client writes no policy in its version file. It is `required` when it published a bundle for us, else `off`. - -**Known limit.** An earlier client that encrypted once and then turned encryption off still has its old bundle on Drive, so it reads as `required`. Its plaintext is rejected, and an earlier client does not resend rejected messages, so the pair stops syncing. To fix it, upgrade that client, or delete its file `encryption_bundle_{owner}_for_{peer}.json` in `syft_encryption_bundles#{owner}/`. +A peer whose version file has no policy runs an outdated syft. It is skipped with a warning to upgrade, and a send to it raises. ## Peer Requests diff --git a/packages/syft-enclave/tests/test_encryption.py b/packages/syft-enclave/tests/test_encryption.py index c8736ffc47b..20681c201a3 100644 --- a/packages/syft-enclave/tests/test_encryption.py +++ b/packages/syft-enclave/tests/test_encryption.py @@ -70,7 +70,7 @@ def test_encrypted_message_to_enclave_roundtrips(): assert "simple_job" in [j.name for j in enclave.jobs] -def test_quad_builder_applies_off_to_the_enclave(): +def test_quad_builder_applies_off_to_enclave(): from syft.sync.encryption_policy import EncryptionPolicy enclave, *_ = SyftEnclaveClient.quad_with_mock_drive_service_connection( diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index 65714bd6292..a744844aaf1 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -1,9 +1,5 @@ -import json import logging -import warnings -from googleapiclient.errors import HttpError -from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Callable, Dict, List, Optional from pydantic import BaseModel, PrivateAttr @@ -22,12 +18,6 @@ RejectedMessage, SyftboxPlatformConnection, ) -from syft.sync.connections.drive.gdrive_retry import RETRYABLE_TRANSPORT_ERRORS -from syft.sync.peers.key_bundle import ( - BUNDLE_FILE_KEY, - InvalidPeerBundleError, - parse_and_validate_bundle, -) from syft.sync.encryption_policy import ( EncryptionPolicyConflictError, PeerEncryptionPendingError, @@ -55,9 +45,6 @@ logger = logging.getLogger(__name__) -# Downloads in flight at once when the router reads many files. -PARALLEL_DOWNLOADS = 10 - class ConnectionRouter(BaseModel): connections: List[SyftboxPlatformConnection] @@ -182,47 +169,6 @@ def _refresh_unknown_peer(self, peer_email: str) -> None: self._version_loader(peer_email) else: peer.version = self.read_peer_version_file(peer_email) - self.resolve_legacy_policy(peer) - - def resolve_legacy_policy(self, peer: Peer) -> None: - """Set the policy of ``peer`` when its version file does not say it.""" - if peer.needs_legacy_policy: - policy = self.peer_store.legacy_policy( - peer.email, lambda: self.legacy_bundle_published(peer.email) - ) - peer.set_legacy_policy(policy) - - def legacy_bundle_published( - self, - peer_email: str, - connection: SyftboxPlatformConnection | None = None, - ) -> Optional[bool]: - """Whether the earlier client ``peer_email`` published a valid bundle for us. - - None when the check failed, or the file there is not a valid bundle: - neither shows that the peer does not encrypt, so the policy stays - unknown and the next load checks again. - """ - if connection is None: - connection = self.connection_for_datasite_watcher() - try: - if not connection.peer_encryption_bundle_exists(peer_email): - return False - raw = connection.read_peer_encryption_bundle(peer_email) - except (HttpError, *RETRYABLE_TRANSPORT_ERRORS) as e: - logger.warning(f"Could not check the bundle of {peer_email}: {e}") - return None - if raw is None: - return None - try: - payload = json.loads(raw) - if not isinstance(payload, dict): - raise InvalidPeerBundleError(f"not a JSON object: {raw[:40]!r}") - parse_and_validate_bundle(peer_email, payload.get(BUNDLE_FILE_KEY)) - except ValueError as e: - warnings.warn(f"Ignoring the encryption bundle of {peer_email}: {e}") - return None - return True def watcher_send_proposed_file_changes_message( self, recipient: str, proposed_file_changes_message: ProposedFileChangesMessage @@ -270,24 +216,12 @@ def _reject_plaintext_inbox_messages( The sender then resends them all on one sync, not one per round. It encrypts all messages after the first envelope, so the scan stops - there. Files download in parallel batches. + there. """ - file_ids = connection.owner_get_inbox_message_ids(sender_email) - for start in range(0, len(file_ids), PARALLEL_DOWNLOADS): - batch = file_ids[start : start + PARALLEL_DOWNLOADS] - for file_id, raw in zip(batch, self._download_parallel(batch)): - if self.peer_store.is_envelope(raw): - return - connection.mark_message_rejected(file_id) - - def _download_parallel(self, file_ids: List[str]) -> List[bytes]: - """Download ``file_ids`` in parallel, each on its own connection.""" - - def download(file_id: str) -> bytes: - return self.connection_for_parallel_download().download_file(file_id) - - with ThreadPoolExecutor(max_workers=PARALLEL_DOWNLOADS) as executor: - return list(executor.map(download, file_ids)) + for file_id in connection.owner_get_inbox_message_ids(sender_email): + if self.peer_store.is_envelope(connection.download_file(file_id)): + return + connection.mark_message_rejected(file_id) def owner_resend_rejected_outbox_messages(self, recipient: str) -> int: """Resend, encrypted, the plaintext outbox messages ``recipient`` rejected. @@ -456,7 +390,8 @@ def owner_encrypt_files_at_rest(self) -> None: def encrypt_files() -> None: file_ids = connection.owner_get_all_accepted_event_file_ids() file_ids += connection.owner_get_checkpoint_and_state_file_ids() - for file_id, raw in zip(file_ids, self._download_parallel(file_ids)): + for file_id in file_ids: + raw = connection.owner_download_raw_bytes_by_id(file_id) if not self.peer_store.is_envelope(raw): connection.owner_overwrite_raw_bytes_by_id( file_id, self.peer_store.encrypt_for_self(raw) diff --git a/syft/sync/connections/drive/gdrive_transport.py b/syft/sync/connections/drive/gdrive_transport.py index 87903866d0c..57ce8a46df1 100644 --- a/syft/sync/connections/drive/gdrive_transport.py +++ b/syft/sync/connections/drive/gdrive_transport.py @@ -2573,15 +2573,6 @@ def read_own_encryption_bundle(self, peer_email: str) -> str | None: return None return self.download_file(items[0]["id"]).decode("utf-8") - def peer_encryption_bundle_exists(self, peer_email: str) -> bool: - """Whether ``peer_email`` published a bundle for us. Errors propagate.""" - filename = self._encryption_bundle_filename(peer_email, self.email) - query = f"name='{filename}' and trashed=false and '{peer_email}' in owners" - results = execute_with_retries( - self.drive_service.files().list(q=query, fields="files(id)") - ) - return bool(results.get("files", [])) - def read_peer_encryption_bundle(self, peer_email: str) -> str | None: """Read encryption bundle that peer wrote for us. diff --git a/syft/sync/encryption_policy.py b/syft/sync/encryption_policy.py index c099c5e5cf8..7d5d279ccad 100644 --- a/syft/sync/encryption_policy.py +++ b/syft/sync/encryption_policy.py @@ -1,7 +1,6 @@ """Encryption policy that a client advertises to its peers.""" from enum import Enum -from typing import Any class EncryptionPolicy(str, Enum): @@ -31,17 +30,6 @@ def coerce(cls, value: "bool | str | EncryptionPolicy") -> "EncryptionPolicy": return cls(value) -def policy_from_use_encryption(data: Any) -> Any: - """Model input with the earlier ``use_encryption`` bool as ``encryption_policy``. - - For a ``model_validator(mode="before")`` of a model that took the bool. - """ - if isinstance(data, dict) and "use_encryption" in data: - data = dict(data) - data["encryption_policy"] = EncryptionPolicy.coerce(data.pop("use_encryption")) - return data - - class PeerEncryptionPendingError(ValueError): """A message to a peer cannot go yet. A later sync sends it.""" @@ -84,6 +72,18 @@ def __init__(self, peer_email: str): self.peer_email = peer_email +class PeerOutdatedError(EncryptionPolicyConflictError): + """The peer runs a syft that predates encryption negotiation.""" + + def __init__(self, peer_email: str): + ValueError.__init__( + self, + f"{peer_email} runs an outdated syft: its version file has no " + "encryption policy. Ask them to upgrade syft.", + ) + self.peer_email = peer_email + + class UnencryptedMessageError(ValueError): """A client that requires encryption received plaintext.""" diff --git a/syft/sync/peers/key_bundle.py b/syft/sync/peers/key_bundle.py index ac0f0f2d64d..f6415cba8a7 100644 --- a/syft/sync/peers/key_bundle.py +++ b/syft/sync/peers/key_bundle.py @@ -20,10 +20,6 @@ DID_PREFIX = "did:syft:" -# Key wrapping the DID document inside a published encryption bundle file. -BUNDLE_FILE_KEY = "public_encryption_bundle" - - class InvalidPeerBundleError(ValueError): """The bundle does not parse, its signatures fail, or it names another identity.""" diff --git a/syft/sync/peers/peer.py b/syft/sync/peers/peer.py index bc55020ba56..ff97e1ddb5c 100644 --- a/syft/sync/peers/peer.py +++ b/syft/sync/peers/peer.py @@ -31,34 +31,19 @@ class Peer(BaseModel): # Set by SyftboxManager.peers when this Peer is handed out. _manager: Any = PrivateAttr(default=None) - # The policy of an earlier client, whose version file does not say. - _legacy_policy: Optional[EncryptionPolicy] = PrivateAttr(default=None) @property def encryption_policy(self) -> Optional[EncryptionPolicy]: - """The policy of the peer, or None while it is not known. - - It is not known before the version file is read, and for an earlier - client until ``set_legacy_policy``. - """ + """The policy the peer advertises, or None before its version file is read.""" if self.version is None: return None - if self.version.encryption_policy is not None: - return self.version.encryption_policy - return self._legacy_policy + return self.version.encryption_policy @property - def needs_legacy_policy(self) -> bool: - """Whether the version file is read and does not say the policy.""" + def is_outdated(self) -> bool: + """Whether the peer runs a syft that predates encryption negotiation.""" return self.version is not None and self.version.encryption_policy is None - def set_legacy_policy(self, policy: Optional[EncryptionPolicy]) -> None: - """Set the policy of an earlier client, whose version file does not say. - - None keeps it unknown. - """ - self._legacy_policy = policy - @property def fingerprint(self) -> Optional[str]: """Fingerprint of the peer's pinned identity key, or None without a bundle. diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 73972428ffa..774a5d3aea6 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -11,21 +11,21 @@ import warnings from contextlib import contextmanager from pathlib import Path -from typing import Any, Callable, Iterator, List, Optional +from typing import Callable, Iterator, List, Optional import portalocker import syft_crypto_python as syc -from pydantic import BaseModel, PrivateAttr, model_validator +from pydantic import BaseModel, PrivateAttr from syft.sync.encryption_policy import ( EncryptionPolicy, EncryptionPolicyConflictError, PeerEncryptionPolicyUnknownError, + PeerOutdatedError, PeerKeysNotReadyError, UnencryptedMessageError, pair_conflicts, pair_uses_encryption, - policy_from_use_encryption, ) from syft.sync.peers.key_bundle import ( @@ -66,9 +66,6 @@ # The version file that advertises a policy is not signed, so such a peer # stays required here until the user trusts a new key for it. REQUIRED_PEERS_KEY = "required_peers" -# An earlier client without a bundle for us is read again on the load after -# the first miss, then once every this many peer loads. -LEGACY_BUNDLE_RECHECK_LOADS = 10 def datasite_crypto_keys_path(syftbox_folder: Path | str, email: str) -> Path: @@ -99,13 +96,6 @@ class PeerStore(BaseModel): _keys_path: Path | None = PrivateAttr(default=None) # Peers whose version file was read again since the last ``set_peers``. _policy_rereads: set[str] = PrivateAttr(default_factory=set) - # Earlier clients known to encrypt. Their version file does not say, so - # the policy comes from a Drive read; a found bundle is read once. - _legacy_policies: dict[str, EncryptionPolicy] = PrivateAttr(default_factory=dict) - # Number of peer loads (``set_peers``) so far. - _load_count: int = PrivateAttr(default=0) - # Earlier clients without a found bundle: the load at which none was found. - _bundle_missing_since: dict[str, int] = PrivateAttr(default_factory=dict) # Peers seen to require encryption (see REQUIRED_PEERS_KEY). _required_peers: set[str] = PrivateAttr(default_factory=set) # The last policy known of each peer in this process (see peer_policy). @@ -115,20 +105,11 @@ class PeerStore(BaseModel): # Whether the files at rest are known to be encrypted. _at_rest_encrypted: bool = PrivateAttr(default=False) - @model_validator(mode="before") - @classmethod - def _policy_from_use_encryption(cls, data: Any) -> Any: - return policy_from_use_encryption(data) - @property def use_encryption(self) -> bool: """True when this client requires encryption with every peer and at rest.""" return self.encryption_policy == EncryptionPolicy.REQUIRED - @use_encryption.setter - def use_encryption(self, value: bool) -> None: - self.encryption_policy = EncryptionPolicy.coerce(value) - # ========== Peer list methods ========== def clear_caches(self) -> None: @@ -167,10 +148,10 @@ def encrypt_if_needed(self, email: str, data: bytes) -> bytes: pinned peer bundle are missing. EncryptionPolicyConflictError: when the policies of the pair conflict. """ + if self.peer_conflicts(email): + raise self._conflict_error(email) if not self.peer_policy_known(email): raise PeerEncryptionPolicyUnknownError(email) - if self.peer_conflicts(email): - raise EncryptionPolicyConflictError(email) if not self.peer_uses_encryption(email): return data if not self.encryption_ready(email): @@ -196,7 +177,7 @@ def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: EncryptionPolicyConflictError: when the policies of the pair conflict. """ if self.peer_conflicts(email): - raise EncryptionPolicyConflictError(email) + raise self._conflict_error(email) if not self.is_envelope(data): if not self.peer_policy_known(email): raise PeerEncryptionPolicyUnknownError(email) @@ -243,7 +224,7 @@ def peer_policy(self, email: str) -> Optional[EncryptionPolicy]: if email in self._required_peers: return EncryptionPolicy.REQUIRED policy = peer.encryption_policy - if policy is None: + if policy is None and not peer.is_outdated: return self._last_known_policies.get(email) self._last_known_policies[email] = policy return policy @@ -286,9 +267,22 @@ def encryption_ready(self, email: str) -> bool: return self.has_my_keys() and self.has_peer_bundle(email) def peer_conflicts(self, email: str) -> bool: - """Whether this client has encryption off and ``email`` requires it, or the reverse.""" + """Whether this pair cannot exchange messages. + + That is when one side has encryption off and the other requires it, + or when the peer runs an outdated syft. + """ + peer = self.get_cached_peer(email) + if peer is not None and peer.is_outdated: + return True return pair_conflicts(self.encryption_policy, self.peer_policy(email)) + def _conflict_error(self, email: str) -> EncryptionPolicyConflictError: + peer = self.get_cached_peer(email) + if peer is not None and peer.is_outdated: + return PeerOutdatedError(email) + return EncryptionPolicyConflictError(email) + def peer_uses_encryption(self, email: str) -> bool: """Whether the pair with ``email`` encrypts. False when the peer policy is not known.""" return pair_uses_encryption(self.encryption_policy, self.peer_policy(email)) @@ -310,61 +304,14 @@ def set_peers(self, peers: List[Peer]) -> None: self._keep_pinned_bundle(p) self._peers = peers self._policy_rereads.clear() - self._load_count += 1 - @property - def peers_loaded(self) -> bool: - """Whether a peer load ran. Before it, no recipient is known to be a peer.""" - return self._load_count > 0 + def should_hold(self, recipient: str) -> bool: + """Whether a message to ``recipient`` waits while encryption is pending. - def legacy_policy( - self, - email: str, - published_bundle: Callable[[], Optional[bool]], - record_miss: bool = False, - ) -> Optional[EncryptionPolicy]: - """The policy of the earlier client ``email``, or None while not known. - - Such a client publishes a bundle for us only when it encrypts. One - that does not has no keys and cannot decrypt, so it is off. A bundle - can be not yet visible on Drive, so a client without one is unknown - during the first peer load that misses it, and off from the next. - ``published_bundle`` returns None when the check failed: the policy - then stays unknown. A found bundle is kept for the process. - - Only a peer load records a miss (``record_miss``): a check outside one - (add_peer, a re-read on send) must not start the grace, or the load - that follows would already make the peer off. + Only a peer waits. Any other recipient never gets a policy, so a + message to it would wait forever. """ - if email in self._legacy_policies: - return self._legacy_policies[email] - published = published_bundle() - if published is None: - return None - if published: - self._legacy_policies[email] = EncryptionPolicy.REQUIRED - self._bundle_missing_since.pop(email, None) - return EncryptionPolicy.REQUIRED - if record_miss: - self._bundle_missing_since.setdefault(email, self._load_count) - first_missed = self._bundle_missing_since.get(email) - if first_missed is None or self._load_count <= first_missed: - return None - return EncryptionPolicy.OFF - - def legacy_bundle_needs_read(self, email: str) -> bool: - """Whether to read again if the earlier client ``email`` published a bundle. - - Not once one was found. After a miss: on the next load, then once - every LEGACY_BUNDLE_RECHECK_LOADS loads. - """ - if email in self._legacy_policies: - return False - first_missed = self._bundle_missing_since.get(email) - if first_missed is None: - return True - loads = self._load_count - first_missed - return loads <= 1 or loads % LEGACY_BUNDLE_RECHECK_LOADS == 0 + return self.get_cached_peer(recipient) is not None def take_policy_reread(self, email: str) -> bool: """True once per peer load: the caller may read the version file again.""" @@ -830,7 +777,6 @@ def create( email: str, encryption_policy: EncryptionPolicy = EncryptionPolicy.OPTIONAL, keys_path: Path | str | None = None, - use_encryption: bool | None = None, ) -> "PeerStore": """Build a PeerStore for ``email`` with its per-datasite key file. @@ -843,8 +789,6 @@ def create( Without a file, a required store generates keys and saves them. An optional store has no keys until :meth:`ensure_keys`. - ``use_encryption`` is the earlier bool form of ``encryption_policy``. - Scoping keys to a per-datasite file lets several identities run on one machine (e.g. two data owners in the same notebook) without colliding, and ties key lifetime to the datasite folder. @@ -853,8 +797,6 @@ def create( ValueError: if the policy is required but ``keys_path`` is missing, or the existing key file belongs to a different identity. """ - if use_encryption is not None: - encryption_policy = EncryptionPolicy.coerce(use_encryption) store = cls(email=email, encryption_policy=encryption_policy) if keys_path is None: if store.use_encryption: diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index 1becf7b0730..a6a1fe2c0e1 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -192,7 +192,7 @@ def for_colab( connection_configs=connection_configs, has_do_role=has_do_role, has_ds_role=has_ds_role, - use_encryption=encryption, + encryption_policy=EncryptionPolicy.coerce(encryption), crypto_keys_path=crypto_keys_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, force_ignore_peer_version=force_ignore_peer_version, @@ -278,7 +278,7 @@ def for_jupyter( connection_configs=connection_configs, has_do_role=has_do_role, has_ds_role=has_ds_role, - use_encryption=encryption, + encryption_policy=EncryptionPolicy.coerce(encryption), crypto_keys_path=crypto_keys_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, force_ignore_peer_version=force_ignore_peer_version, diff --git a/syft/sync/sync/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py index 31bf5778357..6128efd1589 100644 --- a/syft/sync/sync/datasite_owner_syncer.py +++ b/syft/sync/sync/datasite_owner_syncer.py @@ -851,11 +851,9 @@ def _send_outbox_message( recipient, message ) except PeerEncryptionPendingError: - peer_store = self.connection_router.peer_store - if not peer_store.peers_loaded or peer_store.get_cached_peer(recipient): + if self.connection_router.peer_store.should_hold(recipient): return False - # Not a peer any more: it never gets a policy. - logger.warning(f"Dropped an outbox message for {recipient}") + logger.warning(f"Dropped an outbox message for {recipient}: not a peer") except EncryptionPolicyConflictError as e: logger.warning(f"Dropped an outbox message: {e}") return True @@ -864,11 +862,6 @@ def _send_outbox_message( def _held_outbox(self) -> HeldMessages: return HeldMessages(self.syftbox_folder / CACHE_DIR / HELD_OUTBOX_FILENAME) - @property - def held_message_count(self) -> int: - """The number of outbox messages held until encryption is set up.""" - return len(self._held_outbox) - def _download_outbox_message_with_new_connection( self, file_id: str ) -> FileChangeEventsMessage: diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index 8f87299bb93..9ad1e9c8dc3 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -52,10 +52,6 @@ class Config: _executor: ThreadPoolExecutor = PrivateAttr( default_factory=lambda: ThreadPoolExecutor(max_workers=10) ) - # (held file stamp, held hashes): decoded again only when the file changes. - _held_hashes_cache: tuple[object, Dict[Path, int | None]] | None = PrivateAttr( - default=None - ) @classmethod def from_config(cls, config: DatasiteWatcherSyncerConfig): @@ -177,19 +173,12 @@ def _held_messages(self) -> HeldMessages: def _held_hashes(self) -> Dict[Path, int | None]: """The new hash of each file that a held change touches.""" - store = self._held_messages - stamp = store.stamp() - if stamp is None: - return {} - if self._held_hashes_cache is not None and self._held_hashes_cache[0] == stamp: - return self._held_hashes_cache[1] hashes: Dict[Path, int | None] = {} - for _, data in store.read(): + for _, data in self._held_messages.read(): message = ProposedFileChangesMessage.from_compressed_data(data) for fc in message.proposed_file_changes: path_key = Path(fc.datasite_email) / fc.path_in_datasite hashes[path_key] = None if fc.is_deleted else fc.new_hash - self._held_hashes_cache = (stamp, hashes) return hashes def _send_or_hold( @@ -215,19 +204,12 @@ def _send_or_hold( ) return True except PeerEncryptionPendingError as e: - peer_store = self.connection_router.peer_store - if peer_store.peers_loaded and not peer_store.get_cached_peer( - recipient - ): + if not self.connection_router.peer_store.should_hold(recipient): raise print(f"Held a message for {recipient}: {e}") held.append((recipient, message.as_compressed_data())) return False - @property - def pending_message_count(self) -> int: - return len(self._held_messages) - def send_pending_messages(self) -> None: """Send the held messages whose recipient policy is now known. @@ -238,10 +220,7 @@ def send_pending_messages(self) -> None: def _send_held_message(self, recipient: str, data: bytes) -> bool: """Send one held message. False while encryption is pending.""" - peer_store = self.connection_router.peer_store - if not peer_store.peers_loaded: - return False - if peer_store.get_cached_peer(recipient) is None: + if not self.connection_router.peer_store.should_hold(recipient): logger.warning(f"Dropped a held message for {recipient}: it is not a peer.") return True message = ProposedFileChangesMessage.from_compressed_data(data) diff --git a/syft/sync/sync/held_messages.py b/syft/sync/sync/held_messages.py index 76aa5de4298..7adc9eabed7 100644 --- a/syft/sync/sync/held_messages.py +++ b/syft/sync/sync/held_messages.py @@ -8,7 +8,6 @@ """ import base64 -import hashlib import json import os import tempfile @@ -90,16 +89,6 @@ def send_all( self._write(held) return len(held) - def stamp(self) -> str | None: - """Changes when the content changes: a hash of the file, or None without one. - - Not the modification time: some mounts keep it only to the second. - """ - try: - return hashlib.sha256(self._path.read_bytes()).hexdigest() - except FileNotFoundError: - return None - def __len__(self) -> int: return len(self.read()) diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 0ac19581dfd..ba97d77308a 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -8,7 +8,7 @@ from concurrent.futures import ThreadPoolExecutor from enum import Enum from pathlib import Path -from typing import Any, Dict, List, Optional, Set +from typing import Dict, List, Optional, Set from googleapiclient.errors import HttpError from pydantic import BaseModel, ConfigDict, PrivateAttr, model_validator @@ -21,10 +21,8 @@ EncryptionPolicy, pair_conflicts, pair_uses_encryption, - policy_from_use_encryption, ) from syft.sync.peers.key_bundle import ( - BUNDLE_FILE_KEY, InvalidPeerBundleError, PeerFingerprintMismatchError, PeerKeyChangedError, @@ -67,6 +65,17 @@ def _conflict_skip_message(peer_email: str) -> str: ) +def _outdated_skip_message(peer_email: str) -> str: + return ( + f"{peer_email} is skipped: it runs an outdated syft, which does not " + "advertise an encryption policy. Ask them to upgrade syft." + ) + + +# Key wrapping the DID document inside a published encryption bundle file. +BUNDLE_FILE_KEY = "public_encryption_bundle" + + class PeerCompatibilityResult(BaseModel): """Outcome of a per-peer version compatibility check. @@ -139,11 +148,6 @@ class PeerManagerConfig(BaseModel): None # None: value is determined by the role (resolves to has_do_role) ) - @model_validator(mode="before") - @classmethod - def _policy_from_use_encryption(cls, data: Any) -> Any: - return policy_from_use_encryption(data) - @property def use_encryption(self) -> bool: """True when the policy is required.""" @@ -371,20 +375,14 @@ def load_peer_version(self, peer_email: str) -> Optional[VersionInfo]: cached_peer = self.get_cached_peer(peer_email) if cached_peer: cached_peer.version = version_info - self.connection_router.resolve_legacy_policy(cached_peer) self._note_peer_policy(cached_peer) self._update_peer_schemas(peer_email, version_info) return version_info def _load_single_peer_version( self, peer_email: str - ) -> tuple[str, Optional[VersionInfo], Optional[bool]]: - """Load version for a single peer. Used by parallel loader. - - For an earlier client, whose version file does not say its policy, it - also checks whether the peer published a bundle for us, so that these - Drive calls run in parallel too: None when that check failed. - """ + ) -> tuple[str, Optional[VersionInfo]]: + """Load version for a single peer. Used by parallel loader.""" try: # Create new connection for thread safety connection = self.connection_router.connection_for_version_read( @@ -394,18 +392,8 @@ def _load_single_peer_version( except Exception as e: if not self.suppress_version_warnings: warnings.warn(f"Failed to load version for {peer_email}: {e}") - return (peer_email, None, False) - published: Optional[bool] = False - if ( - version_info is not None - and version_info.encryption_policy is None - and self.peer_store.legacy_bundle_needs_read(peer_email) - ): - # Its own check: a failure here keeps the version that was read. - published = self.connection_router.legacy_bundle_published( - peer_email, connection - ) - return (peer_email, version_info, published) + return (peer_email, None) + return (peer_email, version_info) def load_peer_versions_parallel( self, peer_emails: List[str], force: bool = False @@ -419,20 +407,14 @@ def load_peer_versions_parallel( results = list(self._executor.map(self._load_single_peer_version, peer_emails)) # Update peer version fields and return - for email, version, published in results: + for email, version in results: peer = self.get_cached_peer(email) if peer: peer.version = version - if peer.needs_legacy_policy: - peer.set_legacy_policy( - self.peer_store.legacy_policy( - email, lambda p=published: p, record_miss=True - ) - ) self._note_peer_policy(peer) self._update_peer_schemas(email, version) - return {email: version for email, version, _ in results} + return {email: version for email, version in results} def _note_peer_policy(self, peer: Peer) -> None: """Remember a peer that requires encryption; warn when it seems to stop. @@ -598,11 +580,16 @@ def without_encryption_conflicts(self, peer_emails: List[str]) -> List[str]: """``peer_emails`` without the peers whose encryption policy conflicts. Such a pair cannot exchange messages, so it is skipped with a warning. + A peer that runs an outdated syft is skipped too, with a warning to + upgrade. ``ignore_peer_version`` does not apply: it is not a version question. """ result = [] for email in peer_emails: - if self.peer_store.peer_conflicts(email): + peer = self.peer_store.get_cached_peer(email) + if peer is not None and peer.is_outdated: + warnings.warn(_outdated_skip_message(email)) + elif self.peer_store.peer_conflicts(email): warnings.warn(_conflict_skip_message(email)) else: result.append(email) @@ -685,14 +672,15 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): # load_peers publishes and pins when the policy becomes known. version_info = self.connection_router.read_peer_version_file(peer_email) new_peer_obj.version = version_info - self.connection_router.resolve_legacy_policy(new_peer_obj) self._note_peer_policy(new_peer_obj) encrypted = pair_uses_encryption( self.peer_store.encryption_policy, new_peer_obj.encryption_policy ) if encrypted: self._write_encryption_bundle_for_peer(peer_email) - if pair_conflicts( + if new_peer_obj.is_outdated: + warnings.warn(_outdated_skip_message(peer_email)) + elif pair_conflicts( self.peer_store.encryption_policy, new_peer_obj.encryption_policy ): warnings.warn(_conflict_skip_message(peer_email)) diff --git a/syft/sync/version/version_info.py b/syft/sync/version/version_info.py index e69879ff5ae..1aa073b813b 100644 --- a/syft/sync/version/version_info.py +++ b/syft/sync/version/version_info.py @@ -215,8 +215,8 @@ class VersionInfoV2(VersionInfoV1): # pydantic's own ``extra`` config, which governs unknown *fields*. extra: dict[str, Any] = Field(default_factory=dict) - # Peers read it to select encryption for the pair. None in a file from an - # earlier client, which does not say; see ``Peer.encryption_policy``. + # Peers read it to select encryption for the pair. None in a file from a + # syft that predates it: such a peer is outdated and must upgrade. encryption_policy: Optional[EncryptionPolicy] = None @classmethod diff --git a/tests/unit/test_crypto_keys_version.py b/tests/unit/test_crypto_keys_version.py index 785f5b4309d..f35cc42b661 100644 --- a/tests/unit/test_crypto_keys_version.py +++ b/tests/unit/test_crypto_keys_version.py @@ -8,11 +8,14 @@ import json import pytest +from syft.sync.encryption_policy import EncryptionPolicy from syft.sync.peers.peer_store import CRYPTO_KEYS_VERSION, PeerStore def _saved(tmp_path): - store = PeerStore(email="alice@example.com", use_encryption=True) + store = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) store.generate_keys() path = tmp_path / "crypto_keys.json" store.save_keys(path) diff --git a/tests/unit/test_encryption.py b/tests/unit/test_encryption.py index 96476330404..d59ba443568 100644 --- a/tests/unit/test_encryption.py +++ b/tests/unit/test_encryption.py @@ -4,6 +4,7 @@ import pytest from syft.sync.peers.peer import Peer +from syft.sync.encryption_policy import EncryptionPolicy from syft.sync.peers.peer_store import PeerStore from syft.sync.syftbox_manager import SyftboxManager from tests.unit.test_sync_manager import path_for_job @@ -16,7 +17,9 @@ def test_key_generation(): - ps = PeerStore(email="alice@example.com", use_encryption=True) + ps = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) assert not ps.has_my_keys() ps.generate_keys() assert ps.has_my_keys() @@ -25,9 +28,13 @@ def test_key_generation(): def test_encrypt_decrypt_roundtrip(): - alice = PeerStore(email="alice@example.com", use_encryption=True) + alice = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) alice.generate_keys() - bob = PeerStore(email="bob@example.com", use_encryption=True) + bob = PeerStore( + email="bob@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) bob.generate_keys() # Add peers so bundles can be stored on Peer objects @@ -47,14 +54,18 @@ def test_encrypt_decrypt_roundtrip(): def test_encrypt_without_keys_raises(): - ps = PeerStore(email="alice@example.com", use_encryption=True) + ps = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) ps.add_peer(Peer(email="bob@example.com")) with pytest.raises(ValueError, match="No private keys"): ps.encrypt("bob@example.com", b"data") def test_encrypt_without_peer_bundle_raises(): - ps = PeerStore(email="alice@example.com", use_encryption=True) + ps = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) ps.generate_keys() ps.add_peer(Peer(email="bob@example.com")) with pytest.raises(ValueError, match="No public encryption bundle"): @@ -62,14 +73,18 @@ def test_encrypt_without_peer_bundle_raises(): def test_try_decrypt_no_keys(): - ps = PeerStore(email="alice@example.com", use_encryption=True) + ps = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) data = b"some unencrypted data" with pytest.raises(ValueError, match="No private keys"): ps.decrypt("bob@example.com", data) == data def test_try_decrypt_no_peer_bundle(): - ps = PeerStore(email="alice@example.com", use_encryption=True) + ps = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) ps.generate_keys() data = b"some unencrypted data" with pytest.raises(ValueError, match="No cached peer for"): @@ -77,9 +92,13 @@ def test_try_decrypt_no_peer_bundle(): def test_try_decrypt_invalid_envelope(): - alice = PeerStore(email="alice@example.com", use_encryption=True) + alice = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) alice.generate_keys() - bob = PeerStore(email="bob@example.com", use_encryption=True) + bob = PeerStore( + email="bob@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) bob.generate_keys() alice.add_peer(Peer(email="bob@example.com")) @@ -94,9 +113,13 @@ def test_try_decrypt_invalid_envelope(): def test_save_and_load_keys(): - alice = PeerStore(email="alice@example.com", use_encryption=True) + alice = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) alice.generate_keys() - bob = PeerStore(email="bob@example.com", use_encryption=True) + bob = PeerStore( + email="bob@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) bob.generate_keys() alice.add_peer(Peer(email="bob@example.com")) @@ -239,7 +262,9 @@ def test_bundle_exchange_through_peer_approval(): def test_self_encrypt_decrypt_roundtrip(): """PeerStore can encrypt and decrypt data for itself.""" - ps = PeerStore(email="alice@example.com", use_encryption=True) + ps = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) ps.generate_keys() plaintext = b"secret at-rest data" @@ -252,7 +277,9 @@ def test_self_encrypt_decrypt_roundtrip(): def test_self_encrypt_if_needed_with_encryption(): """encrypt_for_self_if_needed encrypts when enabled and keys exist.""" - ps = PeerStore(email="alice@example.com", use_encryption=True) + ps = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) ps.generate_keys() data = b"hello" @@ -265,7 +292,9 @@ def test_self_encrypt_if_needed_with_encryption(): def test_self_encrypt_if_needed_without_encryption(): """encrypt_for_self_if_needed is a no-op when encryption is disabled.""" - ps = PeerStore(email="alice@example.com", use_encryption=False) + ps = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.OPTIONAL + ) ps.generate_keys() data = b"hello" @@ -275,7 +304,9 @@ def test_self_encrypt_if_needed_without_encryption(): def test_self_encrypt_if_needed_without_keys(): """encrypt_for_self_if_needed is a no-op when keys are missing.""" - ps = PeerStore(email="alice@example.com", use_encryption=True) + ps = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) data = b"hello" assert ps.encrypt_for_self_if_needed(data) == data @@ -399,9 +430,13 @@ def test_at_rest_no_encryption_backward_compat(): def _setup_alice_bob(): """Helper: create alice and bob PeerStores with exchanged bundles.""" - alice = PeerStore(email="alice@example.com", use_encryption=True) + alice = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) alice.generate_keys() - bob = PeerStore(email="bob@example.com", use_encryption=True) + bob = PeerStore( + email="bob@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) bob.generate_keys() alice.add_peer(Peer(email="bob@example.com")) @@ -427,7 +462,9 @@ def test_verify_message_wrong_sender_raises(): """Verification fails when checked against the wrong sender's key.""" alice, bob = _setup_alice_bob() - carol = PeerStore(email="carol@example.com", use_encryption=True) + carol = PeerStore( + email="carol@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) carol.generate_keys() bob.add_peer(Peer(email="carol@example.com")) bob.set_peer_bundle("carol@example.com", carol.get_public_bundle()) @@ -453,7 +490,9 @@ def test_decrypt_tampered_envelope_raises(): def test_verify_message_from_self(): """Verify signature of a self-encrypted envelope.""" - ps = PeerStore(email="alice@example.com", use_encryption=True) + ps = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) ps.generate_keys() plaintext = b"self-signed data" diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 17ee3294315..282c98a97e1 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -4,9 +4,7 @@ ``required``. A pair encrypts when either side requires it. """ -import inspect import json -import os import threading import warnings from pathlib import Path @@ -19,11 +17,11 @@ from syft.sync.encryption_policy import ( EncryptionPolicy, EncryptionPolicyConflictError, + PeerOutdatedError, PeerEncryptionPolicyUnknownError, PeerKeysNotReadyError, ) from syft.sync.events.file_change_event import FileChangeEventsMessage -from syft.sync.messages.proposed_filechange import ProposedFileChangesMessage from syft.sync.peers.peer import Peer, PeerState from syft.sync.peers.peer_store import PeerStore from syft.sync.sync.datasite_watcher_syncer import DatasiteWatcherSyncer @@ -74,6 +72,16 @@ def _keyed_pair( return alice, bob +def _held_proposals(manager) -> int: + """The number of proposed-change messages ``manager`` holds.""" + return len(HeldMessages(manager.syftbox_folder / ".cache" / "held_proposals.json")) + + +def _held_outbox(manager) -> int: + """The number of outbox messages ``manager`` holds.""" + return len(HeldMessages(manager.syftbox_folder / ".cache" / "held_outbox.json")) + + def _forget(store: PeerStore, email: str) -> None: """Make ``store`` forget what it knew of ``email``'s policy, as a new client.""" store.forget_required_peer(email) @@ -152,19 +160,11 @@ def test_policy_defaults_to_optional(): assert PeerStore(email="alice@example.com").encryption_policy == OPTIONAL -@pytest.mark.parametrize( - "use_encryption, expected", [(True, REQUIRED), (False, OPTIONAL)] -) -def test_use_encryption_bool_maps_to_policy(use_encryption, expected): - store = PeerStore(email="alice@example.com", use_encryption=use_encryption) - assert store.encryption_policy == expected - - @pytest.mark.parametrize( "value, expected", [(True, REQUIRED), (False, OPTIONAL), ("off", OFF), (OFF, OFF)], ) -def test_encryption_setting_is_coerced_to_policy(value, expected): +def test_encryption_setting_coerced_to_policy(value, expected): assert EncryptionPolicy.coerce(value) == expected @@ -175,59 +175,25 @@ def test_version_info_without_policy_field_has_no_policy(): assert VersionInfo.model_validate(data).encryption_policy is None -def _advertise_as_legacy_client(manager, observer) -> None: - """Write the version file of ``manager`` without the policy field. +def _advertise_as_outdated_client(manager) -> None: + """Write the version file of ``manager`` without the policy field.""" + outdated = manager.peer_manager.get_own_version().model_copy() + outdated.encryption_policy = None + manager.peer_manager.connection_router.write_version_file(outdated) - ``observer`` is the other client: it forgets what it remembered of - ``manager``, as a client that only ever saw an earlier client. - """ - legacy = manager.peer_manager.get_own_version().model_copy() - legacy.encryption_policy = None - manager.peer_manager.connection_router.write_version_file(legacy) - _forget(observer.peer_manager.peer_store, manager.email) - - -@pytest.mark.parametrize("do_policy, expected", [(REQUIRED, REQUIRED), (OPTIONAL, OFF)]) -def test_legacy_peer_policy_follows_its_published_bundle(do_policy, expected): - """An earlier client publishes a bundle for us only when it encrypts. - - One that does not has no keys and cannot decrypt, so it is off. - """ - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - do_encryption_policy=do_policy, - ) - _advertise_as_legacy_client(do_manager, ds_manager) - ds_manager.load_peers() - ds_manager.load_peers() - assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) == expected - -def test_legacy_peer_without_bundle_is_read_again_only_now_and_then(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - _advertise_as_legacy_client(do_manager, ds_manager) - reads = [] - original = GDriveConnection.peer_encryption_bundle_exists - - def spy(self, email): - reads.append(email) - return original(self, email) - - monkeypatch.setattr(GDriveConnection, "peer_encryption_bundle_exists", spy) - for _ in range(5): - ds_manager.load_peers() - # The load that first misses it, the next one, and then every tenth. - assert len(reads) == 2 - - -def test_legacy_peer_without_bundle_is_unknown_for_one_load(): - """Its bundle can be not yet visible, so the first load holds sends.""" +def test_outdated_peer_skipped_with_upgrade_warning(): + """A peer without a policy in its version file runs an outdated syft.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - _advertise_as_legacy_client(do_manager, ds_manager) - ds_manager.load_peers() - store = ds_manager.peer_manager.peer_store - assert store.peer_policy(do_manager.email) is None + grant_job_inbox_access(do_manager, ds_manager.email) + _advertise_as_outdated_client(do_manager) ds_manager.load_peers() - assert store.peer_policy(do_manager.email) == OFF + + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + with pytest.raises(PeerOutdatedError, match="upgrade"): + ds_manager._send_file_change(file_path, "content") + with pytest.warns(UserWarning, match="upgrade"): + ds_manager.sync() def test_own_version_advertises_policy(): @@ -241,7 +207,7 @@ def test_own_version_advertises_policy(): assert advertised.encryption_policy == REQUIRED -def test_missing_version_file_is_created_with_own_policy(): +def test_missing_version_file_created_with_own_policy(): a_conn, b_conn = mock_drive_service.pair_with_mock_service( "a@example.com", "b@example.com" ) @@ -249,7 +215,7 @@ def test_missing_version_file_is_created_with_own_policy(): assert b_conn.read_peer_version_file("a@example.com").encryption_policy == REQUIRED -def test_policy_change_in_memory_is_republished(): +def test_policy_change_in_memory_republished(): """A key file marked required makes this client required: peers must see it.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, @@ -271,48 +237,6 @@ def test_policy_change_in_memory_is_republished(): assert advertised.encryption_policy == REQUIRED -def test_legacy_required_policy_is_read_once_per_peer(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - do_encryption_policy=REQUIRED, - ) - _advertise_as_legacy_client(do_manager, ds_manager) - reads = [] - original = GDriveConnection.peer_encryption_bundle_exists - - def spy(self, email): - # Count the reads of the version load; pinning and the key rotation - # check read the bundle too. - if any(f.function == "_load_single_peer_version" for f in inspect.stack()): - reads.append(email) - return original(self, email) - - monkeypatch.setattr(GDriveConnection, "peer_encryption_bundle_exists", spy) - ds_manager.load_peers() - ds_manager.load_peers() - assert reads == [do_manager.email] - - -def test_legacy_peer_without_bundle_is_checked_again(monkeypatch): - """A bundle that was not found once, e.g. not yet visible on Drive, is read again.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - do_encryption_policy=REQUIRED, - ) - _advertise_as_legacy_client(do_manager, ds_manager) - original = GDriveConnection.peer_encryption_bundle_exists - calls = [] - - def first_read_finds_nothing(self, email): - calls.append(email) - return False if len(calls) == 1 else original(self, email) - - monkeypatch.setattr( - GDriveConnection, "peer_encryption_bundle_exists", first_read_finds_nothing - ) - ds_manager.load_peers() - ds_manager.load_peers() - assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) == REQUIRED - - def _advertise_policy(manager, policy) -> None: """Write the version file of ``manager`` with ``policy``, as a tamperer could.""" version = manager.peer_manager.get_own_version().model_copy() @@ -320,7 +244,7 @@ def _advertise_policy(manager, policy) -> None: manager.peer_manager.connection_router.write_version_file(version) -def test_advertised_downgrade_of_required_peer_is_ignored(): +def test_advertised_downgrade_of_required_peer_ignored(): """The version file is not signed, so a required peer stays required.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=OPTIONAL, @@ -341,7 +265,7 @@ def test_advertised_downgrade_of_required_peer_is_ignored(): assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) -def test_required_peer_is_remembered_in_the_key_file(tmp_path): +def test_required_peer_remembered_in_key_file(tmp_path): keys_path = tmp_path / "crypto_keys.json" store = PeerStore.create(email="alice@example.com", keys_path=keys_path) store.ensure_keys() @@ -357,7 +281,7 @@ def test_required_peer_is_remembered_in_the_key_file(tmp_path): assert later.peer_policy("bob@example.com") == REQUIRED -def test_trusted_key_change_clears_a_remembered_required_peer(): +def test_trusted_key_change_clears_remembered_required_peer(): """A peer can drop required only with a new key file, so a new identity key.""" alice, bob = _keyed_pair(OPTIONAL, REQUIRED) alice.remember_required_peer("bob@example.com") @@ -372,97 +296,35 @@ def test_trusted_key_change_clears_a_remembered_required_peer(): assert alice.peer_policy("bob@example.com") == OPTIONAL -def _drive_error(): - from googleapiclient.errors import HttpError - - return HttpError(resp=type("R", (), {"status": 503, "reason": ""})(), content=b"") - - -def test_failed_legacy_bundle_check_keeps_the_policy_unknown(monkeypatch): - """A Drive error is not a missing bundle, so it never makes the peer off.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - do_encryption_policy=REQUIRED, - ) - _advertise_as_legacy_client(do_manager, ds_manager) - original = GDriveConnection.peer_encryption_bundle_exists - calls = [] - - def fail_twice(self, email): - calls.append(email) - if len(calls) <= 2: - raise _drive_error() - return original(self, email) - - monkeypatch.setattr(GDriveConnection, "peer_encryption_bundle_exists", fail_twice) - store = ds_manager.peer_manager.peer_store - ds_manager.load_peers() - ds_manager.load_peers() - assert store.peer_policy(do_manager.email) is None - # The version itself is kept when only the bundle check fails. - assert ds_manager.peer_manager.get_peer_version(do_manager.email) is not None - ds_manager.load_peers() - assert store.peer_policy(do_manager.email) == REQUIRED - - -def test_legacy_miss_before_first_load_still_gives_a_load_of_grace(): - """add_peer can run before any peer load; the grace still covers one sync.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - add_peers=False, +def _write_garbage_own_bundle(ds_manager, do_manager, monkeypatch) -> None: + ds_manager.peer_manager.connection_router.write_encryption_bundle( + do_manager.email, "not json" ) - _advertise_as_legacy_client(do_manager, ds_manager) - do_manager.add_peer(ds_manager.email, sync=False) - ds_manager.add_peer(do_manager.email, sync=False) - ds_manager.load_peers() - assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) is None -def test_legacy_miss_in_add_peer_after_a_load_still_gives_a_load_of_grace(): - """add_peer then syncs: that load must not already make the peer off.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - add_peers=False, - ) - ds_manager.load_peers() - _advertise_as_legacy_client(do_manager, ds_manager) - do_manager.add_peer(ds_manager.email, sync=False) - ds_manager.add_peer(do_manager.email, sync=False) - ds_manager.load_peers() - assert ds_manager.peer_manager.peer_store.peer_policy(do_manager.email) is None - - -def test_unreadable_own_bundle_does_not_stop_peer_load(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - do_encryption_policy=REQUIRED, - ) - +def _make_own_bundle_undecodable(ds_manager, do_manager, monkeypatch) -> None: def undecodable(self, peer_email): raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") monkeypatch.setattr(ConnectionRouter, "read_own_encryption_bundle", undecodable) - ds_manager.peer_manager._bundle_published_for.clear() # as a new process - ds_manager.load_peers() - assert do_manager.email in ds_manager.peer_manager._bundle_published_for -def test_junk_legacy_bundle_keeps_the_policy_unknown_until_valid(): - """A bundle that does not validate is not taken as required, and is read again.""" +@pytest.mark.parametrize( + "damage", + [_write_garbage_own_bundle, _make_own_bundle_undecodable], + ids=["not json", "not utf-8"], +) +def test_unreadable_own_bundle_is_written_again(damage, monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( do_encryption_policy=REQUIRED, ) - _advertise_as_legacy_client(do_manager, ds_manager) - router = do_manager.peer_manager.connection_router - router.write_encryption_bundle(ds_manager.email, "not json") - store = ds_manager.peer_manager.peer_store - with pytest.warns(UserWarning): - ds_manager.load_peers() - ds_manager.load_peers() - assert store.peer_policy(do_manager.email) is None - - do_manager.peer_manager._write_encryption_bundle_for_peer(ds_manager.email) + damage(ds_manager, do_manager, monkeypatch) + ds_manager.peer_manager._bundle_published_for.clear() # as a new process ds_manager.load_peers() - assert store.peer_policy(do_manager.email) == REQUIRED + assert do_manager.email in ds_manager.peer_manager._bundle_published_for -def test_malformed_bundle_files_do_not_stop_peer_load(): +def test_malformed_peer_bundle_does_not_stop_peer_load(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, do_encryption_policy=REQUIRED, @@ -470,18 +332,12 @@ def test_malformed_bundle_files_do_not_stop_peer_load(): ds_manager.add_peer(do_manager.email, sync=False) do_manager.load_peers() do_manager.approve_peer_request(ds_manager.email) - # The owner's bundle for the DS, and the DS's own published bundle, are garbage. do_manager.peer_manager.connection_router.write_encryption_bundle( ds_manager.email, "not json" ) - ds_manager.load_peers() - ds_manager.peer_manager.connection_router.write_encryption_bundle( - do_manager.email, "not json" - ) - ds_manager.peer_manager._bundle_published_for.clear() # as a new process - with pytest.warns(UserWarning): + with pytest.warns(UserWarning, match="Refusing"): ds_manager.load_peers() - assert ds_manager.peer_manager._own_bundle_is_published(do_manager.email) + assert not ds_manager.peer_manager.peer_store.has_peer_bundle(do_manager.email) # ========================================================================= @@ -501,7 +357,7 @@ def test_malformed_bundle_files_do_not_stop_peer_load(): (OPTIONAL, OFF, False), ], ) -def test_peer_encryption_is_resolved_from_both_policies(mine, theirs, encrypted): +def test_peer_encryption_resolved_from_both_policies(mine, theirs, encrypted): store = _store_with_peer(mine, theirs) assert store.peer_uses_encryption("bob@example.com") is encrypted @@ -549,7 +405,7 @@ def test_required_sender_needs_no_peer_policy(): [(OPTIONAL, REQUIRED, False), (REQUIRED, OPTIONAL, True)], ids=["no own keys", "no peer bundle"], ) -def test_encrypting_pair_without_keys_or_pin_is_not_ready(mine, theirs, with_keys): +def test_encrypting_pair_without_keys_or_pin_not_ready(mine, theirs, with_keys): store = _store_with_peer(mine, theirs) if with_keys: store.generate_keys() @@ -562,25 +418,17 @@ def test_encrypting_pair_without_keys_or_pin_is_not_ready(mine, theirs, with_key # ========================================================================= -def test_optional_store_creates_no_key_file(tmp_path): +@pytest.mark.parametrize("policy", [OPTIONAL, OFF]) +def test_store_not_requiring_encryption_creates_no_key_file(tmp_path, policy): keys_path = tmp_path / "crypto_keys.json" store = PeerStore.create( - email="alice@example.com", encryption_policy=OPTIONAL, keys_path=keys_path + email="alice@example.com", encryption_policy=policy, keys_path=keys_path ) assert not store.has_my_keys() assert not keys_path.exists() -def test_off_store_creates_no_key_file(tmp_path): - keys_path = tmp_path / "crypto_keys.json" - store = PeerStore.create( - email="alice@example.com", encryption_policy=OFF, keys_path=keys_path - ) - assert not store.has_my_keys() - assert not keys_path.exists() - - -def test_off_is_refused_when_key_file_requires_encryption(tmp_path): +def test_off_refused_when_key_file_requires_encryption(tmp_path): """The files at rest of such a datasite are encrypted; off cannot read them.""" keys_path = tmp_path / "crypto_keys.json" with pytest.warns(UserWarning): @@ -686,7 +534,7 @@ def test_switch_to_required_needs_at_rest_encryption_until_marked(tmp_path): assert not reloaded.needs_at_rest_encryption() -def test_legacy_key_file_checks_files_at_rest_once(tmp_path): +def test_key_file_without_policy_checks_files_at_rest_once(tmp_path): """An earlier client without encryption can have written plaintext at rest.""" keys_path = tmp_path / "crypto_keys.json" PeerStore.create( @@ -701,45 +549,49 @@ def test_legacy_key_file_checks_files_at_rest_once(tmp_path): assert store.needs_at_rest_encryption() -def test_first_required_login_warns_about_the_key_file(tmp_path): - keys_path = tmp_path / "crypto_keys.json" - with pytest.warns(UserWarning, match="keep a copy"): - PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) +def _create(keys_path, policy=OPTIONAL) -> PeerStore: + return PeerStore.create( + email="alice@example.com", encryption_policy=policy, keys_path=keys_path + ) -def test_switch_to_required_warns_about_the_key_file(tmp_path): - keys_path = tmp_path / "crypto_keys.json" - PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() - with pytest.warns(UserWarning, match="keep a copy"): - PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) +def _make_optional_keys(keys_path) -> None: + _create(keys_path).ensure_keys() -@pytest.mark.parametrize("policy", [REQUIRED, OPTIONAL]) -def test_later_logins_do_not_warn(tmp_path, policy): - keys_path = tmp_path / "crypto_keys.json" +def _first_required_login(keys_path) -> None: with pytest.warns(UserWarning): - PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) - with warnings.catch_warnings(): - warnings.simplefilter("error") - PeerStore.create( - email="alice@example.com", encryption_policy=policy, keys_path=keys_path - ) + _create(keys_path, REQUIRED) -def test_optional_keys_do_not_warn(tmp_path): +@pytest.mark.parametrize( + "before", [lambda keys_path: None, _make_optional_keys], ids=["first", "switch"] +) +def test_datasite_starting_to_require_warns_about_key_file(tmp_path, before): keys_path = tmp_path / "crypto_keys.json" + before(keys_path) + with pytest.warns(UserWarning, match="keep a copy"): + _create(keys_path, REQUIRED) + + +@pytest.mark.parametrize( + "before, action", + [ + (_first_required_login, lambda keys_path: _create(keys_path, REQUIRED)), + (_first_required_login, lambda keys_path: _create(keys_path, OPTIONAL)), + (lambda keys_path: None, _make_optional_keys), + ], + ids=["later required login", "later optional login", "optional keys"], +) +def test_no_key_file_warning_without_switch_to_required(tmp_path, before, action): + keys_path = tmp_path / "crypto_keys.json" + before(keys_path) with warnings.catch_warnings(): warnings.simplefilter("error") - PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() + action(keys_path) -def test_keys_from_another_process_keep_a_pin_held_in_memory(tmp_path): +def test_keys_from_other_process_keep_pin_held_in_memory(tmp_path): """A pin set before there were keys is saved once the key file exists.""" keys_path = tmp_path / "crypto_keys.json" bob = PeerStore(email="bob@example.com", encryption_policy=REQUIRED) @@ -774,7 +626,7 @@ def test_reload_reads_keys_only_for_a_required_key_file(tmp_path, monkeypatch): assert loads == [] -def test_at_rest_pass_leaves_the_key_file_lock_free(tmp_path): +def test_at_rest_pass_leaves_key_file_lock_free(tmp_path): """A notebook can pin keys while syft-bg encrypts the files at rest.""" from syft.sync.peers.peer_store import _locked_key_file @@ -885,7 +737,7 @@ def test_plaintext_from_peer_with_unknown_policy_waits(): bob.decrypt_and_verify_if_needed("alice@example.com", b"plain") -def test_last_known_policy_is_kept_when_the_version_vanishes(): +def test_last_known_policy_kept_when_version_vanishes(): """E.g. after the owner's delete_syftbox(): its plaintext deletes still apply.""" _, bob = _keyed_pair(OPTIONAL, OPTIONAL) assert bob.peer_policy("alice@example.com") == OPTIONAL @@ -964,7 +816,7 @@ def test_negotiated_encryption_flow(ds_policy, do_policy): assert len(_ds_cached_contents(ds_manager)) > 0 -def test_client_that_becomes_required_after_peering_publishes_its_bundle(): +def test_client_required_after_peering_publishes_bundle(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() grant_job_inbox_access(do_manager, ds_manager.email) @@ -983,7 +835,7 @@ def test_client_that_becomes_required_after_peering_publishes_its_bundle(): assert any("after switch" in c for c in _accepted_contents(do_manager)) -def test_policy_recorded_by_another_process_is_taken_at_next_sync(): +def test_policy_from_other_process_taken_at_next_sync(): """syft-bg starts required next to a notebook that started optional.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() grant_job_inbox_access(do_manager, ds_manager.email) @@ -1006,7 +858,7 @@ def test_policy_recorded_by_another_process_is_taken_at_next_sync(): assert advertised.encryption_policy == REQUIRED -def test_switch_is_advertised_by_process_that_never_wrote_version(): +def test_switch_advertised_by_process_that_never_wrote_version(): """syft-bg does not write the version file, but must advertise required.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() do_manager.peer_manager._advertised_policy = None # as a fresh syft-bg @@ -1021,20 +873,6 @@ def test_switch_is_advertised_by_process_that_never_wrote_version(): assert advertised.encryption_policy == REQUIRED -def test_direct_event_write_takes_policy_recorded_by_another_process(): - """syft-rds and the enclave client write events outside sync().""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - keys_path = do_manager.peer_manager.peer_store._keys_path - with pytest.warns(UserWarning): - PeerStore.create( - email=do_manager.email, encryption_policy=REQUIRED, keys_path=keys_path - ) - syncer = do_manager.datasite_owner_syncer - syncer.queue_event_for_syftbox([], FileChangeEventsMessage(events=[])) - syncer.process_syftbox_events_queue() - assert all(_is_envelope(raw) for raw in _raw_event_log(do_manager)) - - def test_direct_event_write_encrypts_earlier_files_at_rest(): """Files written while optional are encrypted before a direct write reads them.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() @@ -1061,7 +899,7 @@ def _raw_event_log(do_manager) -> list[bytes]: return connection.owner_download_all_raw_events_from_syftbox() -def test_owner_that_becomes_required_encrypts_files_at_rest(): +def test_owner_switching_to_required_encrypts_files_at_rest(): """Plaintext written while optional is encrypted once, then read as usual.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() grant_job_inbox_access(do_manager, ds_manager.email) @@ -1115,18 +953,24 @@ def test_off_and_required_peers_skip_each_other(): ds_manager.sync() -def test_owner_drops_outbox_message_for_conflicting_peer(): +@pytest.mark.parametrize( + "ds_policy, recipient", + [(OPTIONAL, "former@example.com"), (OFF, None)], + ids=["former peer", "conflicting peer"], +) +def test_owner_drops_outbox_message_it_cannot_send(ds_policy, recipient): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - ds_encryption_policy=OFF, + ds_encryption_policy=ds_policy, do_encryption_policy=REQUIRED, ) syncer = do_manager.datasite_owner_syncer - syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) + message = FileChangeEventsMessage(events=[]) + syncer.outbox_queue.put((recipient or ds_manager.email, message)) syncer.process_syftbox_events_queue() - assert syncer.held_message_count == 0 + assert _held_outbox(do_manager) == 0 -def test_conflict_found_on_read_makes_the_peer_wait(): +def test_conflict_found_on_read_makes_peer_wait(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=OFF, do_encryption_policy=OPTIONAL, @@ -1146,7 +990,7 @@ def test_conflict_found_on_read_makes_the_peer_wait(): ) -def test_current_published_bundle_is_not_written_again(monkeypatch): +def test_current_published_bundle_not_written_again(monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( do_encryption_policy=REQUIRED, ) @@ -1225,23 +1069,7 @@ def test_send_reread_updates_peer_manager(): assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) -def test_send_rereads_unknown_peer_policy(): - """The peer can have accepted since the last load; one re-read finds its policy.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - ds_encryption_policy=OPTIONAL, - do_encryption_policy=REQUIRED, - ) - grant_job_inbox_access(do_manager, ds_manager.email) - ds_manager.load_peers() - ds_manager.peer_manager.get_cached_peer(do_manager.email).version = None - _forget(ds_manager.peer_manager.peer_store, do_manager.email) - - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "content") - assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) - - -def test_send_before_peer_accepts_is_held_until_sync(capsys): +def test_send_before_peer_accepts_held_until_sync(capsys): """Before the owner accepts, its policy is unknown, so the send waits.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, @@ -1251,24 +1079,26 @@ def test_send_before_peer_accepts_is_held_until_sync(capsys): file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "held content") - watcher = ds_manager.datasite_watcher_syncer - assert watcher.pending_message_count == 1 + assert _held_proposals(ds_manager) == 1 assert "Held a message" in capsys.readouterr().out - # The held message is on disk, so a restart keeps it. + # The held message is on disk, so a restart keeps it, and the hash cache + # records only what the owner received. assert (ds_manager.syftbox_folder / ".cache" / "held_proposals.json").exists() + cache = ds_manager.datasite_watcher_syncer.datasite_watcher_cache + assert Path(file_path) not in cache.file_hashes do_manager.load_peers() do_manager.approve_peer_request(ds_manager.email) grant_job_inbox_access(do_manager, ds_manager.email) ds_manager.sync() - assert watcher.pending_message_count == 0 + assert _held_proposals(ds_manager) == 0 assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) do_manager.sync() assert any("held content" in c for c in _accepted_contents(do_manager)) -def test_send_after_accept_before_peer_load_is_held(): +def test_send_after_accept_before_peer_load_held(): """The owner accepted, but the scientist has no keys or pin yet: hold, then send.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, @@ -1281,26 +1111,13 @@ def test_send_after_accept_before_peer_load_is_held(): file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "after accept") - assert ds_manager.datasite_watcher_syncer.pending_message_count == 1 + assert _held_proposals(ds_manager) == 1 ds_manager.sync() do_manager.sync() assert any("after accept" in c for c in _accepted_contents(do_manager)) -def test_held_change_does_not_update_hash_cache(): - """The cache records what the owner accepted, so a restart re-proposes correctly.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - add_peers=False, - ) - ds_manager.add_peer(do_manager.email, sync=False) - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "held content") - - cache = ds_manager.datasite_watcher_syncer.datasite_watcher_cache - assert Path(file_path) not in cache.file_hashes - - def test_two_held_changes_to_one_file_apply_in_order(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, @@ -1309,7 +1126,7 @@ def test_two_held_changes_to_one_file_apply_in_order(): file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "first") ds_manager._send_file_change(file_path, "second") - assert ds_manager.datasite_watcher_syncer.pending_message_count == 2 + assert _held_proposals(ds_manager) == 2 do_manager.load_peers() do_manager.approve_peer_request(ds_manager.email) @@ -1319,7 +1136,7 @@ def test_two_held_changes_to_one_file_apply_in_order(): assert any("second" in c for c in _accepted_contents(do_manager)) -def test_held_hashes_are_read_once_per_queue_flush(monkeypatch): +def test_held_hashes_read_once_per_queue_flush(monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() reads = [] original = DatasiteWatcherSyncer._held_hashes @@ -1337,42 +1154,15 @@ def test_held_hashes_are_read_once_per_queue_flush(monkeypatch): assert len(reads) == 1 -def test_held_file_stamp_follows_content_with_same_time_and_size(tmp_path): - """Coarse time stamps on some mounts must not keep a stale hash cache.""" - path = tmp_path / "held.json" - store = HeldMessages(path) - with store.edit() as held: - held.append(("a@example.com", b"AAAA")) - stat = path.stat() - first = store.stamp() - - with store.edit() as held: - held[0] = ("a@example.com", b"BBBB") - os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns)) - assert path.stat().st_size == stat.st_size - assert store.stamp() != first - - -def test_send_before_any_peer_load_is_held(): - """With load_peers=False no recipient is a peer yet, so the send waits.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - add_peers=False, - ) - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "content") - assert ds_manager.datasite_watcher_syncer.pending_message_count == 1 - - def test_send_to_non_peer_raises(): """A recipient that is not a peer never gets a policy, so holding would be forever.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, ) - ds_manager.load_peers() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") with pytest.raises(PeerEncryptionPolicyUnknownError): ds_manager._send_file_change(file_path, "content") - assert ds_manager.datasite_watcher_syncer.pending_message_count == 0 + assert _held_proposals(ds_manager) == 0 def test_changes_for_two_datasites_go_to_each(monkeypatch): @@ -1398,59 +1188,30 @@ def test_changes_for_two_datasites_go_to_each(monkeypatch): ) -def test_conflict_with_one_datasite_still_sends_to_the_other(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - sent = [] - - def send_or_hold(self, recipient, message): - if recipient == "conflict@example.com": - raise EncryptionPolicyConflictError(recipient) - sent.append(recipient) - return True - - monkeypatch.setattr(DatasiteWatcherSyncer, "_send_or_hold", send_or_hold) - watcher = ds_manager.datasite_watcher_syncer - watcher.on_file_change("conflict@example.com/a.txt", "a", process_now=False) - watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) - with pytest.raises(EncryptionPolicyConflictError): - watcher.process_file_changes_queue() - assert sent == [do_manager.email] - - -def test_non_peer_recipient_still_sends_to_the_other(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - sent = [] - - def send_or_hold(self, recipient, message): - if recipient == "stranger@example.com": - raise PeerEncryptionPolicyUnknownError(recipient) - sent.append(recipient) - return True - - monkeypatch.setattr(DatasiteWatcherSyncer, "_send_or_hold", send_or_hold) - watcher = ds_manager.datasite_watcher_syncer - watcher.on_file_change("stranger@example.com/a.txt", "a", process_now=False) - watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) - with pytest.raises(PeerEncryptionPolicyUnknownError): - watcher.process_file_changes_queue() - assert sent == [do_manager.email] - - -def test_keys_not_ready_for_one_recipient_still_sends_to_the_other(monkeypatch): +@pytest.mark.parametrize( + "error", + [ + EncryptionPolicyConflictError, + PeerEncryptionPolicyUnknownError, + PeerKeysNotReadyError, + ], + ids=["conflict", "not a peer", "keys not ready"], +) +def test_failure_for_one_recipient_still_sends_to_other(monkeypatch, error): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() sent = [] def send_or_hold(self, recipient, message): - if recipient == "stranger@example.com": - raise PeerKeysNotReadyError(recipient) + if recipient == "other@example.com": + raise error(recipient) sent.append(recipient) return True monkeypatch.setattr(DatasiteWatcherSyncer, "_send_or_hold", send_or_hold) watcher = ds_manager.datasite_watcher_syncer - watcher.on_file_change("stranger@example.com/a.txt", "a", process_now=False) + watcher.on_file_change("other@example.com/a.txt", "a", process_now=False) watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) - with pytest.raises(PeerKeysNotReadyError): + with pytest.raises(error): watcher.process_file_changes_queue() assert sent == [do_manager.email] @@ -1473,28 +1234,7 @@ def test_owner_flush_writes_no_held_file_when_nothing_waits(monkeypatch): assert all(n == 0 for n in written) -def test_held_hashes_are_decoded_again_only_after_a_change(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - add_peers=False, - ) - ds_manager.add_peer(do_manager.email, sync=False) - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "held") - - decodes = [] - original = ProposedFileChangesMessage.from_compressed_data.__func__ - monkeypatch.setattr( - ProposedFileChangesMessage, - "from_compressed_data", - classmethod(lambda cls, data: decodes.append(1) or original(cls, data)), - ) - watcher = ds_manager.datasite_watcher_syncer - watcher._held_hashes() - watcher._held_hashes() - assert len(decodes) == 1 - - -def test_drive_error_while_sending_changes_holds_the_unsent(monkeypatch): +def test_drive_error_while_sending_changes_holds_unsent(monkeypatch): """The queue is already empty, so unsent changes go to the held file.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() @@ -1507,18 +1247,10 @@ def fail(self, recipient, message): watcher.on_file_change("other@example.com/b.txt", "b", process_now=False) with pytest.raises(RuntimeError): watcher.process_file_changes_queue() - assert watcher.pending_message_count == 2 - - -def test_owner_drops_outbox_message_for_former_peer(): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - syncer = do_manager.datasite_owner_syncer - syncer.outbox_queue.put(("former@example.com", FileChangeEventsMessage(events=[]))) - syncer.process_syftbox_events_queue() - assert syncer.held_message_count == 0 + assert _held_proposals(ds_manager) == 2 -def test_policy_is_reread_once_per_peer_load(monkeypatch): +def test_policy_reread_once_per_peer_load(monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() syncer = do_manager.datasite_owner_syncer do_manager.peer_manager.get_cached_peer(ds_manager.email).version = None @@ -1572,11 +1304,11 @@ def write_once_then_fail(self, recipient, message): with pytest.raises(RuntimeError): syncer.process_syftbox_events_queue() assert sent == [ds_manager.email] - assert syncer.held_message_count == 1 + assert _held_outbox(do_manager) == 1 monkeypatch.undo() syncer.process_syftbox_events_queue() - assert syncer.held_message_count == 0 + assert _held_outbox(do_manager) == 0 def test_held_send_flush_keeps_only_unsent_messages_on_error(monkeypatch): @@ -1588,7 +1320,7 @@ def test_held_send_flush_keeps_only_unsent_messages_on_error(monkeypatch): file_path = path_for_job(do_manager.email, ds_manager.email, name) ds_manager._send_file_change(file_path, name) watcher = ds_manager.datasite_watcher_syncer - assert watcher.pending_message_count == 2 + assert _held_proposals(ds_manager) == 2 do_manager.load_peers() do_manager.approve_peer_request(ds_manager.email) @@ -1609,7 +1341,7 @@ def send_once_then_fail(self, recipient, message): ) with pytest.raises(RuntimeError): watcher.send_pending_messages() - assert watcher.pending_message_count == 1 + assert _held_proposals(ds_manager) == 1 def _unknown_policy(ds_manager, do_manager, monkeypatch): @@ -1644,9 +1376,7 @@ def restore(): @pytest.mark.parametrize( "make_pending", [_unknown_policy, _unpinned_bundle], ids=["policy", "pin"] ) -def test_owner_holds_outbox_message_until_encryption_is_set_up( - make_pending, monkeypatch -): +def test_owner_holds_outbox_message_until_encryption_set_up(make_pending, monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=REQUIRED, ) @@ -1655,13 +1385,13 @@ def test_owner_holds_outbox_message_until_encryption_is_set_up( syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) syncer.process_syftbox_events_queue() - assert syncer.held_message_count == 1 + assert _held_outbox(do_manager) == 1 # The held message is on disk, so a restart keeps it. assert (do_manager.syftbox_folder / ".cache" / "held_outbox.json").exists() restore() syncer.process_syftbox_events_queue() - assert syncer.held_message_count == 0 + assert _held_outbox(do_manager) == 0 # ========================================================================= @@ -1669,7 +1399,7 @@ def test_owner_holds_outbox_message_until_encryption_is_set_up( # ========================================================================= -def test_rejected_inbox_message_is_resent_in_order(): +def test_rejected_inbox_message_resent_in_order(): """A newer encrypted change waits until the rejected one is resent encrypted.""" ds_manager, do_manager = _pair_with_stale_sender_view() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") @@ -1690,7 +1420,7 @@ def test_rejected_inbox_message_is_resent_in_order(): assert any("second" in c for c in contents) -def test_rejected_outbox_message_is_resent_in_order(monkeypatch): +def test_rejected_outbox_message_resent_in_order(monkeypatch): ds_manager, do_manager = _required_ds_pair() _owner_sends_with_stale_view(ds_manager, do_manager, "first") ds_manager.sync() @@ -1718,7 +1448,7 @@ def test_rejected_outbox_message_is_resent_in_order(monkeypatch): assert any("second" in c for c in contents) -def test_all_plaintext_inbox_messages_are_rejected_in_one_pass(): +def test_all_plaintext_inbox_messages_rejected_in_one_pass(): ds_manager, do_manager = _pair_with_stale_sender_view() for name in ("a.job", "b.job", "c.job"): file_path = path_for_job(do_manager.email, ds_manager.email, name) @@ -1749,7 +1479,7 @@ def test_message_rejected_twice_keeps_one_suffix(): assert any("rejected twice" in c for c in _accepted_contents(do_manager)) -def test_optional_pair_does_not_look_for_rejected_messages(monkeypatch): +def test_optional_pair_skips_rejected_message_lookup(monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() calls = [] original = GDriveConnection.watcher_get_rejected_inbox_messages @@ -1763,7 +1493,7 @@ def spy(self, recipient): assert calls == [] -def test_resend_waits_while_peer_bundle_is_not_pinned(): +def test_resend_waits_while_peer_bundle_not_pinned(): ds_manager, do_manager = _pair_with_stale_sender_view() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "plain content") diff --git a/tests/unit/test_peer_key_pinning.py b/tests/unit/test_peer_key_pinning.py index e56e2336398..1b743375c2d 100644 --- a/tests/unit/test_peer_key_pinning.py +++ b/tests/unit/test_peer_key_pinning.py @@ -25,6 +25,7 @@ format_fingerprint, ) from syft.sync.peers.peer import Peer +from syft.sync.encryption_policy import EncryptionPolicy from syft.sync.peers.peer_store import PeerStore, datasite_crypto_keys_path from syft.sync.syftbox_manager import SyftboxManager from syft.sync.version.peer_manager import PeerManager @@ -37,7 +38,7 @@ def _store(email: str) -> PeerStore: - store = PeerStore(email=email, use_encryption=True) + store = PeerStore(email=email, encryption_policy=EncryptionPolicy.REQUIRED) store.generate_keys() return store @@ -199,7 +200,9 @@ def test_set_peers_drops_invalid_cached_bundle(): def test_pins_persist_in_private_key_file(tmp_path): path = tmp_path / "crypto_keys.json" - alice = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + alice = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) bob = _store(BOB) alice.add_peer(Peer(email=BOB)) alice.set_peer_bundle(BOB, bob.get_public_bundle()) @@ -207,13 +210,17 @@ def test_pins_persist_in_private_key_file(tmp_path): on_disk = json.loads(path.read_text())["peer_bundles"] assert bundle_fingerprint(on_disk[BOB]) == bob.my_fingerprint - reloaded = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + reloaded = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) assert reloaded.peer_fingerprint(BOB) == bob.my_fingerprint def test_persisting_pin_keeps_pins_written_by_another_process(tmp_path): path = tmp_path / "crypto_keys.json" - alice = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + alice = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) bob, carol = _store(BOB), _store("carol@example.com") # Another process (syft-bg) pinned Carol meanwhile. @@ -231,13 +238,17 @@ def test_persisting_pin_keeps_pins_written_by_another_process(tmp_path): def test_key_trusted_by_another_process_is_adopted_without_warning(tmp_path): """A notebook and syft-bg share one key file; whichever trusted a new key wins.""" path = tmp_path / "crypto_keys.json" - notebook = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + notebook = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) bob = _store(BOB) notebook.add_peer(Peer(email=BOB)) notebook.set_peer_bundle(BOB, bob.get_public_bundle()) new_bob = _store(BOB) - daemon = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + daemon = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) daemon.set_peer_bundle(BOB, new_bob.get_public_bundle(), allow_key_change=True) # The notebook reloads peers from SYFT_peers.json, which the daemon updated. @@ -252,7 +263,9 @@ def test_key_trusted_by_another_process_is_adopted_without_warning(tmp_path): def test_pin_survives_sync_that_leaves_peer_out(tmp_path): """A peer rejected for one sync drops out of memory; the key file keeps the pin.""" path = tmp_path / "crypto_keys.json" - alice = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + alice = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) bob = _store(BOB) alice.add_peer(Peer(email=BOB)) alice.set_peer_bundle(BOB, bob.get_public_bundle()) @@ -275,11 +288,15 @@ def test_pin_survives_sync_that_leaves_peer_out(tmp_path): def test_pin_from_another_process_wins_over_first_use(tmp_path): """Bob is in memory without a key while syft-bg already pinned him in the key file.""" path = tmp_path / "crypto_keys.json" - notebook = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + notebook = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) notebook.add_peer(Peer(email=BOB)) bob = _store(BOB) - daemon = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + daemon = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) daemon.add_peer(Peer(email=BOB)) daemon.set_peer_bundle(BOB, bob.get_public_bundle()) @@ -293,11 +310,15 @@ def test_pin_from_another_process_wins_over_first_use(tmp_path): def test_set_peer_bundle_refuses_key_other_than_stored_pin(tmp_path): path = tmp_path / "crypto_keys.json" - notebook = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + notebook = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) notebook.add_peer(Peer(email=BOB)) bob = _store(BOB) - daemon = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + daemon = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) daemon.add_peer(Peer(email=BOB)) daemon.set_peer_bundle(BOB, bob.get_public_bundle()) @@ -309,7 +330,9 @@ def test_set_peer_bundle_refuses_key_other_than_stored_pin(tmp_path): def test_failed_key_file_write_leaves_old_file(tmp_path, monkeypatch): path = tmp_path / "crypto_keys.json" - alice = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + alice = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) alice.add_peer(Peer(email=BOB)) before = path.read_text() @@ -329,9 +352,13 @@ def crash(src, dst): def test_concurrent_pins_from_two_stores_are_all_kept(tmp_path): """The lock covers read, change and write, so no pin is lost between writers.""" path = tmp_path / "crypto_keys.json" - PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) stores = [ - PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) for _ in range(2) ] peers = [[_store(f"peer{s}-{i}@example.com") for i in range(5)] for s in range(2)] @@ -356,7 +383,9 @@ def pin_all(store, owners): def test_corrupt_stored_pin_is_dropped_on_load(tmp_path): path = tmp_path / "crypto_keys.json" - alice = PeerStore.create(email=ALICE, use_encryption=True, keys_path=path) + alice = PeerStore.create( + email=ALICE, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=path + ) bob = _store(BOB) alice.add_peer(Peer(email=BOB)) alice.set_peer_bundle(BOB, bob.get_public_bundle()) @@ -559,7 +588,7 @@ def test_pins_survive_new_manager_on_same_datasite(): do.trust_peer_key(ds.email, ds.encryption_fingerprint) reloaded = PeerStore.create( - email=do.email, use_encryption=True, keys_path=keys_path + email=do.email, encryption_policy=EncryptionPolicy.REQUIRED, keys_path=keys_path ) assert reloaded.peer_fingerprint(ds.email) == ds.encryption_fingerprint From e460f59fb1d84f117ac20bed813c7ad23787b6c7 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Thu, 8 Oct 2026 13:01:20 -0300 Subject: [PATCH 07/30] fix(sync): reload the key file policy on every write at rest An off client keeps no record of required peers; say so in the docs. --- docs/connections.md | 2 +- syft/sync/peers/peer_store.py | 7 ++++++- tests/unit/test_encryption_policy.py | 12 ++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/connections.md b/docs/connections.md index cb1121d4b6a..8af11cd94da 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -60,7 +60,7 @@ Each client advertises an encryption policy in `SYFT_version.json`: A pair encrypts when one side is `required`. An `off` and a `required` client conflict: each side skips the other with a warning. -The version file is not signed. So a client keeps a peer that it saw as `required` as `required` (in its key file), and warns when the peer seems to advertise less. Trusting a new key for that peer (`client.trust_peer_key`) clears this. +The version file is not signed. So a client keeps a peer that it saw as `required` as `required` (in its key file), and warns when the peer seems to advertise less. Trusting a new key for that peer (`client.trust_peer_key`) clears this. An `off` client has no key file, so it keeps no such record: a forged downgrade can only make it send what its user chose not to encrypt. A pair that encrypts refuses plaintext. The receiver renames a plaintext message to `.rejected`, and waits for that peer. The sender resends the message encrypted on its next sync. diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 774a5d3aea6..0695489dfa1 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -600,7 +600,12 @@ def decrypt_for_self(self, envelope: bytes) -> bytes: return syc.decrypt_message(self.email, keys, own_bundle, parsed) def encrypt_for_self_if_needed(self, data: bytes) -> bytes: - """Encrypt for self if encryption is enabled and keys are available.""" + """Encrypt for self if encryption is enabled and keys are available. + + It reads the key file first: another process on this datasite can + have made it required since this process last did. + """ + self.reload_policy_from_key_file() if self.use_encryption and self.has_my_keys(): return self.encrypt_for_self(data) return data diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 282c98a97e1..c41ebb60981 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -649,6 +649,18 @@ def other_process(): assert took_lock == [True] +def test_write_at_rest_takes_required_policy_from_other_process(tmp_path): + """Every write at rest reads the key file, so none goes out as plaintext.""" + keys_path = tmp_path / "crypto_keys.json" + notebook = PeerStore.create(email="alice@example.com", keys_path=keys_path) + notebook.ensure_keys() + with pytest.warns(UserWarning): + PeerStore.create( + email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path + ) + assert _is_envelope(notebook.encrypt_for_self_if_needed(b"rolling state")) + + def test_concurrent_processes_make_one_key_pair(tmp_path): """A notebook and syft-bg that need keys at once must end with the same keys.""" keys_path = tmp_path / "crypto_keys.json" From a9e63e5dfa147ac59f8e6c05f3ceb65187773b83 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Thu, 8 Oct 2026 13:37:30 -0300 Subject: [PATCH 08/30] fix(sync): strict reads at rest, delta writes for required peers, rejected_ prefix - Refuse plaintext at rest once the files at rest are encrypted - Write only changes to remembered required peers - Use the effective peer policy in add_peer - Find rejected messages with a Drive name query - Read the key file again only after it changes - Share the file lock and atomic write helpers - Record required_since; accept older plaintext outbox history --- docs/connections.md | 2 +- syft/sync/connections/connection_router.py | 23 +- .../connections/drive/gdrive_transport.py | 46 ++-- syft/sync/encryption_policy.py | 10 +- syft/sync/peers/peer_store.py | 246 +++++++++++------- .../sync/caches/datasite_watcher_cache.py | 8 +- syft/sync/sync/datasite_watcher_syncer.py | 8 +- syft/sync/sync/held_messages.py | 34 +-- syft/sync/utils/locked_files.py | 45 ++++ syft/sync/version/peer_manager.py | 37 +-- tests/unit/test_encryption_policy.py | 114 +++++++- tests/unit/test_peer_key_pinning.py | 2 +- 12 files changed, 386 insertions(+), 189 deletions(-) create mode 100644 syft/sync/utils/locked_files.py diff --git a/docs/connections.md b/docs/connections.md index 8af11cd94da..6a0ea24c3eb 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -62,7 +62,7 @@ A pair encrypts when one side is `required`. An `off` and a `required` client co The version file is not signed. So a client keeps a peer that it saw as `required` as `required` (in its key file), and warns when the peer seems to advertise less. Trusting a new key for that peer (`client.trust_peer_key`) clears this. An `off` client has no key file, so it keeps no such record: a forged downgrade can only make it send what its user chose not to encrypt. -A pair that encrypts refuses plaintext. The receiver renames a plaintext message to `.rejected`, and waits for that peer. The sender resends the message encrypted on its next sync. +A pair that encrypts refuses plaintext. The receiver renames a plaintext message to `rejected_`, and waits for that peer. The sender resends the message encrypted on its next sync. A peer whose version file has no policy runs an outdated syft. It is skipped with a warning to upgrade, and a send to it raises. diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index a744844aaf1..c7e9a5acc0a 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -297,7 +297,7 @@ def watcher_get_events_messages( # After one message that cannot be read, later ones are not applied, # but each is still read, so all plaintext ones are rejected at once. message = self.watcher_read_events_message( - connection, peer_email, meta["file_id"] + connection, peer_email, meta["file_id"], meta["timestamp"] ) waiting = waiting or message is None if not waiting: @@ -305,15 +305,23 @@ def watcher_get_events_messages( return messages def watcher_read_events_message( - self, connection: SyftboxPlatformConnection, peer_email: str, file_id: str + self, + connection: SyftboxPlatformConnection, + peer_email: str, + file_id: str, + sent_at: float | None = None, ) -> FileChangeEventsMessage | None: """Download, verify and decrypt one outbox events message. - Returns None when the message cannot be read yet (see - ``_decrypt_or_reject``). + ``sent_at`` is the timestamp in its file name. Returns None when the + message cannot be read yet (see ``_decrypt_or_reject``). """ raw = self._decrypt_or_reject( - connection, peer_email, connection.download_file(file_id), file_id + connection, + peer_email, + connection.download_file(file_id), + file_id, + sent_at=sent_at, ) if raw is None: return None @@ -326,6 +334,7 @@ def _decrypt_or_reject( raw: bytes, file_id: str, on_reject: Callable[[], None] | None = None, + sent_at: float | None = None, ) -> bytes | None: """Verify and decrypt an inbox or outbox message from ``peer_email``. @@ -335,7 +344,9 @@ def _decrypt_or_reject( encrypted on its next sync. """ try: - return self.peer_store.decrypt_and_verify_if_needed(peer_email, raw) + return self.peer_store.decrypt_and_verify_if_needed( + peer_email, raw, sent_at + ) except UnencryptedMessageError as e: logger.warning(f"Rejected message file {file_id}: {e}") connection.mark_message_rejected(file_id) diff --git a/syft/sync/connections/drive/gdrive_transport.py b/syft/sync/connections/drive/gdrive_transport.py index 57ce8a46df1..e81445f7bc7 100644 --- a/syft/sync/connections/drive/gdrive_transport.py +++ b/syft/sync/connections/drive/gdrive_transport.py @@ -76,7 +76,7 @@ def _waits_for_rejected_message(file_metadatas: list[dict], folder: str) -> bool Later messages can depend on the rejected one, so they wait until then. """ - if not any(m["name"].endswith(REJECTED_MESSAGE_SUFFIX) for m in file_metadatas): + if not any(m["name"].startswith(REJECTED_MESSAGE_PREFIX) for m in file_metadatas): return False logger.warning(f"The {folder} waits for the sender to resend a rejected message.") return True @@ -107,10 +107,11 @@ def build_drive_service( LEGACY_GDRIVE_OUTBOX_INBOX_FOLDER_PREFIX = "syft_outbox_inbox" # legacy prefix -# Suffix the recipient adds to a plaintext inbox or outbox message when the +# Prefix the recipient adds to a plaintext inbox or outbox message when the # pair encrypts. The sender then resends the message encrypted, under the -# original name. -REJECTED_MESSAGE_SUFFIX = ".rejected" +# original name. It is a prefix because Drive's ``name contains`` matches +# only the start of a name term. +REJECTED_MESSAGE_PREFIX = "rejected_" GDRIVE_P2P_FOLDER_DATASITE_PREFIX = "syft_datasite" SYFT_PEERS_FILE = "SYFT_peers.json" @@ -1123,7 +1124,7 @@ def _get_valid_events_from_file_metadatas( res = [] for file_metadata in file_metadatas: fname = file_metadata["name"] - if fname.endswith(REJECTED_MESSAGE_SUFFIX): + if fname.startswith(REJECTED_MESSAGE_PREFIX): continue try: message_filename = FileChangeEventsMessageFileName.from_string(fname) @@ -1139,7 +1140,7 @@ def _get_valid_messages_from_file_metadatas( ) -> list[MessageFileName]: res = [] for file_metadata in file_metadatas: - if file_metadata["name"].endswith(REJECTED_MESSAGE_SUFFIX): + if file_metadata["name"].startswith(REJECTED_MESSAGE_PREFIX): continue try: message_filename = MessageFileName.from_string(file_metadata["name"]) @@ -1182,20 +1183,20 @@ def owner_get_inbox_message_ids(self, sender_email: str) -> list[str]: ] def mark_message_rejected(self, file_id: str) -> None: - """Rename message file ``file_id`` with the rejected suffix, in place. + """Rename message file ``file_id`` with the rejected prefix, in place. Two readers on one datasite (a notebook and syft-bg) can reject the - same file; the second finds the suffix and leaves the name. + same file; the second finds the prefix and leaves the name. """ file_info = execute_with_retries( self.drive_service.files().get(fileId=file_id, fields="name") ) - if file_info["name"].endswith(REJECTED_MESSAGE_SUFFIX): + if file_info["name"].startswith(REJECTED_MESSAGE_PREFIX): return execute_with_retries( self.drive_service.files().update( fileId=file_id, - body={"name": file_info["name"] + REJECTED_MESSAGE_SUFFIX}, + body={"name": REJECTED_MESSAGE_PREFIX + file_info["name"]}, fields="id, name", supportsAllDrives=True, ) @@ -1218,17 +1219,30 @@ def owner_get_rejected_outbox_messages( def _rejected_messages_in_folder( self, folder_id: str | None ) -> list[RejectedMessage]: + """The rejected messages in ``folder_id``, without a listing of the whole folder.""" if folder_id is None: return [] - metadatas = self.get_file_metadatas_from_folder(folder_id) - names = {m["name"] for m in metadatas} res = [] - for m in metadatas: - if m["name"].endswith(REJECTED_MESSAGE_SUFFIX): - original = m["name"].removesuffix(REJECTED_MESSAGE_SUFFIX) - res.append(RejectedMessage(m["id"], original, original in names)) + for m in self._files_in_folder_named( + folder_id, f"name contains '{REJECTED_MESSAGE_PREFIX}'" + ): + # ``name contains`` is not exact: check the name here. + if m["name"].startswith(REJECTED_MESSAGE_PREFIX): + original = m["name"].removeprefix(REJECTED_MESSAGE_PREFIX) + resent = bool( + self._files_in_folder_named(folder_id, f"name = '{original}'") + ) + res.append(RejectedMessage(m["id"], original, resent)) return res + def _files_in_folder_named(self, folder_id: str, name_query: str) -> list[dict]: + """The files in ``folder_id`` that match ``name_query``, a Drive name clause.""" + query = f"'{folder_id}' in parents and {name_query} and trashed=false" + results = execute_with_retries( + self.drive_service.files().list(q=query, fields="files(id, name)") + ) + return results.get("files", []) + def owner_get_next_proposed_filechange_message( self, sender_email: str ) -> ProposedFileChangesMessage | None: diff --git a/syft/sync/encryption_policy.py b/syft/sync/encryption_policy.py index 7d5d279ccad..f7e6a3c90a1 100644 --- a/syft/sync/encryption_policy.py +++ b/syft/sync/encryption_policy.py @@ -63,9 +63,10 @@ def __init__(self, peer_email: str): class EncryptionPolicyConflictError(ValueError): """One side of the pair has encryption off and the other requires it.""" - def __init__(self, peer_email: str): + def __init__(self, peer_email: str, message: str | None = None): super().__init__( - f"Encryption with {peer_email} is off on one side and required on " + message + or f"Encryption with {peer_email} is off on one side and required on " "the other, so this pair cannot exchange messages. Set the same " "policy on both sides, or use optional on one of them." ) @@ -76,12 +77,11 @@ class PeerOutdatedError(EncryptionPolicyConflictError): """The peer runs a syft that predates encryption negotiation.""" def __init__(self, peer_email: str): - ValueError.__init__( - self, + super().__init__( + peer_email, f"{peer_email} runs an outdated syft: its version file has no " "encryption policy. Ask them to upgrade syft.", ) - self.peer_email = peer_email class UnencryptedMessageError(ValueError): diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 0695489dfa1..f35be66849d 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -6,14 +6,11 @@ import json import logging -import os -import tempfile +import time import warnings -from contextlib import contextmanager from pathlib import Path -from typing import Callable, Iterator, List, Optional +from typing import Callable, List, Optional -import portalocker import syft_crypto_python as syc from pydantic import BaseModel, PrivateAttr @@ -37,6 +34,7 @@ parse_and_validate_bundle, ) from syft.sync.peers.peer import Peer +from syft.sync.utils.locked_files import locked_file, write_json_atomically logger = logging.getLogger(__name__) @@ -66,6 +64,10 @@ # The version file that advertises a policy is not signed, so such a peer # stays required here until the user trusts a new key for it. REQUIRED_PEERS_KEY = "required_peers" +# Key in the crypto key file that records when this datasite started to +# require encryption (seconds since the epoch). A peer can have written +# plaintext to its outbox before then. +REQUIRED_SINCE_KEY = "required_since" def datasite_crypto_keys_path(syftbox_folder: Path | str, email: str) -> Path: @@ -98,6 +100,12 @@ class PeerStore(BaseModel): _policy_rereads: set[str] = PrivateAttr(default_factory=set) # Peers seen to require encryption (see REQUIRED_PEERS_KEY). _required_peers: set[str] = PrivateAttr(default_factory=set) + # Remembered peers not in the key file yet: there were no keys then. + _unsaved_required_peers: set[str] = PrivateAttr(default_factory=set) + # When this datasite started to require encryption (see REQUIRED_SINCE_KEY). + _required_since: float | None = PrivateAttr(default=None) + # The (mtime, inode, size) of the key file at the last reload. + _key_file_stamp: tuple[int, int, int] | None = PrivateAttr(default=None) # The last policy known of each peer in this process (see peer_policy). _last_known_policies: dict[str, EncryptionPolicy] = PrivateAttr( default_factory=dict @@ -148,8 +156,9 @@ def encrypt_if_needed(self, email: str, data: bytes) -> bytes: pinned peer bundle are missing. EncryptionPolicyConflictError: when the policies of the pair conflict. """ - if self.peer_conflicts(email): - raise self._conflict_error(email) + error = self._cached_conflict_error(email) + if error is not None: + raise error if not self.peer_policy_known(email): raise PeerEncryptionPolicyUnknownError(email) if not self.peer_uses_encryption(email): @@ -158,7 +167,9 @@ def encrypt_if_needed(self, email: str, data: bytes) -> bytes: raise PeerKeysNotReadyError(email) return self.encrypt(email, data) - def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: + def decrypt_and_verify_if_needed( + self, email: str, data: bytes, sent_at: float | None = None + ) -> bytes: """Verify and decrypt an envelope from ``email``; pass plaintext through. When the pair encrypts, plaintext is refused, so that a peer or the @@ -167,6 +178,10 @@ def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: encrypt, an envelope is still accepted: the peer can have started to require encryption after the last load. + ``sent_at`` is the time a message was written. Plaintext written + before this datasite started to require encryption is accepted, + unless the peer requires encryption itself. + Raises: PeerEncryptionPolicyUnknownError: when ``data`` is plaintext and the peer policy is not known. @@ -176,12 +191,15 @@ def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: or the pinned peer bundle are missing. EncryptionPolicyConflictError: when the policies of the pair conflict. """ - if self.peer_conflicts(email): - raise self._conflict_error(email) + error = self._cached_conflict_error(email) + if error is not None: + raise error if not self.is_envelope(data): if not self.peer_policy_known(email): raise PeerEncryptionPolicyUnknownError(email) - if self.peer_uses_encryption(email): + if self.peer_uses_encryption(email) and not self._sent_before_required( + email, sent_at + ): raise UnencryptedMessageError(email) return data if not self.encryption_ready(email): @@ -189,6 +207,15 @@ def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: self.verify_message(email, data) return self.decrypt(email, data) + def _sent_before_required(self, email: str, sent_at: float | None) -> bool: + """Whether plaintext from ``email`` sent at ``sent_at`` predates our switch.""" + return ( + sent_at is not None + and self._required_since is not None + and sent_at < self._required_since + and self.peer_policy(email) != EncryptionPolicy.REQUIRED + ) + @staticmethod def is_envelope(data: bytes) -> bool: """True if `data` is an SYC encryption envelope (vs plaintext).""" @@ -211,22 +238,24 @@ def decrypt_dataset_if_needed(self, owner_email: str, data: bytes) -> bytes: return self.decrypt(owner_email, data) def peer_policy(self, email: str) -> Optional[EncryptionPolicy]: - """The policy of ``email``, or None when not known. + """The policy of the cached peer ``email``, or None when not known.""" + peer = self.get_cached_peer(email) + return None if peer is None else self.policy_of(peer) + + def policy_of(self, peer: Peer) -> Optional[EncryptionPolicy]: + """The policy of ``peer``, or None when not known. A peer seen to require encryption stays required, whatever its version file says now (see ``remember_required_peer``). When the version file can no longer be read (the peer deleted its SyftBox, or a read failed), the last policy known in this process applies. """ - peer = self.get_cached_peer(email) - if peer is None: - return None - if email in self._required_peers: + if peer.email in self._required_peers: return EncryptionPolicy.REQUIRED policy = peer.encryption_policy if policy is None and not peer.is_outdated: - return self._last_known_policies.get(email) - self._last_known_policies[email] = policy + return self._last_known_policies.get(peer.email) + self._last_known_policies[peer.email] = policy return policy def is_remembered_required(self, email: str) -> bool: @@ -235,25 +264,40 @@ def is_remembered_required(self, email: str) -> bool: def remember_required_peer(self, email: str) -> None: """Keep ``email`` required, in memory and in the key file.""" if email not in self._required_peers: - self._required_peers.add(email) - self._persist_required_peers() + self._update_required_peers(added={email}) def forget_required_peer(self, email: str) -> None: """Stop keeping ``email`` required. For a trusted key change only.""" if email in self._required_peers: - self._required_peers.discard(email) - self._persist_required_peers(removed=email) + self._update_required_peers(removed={email}) - def _persist_required_peers(self, removed: str | None = None) -> None: - """Record the remembered peers in the key file, with those of other processes.""" + def _update_required_peers( + self, added: set[str] = frozenset(), removed: set[str] = frozenset() + ) -> None: + """Apply a change to the remembered peers, in the key file and in memory. + + Only the change is written, and memory then follows the file. So a + peer that another process forgot is not written back. Without keys, + the change stays in memory until ``_save_unsaved_required_peers``. + """ + self._required_peers = (self._required_peers | added) - removed if self._keys_path is None or self._private_keys is None: + self._unsaved_required_peers = ( + self._unsaved_required_peers | added + ) - removed return - with _locked_key_file(self._keys_path): + with locked_file(self._keys_path): data = self._read_key_file() or self._key_file_data() - stored = set(data.get(REQUIRED_PEERS_KEY, [])) - {removed} - self._required_peers |= stored - data = {**data, REQUIRED_PEERS_KEY: sorted(self._required_peers)} + stored = (set(data.get(REQUIRED_PEERS_KEY, [])) | added) - removed + data = {**data, REQUIRED_PEERS_KEY: sorted(stored)} _write_key_file(self._keys_path, data) + self._required_peers = stored + + def _save_unsaved_required_peers(self) -> None: + """Record the peers remembered while there were no keys.""" + unsaved, self._unsaved_required_peers = self._unsaved_required_peers, set() + if unsaved: + self._update_required_peers(added=unsaved) def peer_policy_known(self, email: str) -> bool: """Whether this client can select the format for ``email``. @@ -266,22 +310,23 @@ def encryption_ready(self, email: str) -> bool: """Whether our keys and the pinned bundle of ``email`` exist.""" return self.has_my_keys() and self.has_peer_bundle(email) - def peer_conflicts(self, email: str) -> bool: - """Whether this pair cannot exchange messages. + def conflict_error(self, peer: Peer) -> EncryptionPolicyConflictError | None: + """Why this client and ``peer`` cannot exchange messages, or None when they can. That is when one side has encryption off and the other requires it, or when the peer runs an outdated syft. """ - peer = self.get_cached_peer(email) - if peer is not None and peer.is_outdated: - return True - return pair_conflicts(self.encryption_policy, self.peer_policy(email)) + if peer.is_outdated: + return PeerOutdatedError(peer.email) + if pair_conflicts(self.encryption_policy, self.policy_of(peer)): + return EncryptionPolicyConflictError(peer.email) + return None - def _conflict_error(self, email: str) -> EncryptionPolicyConflictError: + def _cached_conflict_error( + self, email: str + ) -> EncryptionPolicyConflictError | None: peer = self.get_cached_peer(email) - if peer is not None and peer.is_outdated: - return PeerOutdatedError(email) - return EncryptionPolicyConflictError(email) + return None if peer is None else self.conflict_error(peer) def peer_uses_encryption(self, email: str) -> bool: """Whether the pair with ``email`` encrypts. False when the peer policy is not known.""" @@ -440,7 +485,7 @@ def ensure_keys(self) -> None: self.generate_keys() return unsaved_pins: list[tuple[str, dict]] = [] - with _locked_key_file(self._keys_path): + with locked_file(self._keys_path): if self._keys_path.exists(): unsaved_pins = self._take_key_file(self.load_keys(self._keys_path)) else: @@ -449,13 +494,13 @@ def ensure_keys(self) -> None: # Saving a pin takes the lock again, so it waits until here. for email, bundle in unsaved_pins: self._persist_peer_bundle(email, bundle) - self._persist_required_peers() + self._save_unsaved_required_peers() def _adopt_key_file(self, loaded: "PeerStore") -> None: """Take the keys, pins and recorded policy of ``loaded``, a store read from our key file.""" for email, bundle in self._take_key_file(loaded): self._persist_peer_bundle(email, bundle) - self._persist_required_peers() + self._save_unsaved_required_peers() def _take_key_file(self, loaded: "PeerStore") -> list[tuple[str, dict]]: """Like ``_adopt_key_file``, but return the pins to save, without file I/O. @@ -470,7 +515,8 @@ def _take_key_file(self, loaded: "PeerStore") -> list[tuple[str, dict]]: ) self._private_keys = loaded._private_keys self._at_rest_encrypted = loaded._at_rest_encrypted - self._required_peers |= loaded._required_peers + self._required_since = loaded._required_since + self._required_peers = loaded._required_peers | self._unsaved_required_peers if loaded.use_encryption: self.encryption_policy = EncryptionPolicy.REQUIRED if not self._peers: @@ -611,10 +657,25 @@ def encrypt_for_self_if_needed(self, data: bytes) -> bytes: return data def decrypt_and_verify_for_self_if_needed(self, data: bytes) -> bytes: - """Verify and decrypt self-encrypted data if encryption is enabled and keys are available.""" - if self.use_encryption and self.has_my_keys(): + """Verify and decrypt an envelope this client wrote for itself; pass plaintext through. + + Plaintext is refused once this client requires encryption and its + files at rest are encrypted. Until then, they can hold plaintext. It + reads the key file first, as ``encrypt_for_self_if_needed`` does. + + Raises: + ValueError: when ``data`` is plaintext, and the files at rest + must be encrypted. + """ + self.reload_policy_from_key_file() + if self.is_envelope(data): self.verify_message_from_self(data) return self.decrypt_for_self(data) + if self.use_encryption and self._at_rest_encrypted: + raise ValueError( + f"A file of {self.email} at rest is not encrypted, and the " + "datasite requires encryption." + ) return data # ========== Persistence ========== @@ -637,32 +698,41 @@ def _key_file_data(self) -> dict: ENCRYPTION_POLICY_KEY: self.encryption_policy.value, AT_REST_ENCRYPTED_KEY: self._at_rest_encrypted, REQUIRED_PEERS_KEY: sorted(self._required_peers), + REQUIRED_SINCE_KEY: self._required_since, } def reload_policy_from_key_file(self) -> None: - """Take a required policy that another process recorded in the key file. + """Take what other processes on this datasite recorded in the key file. - A notebook that started optional must not write plaintext at rest once - syft-bg made the datasite required. + That is a required policy, the remembered peers, and the encryption + of the files at rest. A notebook that started optional must not write + plaintext at rest once syft-bg made the datasite required. The file + is read again only after it changes. Raises: ValueError: when this client has encryption off. """ - if self.use_encryption or self._keys_path is None: + if self._keys_path is None: + return + stamp = _file_stamp(self._keys_path) + if stamp is None or stamp == self._key_file_stamp: return - # Most syncs find no change: read the policy before the keys. data = self._read_key_file() - recorded = ( - data.get(ENCRYPTION_POLICY_KEY, EncryptionPolicy.REQUIRED.value) - if data - else None - ) - if recorded != EncryptionPolicy.REQUIRED.value: + if data is None: return - loaded = self.load_keys(self._keys_path) - if self.encryption_policy == EncryptionPolicy.OFF: - raise _off_refused_error(self._keys_path) - self._adopt_key_file(loaded) + recorded = data.get(ENCRYPTION_POLICY_KEY, EncryptionPolicy.REQUIRED.value) + if not self.use_encryption and recorded == EncryptionPolicy.REQUIRED.value: + loaded = self.load_keys(self._keys_path) + if self.encryption_policy == EncryptionPolicy.OFF: + raise _off_refused_error(self._keys_path) + self._adopt_key_file(loaded) + elif self.has_my_keys(): + self._required_peers = ( + set(data.get(REQUIRED_PEERS_KEY, [])) | self._unsaved_required_peers + ) + self._at_rest_encrypted |= bool(data.get(AT_REST_ENCRYPTED_KEY)) + self._required_since = data.get(REQUIRED_SINCE_KEY, self._required_since) + self._key_file_stamp = stamp def needs_at_rest_encryption(self) -> bool: """Whether this client is required, but its files at rest can be plaintext.""" @@ -680,11 +750,11 @@ def encrypt_files_at_rest_once(self, encrypt_files: Callable[[], None]) -> None: encrypt_files() else: at_rest_lock = self._keys_path.with_name(self._keys_path.name + ".at_rest") - with _locked_key_file(at_rest_lock): + with locked_file(at_rest_lock): data = self._read_key_file() or {} if not data.get(AT_REST_ENCRYPTED_KEY): encrypt_files() - with _locked_key_file(self._keys_path): + with locked_file(self._keys_path): data = self._read_key_file() or self._key_file_data() data = {**data, AT_REST_ENCRYPTED_KEY: True} _write_key_file(self._keys_path, data) @@ -694,18 +764,23 @@ def _record_required_policy(self) -> None: """Record a required policy in the key file, keeping what else it holds.""" if self._keys_path is None or not self.use_encryption: return - with _locked_key_file(self._keys_path): + with locked_file(self._keys_path): data = self._read_key_file() or self._key_file_data() if data.get(ENCRYPTION_POLICY_KEY) == EncryptionPolicy.REQUIRED.value: return - data = {**data, ENCRYPTION_POLICY_KEY: EncryptionPolicy.REQUIRED.value} + self._required_since = time.time() + data = { + **data, + ENCRYPTION_POLICY_KEY: EncryptionPolicy.REQUIRED.value, + REQUIRED_SINCE_KEY: self._required_since, + } _write_key_file(self._keys_path, data) _warn_key_file_holds_data(self._keys_path) def save_keys(self, path: Path) -> None: data = self._key_file_data() path = Path(path) - with _locked_key_file(path): + with locked_file(path): _write_key_file(path, data) self._keys_path = path @@ -750,7 +825,7 @@ def _persist_peer_bundle(self, peer_email: str, bundle: dict) -> None: """ if self._keys_path is None or self._private_keys is None: return - with _locked_key_file(self._keys_path): + with locked_file(self._keys_path): data = self._read_key_file() or self._key_file_data() _write_key_file(self._keys_path, _with_pin(data, peer_email, bundle)) @@ -771,6 +846,7 @@ def load_keys(cls, path: Path) -> "PeerStore": store = cls(email=data["email"], encryption_policy=EncryptionPolicy(policy)) store._at_rest_encrypted = bool(data.get(AT_REST_ENCRYPTED_KEY)) store._required_peers = set(data.get(REQUIRED_PEERS_KEY, [])) + store._required_since = data.get(REQUIRED_SINCE_KEY) store._private_keys = syc.SyftPrivateKeys.from_jwks(data["keys_jwk"]) store._keys_path = Path(path) store._peers = _peers_from_stored_pins(data.get(PEER_BUNDLES_KEY, {})) @@ -816,6 +892,7 @@ def create( store._record_required_policy() elif store.use_encryption: store.generate_keys() + store._required_since = time.time() store.save_keys(store._keys_path) _warn_key_file_holds_data(store._keys_path) return store @@ -839,22 +916,6 @@ def _warn_key_file_holds_data(keys_path: Path) -> None: ) -@contextmanager -def _locked_key_file(path: Path) -> Iterator[None]: - """Hold an exclusive cross-process lock on the key file at ``path``. - - The lock is on a sibling ``.lock`` file, as in ``PersistedDict``, so the - key file itself can be replaced while the lock is held. - """ - path.parent.mkdir(parents=True, exist_ok=True) - with open(path.with_suffix(f"{path.suffix}.lock"), "a") as f: - portalocker.lock(f, portalocker.LOCK_EX) - try: - yield - finally: - portalocker.unlock(f) - - def _with_pin(data: dict, peer_email: str, bundle: dict) -> dict: """``data`` from the key file, with ``bundle`` recorded as the pin of ``peer_email``.""" bundles = data.get(PEER_BUNDLES_KEY) @@ -866,22 +927,17 @@ def _with_pin(data: dict, peer_email: str, bundle: dict) -> dict: def _write_key_file(path: Path, data: dict) -> None: - """Replace the key file at ``path`` with ``data`` in one step. + """Replace the key file at ``path`` with ``data``. It holds the only copy of the private keys.""" + write_json_atomically(path, data, indent=2) - The key file holds the only copy of the private keys, so a write cut short - must leave the old file in place. The data goes to a temporary file in the - same folder, reaches the disk, and then takes the name of the key file. - ``mkstemp`` creates the temporary file readable by this user only. - """ - fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + +def _file_stamp(path: Path) -> tuple[int, int, int] | None: + """(mtime, inode, size) of the file at ``path``, or None when it is missing.""" try: - with os.fdopen(fd, "w") as f: - f.write(json.dumps(data, indent=2)) - f.flush() - os.fsync(f.fileno()) - os.replace(tmp, path) - finally: - Path(tmp).unlink(missing_ok=True) + st = path.stat() + except FileNotFoundError: + return None + return (st.st_mtime_ns, st.st_ino, st.st_size) def _peers_from_stored_pins(bundles: dict[str, dict]) -> List[Peer]: diff --git a/syft/sync/sync/caches/datasite_watcher_cache.py b/syft/sync/sync/caches/datasite_watcher_cache.py index 425a34c29a4..f77374be62a 100644 --- a/syft/sync/sync/caches/datasite_watcher_cache.py +++ b/syft/sync/sync/caches/datasite_watcher_cache.py @@ -241,11 +241,12 @@ def sync_down_parallel( self, peer_email: str, executor: ThreadPoolExecutor, - download_fn: Callable[[str], FileChangeEventsMessage | None], + download_fn: Callable[[dict], FileChangeEventsMessage | None], ) -> int: """Sync with parallel file downloads. Returns the number of events applied. - ``download_fn`` returns None for a message that was rejected or cannot + ``download_fn`` gets the metadata of one file (``file_id`` and + ``timestamp``). It returns None for a message that was rejected or cannot be read yet. Messages from its timestamp on are not applied, and the cursor stays before it, so they apply in order when it can be read. """ @@ -263,8 +264,7 @@ def sync_down_parallel( return 0 # Download all files in parallel - file_ids = [m["file_id"] for m in file_metadatas] - downloaded_messages = list(executor.map(download_fn, file_ids)) + downloaded_messages = list(executor.map(download_fn, file_metadatas)) # Apply in timestamp order, up to the first rejected message event_count = 0 diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index 9ad1e9c8dc3..c9ae7719a32 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -260,7 +260,7 @@ def on_file_change( # --- Pulling from datasite outboxes --- def download_events_message_with_new_connection( - self, file_id: str, peer_email: str + self, file_id: str, peer_email: str, sent_at: float | None = None ) -> FileChangeEventsMessage | None: """Download from outbox using a new connection (thread-safe). @@ -269,7 +269,7 @@ def download_events_message_with_new_connection( """ connection = self.connection_router.connection_for_parallel_download() return self.connection_router.watcher_read_events_message( - connection, peer_email, file_id + connection, peer_email, file_id, sent_at ) def download_collection_file_with_new_connection( @@ -289,9 +289,9 @@ def sync_down(self, peer_emails: list[str]): event_count = self.datasite_watcher_cache.sync_down_parallel( peer_email, self._executor, - lambda fid, + lambda meta, pe=peer_email: self.download_events_message_with_new_connection( - fid, pe + meta["file_id"], pe, meta["timestamp"] ), ) if event_count: diff --git a/syft/sync/sync/held_messages.py b/syft/sync/sync/held_messages.py index 7adc9eabed7..103c740a11a 100644 --- a/syft/sync/sync/held_messages.py +++ b/syft/sync/sync/held_messages.py @@ -9,13 +9,11 @@ import base64 import json -import os -import tempfile from contextlib import contextmanager from pathlib import Path from typing import Any, Callable, Iterable, Iterator -import portalocker +from syft.sync.utils.locked_files import locked_file, write_json_atomically # Format of the file: {"version": N, "messages": [...]}. Raise it when the # layout of a message entry changes. @@ -36,25 +34,15 @@ def edit(self) -> Iterator[list[HeldMessage]]: When the block raises, the file stays as it was. """ - self._path.parent.mkdir(parents=True, exist_ok=True) - with open(self._path.with_suffix(".lock"), "a") as lock_file: - portalocker.lock(lock_file, portalocker.LOCK_EX) - try: - messages = self._read() - yield messages - self._write(messages) - finally: - portalocker.unlock(lock_file) + with locked_file(self._path): + messages = self._read() + yield messages + self._write(messages) def read(self) -> list[HeldMessage]: """The held messages, read under the lock, without a write.""" - self._path.parent.mkdir(parents=True, exist_ok=True) - with open(self._path.with_suffix(".lock"), "a") as lock_file: - portalocker.lock(lock_file, portalocker.LOCK_EX) - try: - return self._read() - finally: - portalocker.unlock(lock_file) + with locked_file(self._path): + return self._read() def send_all( self, @@ -117,10 +105,4 @@ def _write(self, messages: list[HeldMessage]) -> None: for r, d in messages ], } - fd, tmp = tempfile.mkstemp(dir=self._path.parent, suffix=".tmp") - try: - with os.fdopen(fd, "w") as f: - f.write(json.dumps(data)) - os.replace(tmp, self._path) - finally: - Path(tmp).unlink(missing_ok=True) + write_json_atomically(self._path, data) diff --git a/syft/sync/utils/locked_files.py b/syft/sync/utils/locked_files.py new file mode 100644 index 00000000000..49c2e064fcd --- /dev/null +++ b/syft/sync/utils/locked_files.py @@ -0,0 +1,45 @@ +"""Cross-process locks and atomic writes for small local JSON files.""" + +import json +import os +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +import portalocker + + +@contextmanager +def locked_file(path: Path) -> Iterator[None]: + """Hold an exclusive cross-process lock on the file at ``path``. + + The lock is on a sibling ``.lock`` file, so the file itself can be + replaced while the lock is held. + """ + path.parent.mkdir(parents=True, exist_ok=True) + with open(path.with_suffix(f"{path.suffix}.lock"), "a") as f: + portalocker.lock(f, portalocker.LOCK_EX) + try: + yield + finally: + portalocker.unlock(f) + + +def write_json_atomically(path: Path, data: Any, indent: int | None = None) -> None: + """Replace the file at ``path`` with ``data`` as JSON, in one step. + + A write cut short leaves the old file in place. The data goes to a + temporary file in the same folder, reaches the disk, and then takes the + name of the file. ``mkstemp`` creates the temporary file readable by this + user only. + """ + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "w") as f: + f.write(json.dumps(data, indent=indent)) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + finally: + Path(tmp).unlink(missing_ok=True) diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index ba97d77308a..05865e6b9f0 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -19,7 +19,6 @@ from syft.sync.connections.drive.gdrive_retry import RETRYABLE_TRANSPORT_ERRORS from syft.sync.encryption_policy import ( EncryptionPolicy, - pair_conflicts, pair_uses_encryption, ) from syft.sync.peers.key_bundle import ( @@ -57,21 +56,6 @@ class CompatAction(str, Enum): EXECUTE = "execute" -def _conflict_skip_message(peer_email: str) -> str: - return ( - f"{peer_email} is skipped: encryption is off on one side and required " - "on the other. Set the same policy on both sides, or use optional on " - "one of them." - ) - - -def _outdated_skip_message(peer_email: str) -> str: - return ( - f"{peer_email} is skipped: it runs an outdated syft, which does not " - "advertise an encryption policy. Ask them to upgrade syft." - ) - - # Key wrapping the DID document inside a published encryption bundle file. BUNDLE_FILE_KEY = "public_encryption_bundle" @@ -587,12 +571,11 @@ def without_encryption_conflicts(self, peer_emails: List[str]) -> List[str]: result = [] for email in peer_emails: peer = self.peer_store.get_cached_peer(email) - if peer is not None and peer.is_outdated: - warnings.warn(_outdated_skip_message(email)) - elif self.peer_store.peer_conflicts(email): - warnings.warn(_conflict_skip_message(email)) - else: + error = None if peer is None else self.peer_store.conflict_error(peer) + if error is None: result.append(email) + else: + warnings.warn(f"{email} is skipped: {error}") return result def warn_if_all_peers_incompatible(self, peer_emails: List[str]) -> None: @@ -674,16 +657,14 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): new_peer_obj.version = version_info self._note_peer_policy(new_peer_obj) encrypted = pair_uses_encryption( - self.peer_store.encryption_policy, new_peer_obj.encryption_policy + self.peer_store.encryption_policy, + self.peer_store.policy_of(new_peer_obj), ) if encrypted: self._write_encryption_bundle_for_peer(peer_email) - if new_peer_obj.is_outdated: - warnings.warn(_outdated_skip_message(peer_email)) - elif pair_conflicts( - self.peer_store.encryption_policy, new_peer_obj.encryption_policy - ): - warnings.warn(_conflict_skip_message(peer_email)) + error = self.peer_store.conflict_error(new_peer_obj) + if error is not None: + warnings.warn(f"{peer_email} is skipped: {error}") # Accepting a request or re-adding an accepted peer with force is an # explicit user decision, so a changed key is adopted with a warning. diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index c41ebb60981..15103ef31b5 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -368,7 +368,8 @@ def test_peer_encryption_resolved_from_both_policies(mine, theirs, encrypted): def test_off_and_required_conflict(mine, theirs): """Neither side gives way, so the pair cannot exchange messages.""" alice, bob = _keyed_pair(mine, theirs) - assert alice.peer_conflicts("bob@example.com") + peer = alice.get_cached_peer("bob@example.com") + assert isinstance(alice.conflict_error(peer), EncryptionPolicyConflictError) with pytest.raises(EncryptionPolicyConflictError): alice.encrypt_if_needed("bob@example.com", b"data") with pytest.raises(EncryptionPolicyConflictError): @@ -628,7 +629,7 @@ def test_reload_reads_keys_only_for_a_required_key_file(tmp_path, monkeypatch): def test_at_rest_pass_leaves_key_file_lock_free(tmp_path): """A notebook can pin keys while syft-bg encrypts the files at rest.""" - from syft.sync.peers.peer_store import _locked_key_file + from syft.sync.utils.locked_files import locked_file keys_path = tmp_path / "crypto_keys.json" store = PeerStore.create(email="alice@example.com", keys_path=keys_path) @@ -638,7 +639,7 @@ def test_at_rest_pass_leaves_key_file_lock_free(tmp_path): def encrypt_files(): def other_process(): - with _locked_key_file(keys_path): + with locked_file(keys_path): took_lock.append(True) thread = threading.Thread(target=other_process, daemon=True) @@ -661,6 +662,58 @@ def test_write_at_rest_takes_required_policy_from_other_process(tmp_path): assert _is_envelope(notebook.encrypt_for_self_if_needed(b"rolling state")) +def test_read_at_rest_accepts_plaintext_only_until_files_are_encrypted(tmp_path): + """Another process made the datasite required; its one-time pass is pending.""" + keys_path = tmp_path / "crypto_keys.json" + notebook = _create(keys_path) + notebook.ensure_keys() + with pytest.warns(UserWarning): + _create(keys_path, REQUIRED) + assert notebook.decrypt_and_verify_for_self_if_needed(b"plain") == b"plain" + + daemon = PeerStore.load_keys(keys_path) + envelope = daemon.encrypt_for_self(b"secret") + assert notebook.decrypt_and_verify_for_self_if_needed(envelope) == b"secret" + + daemon.encrypt_files_at_rest_once(lambda: None) + with pytest.raises(ValueError, match="not encrypted"): + notebook.decrypt_and_verify_for_self_if_needed(b"plain") + + +def test_trusted_peer_stays_forgotten_in_other_process(tmp_path): + """syft-bg must not write back a peer that trust_peer_key cleared.""" + keys_path = tmp_path / "crypto_keys.json" + with pytest.warns(UserWarning): + notebook = _create(keys_path, REQUIRED) + daemon = _create(keys_path) + notebook.remember_required_peer("bob@example.com") + daemon.reload_policy_from_key_file() + assert daemon.is_remembered_required("bob@example.com") + + notebook.forget_required_peer("bob@example.com") + daemon.remember_required_peer("carol@example.com") + stored = json.loads(keys_path.read_text())["required_peers"] + assert stored == ["carol@example.com"] + daemon.reload_policy_from_key_file() + assert not daemon.is_remembered_required("bob@example.com") + + +def test_key_file_read_again_only_after_it_changes(tmp_path, monkeypatch): + keys_path = tmp_path / "crypto_keys.json" + store = _create(keys_path) + store.ensure_keys() + reads = [] + original = PeerStore._read_key_file + monkeypatch.setattr( + PeerStore, + "_read_key_file", + lambda self: reads.append(1) or original(self), + ) + for _ in range(3): + store.encrypt_for_self_if_needed(b"data") + assert len(reads) == 1 + + def test_concurrent_processes_make_one_key_pair(tmp_path): """A notebook and syft-bg that need keys at once must end with the same keys.""" keys_path = tmp_path / "crypto_keys.json" @@ -1037,6 +1090,47 @@ def fail(self, peer_email): assert do_manager.email not in ds_manager.peer_manager._bundle_published_for +def test_add_peer_publishes_bundle_for_remembered_required_peer(monkeypatch): + """add_peer follows the same policy as other paths, not the raw version file.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + ds_encryption_policy=OPTIONAL, + do_encryption_policy=REQUIRED, + ) + ds_manager.load_peers() + _advertise_policy(do_manager, OPTIONAL) + writes = [] + original = ConnectionRouter.write_encryption_bundle + monkeypatch.setattr( + ConnectionRouter, + "write_encryption_bundle", + lambda self, peer_email, bundle_json: writes.append(peer_email) + or original(self, peer_email, bundle_json), + ) + with pytest.warns(UserWarning, match="required encryption before"): + ds_manager.add_peer(do_manager.email, force=True, sync=False) + assert writes == [do_manager.email] + + +def test_outbox_history_from_before_switch_stays_readable(): + """A client that turns required accepts plaintext written before it did.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + grant_job_inbox_access(do_manager, ds_manager.email) + file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") + ds_manager._send_file_change(file_path, "history") + do_manager.sync() + + keys_path = ds_manager.peer_manager.peer_store._keys_path + with pytest.warns(UserWarning): + PeerStore.create( + email=ds_manager.email, encryption_policy=REQUIRED, keys_path=keys_path + ) + ds_manager.sync() + + outbox = do_manager.datasite_owner_syncer.connection_router.connection_for_outbox() + assert outbox.owner_get_rejected_outbox_messages(ds_manager.email) == [] + assert any("history" in c for c in _ds_cached_contents(ds_manager)) + + def test_owner_waits_on_envelope_from_unpinned_peer(): """The sync goes on; the message stays in the inbox until the pin exists.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( @@ -1505,6 +1599,20 @@ def spy(self, recipient): assert calls == [] +def test_rejected_message_lookup_does_not_list_whole_folder(monkeypatch): + ds_manager, do_manager = _required_ds_pair() + calls = [] + original = GDriveConnection.get_file_metadatas_from_folder + monkeypatch.setattr( + GDriveConnection, + "get_file_metadatas_from_folder", + lambda self, *a, **k: calls.append(a) or original(self, *a, **k), + ) + router = do_manager.datasite_owner_syncer.connection_router + router.owner_resend_rejected_outbox_messages(ds_manager.email) + assert calls == [] + + def test_resend_waits_while_peer_bundle_not_pinned(): ds_manager, do_manager = _pair_with_stale_sender_view() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") diff --git a/tests/unit/test_peer_key_pinning.py b/tests/unit/test_peer_key_pinning.py index 1b743375c2d..363a2a713f0 100644 --- a/tests/unit/test_peer_key_pinning.py +++ b/tests/unit/test_peer_key_pinning.py @@ -339,7 +339,7 @@ def test_failed_key_file_write_leaves_old_file(tmp_path, monkeypatch): def crash(src, dst): raise OSError("disk full") - monkeypatch.setattr("syft.sync.peers.peer_store.os.replace", crash) + monkeypatch.setattr("syft.sync.utils.locked_files.os.replace", crash) with pytest.raises(OSError, match="disk full"): alice.set_peer_bundle(BOB, _store(BOB).get_public_bundle()) monkeypatch.undo() From 955508003f2a7d639aed8e97d4242561ef35f727 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Thu, 8 Oct 2026 14:09:25 -0300 Subject: [PATCH 09/30] fix(sync): accept plaintext at rest written before the pass, harden rejected lookup - Record when the files at rest were encrypted; accept older plaintext - Read the key file once per batch of files at rest - Take key file data in a process without keys - Escape names in Drive queries; read every page - Check resent names in one query per batch --- syft/sync/connections/base_connection.py | 4 +- syft/sync/connections/connection_router.py | 49 +++++--- .../connections/drive/gdrive_transport.py | 65 +++++++--- .../connections/drive/mock_drive_service.py | 29 ++++- syft/sync/peers/peer_store.py | 82 +++++++------ syft/sync/sync/datasite_owner_syncer.py | 7 +- tests/unit/test_encryption_policy.py | 115 +++++++++++++++--- 7 files changed, 262 insertions(+), 89 deletions(-) diff --git a/syft/sync/connections/base_connection.py b/syft/sync/connections/base_connection.py index 346d0b7924d..d372b29a67a 100644 --- a/syft/sync/connections/base_connection.py +++ b/syft/sync/connections/base_connection.py @@ -4,10 +4,10 @@ class RejectedMessage(NamedTuple): - """A message file that the recipient renamed with the rejected suffix.""" + """A message file that the recipient renamed with the rejected prefix.""" file_id: str - # The file name before the suffix. + # The file name without the prefix. filename: str # True when a file with the original name is in the folder again. resent: bool diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index c7e9a5acc0a..a2e8e66dca3 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -1,6 +1,6 @@ import logging -from typing import TYPE_CHECKING, Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, TypeVar from pydantic import BaseModel, PrivateAttr from syft_migration import MigratableObject, ProtocolSchema @@ -46,6 +46,10 @@ logger = logging.getLogger(__name__) +# A file at rest, decoded: it has a ``timestamp``. +AtRest = TypeVar("AtRest") + + class ConnectionRouter(BaseModel): connections: List[SyftboxPlatformConnection] @@ -382,13 +386,27 @@ def owner_write_events_message_to_syftbox( def owner_get_all_accepted_events_messages(self) -> List[FileChangeEventsMessage]: connection = self.connection_for_eventlog() raw_list = connection.owner_download_all_raw_events_from_syftbox() + self.peer_store.reload_policy_from_key_file() return [ - FileChangeEventsMessage.from_compressed_data( - self.peer_store.decrypt_and_verify_for_self_if_needed(raw) - ) + self.decode_at_rest(raw, FileChangeEventsMessage.from_compressed_data) for raw in raw_list ] + def decode_at_rest( + self, raw: bytes, decode: Callable[[bytes], "AtRest"] + ) -> "AtRest": + """Verify, decrypt and decode one file at rest with ``decode``. + + The caller reads the key file first (``reload_policy_from_key_file``), + once for a batch of files. Plaintext is checked by the time it was + written (see ``PeerStore.check_plaintext_at_rest``). + """ + if self.peer_store.is_envelope(raw): + return decode(self.peer_store.decrypt_and_verify_for_self_if_needed(raw)) + item = decode(raw) + self.peer_store.check_plaintext_at_rest(item.timestamp) + return item + def owner_encrypt_files_at_rest(self) -> None: """Encrypt the plaintext files at rest once, after a switch to required. @@ -416,8 +434,8 @@ def download_events_message_by_id( """Download and decrypt a single events message (thread-safe).""" connection = self.connection_for_eventlog(create_new=True) raw = connection.owner_download_raw_bytes_by_id(events_message_id) - raw = self.peer_store.decrypt_and_verify_for_self_if_needed(raw) - return FileChangeEventsMessage.from_compressed_data(raw) + self.peer_store.reload_policy_from_key_file() + return self.decode_at_rest(raw, FileChangeEventsMessage.from_compressed_data) # ========================================================================= # PEER MANAGEMENT @@ -700,8 +718,8 @@ def get_latest_checkpoint(self) -> Checkpoint | None: if raw is None: return None try: - raw = self.peer_store.decrypt_and_verify_for_self_if_needed(raw) - return Checkpoint.from_compressed_data(raw) + self.peer_store.reload_policy_from_key_file() + return self.decode_at_rest(raw, Checkpoint.from_compressed_data) except Exception as e: print(f"Warning: Failed to load checkpoint: {e}") return None @@ -719,10 +737,9 @@ def get_events_messages_since_timestamp( """Download, decrypt, and deserialize events since a timestamp.""" connection = self.connection_for_eventlog() raw_list = connection.download_raw_events_since_timestamp(since_timestamp) + self.peer_store.reload_policy_from_key_file() return [ - FileChangeEventsMessage.from_compressed_data( - self.peer_store.decrypt_and_verify_for_self_if_needed(raw) - ) + self.decode_at_rest(raw, FileChangeEventsMessage.from_compressed_data) for raw in raw_list ] @@ -741,11 +758,13 @@ def get_all_incremental_checkpoints(self) -> List[IncrementalCheckpoint]: """Download, decrypt, and deserialize all incremental checkpoints.""" connection = self.connection_for_own_syftbox() raw_list = connection.download_all_raw_incremental_checkpoints() + self.peer_store.reload_policy_from_key_file() checkpoints = [] for raw in raw_list: try: - raw = self.peer_store.decrypt_and_verify_for_self_if_needed(raw) - cp = IncrementalCheckpoint.from_compressed_data(raw) + cp = self.decode_at_rest( + raw, IncrementalCheckpoint.from_compressed_data + ) checkpoints.append(cp) except Exception as e: print(f"Warning: Failed to load incremental checkpoint: {e}") @@ -785,8 +804,8 @@ def get_rolling_state(self) -> RollingState | None: if raw is None: return None try: - raw = self.peer_store.decrypt_and_verify_for_self_if_needed(raw) - return RollingState.from_compressed_data(raw) + self.peer_store.reload_policy_from_key_file() + return self.decode_at_rest(raw, RollingState.from_compressed_data) except Exception as e: print(f"Warning: Failed to load rolling state: {e}") return None diff --git a/syft/sync/connections/drive/gdrive_transport.py b/syft/sync/connections/drive/gdrive_transport.py index e81445f7bc7..d8a35bddf8f 100644 --- a/syft/sync/connections/drive/gdrive_transport.py +++ b/syft/sync/connections/drive/gdrive_transport.py @@ -71,6 +71,11 @@ logger = logging.getLogger(__name__) +def _query_string(value: str) -> str: + """``value`` escaped for a quoted string in a Drive query.""" + return value.replace("\\", "\\\\").replace("'", "\\'") + + def _waits_for_rejected_message(file_metadatas: list[dict], folder: str) -> bool: """Whether a message folder holds a rejected message the sender must resend. @@ -112,6 +117,8 @@ def build_drive_service( # original name. It is a prefix because Drive's ``name contains`` matches # only the start of a name term. REJECTED_MESSAGE_PREFIX = "rejected_" +# Names in one Drive query that checks which names a folder has. +NAMES_PER_QUERY = 50 GDRIVE_P2P_FOLDER_DATASITE_PREFIX = "syft_datasite" SYFT_PEERS_FILE = "SYFT_peers.json" @@ -1222,26 +1229,50 @@ def _rejected_messages_in_folder( """The rejected messages in ``folder_id``, without a listing of the whole folder.""" if folder_id is None: return [] - res = [] - for m in self._files_in_folder_named( - folder_id, f"name contains '{REJECTED_MESSAGE_PREFIX}'" - ): - # ``name contains`` is not exact: check the name here. - if m["name"].startswith(REJECTED_MESSAGE_PREFIX): - original = m["name"].removeprefix(REJECTED_MESSAGE_PREFIX) - resent = bool( - self._files_in_folder_named(folder_id, f"name = '{original}'") - ) - res.append(RejectedMessage(m["id"], original, resent)) - return res + # ``name contains`` is not exact: check the name here. + rejected = [ + m + for m in self._files_in_folder_named( + folder_id, f"name contains '{REJECTED_MESSAGE_PREFIX}'" + ) + if m["name"].startswith(REJECTED_MESSAGE_PREFIX) + ] + originals = [m["name"].removeprefix(REJECTED_MESSAGE_PREFIX) for m in rejected] + present = self._names_in_folder(folder_id, originals) + return [ + RejectedMessage(m["id"], original, original in present) + for m, original in zip(rejected, originals) + ] + + def _names_in_folder(self, folder_id: str, names: list[str]) -> set[str]: + """The names of ``names`` that a file in ``folder_id`` has, with one query per batch.""" + present: set[str] = set() + for i in range(0, len(names), NAMES_PER_QUERY): + clause = " or ".join( + f"name = '{_query_string(name)}'" + for name in names[i : i + NAMES_PER_QUERY] + ) + files = self._files_in_folder_named(folder_id, f"({clause})") + present |= {m["name"] for m in files} + return present def _files_in_folder_named(self, folder_id: str, name_query: str) -> list[dict]: - """The files in ``folder_id`` that match ``name_query``, a Drive name clause.""" + """All files in ``folder_id`` that match ``name_query``, a Drive name clause.""" query = f"'{folder_id}' in parents and {name_query} and trashed=false" - results = execute_with_retries( - self.drive_service.files().list(q=query, fields="files(id, name)") - ) - return results.get("files", []) + files: list[dict] = [] + page_token = None + while True: + results = execute_with_retries( + self.drive_service.files().list( + q=query, + fields="files(id, name), nextPageToken", + pageToken=page_token, + ) + ) + files += results.get("files", []) + page_token = results.get("nextPageToken") + if not page_token: + return files def owner_get_next_proposed_filechange_message( self, sender_email: str diff --git a/syft/sync/connections/drive/mock_drive_service.py b/syft/sync/connections/drive/mock_drive_service.py index a8221543f3f..f98cf6b589d 100644 --- a/syft/sync/connections/drive/mock_drive_service.py +++ b/syft/sync/connections/drive/mock_drive_service.py @@ -133,6 +133,14 @@ def _folders(self) -> List[MockDriveFile]: ] +def _name_equals_value(clause: str) -> str | None: + """The name in a "name = 'X'" clause, unescaped, or None for another clause.""" + match = re.match(r"name\s*=\s*'((?:[^'\\]|\\.)*)'", clause, re.IGNORECASE) + if match is None: + return None + return re.sub(r"\\(.)", r"\1", match.group(1)) + + def parse_gdrive_query( query: str, current_user: str ) -> Callable[[MockDriveFile], bool]: @@ -147,7 +155,10 @@ def parse_gdrive_query( - 'me' in owners - Current user is owner - not 'me' in owners - Current user is NOT owner - trashed=false - Not trashed + - (name = 'X' or name = 'Y') - Any of these names - Combinations with 'and' + + A quoted name can hold ``\\'`` and ``\\\\`` escapes. """ if not query or not query.strip(): return lambda f: True @@ -158,6 +169,19 @@ def parse_gdrive_query( for part in parts: part = part.strip() + # Handle "(name = 'X' or name = 'Y')" + if part.startswith("(") and part.endswith(")"): + names = { + _name_equals_value(p) + for p in re.split(r"\s+or\s+", part[1:-1], flags=re.IGNORECASE) + } + + def name_in(f, ns=names): + return f.name in ns + + conditions.append(name_in) + continue + # Handle "not 'me' in owners" if re.match(r"not\s+['\"]me['\"]\s+in\s+owners", part, re.IGNORECASE): @@ -208,9 +232,8 @@ def has_parent(f, pid=parent_id): continue # Handle "name='X'" or "name = 'X'" - match = re.match(r"name\s*=\s*['\"]([^'\"]+)['\"]", part, re.IGNORECASE) - if match: - name_val = match.group(1) + name_val = _name_equals_value(part) + if name_val is not None: def name_equals(f, n=name_val): return f.name == n diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index f35be66849d..4bf1063aec9 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -55,11 +55,12 @@ # loads the file (a notebook and syft-bg) then uses the same policy. A file # without it was written when only required clients had keys. ENCRYPTION_POLICY_KEY = "encryption_policy" -# Key in the crypto key file that says the files of this datasite at rest -# (event log, checkpoints, rolling state) are encrypted. A required client -# encrypts them once and sets it: an optional client, or an earlier client -# without encryption, can have written plaintext there. -AT_REST_ENCRYPTED_KEY = "at_rest_encrypted" +# Key in the crypto key file that records when the files of this datasite at +# rest (event log, checkpoints, rolling state) were encrypted (seconds since +# the epoch). A required client encrypts them once and sets it: an optional +# client, or an earlier client without encryption, can have written plaintext +# there. +AT_REST_ENCRYPTED_KEY = "at_rest_encrypted_at" # Key in the crypto key file that lists the peers seen to require encryption. # The version file that advertises a policy is not signed, so such a peer # stays required here until the user trusts a new key for it. @@ -110,8 +111,8 @@ class PeerStore(BaseModel): _last_known_policies: dict[str, EncryptionPolicy] = PrivateAttr( default_factory=dict ) - # Whether the files at rest are known to be encrypted. - _at_rest_encrypted: bool = PrivateAttr(default=False) + # When the files at rest were encrypted (see AT_REST_ENCRYPTED_KEY). + _at_rest_encrypted_at: float | None = PrivateAttr(default=None) @property def use_encryption(self) -> bool: @@ -514,7 +515,7 @@ def _take_key_file(self, loaded: "PeerStore") -> list[tuple[str, dict]]: f"{loaded.email!r}, not {self.email!r}" ) self._private_keys = loaded._private_keys - self._at_rest_encrypted = loaded._at_rest_encrypted + self._at_rest_encrypted_at = loaded._at_rest_encrypted_at self._required_since = loaded._required_since self._required_peers = loaded._required_peers | self._unsaved_required_peers if loaded.use_encryption: @@ -659,24 +660,33 @@ def encrypt_for_self_if_needed(self, data: bytes) -> bytes: def decrypt_and_verify_for_self_if_needed(self, data: bytes) -> bytes: """Verify and decrypt an envelope this client wrote for itself; pass plaintext through. - Plaintext is refused once this client requires encryption and its - files at rest are encrypted. Until then, they can hold plaintext. It - reads the key file first, as ``encrypt_for_self_if_needed`` does. - - Raises: - ValueError: when ``data`` is plaintext, and the files at rest - must be encrypted. + The caller checks plaintext with ``check_plaintext_at_rest``. """ - self.reload_policy_from_key_file() if self.is_envelope(data): self.verify_message_from_self(data) return self.decrypt_for_self(data) - if self.use_encryption and self._at_rest_encrypted: + return data + + def check_plaintext_at_rest(self, written_at: float) -> None: + """Refuse a plaintext file at rest written at ``written_at``. + + Plaintext is accepted until the files at rest were encrypted: another + process can have written it while the one-time pass ran. The caller + reads the key file first (``reload_policy_from_key_file``). + + Raises: + ValueError: when this client requires encryption, and the file was + written after the files at rest were encrypted. + """ + if ( + self.use_encryption + and self._at_rest_encrypted_at is not None + and written_at >= self._at_rest_encrypted_at + ): raise ValueError( f"A file of {self.email} at rest is not encrypted, and the " "datasite requires encryption." ) - return data # ========== Persistence ========== @@ -696,7 +706,7 @@ def _key_file_data(self) -> dict: "keys_jwk": keys.to_jwks(), PEER_BUNDLES_KEY: self._pinned_bundles(), ENCRYPTION_POLICY_KEY: self.encryption_policy.value, - AT_REST_ENCRYPTED_KEY: self._at_rest_encrypted, + AT_REST_ENCRYPTED_KEY: self._at_rest_encrypted_at, REQUIRED_PEERS_KEY: sorted(self._required_peers), REQUIRED_SINCE_KEY: self._required_since, } @@ -726,17 +736,19 @@ def reload_policy_from_key_file(self) -> None: if self.encryption_policy == EncryptionPolicy.OFF: raise _off_refused_error(self._keys_path) self._adopt_key_file(loaded) - elif self.has_my_keys(): + else: self._required_peers = ( set(data.get(REQUIRED_PEERS_KEY, [])) | self._unsaved_required_peers ) - self._at_rest_encrypted |= bool(data.get(AT_REST_ENCRYPTED_KEY)) + self._at_rest_encrypted_at = ( + data.get(AT_REST_ENCRYPTED_KEY) or self._at_rest_encrypted_at + ) self._required_since = data.get(REQUIRED_SINCE_KEY, self._required_since) self._key_file_stamp = stamp def needs_at_rest_encryption(self) -> bool: """Whether this client is required, but its files at rest can be plaintext.""" - return self.use_encryption and not self._at_rest_encrypted + return self.use_encryption and self._at_rest_encrypted_at is None def encrypt_files_at_rest_once(self, encrypt_files: Callable[[], None]) -> None: """Run ``encrypt_files`` unless a process already did, then record it. @@ -748,17 +760,19 @@ def encrypt_files_at_rest_once(self, encrypt_files: Callable[[], None]) -> None: self.ensure_keys() if self._keys_path is None: encrypt_files() - else: - at_rest_lock = self._keys_path.with_name(self._keys_path.name + ".at_rest") - with locked_file(at_rest_lock): - data = self._read_key_file() or {} - if not data.get(AT_REST_ENCRYPTED_KEY): - encrypt_files() - with locked_file(self._keys_path): - data = self._read_key_file() or self._key_file_data() - data = {**data, AT_REST_ENCRYPTED_KEY: True} - _write_key_file(self._keys_path, data) - self._at_rest_encrypted = True + self._at_rest_encrypted_at = time.time() + return + at_rest_lock = self._keys_path.with_name(self._keys_path.name + ".at_rest") + with locked_file(at_rest_lock): + done_at = (self._read_key_file() or {}).get(AT_REST_ENCRYPTED_KEY) + if done_at is None: + encrypt_files() + done_at = time.time() + with locked_file(self._keys_path): + data = self._read_key_file() or self._key_file_data() + data = {**data, AT_REST_ENCRYPTED_KEY: done_at} + _write_key_file(self._keys_path, data) + self._at_rest_encrypted_at = done_at def _record_required_policy(self) -> None: """Record a required policy in the key file, keeping what else it holds.""" @@ -844,7 +858,7 @@ def load_keys(cls, path: Path) -> "PeerStore": ) policy = data.get(ENCRYPTION_POLICY_KEY, EncryptionPolicy.REQUIRED.value) store = cls(email=data["email"], encryption_policy=EncryptionPolicy(policy)) - store._at_rest_encrypted = bool(data.get(AT_REST_ENCRYPTED_KEY)) + store._at_rest_encrypted_at = data.get(AT_REST_ENCRYPTED_KEY) store._required_peers = set(data.get(REQUIRED_PEERS_KEY, [])) store._required_since = data.get(REQUIRED_SINCE_KEY) store._private_keys = syc.SyftPrivateKeys.from_jwks(data["keys_jwk"]) diff --git a/syft/sync/sync/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py index 6128efd1589..97d412a2adb 100644 --- a/syft/sync/sync/datasite_owner_syncer.py +++ b/syft/sync/sync/datasite_owner_syncer.py @@ -247,10 +247,9 @@ def download_events_message_by_id_with_connection( # we need a new connection object because gdrive connections are not thread safe connection = self.connection_router.connection_for_eventlog(create_new=True) raw = connection.owner_download_raw_bytes_by_id(events_message_id) - raw = self.connection_router.peer_store.decrypt_and_verify_for_self_if_needed( - raw + return self.connection_router.decode_at_rest( + raw, FileChangeEventsMessage.from_compressed_data ) - return FileChangeEventsMessage.from_compressed_data(raw) def get_all_accepted_events_messages( self, since_timestamp: float | None = None @@ -258,6 +257,8 @@ def get_all_accepted_events_messages( message_ids = self.connection_router.owner_get_all_accepted_event_file_ids( since_timestamp=since_timestamp ) + # Once for the batch: the downloads run in threads. + self.connection_router.peer_store.reload_policy_from_key_file() result_messages = self._executor.map( self.download_events_message_by_id_with_connection, message_ids ) diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 15103ef31b5..ec4e4c3a424 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -6,6 +6,7 @@ import json import threading +import time import warnings from pathlib import Path @@ -21,7 +22,10 @@ PeerEncryptionPolicyUnknownError, PeerKeysNotReadyError, ) -from syft.sync.events.file_change_event import FileChangeEventsMessage +from syft.sync.events.file_change_event import ( + FileChangeEventsMessage, + FileChangeEventsMessageFileName, +) from syft.sync.peers.peer import Peer, PeerState from syft.sync.peers.peer_store import PeerStore from syft.sync.sync.datasite_watcher_syncer import DatasiteWatcherSyncer @@ -543,7 +547,7 @@ def test_key_file_without_policy_checks_files_at_rest_once(tmp_path): ) data = json.loads(keys_path.read_text()) del data["encryption_policy"] - data.pop("at_rest_encrypted", None) + data.pop("at_rest_encrypted_at", None) keys_path.write_text(json.dumps(data)) store = PeerStore.create(email="alice@example.com", keys_path=keys_path) @@ -662,22 +666,62 @@ def test_write_at_rest_takes_required_policy_from_other_process(tmp_path): assert _is_envelope(notebook.encrypt_for_self_if_needed(b"rolling state")) -def test_read_at_rest_accepts_plaintext_only_until_files_are_encrypted(tmp_path): - """Another process made the datasite required; its one-time pass is pending.""" - keys_path = tmp_path / "crypto_keys.json" - notebook = _create(keys_path) - notebook.ensure_keys() - with pytest.warns(UserWarning): - _create(keys_path, REQUIRED) - assert notebook.decrypt_and_verify_for_self_if_needed(b"plain") == b"plain" +def _plaintext_event_at_rest(manager, timestamp: float) -> None: + message = FileChangeEventsMessage( + events=[], + message_filepath=FileChangeEventsMessageFileName(timestamp=timestamp), + ) + manager.datasite_owner_syncer.connection_router.connection_for_eventlog().owner_write_raw_bytes_to_syftbox( + message.message_filepath.as_string(), message.as_compressed_data() + ) - daemon = PeerStore.load_keys(keys_path) - envelope = daemon.encrypt_for_self(b"secret") - assert notebook.decrypt_and_verify_for_self_if_needed(envelope) == b"secret" - daemon.encrypt_files_at_rest_once(lambda: None) +def test_plaintext_at_rest_accepted_only_if_written_before_files_were_encrypted(): + """A process can write plaintext while another one encrypts the files at rest.""" + _, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=REQUIRED, + ) + written_during_pass = time.time() + do_manager.sync() + router = do_manager.datasite_owner_syncer.connection_router + _plaintext_event_at_rest(do_manager, written_during_pass) + router.owner_get_all_accepted_events_messages() + + _plaintext_event_at_rest(do_manager, time.time() + 1) with pytest.raises(ValueError, match="not encrypted"): - notebook.decrypt_and_verify_for_self_if_needed(b"plain") + router.owner_get_all_accepted_events_messages() + + +def test_key_file_read_once_for_batch_of_files_at_rest(monkeypatch): + _, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=REQUIRED, + ) + router = do_manager.datasite_owner_syncer.connection_router + for _ in range(3): + router.owner_write_events_message_to_syftbox(FileChangeEventsMessage(events=[])) + reloads = [] + original = PeerStore.reload_policy_from_key_file + monkeypatch.setattr( + PeerStore, + "reload_policy_from_key_file", + lambda self: reloads.append(1) or original(self), + ) + router.owner_get_all_accepted_events_messages() + assert len(reloads) == 1 + reloads.clear() + do_manager.datasite_owner_syncer.get_all_accepted_events_messages() + assert len(reloads) == 1 + + +def test_keyless_process_takes_remembered_peers_from_key_file(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + notebook = _create(keys_path) + daemon = _create(keys_path) + daemon.ensure_keys() + notebook.reload_policy_from_key_file() + daemon.remember_required_peer("bob@example.com") + notebook.reload_policy_from_key_file() + assert notebook.is_remembered_required("bob@example.com") def test_trusted_peer_stays_forgotten_in_other_process(tmp_path): @@ -1613,6 +1657,47 @@ def test_rejected_message_lookup_does_not_list_whole_folder(monkeypatch): assert calls == [] +def _outbox_with_files(names): + """The owner's outbox connection, with files ``names`` in its folder for the DS.""" + ds_manager, do_manager = _required_ds_pair() + outbox = do_manager.datasite_owner_syncer.connection_router.connection_for_outbox() + folder_id = outbox._get_own_datasite_outbox_id(ds_manager.email) + for name in names: + outbox.drive_service.files().create( + body={"name": name, "parents": [folder_id]} + ).execute() + return outbox, ds_manager.email + + +def test_rejected_lookup_handles_quotes_in_names(): + outbox, ds_email = _outbox_with_files(["a'b", "rejected_a'b", "rejected_c\\d"]) + rejected = outbox.owner_get_rejected_outbox_messages(ds_email) + assert {(r.filename, r.resent) for r in rejected} == { + ("a'b", True), + ("c\\d", False), + } + + +def test_rejected_lookup_reads_every_page(): + names = [f"rejected_msgv2_{i}_x.tar.gz" for i in range(101)] + outbox, ds_email = _outbox_with_files(names) + assert len(outbox.owner_get_rejected_outbox_messages(ds_email)) == 101 + + +def test_rejected_lookup_checks_resent_names_in_one_query(monkeypatch): + names = [f"rejected_msgv2_{i}_x.tar.gz" for i in range(3)] + outbox, ds_email = _outbox_with_files(names) + lists = [] + original = mock_drive_service.MockListRequest.execute + monkeypatch.setattr( + mock_drive_service.MockListRequest, + "execute", + lambda self: lists.append(self._q) or original(self), + ) + outbox.owner_get_rejected_outbox_messages(ds_email) + assert len(lists) == 2 + + def test_resend_waits_while_peer_bundle_not_pinned(): ds_manager, do_manager = _pair_with_stale_sender_view() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") From 6de6f372b0497e9f313566b6b3eaf527fd79a245 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Thu, 8 Oct 2026 14:40:30 -0300 Subject: [PATCH 10/30] refactor(sync): simplify encryption negotiation - Drop plaintext to an encrypting pair with a warning; remove reject-and-resend - Keep pending sends in memory until the next sync; remove held message files - Encrypt only new files at rest; remove the one-time pass and required_since - Remove remembered required peers (moved to a follow-up) - Take the policy from the key file at startup only --- docs/connections.md | 6 +- syft/sync/connections/base_connection.py | 40 +- syft/sync/connections/connection_router.py | 250 ++---- .../connections/drive/gdrive_transport.py | 148 ---- .../connections/drive/mock_drive_service.py | 29 +- syft/sync/peers/peer_store.py | 283 ++----- syft/sync/syftbox_manager.py | 5 +- .../sync/caches/datasite_watcher_cache.py | 10 +- syft/sync/sync/constants.py | 2 - syft/sync/sync/datasite_owner_syncer.py | 66 +- syft/sync/sync/datasite_watcher_syncer.py | 164 +--- syft/sync/sync/held_messages.py | 108 --- syft/sync/utils/locked_files.py | 45 -- syft/sync/version/peer_manager.py | 56 -- tests/unit/test_encryption_policy.py | 752 ++---------------- tests/unit/test_peer_key_pinning.py | 2 +- 16 files changed, 262 insertions(+), 1704 deletions(-) delete mode 100644 syft/sync/sync/held_messages.py delete mode 100644 syft/sync/utils/locked_files.py diff --git a/docs/connections.md b/docs/connections.md index 6a0ea24c3eb..e1510020583 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -60,9 +60,11 @@ Each client advertises an encryption policy in `SYFT_version.json`: A pair encrypts when one side is `required`. An `off` and a `required` client conflict: each side skips the other with a warning. -The version file is not signed. So a client keeps a peer that it saw as `required` as `required` (in its key file), and warns when the peer seems to advertise less. Trusting a new key for that peer (`client.trust_peer_key`) clears this. An `off` client has no key file, so it keeps no such record: a forged downgrade can only make it send what its user chose not to encrypt. +A client takes its policy at login. The key file records a `required` policy, so later logins (and syft-bg) stay `required`; restart syft-bg after a change. Files at rest written before the switch stay plaintext; new ones are encrypted. -A pair that encrypts refuses plaintext. The receiver renames a plaintext message to `rejected_`, and waits for that peer. The sender resends the message encrypted on its next sync. +While a peer's policy or keys are not known yet, a change for it waits in memory, and the next sync sends it. + +A pair that encrypts refuses plaintext. The receiver drops a plaintext message with a warning: the owner archives it unread, and a scientist skips it. A peer whose version file has no policy runs an outdated syft. It is skipped with a warning to upgrade, and a send to it raises. diff --git a/syft/sync/connections/base_connection.py b/syft/sync/connections/base_connection.py index d372b29a67a..da97faf303a 100644 --- a/syft/sync/connections/base_connection.py +++ b/syft/sync/connections/base_connection.py @@ -1,18 +1,8 @@ -from typing import ClassVar, NamedTuple, Type +from typing import ClassVar, Type from pydantic import BaseModel from syft.sync.messages.proposed_filechange import ProposedFileChangesMessage -class RejectedMessage(NamedTuple): - """A message file that the recipient renamed with the rejected prefix.""" - - file_id: str - # The file name without the prefix. - filename: str - # True when a file with the original name is in the folder again. - resent: bool - - class FileCollection(BaseModel): """A collection of files that can be shared and synced.""" @@ -118,26 +108,6 @@ def owner_download_next_raw_proposed_message_from_inbox( """Download next message from inbox as raw bytes. Returns (data, file_id) or None.""" raise NotImplementedError() - def owner_get_inbox_message_ids(self, sender_email: str) -> list[str]: - """The ids of the messages in the inbox of ``sender_email``, oldest first.""" - raise NotImplementedError() - - def mark_message_rejected(self, file_id: str) -> None: - """Mark an inbox or outbox message as rejected, so the sender resends it.""" - raise NotImplementedError() - - def watcher_get_rejected_inbox_messages( - self, recipient: str - ) -> list[RejectedMessage]: - """The inbox messages ``recipient`` rejected.""" - raise NotImplementedError() - - def owner_get_rejected_outbox_messages( - self, recipient: str - ) -> list[RejectedMessage]: - """The outbox messages ``recipient`` rejected.""" - raise NotImplementedError() - def owner_write_raw_bytes_to_outbox( self, recipient: str, filename: str, data: bytes ) -> None: @@ -158,14 +128,6 @@ def owner_write_raw_bytes_to_syftbox(self, filename: str, data: bytes) -> str: def owner_download_raw_bytes_by_id(self, file_id: str) -> bytes: raise NotImplementedError() - def owner_overwrite_raw_bytes_by_id(self, file_id: str, data: bytes) -> None: - """Replace the content of file ``file_id``, keeping its name and place.""" - raise NotImplementedError() - - def owner_get_checkpoint_and_state_file_ids(self) -> list[str]: - """The ids of all checkpoint and rolling state files.""" - raise NotImplementedError() - def owner_download_all_raw_events_from_syftbox(self) -> list[bytes]: raise NotImplementedError() diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index a2e8e66dca3..cd07f7be58c 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -1,6 +1,6 @@ import logging - -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, TypeVar +import warnings +from typing import TYPE_CHECKING, Callable, Dict, List, Optional from pydantic import BaseModel, PrivateAttr from syft_migration import MigratableObject, ProtocolSchema @@ -15,7 +15,6 @@ from syft.sync.connections.base_connection import ( ConnectionConfig, FileCollection, - RejectedMessage, SyftboxPlatformConnection, ) from syft.sync.encryption_policy import ( @@ -29,6 +28,7 @@ ) from syft.sync.events.file_change_event import ( FileChangeEventsMessage, + FileChangeEventsMessageFileName, ) from syft.sync.messages.proposed_filechange import ProposedFileChangesMessage from syft.sync.peers.peer import Peer, PeerState @@ -46,10 +46,6 @@ logger = logging.getLogger(__name__) -# A file at rest, decoded: it has a ``timestamp``. -AtRest = TypeVar("AtRest") - - class ConnectionRouter(BaseModel): connections: List[SyftboxPlatformConnection] @@ -191,91 +187,35 @@ def watcher_send_proposed_file_changes_message( def owner_get_next_proposed_filechange_message( self, sender_email: str ) -> ProposedFileChangesMessage | None: - connection = self.connection_for_receive_message() - result = connection.owner_download_next_raw_proposed_message_from_inbox( - sender_email - ) - if result is None: - return None - raw_data, file_id = result - raw_data = self._decrypt_or_reject( - connection, - sender_email, - raw_data, - file_id, - on_reject=lambda: self._reject_plaintext_inbox_messages( - connection, sender_email - ), - ) - if raw_data is None: - return None - msg = ProposedFileChangesMessage.from_compressed_data(raw_data) - msg.platform_id = file_id - return msg - - def _reject_plaintext_inbox_messages( - self, connection: SyftboxPlatformConnection, sender_email: str - ) -> None: - """Reject every other plaintext message from ``sender_email`` at once. - - The sender then resends them all on one sync, not one per round. It - encrypts all messages after the first envelope, so the scan stops - there. - """ - for file_id in connection.owner_get_inbox_message_ids(sender_email): - if self.peer_store.is_envelope(connection.download_file(file_id)): - return - connection.mark_message_rejected(file_id) - - def owner_resend_rejected_outbox_messages(self, recipient: str) -> int: - """Resend, encrypted, the plaintext outbox messages ``recipient`` rejected. + """The next message from ``sender_email`` that can be read, or None. - Returns the number of messages resent. + A plaintext message to a pair that encrypts is archived unread, with a + warning. None also means the messages of this peer wait (see + ``_decrypt_or_wait``). """ - connection = self.connection_for_outbox() - return self._resend_rejected( - recipient, - connection, - connection.owner_get_rejected_outbox_messages, - connection.owner_write_raw_bytes_to_outbox, - ) - - def watcher_resend_rejected_inbox_messages(self, recipient: str) -> int: - """Resend, encrypted, the plaintext messages ``recipient`` rejected. - - Returns the number of messages resent. - """ - connection = self.connection_for_send_message() - return self._resend_rejected( - recipient, - connection, - connection.watcher_get_rejected_inbox_messages, - connection.watcher_send_raw_bytes_to_inbox, - ) - - def _resend_rejected( - self, - recipient: str, - connection: SyftboxPlatformConnection, - list_rejected: Callable[[str], list[RejectedMessage]], - write: Callable[[str, str, bytes], None], - ) -> int: - # Only a pair that encrypts rejects plaintext; skip the listing otherwise. - if not self.peer_store.peer_uses_encryption(recipient): - return 0 - if not self.peer_store.encryption_ready(recipient): - logger.info(f"Resend to {recipient} waits for the keys and the pin") - return 0 - rejected = list_rejected(recipient) - for file_id, filename, resent in rejected: - # A resend whose delete failed is not uploaded a second time. - if not resent: - plaintext = connection.download_file(file_id) - write( - recipient, filename, self.peer_store.encrypt(recipient, plaintext) + connection = self.connection_for_receive_message() + while True: + result = connection.owner_download_next_raw_proposed_message_from_inbox( + sender_email + ) + if result is None: + return None + raw_data, file_id = result + try: + decrypted = self._decrypt_or_wait(sender_email, raw_data) + except UnencryptedMessageError as e: + warnings.warn(f"Archived message file {file_id} unread: {e}") + msg = ProposedFileChangesMessage.from_compressed_data(raw_data) + msg.platform_id = file_id + connection.owner_remove_proposed_filechange_message_from_inbox( + msg, sender_email ) - connection.delete_file_by_id(file_id, raise_on_error=True) - return len(rejected) + continue + if decrypted is None: + return None + msg = ProposedFileChangesMessage.from_compressed_data(decrypted) + msg.platform_id = file_id + return msg def owner_write_event_messages_to_outbox( self, recipient_email: str, events_message: FileChangeEventsMessage @@ -292,70 +232,58 @@ def owner_write_event_messages_to_outbox( def watcher_get_events_messages( self, peer_email: str, since_timestamp: float | None ) -> List[FileChangeEventsMessage]: + """The outbox messages of ``peer_email``, up to the first that cannot be read yet.""" connection = self.connection_for_datasite_watcher() messages = [] - waiting = False for meta in connection.watcher_get_outbox_file_metadatas( peer_email, since_timestamp ): - # After one message that cannot be read, later ones are not applied, - # but each is still read, so all plaintext ones are rejected at once. - message = self.watcher_read_events_message( - connection, peer_email, meta["file_id"], meta["timestamp"] - ) - waiting = waiting or message is None - if not waiting: - messages.append(message) + message = self.watcher_read_events_message(connection, peer_email, meta) + if message is None: + break + messages.append(message) return messages def watcher_read_events_message( self, connection: SyftboxPlatformConnection, peer_email: str, - file_id: str, - sent_at: float | None = None, + file_meta: dict, ) -> FileChangeEventsMessage | None: """Download, verify and decrypt one outbox events message. - ``sent_at`` is the timestamp in its file name. Returns None when the - message cannot be read yet (see ``_decrypt_or_reject``). + ``file_meta`` holds its ``file_id`` and ``file_name``. Returns None + when the message cannot be read yet (see ``_decrypt_or_wait``). A + plaintext message to a pair that encrypts is skipped with a warning: + an empty message takes its place, so later messages apply. """ - raw = self._decrypt_or_reject( - connection, - peer_email, - connection.download_file(file_id), - file_id, - sent_at=sent_at, - ) + raw = connection.download_file(file_meta["file_id"]) + try: + raw = self._decrypt_or_wait(peer_email, raw) + except UnencryptedMessageError as e: + warnings.warn(f"Skipped outbox message {file_meta['file_name']}: {e}") + return FileChangeEventsMessage( + events=[], + message_filepath=FileChangeEventsMessageFileName.from_string( + file_meta["file_name"] + ), + ) if raw is None: return None return FileChangeEventsMessage.from_compressed_data(raw) - def _decrypt_or_reject( - self, - connection: SyftboxPlatformConnection, - peer_email: str, - raw: bytes, - file_id: str, - on_reject: Callable[[], None] | None = None, - sent_at: float | None = None, - ) -> bytes | None: + def _decrypt_or_wait(self, peer_email: str, raw: bytes) -> bytes | None: """Verify and decrypt an inbox or outbox message from ``peer_email``. Returns None when the message cannot be read yet; the messages of this - peer then wait, so they keep their order. A plaintext message to a pair - that encrypts is also marked rejected, and the sender resends it - encrypted on its next sync. + peer then wait, so they keep their order. + + Raises: + UnencryptedMessageError: when the pair encrypts and ``raw`` is + plaintext. """ try: - return self.peer_store.decrypt_and_verify_if_needed( - peer_email, raw, sent_at - ) - except UnencryptedMessageError as e: - logger.warning(f"Rejected message file {file_id}: {e}") - connection.mark_message_rejected(file_id) - if on_reject is not None: - on_reject() + return self.peer_store.decrypt_and_verify_if_needed(peer_email, raw) except (PeerEncryptionPendingError, EncryptionPolicyConflictError) as e: # A conflict found here is new since the peer filter of this sync. logger.info(f"Messages from {peer_email} wait: {e}") @@ -386,56 +314,21 @@ def owner_write_events_message_to_syftbox( def owner_get_all_accepted_events_messages(self) -> List[FileChangeEventsMessage]: connection = self.connection_for_eventlog() raw_list = connection.owner_download_all_raw_events_from_syftbox() - self.peer_store.reload_policy_from_key_file() return [ - self.decode_at_rest(raw, FileChangeEventsMessage.from_compressed_data) + FileChangeEventsMessage.from_compressed_data( + self.peer_store.decrypt_and_verify_for_self_if_needed(raw) + ) for raw in raw_list ] - def decode_at_rest( - self, raw: bytes, decode: Callable[[bytes], "AtRest"] - ) -> "AtRest": - """Verify, decrypt and decode one file at rest with ``decode``. - - The caller reads the key file first (``reload_policy_from_key_file``), - once for a batch of files. Plaintext is checked by the time it was - written (see ``PeerStore.check_plaintext_at_rest``). - """ - if self.peer_store.is_envelope(raw): - return decode(self.peer_store.decrypt_and_verify_for_self_if_needed(raw)) - item = decode(raw) - self.peer_store.check_plaintext_at_rest(item.timestamp) - return item - - def owner_encrypt_files_at_rest(self) -> None: - """Encrypt the plaintext files at rest once, after a switch to required. - - Files written while this client was optional (event log, checkpoints, - rolling state) are plaintext. Each one is encrypted in place, so its - name and id stay, and reads verify them as usual. - """ - connection = self.connection_for_eventlog() - - def encrypt_files() -> None: - file_ids = connection.owner_get_all_accepted_event_file_ids() - file_ids += connection.owner_get_checkpoint_and_state_file_ids() - for file_id in file_ids: - raw = connection.owner_download_raw_bytes_by_id(file_id) - if not self.peer_store.is_envelope(raw): - connection.owner_overwrite_raw_bytes_by_id( - file_id, self.peer_store.encrypt_for_self(raw) - ) - - self.peer_store.encrypt_files_at_rest_once(encrypt_files) - def download_events_message_by_id( self, events_message_id: str ) -> FileChangeEventsMessage: """Download and decrypt a single events message (thread-safe).""" connection = self.connection_for_eventlog(create_new=True) raw = connection.owner_download_raw_bytes_by_id(events_message_id) - self.peer_store.reload_policy_from_key_file() - return self.decode_at_rest(raw, FileChangeEventsMessage.from_compressed_data) + raw = self.peer_store.decrypt_and_verify_for_self_if_needed(raw) + return FileChangeEventsMessage.from_compressed_data(raw) # ========================================================================= # PEER MANAGEMENT @@ -718,8 +611,8 @@ def get_latest_checkpoint(self) -> Checkpoint | None: if raw is None: return None try: - self.peer_store.reload_policy_from_key_file() - return self.decode_at_rest(raw, Checkpoint.from_compressed_data) + raw = self.peer_store.decrypt_and_verify_for_self_if_needed(raw) + return Checkpoint.from_compressed_data(raw) except Exception as e: print(f"Warning: Failed to load checkpoint: {e}") return None @@ -737,9 +630,10 @@ def get_events_messages_since_timestamp( """Download, decrypt, and deserialize events since a timestamp.""" connection = self.connection_for_eventlog() raw_list = connection.download_raw_events_since_timestamp(since_timestamp) - self.peer_store.reload_policy_from_key_file() return [ - self.decode_at_rest(raw, FileChangeEventsMessage.from_compressed_data) + FileChangeEventsMessage.from_compressed_data( + self.peer_store.decrypt_and_verify_for_self_if_needed(raw) + ) for raw in raw_list ] @@ -758,13 +652,11 @@ def get_all_incremental_checkpoints(self) -> List[IncrementalCheckpoint]: """Download, decrypt, and deserialize all incremental checkpoints.""" connection = self.connection_for_own_syftbox() raw_list = connection.download_all_raw_incremental_checkpoints() - self.peer_store.reload_policy_from_key_file() checkpoints = [] for raw in raw_list: try: - cp = self.decode_at_rest( - raw, IncrementalCheckpoint.from_compressed_data - ) + raw = self.peer_store.decrypt_and_verify_for_self_if_needed(raw) + cp = IncrementalCheckpoint.from_compressed_data(raw) checkpoints.append(cp) except Exception as e: print(f"Warning: Failed to load incremental checkpoint: {e}") @@ -804,8 +696,8 @@ def get_rolling_state(self) -> RollingState | None: if raw is None: return None try: - self.peer_store.reload_policy_from_key_file() - return self.decode_at_rest(raw, RollingState.from_compressed_data) + raw = self.peer_store.decrypt_and_verify_for_self_if_needed(raw) + return RollingState.from_compressed_data(raw) except Exception as e: print(f"Warning: Failed to load rolling state: {e}") return None diff --git a/syft/sync/connections/drive/gdrive_transport.py b/syft/sync/connections/drive/gdrive_transport.py index d8a35bddf8f..087e3506e11 100644 --- a/syft/sync/connections/drive/gdrive_transport.py +++ b/syft/sync/connections/drive/gdrive_transport.py @@ -28,7 +28,6 @@ ) from syft.sync.connections.base_connection import ( FileCollection, - RejectedMessage, SyftboxPlatformConnection, ) from syft.sync.connections.drive.gdrive_retry import ( @@ -68,23 +67,6 @@ logging.getLogger("google_auth_httplib2").setLevel(logging.ERROR) -logger = logging.getLogger(__name__) - - -def _query_string(value: str) -> str: - """``value`` escaped for a quoted string in a Drive query.""" - return value.replace("\\", "\\\\").replace("'", "\\'") - - -def _waits_for_rejected_message(file_metadatas: list[dict], folder: str) -> bool: - """Whether a message folder holds a rejected message the sender must resend. - - Later messages can depend on the rejected one, so they wait until then. - """ - if not any(m["name"].startswith(REJECTED_MESSAGE_PREFIX) for m in file_metadatas): - return False - logger.warning(f"The {folder} waits for the sender to resend a rejected message.") - return True def build_drive_service( @@ -112,13 +94,6 @@ def build_drive_service( LEGACY_GDRIVE_OUTBOX_INBOX_FOLDER_PREFIX = "syft_outbox_inbox" # legacy prefix -# Prefix the recipient adds to a plaintext inbox or outbox message when the -# pair encrypts. The sender then resends the message encrypted, under the -# original name. It is a prefix because Drive's ``name contains`` matches -# only the start of a name term. -REJECTED_MESSAGE_PREFIX = "rejected_" -# Names in one Drive query that checks which names a folder has. -NAMES_PER_QUERY = 50 GDRIVE_P2P_FOLDER_DATASITE_PREFIX = "syft_datasite" SYFT_PEERS_FILE = "SYFT_peers.json" @@ -740,8 +715,6 @@ def watcher_get_outbox_file_metadatas( file_metadatas = self.get_file_metadatas_from_folder( folder_id, since_timestamp=since_timestamp ) - if _waits_for_rejected_message(file_metadatas, f"outbox of {peer_email}"): - return [] valid_fname_objs = self._get_valid_events_from_file_metadatas(file_metadatas) name_to_id = {f["name"]: f["id"] for f in file_metadatas} @@ -781,24 +754,6 @@ def owner_download_raw_bytes_by_id(self, file_id: str) -> bytes: """Download raw bytes by file ID.""" return self.download_file(file_id) - def owner_overwrite_raw_bytes_by_id(self, file_id: str, data: bytes) -> None: - payload, _ = self.create_file_payload(data) - execute_with_retries( - self.drive_service.files().update(fileId=file_id, media_body=payload) - ) - - def owner_get_checkpoint_and_state_file_ids(self) -> list[str]: - folder_ids = [ - self._get_checkpoints_folder_id(), - self._get_rolling_state_folder_id(), - ] - return [ - m["id"] - for folder_id in folder_ids - if folder_id is not None - for m in self.get_file_metadatas_from_folder(folder_id) - ] - def owner_get_all_accepted_event_file_ids( self, since_timestamp: float | None = None ) -> list[str]: @@ -1131,8 +1086,6 @@ def _get_valid_events_from_file_metadatas( res = [] for file_metadata in file_metadatas: fname = file_metadata["name"] - if fname.startswith(REJECTED_MESSAGE_PREFIX): - continue try: message_filename = FileChangeEventsMessageFileName.from_string(fname) except ValueError: @@ -1147,8 +1100,6 @@ def _get_valid_messages_from_file_metadatas( ) -> list[MessageFileName]: res = [] for file_metadata in file_metadatas: - if file_metadata["name"].startswith(REJECTED_MESSAGE_PREFIX): - continue try: message_filename = MessageFileName.from_string(file_metadata["name"]) res.append(message_filename) @@ -1163,8 +1114,6 @@ def owner_download_next_raw_proposed_message_from_inbox( if inbox_folder_id is None: raise ValueError(f"Inbox folder for {sender_email} not found") file_metadatas = self.get_file_metadatas_from_folder(inbox_folder_id) - if _waits_for_rejected_message(file_metadatas, f"inbox of {sender_email}"): - return None valid_file_names = self._get_valid_messages_from_file_metadatas(file_metadatas) if len(valid_file_names) == 0: return None @@ -1177,103 +1126,6 @@ def owner_download_next_raw_proposed_message_from_inbox( raw_data = self.download_file(first_file_id) return raw_data, first_file_id - def owner_get_inbox_message_ids(self, sender_email: str) -> list[str]: - inbox_folder_id = self._get_own_datasite_inbox_id(sender_email) - if inbox_folder_id is None: - return [] - file_metadatas = self.get_file_metadatas_from_folder(inbox_folder_id) - name_to_id = {m["name"]: m["id"] for m in file_metadatas} - valid_file_names = self._get_valid_messages_from_file_metadatas(file_metadatas) - return [ - name_to_id[f.as_string()] - for f in sorted(valid_file_names, key=lambda x: x.submitted_timestamp) - ] - - def mark_message_rejected(self, file_id: str) -> None: - """Rename message file ``file_id`` with the rejected prefix, in place. - - Two readers on one datasite (a notebook and syft-bg) can reject the - same file; the second finds the prefix and leaves the name. - """ - file_info = execute_with_retries( - self.drive_service.files().get(fileId=file_id, fields="name") - ) - if file_info["name"].startswith(REJECTED_MESSAGE_PREFIX): - return - execute_with_retries( - self.drive_service.files().update( - fileId=file_id, - body={"name": REJECTED_MESSAGE_PREFIX + file_info["name"]}, - fields="id, name", - supportsAllDrives=True, - ) - ) - - def watcher_get_rejected_inbox_messages( - self, recipient: str - ) -> list[RejectedMessage]: - return self._rejected_messages_in_folder( - self._get_peer_datasite_inbox_id(recipient) - ) - - def owner_get_rejected_outbox_messages( - self, recipient: str - ) -> list[RejectedMessage]: - return self._rejected_messages_in_folder( - self._get_own_datasite_outbox_id(recipient) - ) - - def _rejected_messages_in_folder( - self, folder_id: str | None - ) -> list[RejectedMessage]: - """The rejected messages in ``folder_id``, without a listing of the whole folder.""" - if folder_id is None: - return [] - # ``name contains`` is not exact: check the name here. - rejected = [ - m - for m in self._files_in_folder_named( - folder_id, f"name contains '{REJECTED_MESSAGE_PREFIX}'" - ) - if m["name"].startswith(REJECTED_MESSAGE_PREFIX) - ] - originals = [m["name"].removeprefix(REJECTED_MESSAGE_PREFIX) for m in rejected] - present = self._names_in_folder(folder_id, originals) - return [ - RejectedMessage(m["id"], original, original in present) - for m, original in zip(rejected, originals) - ] - - def _names_in_folder(self, folder_id: str, names: list[str]) -> set[str]: - """The names of ``names`` that a file in ``folder_id`` has, with one query per batch.""" - present: set[str] = set() - for i in range(0, len(names), NAMES_PER_QUERY): - clause = " or ".join( - f"name = '{_query_string(name)}'" - for name in names[i : i + NAMES_PER_QUERY] - ) - files = self._files_in_folder_named(folder_id, f"({clause})") - present |= {m["name"] for m in files} - return present - - def _files_in_folder_named(self, folder_id: str, name_query: str) -> list[dict]: - """All files in ``folder_id`` that match ``name_query``, a Drive name clause.""" - query = f"'{folder_id}' in parents and {name_query} and trashed=false" - files: list[dict] = [] - page_token = None - while True: - results = execute_with_retries( - self.drive_service.files().list( - q=query, - fields="files(id, name), nextPageToken", - pageToken=page_token, - ) - ) - files += results.get("files", []) - page_token = results.get("nextPageToken") - if not page_token: - return files - def owner_get_next_proposed_filechange_message( self, sender_email: str ) -> ProposedFileChangesMessage | None: diff --git a/syft/sync/connections/drive/mock_drive_service.py b/syft/sync/connections/drive/mock_drive_service.py index f98cf6b589d..a8221543f3f 100644 --- a/syft/sync/connections/drive/mock_drive_service.py +++ b/syft/sync/connections/drive/mock_drive_service.py @@ -133,14 +133,6 @@ def _folders(self) -> List[MockDriveFile]: ] -def _name_equals_value(clause: str) -> str | None: - """The name in a "name = 'X'" clause, unescaped, or None for another clause.""" - match = re.match(r"name\s*=\s*'((?:[^'\\]|\\.)*)'", clause, re.IGNORECASE) - if match is None: - return None - return re.sub(r"\\(.)", r"\1", match.group(1)) - - def parse_gdrive_query( query: str, current_user: str ) -> Callable[[MockDriveFile], bool]: @@ -155,10 +147,7 @@ def parse_gdrive_query( - 'me' in owners - Current user is owner - not 'me' in owners - Current user is NOT owner - trashed=false - Not trashed - - (name = 'X' or name = 'Y') - Any of these names - Combinations with 'and' - - A quoted name can hold ``\\'`` and ``\\\\`` escapes. """ if not query or not query.strip(): return lambda f: True @@ -169,19 +158,6 @@ def parse_gdrive_query( for part in parts: part = part.strip() - # Handle "(name = 'X' or name = 'Y')" - if part.startswith("(") and part.endswith(")"): - names = { - _name_equals_value(p) - for p in re.split(r"\s+or\s+", part[1:-1], flags=re.IGNORECASE) - } - - def name_in(f, ns=names): - return f.name in ns - - conditions.append(name_in) - continue - # Handle "not 'me' in owners" if re.match(r"not\s+['\"]me['\"]\s+in\s+owners", part, re.IGNORECASE): @@ -232,8 +208,9 @@ def has_parent(f, pid=parent_id): continue # Handle "name='X'" or "name = 'X'" - name_val = _name_equals_value(part) - if name_val is not None: + match = re.match(r"name\s*=\s*['\"]([^'\"]+)['\"]", part, re.IGNORECASE) + if match: + name_val = match.group(1) def name_equals(f, n=name_val): return f.name == n diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 4bf1063aec9..f5a5a465ade 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -6,11 +6,14 @@ import json import logging -import time +import os +import tempfile import warnings +from contextlib import contextmanager from pathlib import Path -from typing import Callable, List, Optional +from typing import Iterator, List, Optional +import portalocker import syft_crypto_python as syc from pydantic import BaseModel, PrivateAttr @@ -34,7 +37,6 @@ parse_and_validate_bundle, ) from syft.sync.peers.peer import Peer -from syft.sync.utils.locked_files import locked_file, write_json_atomically logger = logging.getLogger(__name__) @@ -55,20 +57,6 @@ # loads the file (a notebook and syft-bg) then uses the same policy. A file # without it was written when only required clients had keys. ENCRYPTION_POLICY_KEY = "encryption_policy" -# Key in the crypto key file that records when the files of this datasite at -# rest (event log, checkpoints, rolling state) were encrypted (seconds since -# the epoch). A required client encrypts them once and sets it: an optional -# client, or an earlier client without encryption, can have written plaintext -# there. -AT_REST_ENCRYPTED_KEY = "at_rest_encrypted_at" -# Key in the crypto key file that lists the peers seen to require encryption. -# The version file that advertises a policy is not signed, so such a peer -# stays required here until the user trusts a new key for it. -REQUIRED_PEERS_KEY = "required_peers" -# Key in the crypto key file that records when this datasite started to -# require encryption (seconds since the epoch). A peer can have written -# plaintext to its outbox before then. -REQUIRED_SINCE_KEY = "required_since" def datasite_crypto_keys_path(syftbox_folder: Path | str, email: str) -> Path: @@ -99,20 +87,10 @@ class PeerStore(BaseModel): _keys_path: Path | None = PrivateAttr(default=None) # Peers whose version file was read again since the last ``set_peers``. _policy_rereads: set[str] = PrivateAttr(default_factory=set) - # Peers seen to require encryption (see REQUIRED_PEERS_KEY). - _required_peers: set[str] = PrivateAttr(default_factory=set) - # Remembered peers not in the key file yet: there were no keys then. - _unsaved_required_peers: set[str] = PrivateAttr(default_factory=set) - # When this datasite started to require encryption (see REQUIRED_SINCE_KEY). - _required_since: float | None = PrivateAttr(default=None) - # The (mtime, inode, size) of the key file at the last reload. - _key_file_stamp: tuple[int, int, int] | None = PrivateAttr(default=None) # The last policy known of each peer in this process (see peer_policy). _last_known_policies: dict[str, EncryptionPolicy] = PrivateAttr( default_factory=dict ) - # When the files at rest were encrypted (see AT_REST_ENCRYPTED_KEY). - _at_rest_encrypted_at: float | None = PrivateAttr(default=None) @property def use_encryption(self) -> bool: @@ -168,9 +146,7 @@ def encrypt_if_needed(self, email: str, data: bytes) -> bytes: raise PeerKeysNotReadyError(email) return self.encrypt(email, data) - def decrypt_and_verify_if_needed( - self, email: str, data: bytes, sent_at: float | None = None - ) -> bytes: + def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: """Verify and decrypt an envelope from ``email``; pass plaintext through. When the pair encrypts, plaintext is refused, so that a peer or the @@ -179,10 +155,6 @@ def decrypt_and_verify_if_needed( encrypt, an envelope is still accepted: the peer can have started to require encryption after the last load. - ``sent_at`` is the time a message was written. Plaintext written - before this datasite started to require encryption is accepted, - unless the peer requires encryption itself. - Raises: PeerEncryptionPolicyUnknownError: when ``data`` is plaintext and the peer policy is not known. @@ -198,9 +170,7 @@ def decrypt_and_verify_if_needed( if not self.is_envelope(data): if not self.peer_policy_known(email): raise PeerEncryptionPolicyUnknownError(email) - if self.peer_uses_encryption(email) and not self._sent_before_required( - email, sent_at - ): + if self.peer_uses_encryption(email): raise UnencryptedMessageError(email) return data if not self.encryption_ready(email): @@ -208,15 +178,6 @@ def decrypt_and_verify_if_needed( self.verify_message(email, data) return self.decrypt(email, data) - def _sent_before_required(self, email: str, sent_at: float | None) -> bool: - """Whether plaintext from ``email`` sent at ``sent_at`` predates our switch.""" - return ( - sent_at is not None - and self._required_since is not None - and sent_at < self._required_since - and self.peer_policy(email) != EncryptionPolicy.REQUIRED - ) - @staticmethod def is_envelope(data: bytes) -> bool: """True if `data` is an SYC encryption envelope (vs plaintext).""" @@ -246,60 +207,16 @@ def peer_policy(self, email: str) -> Optional[EncryptionPolicy]: def policy_of(self, peer: Peer) -> Optional[EncryptionPolicy]: """The policy of ``peer``, or None when not known. - A peer seen to require encryption stays required, whatever its - version file says now (see ``remember_required_peer``). When the - version file can no longer be read (the peer deleted its SyftBox, or - a read failed), the last policy known in this process applies. + When the version file can no longer be read (the peer deleted its + SyftBox, or a read failed), the last policy known in this process + applies. """ - if peer.email in self._required_peers: - return EncryptionPolicy.REQUIRED policy = peer.encryption_policy if policy is None and not peer.is_outdated: return self._last_known_policies.get(peer.email) self._last_known_policies[peer.email] = policy return policy - def is_remembered_required(self, email: str) -> bool: - return email in self._required_peers - - def remember_required_peer(self, email: str) -> None: - """Keep ``email`` required, in memory and in the key file.""" - if email not in self._required_peers: - self._update_required_peers(added={email}) - - def forget_required_peer(self, email: str) -> None: - """Stop keeping ``email`` required. For a trusted key change only.""" - if email in self._required_peers: - self._update_required_peers(removed={email}) - - def _update_required_peers( - self, added: set[str] = frozenset(), removed: set[str] = frozenset() - ) -> None: - """Apply a change to the remembered peers, in the key file and in memory. - - Only the change is written, and memory then follows the file. So a - peer that another process forgot is not written back. Without keys, - the change stays in memory until ``_save_unsaved_required_peers``. - """ - self._required_peers = (self._required_peers | added) - removed - if self._keys_path is None or self._private_keys is None: - self._unsaved_required_peers = ( - self._unsaved_required_peers | added - ) - removed - return - with locked_file(self._keys_path): - data = self._read_key_file() or self._key_file_data() - stored = (set(data.get(REQUIRED_PEERS_KEY, [])) | added) - removed - data = {**data, REQUIRED_PEERS_KEY: sorted(stored)} - _write_key_file(self._keys_path, data) - self._required_peers = stored - - def _save_unsaved_required_peers(self) -> None: - """Record the peers remembered while there were no keys.""" - unsaved, self._unsaved_required_peers = self._unsaved_required_peers, set() - if unsaved: - self._update_required_peers(added=unsaved) - def peer_policy_known(self, email: str) -> bool: """Whether this client can select the format for ``email``. @@ -486,7 +403,7 @@ def ensure_keys(self) -> None: self.generate_keys() return unsaved_pins: list[tuple[str, dict]] = [] - with locked_file(self._keys_path): + with _locked_key_file(self._keys_path): if self._keys_path.exists(): unsaved_pins = self._take_key_file(self.load_keys(self._keys_path)) else: @@ -495,13 +412,11 @@ def ensure_keys(self) -> None: # Saving a pin takes the lock again, so it waits until here. for email, bundle in unsaved_pins: self._persist_peer_bundle(email, bundle) - self._save_unsaved_required_peers() def _adopt_key_file(self, loaded: "PeerStore") -> None: """Take the keys, pins and recorded policy of ``loaded``, a store read from our key file.""" for email, bundle in self._take_key_file(loaded): self._persist_peer_bundle(email, bundle) - self._save_unsaved_required_peers() def _take_key_file(self, loaded: "PeerStore") -> list[tuple[str, dict]]: """Like ``_adopt_key_file``, but return the pins to save, without file I/O. @@ -515,11 +430,6 @@ def _take_key_file(self, loaded: "PeerStore") -> list[tuple[str, dict]]: f"{loaded.email!r}, not {self.email!r}" ) self._private_keys = loaded._private_keys - self._at_rest_encrypted_at = loaded._at_rest_encrypted_at - self._required_since = loaded._required_since - self._required_peers = loaded._required_peers | self._unsaved_required_peers - if loaded.use_encryption: - self.encryption_policy = EncryptionPolicy.REQUIRED if not self._peers: self._peers = loaded._peers return [] @@ -588,9 +498,6 @@ def set_peer_bundle( new_fp = parsed.identity_fingerprint() if old_fp != new_fp and not allow_key_change: raise PeerKeyChangedError(peer_email, old_fp, new_fp) - if old_fp != new_fp: - # A peer drops required only with a new key file, so a new key. - self.forget_required_peer(peer_email) if pinned == bundle: peer.public_encryption_bundle = bundle return @@ -647,12 +554,7 @@ def decrypt_for_self(self, envelope: bytes) -> bytes: return syc.decrypt_message(self.email, keys, own_bundle, parsed) def encrypt_for_self_if_needed(self, data: bytes) -> bytes: - """Encrypt for self if encryption is enabled and keys are available. - - It reads the key file first: another process on this datasite can - have made it required since this process last did. - """ - self.reload_policy_from_key_file() + """Encrypt for self if encryption is enabled and keys are available.""" if self.use_encryption and self.has_my_keys(): return self.encrypt_for_self(data) return data @@ -660,34 +562,13 @@ def encrypt_for_self_if_needed(self, data: bytes) -> bytes: def decrypt_and_verify_for_self_if_needed(self, data: bytes) -> bytes: """Verify and decrypt an envelope this client wrote for itself; pass plaintext through. - The caller checks plaintext with ``check_plaintext_at_rest``. + Files written while this client was optional stay plaintext. """ if self.is_envelope(data): self.verify_message_from_self(data) return self.decrypt_for_self(data) return data - def check_plaintext_at_rest(self, written_at: float) -> None: - """Refuse a plaintext file at rest written at ``written_at``. - - Plaintext is accepted until the files at rest were encrypted: another - process can have written it while the one-time pass ran. The caller - reads the key file first (``reload_policy_from_key_file``). - - Raises: - ValueError: when this client requires encryption, and the file was - written after the files at rest were encrypted. - """ - if ( - self.use_encryption - and self._at_rest_encrypted_at is not None - and written_at >= self._at_rest_encrypted_at - ): - raise ValueError( - f"A file of {self.email} at rest is not encrypted, and the " - "datasite requires encryption." - ) - # ========== Persistence ========== def _pinned_bundles(self) -> dict[str, dict]: @@ -706,95 +587,24 @@ def _key_file_data(self) -> dict: "keys_jwk": keys.to_jwks(), PEER_BUNDLES_KEY: self._pinned_bundles(), ENCRYPTION_POLICY_KEY: self.encryption_policy.value, - AT_REST_ENCRYPTED_KEY: self._at_rest_encrypted_at, - REQUIRED_PEERS_KEY: sorted(self._required_peers), - REQUIRED_SINCE_KEY: self._required_since, } - def reload_policy_from_key_file(self) -> None: - """Take what other processes on this datasite recorded in the key file. - - That is a required policy, the remembered peers, and the encryption - of the files at rest. A notebook that started optional must not write - plaintext at rest once syft-bg made the datasite required. The file - is read again only after it changes. - - Raises: - ValueError: when this client has encryption off. - """ - if self._keys_path is None: - return - stamp = _file_stamp(self._keys_path) - if stamp is None or stamp == self._key_file_stamp: - return - data = self._read_key_file() - if data is None: - return - recorded = data.get(ENCRYPTION_POLICY_KEY, EncryptionPolicy.REQUIRED.value) - if not self.use_encryption and recorded == EncryptionPolicy.REQUIRED.value: - loaded = self.load_keys(self._keys_path) - if self.encryption_policy == EncryptionPolicy.OFF: - raise _off_refused_error(self._keys_path) - self._adopt_key_file(loaded) - else: - self._required_peers = ( - set(data.get(REQUIRED_PEERS_KEY, [])) | self._unsaved_required_peers - ) - self._at_rest_encrypted_at = ( - data.get(AT_REST_ENCRYPTED_KEY) or self._at_rest_encrypted_at - ) - self._required_since = data.get(REQUIRED_SINCE_KEY, self._required_since) - self._key_file_stamp = stamp - - def needs_at_rest_encryption(self) -> bool: - """Whether this client is required, but its files at rest can be plaintext.""" - return self.use_encryption and self._at_rest_encrypted_at is None - - def encrypt_files_at_rest_once(self, encrypt_files: Callable[[], None]) -> None: - """Run ``encrypt_files`` unless a process already did, then record it. - - A lock of its own covers the check, the run and the record, so two - processes on one datasite do not both encrypt the files. The key file - lock is taken only to record, so the run does not block key work. - """ - self.ensure_keys() - if self._keys_path is None: - encrypt_files() - self._at_rest_encrypted_at = time.time() - return - at_rest_lock = self._keys_path.with_name(self._keys_path.name + ".at_rest") - with locked_file(at_rest_lock): - done_at = (self._read_key_file() or {}).get(AT_REST_ENCRYPTED_KEY) - if done_at is None: - encrypt_files() - done_at = time.time() - with locked_file(self._keys_path): - data = self._read_key_file() or self._key_file_data() - data = {**data, AT_REST_ENCRYPTED_KEY: done_at} - _write_key_file(self._keys_path, data) - self._at_rest_encrypted_at = done_at - def _record_required_policy(self) -> None: """Record a required policy in the key file, keeping what else it holds.""" if self._keys_path is None or not self.use_encryption: return - with locked_file(self._keys_path): + with _locked_key_file(self._keys_path): data = self._read_key_file() or self._key_file_data() if data.get(ENCRYPTION_POLICY_KEY) == EncryptionPolicy.REQUIRED.value: return - self._required_since = time.time() - data = { - **data, - ENCRYPTION_POLICY_KEY: EncryptionPolicy.REQUIRED.value, - REQUIRED_SINCE_KEY: self._required_since, - } + data = {**data, ENCRYPTION_POLICY_KEY: EncryptionPolicy.REQUIRED.value} _write_key_file(self._keys_path, data) _warn_key_file_holds_data(self._keys_path) def save_keys(self, path: Path) -> None: data = self._key_file_data() path = Path(path) - with locked_file(path): + with _locked_key_file(path): _write_key_file(path, data) self._keys_path = path @@ -839,7 +649,7 @@ def _persist_peer_bundle(self, peer_email: str, bundle: dict) -> None: """ if self._keys_path is None or self._private_keys is None: return - with locked_file(self._keys_path): + with _locked_key_file(self._keys_path): data = self._read_key_file() or self._key_file_data() _write_key_file(self._keys_path, _with_pin(data, peer_email, bundle)) @@ -858,9 +668,6 @@ def load_keys(cls, path: Path) -> "PeerStore": ) policy = data.get(ENCRYPTION_POLICY_KEY, EncryptionPolicy.REQUIRED.value) store = cls(email=data["email"], encryption_policy=EncryptionPolicy(policy)) - store._at_rest_encrypted_at = data.get(AT_REST_ENCRYPTED_KEY) - store._required_peers = set(data.get(REQUIRED_PEERS_KEY, [])) - store._required_since = data.get(REQUIRED_SINCE_KEY) store._private_keys = syc.SyftPrivateKeys.from_jwks(data["keys_jwk"]) store._keys_path = Path(path) store._peers = _peers_from_stored_pins(data.get(PEER_BUNDLES_KEY, {})) @@ -902,11 +709,12 @@ def create( loaded = cls.load_keys(store._keys_path) if encryption_policy == EncryptionPolicy.OFF and loaded.use_encryption: raise _off_refused_error(store._keys_path) + if loaded.use_encryption: + store.encryption_policy = EncryptionPolicy.REQUIRED store._adopt_key_file(loaded) store._record_required_policy() elif store.use_encryption: store.generate_keys() - store._required_since = time.time() store.save_keys(store._keys_path) _warn_key_file_holds_data(store._keys_path) return store @@ -914,7 +722,7 @@ def create( def _off_refused_error(keys_path: Path) -> ValueError: return ValueError( - f"The key file {keys_path} requires encryption: the files of this " + f"The key file {keys_path} requires encryption: files of this " "datasite at rest are encrypted. Encryption cannot be off for it. Use a " "new SyftBox folder for a client with encryption off." ) @@ -924,12 +732,47 @@ def _warn_key_file_holds_data(keys_path: Path) -> None: """Warn once, when a datasite starts to require encryption.""" warnings.warn( "This datasite now requires encryption, and every later session keeps " - "that setting. Its files at rest are encrypted with the key in " + "that setting. Its new files at rest are encrypted with the key in " f"{keys_path}. Without that file, they cannot be read again: keep a " "copy in a safe place." ) +@contextmanager +def _locked_key_file(path: Path) -> Iterator[None]: + """Hold an exclusive cross-process lock on the key file at ``path``. + + The lock is on a sibling ``.lock`` file, as in ``PersistedDict``, so the + key file itself can be replaced while the lock is held. + """ + path.parent.mkdir(parents=True, exist_ok=True) + with open(path.with_suffix(f"{path.suffix}.lock"), "a") as f: + portalocker.lock(f, portalocker.LOCK_EX) + try: + yield + finally: + portalocker.unlock(f) + + +def _write_key_file(path: Path, data: dict) -> None: + """Replace the key file at ``path`` with ``data`` in one step. + + The key file holds the only copy of the private keys, so a write cut short + must leave the old file in place. The data goes to a temporary file in the + same folder, reaches the disk, and then takes the name of the key file. + ``mkstemp`` creates the temporary file readable by this user only. + """ + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "w") as f: + f.write(json.dumps(data, indent=2)) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + finally: + Path(tmp).unlink(missing_ok=True) + + def _with_pin(data: dict, peer_email: str, bundle: dict) -> dict: """``data`` from the key file, with ``bundle`` recorded as the pin of ``peer_email``.""" bundles = data.get(PEER_BUNDLES_KEY) @@ -940,20 +783,6 @@ def _with_pin(data: dict, peer_email: str, bundle: dict) -> dict: return data -def _write_key_file(path: Path, data: dict) -> None: - """Replace the key file at ``path`` with ``data``. It holds the only copy of the private keys.""" - write_json_atomically(path, data, indent=2) - - -def _file_stamp(path: Path) -> tuple[int, int, int] | None: - """(mtime, inode, size) of the file at ``path``, or None when it is missing.""" - try: - st = path.stat() - except FileNotFoundError: - return None - return (st.st_mtime_ns, st.st_ino, st.st_size) - - def _peers_from_stored_pins(bundles: dict[str, dict]) -> List[Peer]: """Peers carrying the pins in the key file, minus any pin that no longer validates. diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index a6a1fe2c0e1..96edf3cbad5 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -1016,7 +1016,6 @@ def sync( separate process may have updated the file. """ with self.sync_file_lock(): - self.peer_manager.reload_policy() self.load_peers(force_download=force_download_peer_state) if self.has_do_role: peer_emails = [peer.email for peer in self.peer_manager.approved_peers] @@ -1044,8 +1043,8 @@ def sync( peer_emails = self.peer_manager.without_encryption_conflicts( peer_emails ) - self.datasite_watcher_syncer.resend_rejected_messages(peer_emails) - self.datasite_watcher_syncer.send_pending_messages() + # Changes that waited for encryption with their recipient. + self.datasite_watcher_syncer.process_file_changes_queue() self.datasite_watcher_syncer.sync_down(peer_emails) def load_peers(self, force_download: bool = False): diff --git a/syft/sync/sync/caches/datasite_watcher_cache.py b/syft/sync/sync/caches/datasite_watcher_cache.py index f77374be62a..bdffe7e565b 100644 --- a/syft/sync/sync/caches/datasite_watcher_cache.py +++ b/syft/sync/sync/caches/datasite_watcher_cache.py @@ -245,10 +245,10 @@ def sync_down_parallel( ) -> int: """Sync with parallel file downloads. Returns the number of events applied. - ``download_fn`` gets the metadata of one file (``file_id`` and - ``timestamp``). It returns None for a message that was rejected or cannot - be read yet. Messages from its timestamp on are not applied, and the - cursor stays before it, so they apply in order when it can be read. + ``download_fn`` gets the metadata of one file. It returns None for a + message that cannot be read yet. Messages from its timestamp on are not + applied, and the cursor stays before it, so they apply in order when it + can be read. """ peer_timestamp = self.last_event_timestamp_per_peer.get(peer_email) @@ -266,7 +266,7 @@ def sync_down_parallel( # Download all files in parallel downloaded_messages = list(executor.map(download_fn, file_metadatas)) - # Apply in timestamp order, up to the first rejected message + # Apply in timestamp order, up to the first message that cannot be read event_count = 0 by_timestamp = sorted( zip(file_metadatas, downloaded_messages), key=lambda x: x[0]["timestamp"] diff --git a/syft/sync/sync/constants.py b/syft/sync/sync/constants.py index c73793f8ef6..2e8508efb4f 100644 --- a/syft/sync/sync/constants.py +++ b/syft/sync/sync/constants.py @@ -2,5 +2,3 @@ OWNER_FILE_HASHES_FILENAME = "owner_file_hashes.json" ROLLING_STATE_FILENAME = "rolling_state.json" -HELD_OUTBOX_FILENAME = "held_outbox.json" -HELD_PROPOSALS_FILENAME = "held_proposals.json" diff --git a/syft/sync/sync/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py index 97d412a2adb..b85d7f7b29e 100644 --- a/syft/sync/sync/datasite_owner_syncer.py +++ b/syft/sync/sync/datasite_owner_syncer.py @@ -41,20 +41,11 @@ raise_for_later_version, ) from syft_perms import SyftPermContext -from syft.sync.sync.constants import ( - CACHE_DIR, - HELD_OUTBOX_FILENAME, - ROLLING_STATE_FILENAME, -) -from syft.sync.sync.held_messages import HeldMessages +from syft.sync.sync.constants import CACHE_DIR, ROLLING_STATE_FILENAME logger = logging.getLogger(__name__) -def _compressed(message: "FileChangeEventsMessage | bytes") -> bytes: - return message if isinstance(message, bytes) else message.as_compressed_data() - - # Default threshold for creating incremental checkpoint from rolling state DEFAULT_CHECKPOINT_EVENT_THRESHOLD = 50 @@ -206,17 +197,12 @@ def _save_rolling_state(self) -> None: tmp.rename(path) def sync(self, peer_emails: list[str], recompute_hashes: bool = True): - self._encrypt_files_at_rest_if_needed() if self.persist_owner_state: self._load_rolling_state() try: if not self.initial_sync_done: self.pull_initial_state() - # Rejected messages go first: the reader waits for them. - for peer_email in peer_emails: - self.connection_router.owner_resend_rejected_outbox_messages(peer_email) - if recompute_hashes: self.process_local_changes(recipients=peer_emails) @@ -247,9 +233,10 @@ def download_events_message_by_id_with_connection( # we need a new connection object because gdrive connections are not thread safe connection = self.connection_router.connection_for_eventlog(create_new=True) raw = connection.owner_download_raw_bytes_by_id(events_message_id) - return self.connection_router.decode_at_rest( - raw, FileChangeEventsMessage.from_compressed_data + raw = self.connection_router.peer_store.decrypt_and_verify_for_self_if_needed( + raw ) + return FileChangeEventsMessage.from_compressed_data(raw) def get_all_accepted_events_messages( self, since_timestamp: float | None = None @@ -257,8 +244,6 @@ def get_all_accepted_events_messages( message_ids = self.connection_router.owner_get_all_accepted_event_file_ids( since_timestamp=since_timestamp ) - # Once for the batch: the downloads run in threads. - self.connection_router.peer_store.reload_policy_from_key_file() result_messages = self._executor.map( self.download_events_message_by_id_with_connection, message_ids ) @@ -810,43 +795,30 @@ def queue_event_for_syftbox( self.outbox_queue.put((recipient, file_change_events_message)) def process_syftbox_events_queue(self): - # syft-rds and the enclave client call this outside sync(). Another - # process can have made the datasite required since this process last - # read the key file. - self.connection_router.peer_store.reload_policy_from_key_file() - self._encrypt_files_at_rest_if_needed() # TODO: make this atomic while not self.syftbox_events_queue.empty(): file_change_events_message = self.syftbox_events_queue.get() self.connection_router.owner_write_events_message_to_syftbox( file_change_events_message ) - queued = [] + # A message waits in the queue while encryption with its recipient is + # pending, and later messages to that recipient wait behind it. + waiting = [] + waiting_for = set() while not self.outbox_queue.empty(): - queued.append(self.outbox_queue.get()) - # A message waits in the held file only while encryption with its - # recipient is pending. The event is already in the event log, so the - # held file keeps it over a restart. - held_count = self._held_outbox.send_all( - self._send_outbox_message, queued, encode=_compressed - ) - if held_count: - logger.info( - f"Held {held_count} outbox message(s) for peers whose encryption " - "is not set up yet" - ) - - def _encrypt_files_at_rest_if_needed(self) -> None: - """Encrypt plaintext files at rest once, before they are read or written.""" - if self.connection_router.peer_store.needs_at_rest_encryption(): - self.connection_router.owner_encrypt_files_at_rest() + recipient, message = self.outbox_queue.get() + if recipient in waiting_for or not self._send_outbox_message( + recipient, message + ): + waiting_for.add(recipient) + waiting.append((recipient, message)) + for item in waiting: + self.outbox_queue.put(item) def _send_outbox_message( - self, recipient: str, message: "FileChangeEventsMessage | bytes" + self, recipient: str, message: FileChangeEventsMessage ) -> bool: """Write one outbox message. False while encryption is pending.""" - if isinstance(message, bytes): - message = FileChangeEventsMessage.from_compressed_data(message) try: self.connection_router.owner_write_event_messages_to_outbox( recipient, message @@ -859,10 +831,6 @@ def _send_outbox_message( logger.warning(f"Dropped an outbox message: {e}") return True - @property - def _held_outbox(self) -> HeldMessages: - return HeldMessages(self.syftbox_folder / CACHE_DIR / HELD_OUTBOX_FILENAME) - def _download_outbox_message_with_new_connection( self, file_id: str ) -> FileChangeEventsMessage: diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index c9ae7719a32..a07b9e264c3 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -18,8 +18,6 @@ ProposedFileChange, ProposedFileChangesMessage, ) -from syft.sync.sync.constants import CACHE_DIR, HELD_PROPOSALS_FILENAME -from syft.sync.sync.held_messages import HeldMessages from syft.sync.sync.caches.datasite_watcher_cache import ( DataSiteWatcherCache, DataSiteWatcherCacheConfig, @@ -69,23 +67,12 @@ def from_config(cls, config: DatasiteWatcherSyncerConfig): # --- Pushing proposed file changes --- def get_proposed_file_change_object( - self, - relative_path: Path, - content: str, - datasite_email: str | None = None, - held_hashes: Dict[Path, int | None] | None = None, + self, relative_path: Path, content: str, datasite_email: str | None = None ) -> ProposedFileChange: if datasite_email is None: datasite_email = self.email cache_key = Path(datasite_email) / relative_path - # The cache holds only sent changes. A held change to the same file - # gives the next change its old hash. - if held_hashes is None: - held_hashes = self._held_hashes() - if cache_key in held_hashes: - old_hash = held_hashes[cache_key] - else: - old_hash = self.datasite_watcher_cache.current_hash_for_file(cache_key) + old_hash = self.datasite_watcher_cache.current_hash_for_file(cache_key) return ProposedFileChange( datasite_email=datasite_email, path_in_datasite=relative_path, @@ -96,7 +83,7 @@ def get_proposed_file_change_object( def process_file_changes_queue(self): # One message per datasite: each change goes to the owner of its path. changes_by_recipient: Dict[str, List[ProposedFileChange]] = {} - held_hashes: Dict[Path, int | None] | None = None + items_by_recipient: Dict[str, list] = {} while not self.queue.empty(): relative_path, content = self.queue.get() @@ -116,45 +103,32 @@ def process_file_changes_queue(self): recipient = datasite_email path_in_datasite = path_in_datasite - if held_hashes is None: - held_hashes = self._held_hashes() file_change = self.get_proposed_file_change_object( - path_in_datasite, - content, - datasite_email=recipient, - held_hashes=held_hashes, + path_in_datasite, content, datasite_email=recipient ) changes_by_recipient.setdefault(recipient, []).append(file_change) - - messages = [ - ( - r, - ProposedFileChangesMessage( - sender_email=self.email, proposed_file_changes=c - ), + items_by_recipient.setdefault(recipient, []).append( + (relative_path, content) ) - for r, c in changes_by_recipient.items() - ] + error = None - done = 0 - try: - for recipient, message in messages: - try: - sent = self._send_or_hold(recipient, message) - except (EncryptionPolicyConflictError, PeerEncryptionPendingError) as e: - # The other recipients still get their changes. + for recipient, changes in changes_by_recipient.items(): + message = ProposedFileChangesMessage( + sender_email=self.email, proposed_file_changes=changes + ) + try: + self._send_message(recipient, message) + except PeerEncryptionPendingError as e: + if not self.connection_router.peer_store.should_hold(recipient): error = error or e - done += 1 continue - if sent: - self._record_sent_hashes(message) - done += 1 - finally: - # The queue is already empty: on another error, the messages not - # sent go to the held file, and the next sync sends them. - if done < len(messages): - with self._held_messages.edit() as held: - held.extend((r, m.as_compressed_data()) for r, m in messages[done:]) + # The changes wait in the queue; the next sync sends them. + print(f"Changes for {recipient} wait for the next sync: {e}") + for item in items_by_recipient[recipient]: + self.queue.put(item) + except EncryptionPolicyConflictError as e: + # The other recipients still get their changes. + error = error or e if error is not None: raise error @@ -167,87 +141,21 @@ def _record_sent_hashes(self, message: ProposedFileChangesMessage) -> None: else: hashes[path_key] = fc.new_hash - @property - def _held_messages(self) -> HeldMessages: - return HeldMessages(self.syftbox_folder / CACHE_DIR / HELD_PROPOSALS_FILENAME) - - def _held_hashes(self) -> Dict[Path, int | None]: - """The new hash of each file that a held change touches.""" - hashes: Dict[Path, int | None] = {} - for _, data in self._held_messages.read(): - message = ProposedFileChangesMessage.from_compressed_data(data) - for fc in message.proposed_file_changes: - path_key = Path(fc.datasite_email) / fc.path_in_datasite - hashes[path_key] = None if fc.is_deleted else fc.new_hash - return hashes - - def _send_or_hold( + def _send_message( self, recipient: str, message: ProposedFileChangesMessage - ) -> bool: - """Send ``message``, or hold it while encryption with the recipient is pending. - - Encryption is pending while the recipient policy is unknown, or while - the pair encrypts and the keys or the peer bundle are missing. A held - message is kept on disk. Messages to one recipient keep their order. - Returns True when sent. + ) -> None: + """Send ``message`` and record the hashes it sent. Raises: - PeerEncryptionPolicyUnknownError: when ``recipient`` is not a peer. - It never gets a policy, so a held message would never go. + PeerEncryptionPendingError: while encryption with ``recipient`` is + pending: its policy is unknown, or the pair encrypts and the + keys or the peer bundle are missing. EncryptionPolicyConflictError: when the policies of the pair conflict. """ - with self._held_messages.edit() as held: - if not any(r == recipient for r, _ in held): - try: - self.connection_router.watcher_send_proposed_file_changes_message( - recipient, message - ) - return True - except PeerEncryptionPendingError as e: - if not self.connection_router.peer_store.should_hold(recipient): - raise - print(f"Held a message for {recipient}: {e}") - held.append((recipient, message.as_compressed_data())) - return False - - def send_pending_messages(self) -> None: - """Send the held messages whose recipient policy is now known. - - Messages for a recipient that is no longer a peer (the peer request - was rejected), or whose policy conflicts, are dropped with a warning. - """ - self._held_messages.send_all(self._send_held_message) - - def _send_held_message(self, recipient: str, data: bytes) -> bool: - """Send one held message. False while encryption is pending.""" - if not self.connection_router.peer_store.should_hold(recipient): - logger.warning(f"Dropped a held message for {recipient}: it is not a peer.") - return True - message = ProposedFileChangesMessage.from_compressed_data(data) - try: - self.connection_router.watcher_send_proposed_file_changes_message( - recipient, message - ) - except PeerEncryptionPendingError: - return False - except EncryptionPolicyConflictError as e: - print(f"Dropped a held message for {recipient}: {e}") - return True + self.connection_router.watcher_send_proposed_file_changes_message( + recipient, message + ) self._record_sent_hashes(message) - return True - - def resend_rejected_messages(self, peer_emails: list[str]) -> None: - """Resend, encrypted, the plaintext messages that peers rejected. - - A peer rejects plaintext when it requires encryption and this client - sent with an outdated view of its policy. - """ - for peer_email in peer_emails: - count = self.connection_router.watcher_resend_rejected_inbox_messages( - peer_email - ) - if count: - logger.info(f"Resent {count} rejected message(s) to {peer_email}") def on_file_change( self, relative_path: Path | str, content: str | None = None, process_now=True @@ -260,16 +168,16 @@ def on_file_change( # --- Pulling from datasite outboxes --- def download_events_message_with_new_connection( - self, file_id: str, peer_email: str, sent_at: float | None = None + self, file_meta: dict, peer_email: str ) -> FileChangeEventsMessage | None: """Download from outbox using a new connection (thread-safe). - Returns None when the message was rejected, or when it cannot be - read yet (see ``ConnectionRouter.watcher_read_events_message``). + Returns None when the message cannot be read yet (see + ``ConnectionRouter.watcher_read_events_message``). """ connection = self.connection_router.connection_for_parallel_download() return self.connection_router.watcher_read_events_message( - connection, peer_email, file_id, sent_at + connection, peer_email, file_meta ) def download_collection_file_with_new_connection( @@ -291,7 +199,7 @@ def sync_down(self, peer_emails: list[str]): self._executor, lambda meta, pe=peer_email: self.download_events_message_with_new_connection( - meta["file_id"], pe, meta["timestamp"] + meta, pe ), ) if event_count: diff --git a/syft/sync/sync/held_messages.py b/syft/sync/sync/held_messages.py deleted file mode 100644 index 103c740a11a..00000000000 --- a/syft/sync/sync/held_messages.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Messages held until encryption with their recipient is set up. - -The list persists to a JSON file in the cache folder of the datasite, so a -held message survives a restart. The file is the only copy: a process takes -the cross-process lock, sends what it can, and writes back the rest, so two -processes on one datasite (a notebook and syft-bg) never send one message -twice. -""" - -import base64 -import json -from contextlib import contextmanager -from pathlib import Path -from typing import Any, Callable, Iterable, Iterator - -from syft.sync.utils.locked_files import locked_file, write_json_atomically - -# Format of the file: {"version": N, "messages": [...]}. Raise it when the -# layout of a message entry changes. -HELD_MESSAGES_VERSION = 1 - -HeldMessage = tuple[str, bytes] # (recipient email, compressed message) - - -class HeldMessages: - """A list of held messages, persisted to ``path``.""" - - def __init__(self, path: Path): - self._path = Path(path) - - @contextmanager - def edit(self) -> Iterator[list[HeldMessage]]: - """Hold the lock, yield the list, and save the changes made to it. - - When the block raises, the file stays as it was. - """ - with locked_file(self._path): - messages = self._read() - yield messages - self._write(messages) - - def read(self) -> list[HeldMessage]: - """The held messages, read under the lock, without a write.""" - with locked_file(self._path): - return self._read() - - def send_all( - self, - send: Callable[[str, Any], bool], - queued: Iterable[tuple[str, Any]] = (), - encode: Callable[[Any], bytes] = lambda message: message, - ) -> int: - """Send the held messages, then ``queued``, in order for each recipient. - - ``send(recipient, message)`` gets the held bytes, or a queued message. - It returns False while encryption with the recipient is pending: that - message, and later ones to the same recipient, stay held (``encode`` - gives the bytes of a queued message). True means sent, or dropped on - purpose. When ``send`` raises, the messages not handled yet stay held, - so none is lost and none is sent twice. - - Returns the number of messages held after the call. - """ - with self.edit() as held: - items = list(held) + list(queued) - remaining: list[tuple[str, Any]] = [] - waiting: set[str] = set() - done = 0 - try: - for recipient, message in items: - if recipient in waiting or not send(recipient, message): - waiting.add(recipient) - remaining.append((recipient, message)) - done += 1 - finally: - held[:] = [(r, encode(m)) for r, m in remaining + items[done:]] - self._write(held) - return len(held) - - def __len__(self) -> int: - return len(self.read()) - - def _read(self) -> list[HeldMessage]: - if not self._path.exists(): - return [] - data = json.loads(self._path.read_text()) - version = data.get("version") - if version != HELD_MESSAGES_VERSION: - # Held messages are user data, so a file this client cannot read - # is refused, not dropped. - raise ValueError( - f"The held messages in {self._path} have version {version}, and " - f"this client reads version {HELD_MESSAGES_VERSION}." - ) - return [(m["recipient"], base64.b64decode(m["data"])) for m in data["messages"]] - - def _write(self, messages: list[HeldMessage]) -> None: - if not messages: - self._path.unlink(missing_ok=True) - return - data = { - "version": HELD_MESSAGES_VERSION, - "messages": [ - {"recipient": r, "data": base64.b64encode(d).decode("ascii")} - for r, d in messages - ], - } - write_json_atomically(self._path, data) diff --git a/syft/sync/utils/locked_files.py b/syft/sync/utils/locked_files.py deleted file mode 100644 index 49c2e064fcd..00000000000 --- a/syft/sync/utils/locked_files.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Cross-process locks and atomic writes for small local JSON files.""" - -import json -import os -import tempfile -from contextlib import contextmanager -from pathlib import Path -from typing import Any, Iterator - -import portalocker - - -@contextmanager -def locked_file(path: Path) -> Iterator[None]: - """Hold an exclusive cross-process lock on the file at ``path``. - - The lock is on a sibling ``.lock`` file, so the file itself can be - replaced while the lock is held. - """ - path.parent.mkdir(parents=True, exist_ok=True) - with open(path.with_suffix(f"{path.suffix}.lock"), "a") as f: - portalocker.lock(f, portalocker.LOCK_EX) - try: - yield - finally: - portalocker.unlock(f) - - -def write_json_atomically(path: Path, data: Any, indent: int | None = None) -> None: - """Replace the file at ``path`` with ``data`` as JSON, in one step. - - A write cut short leaves the old file in place. The data goes to a - temporary file in the same folder, reaches the disk, and then takes the - name of the file. ``mkstemp`` creates the temporary file readable by this - user only. - """ - fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") - try: - with os.fdopen(fd, "w") as f: - f.write(json.dumps(data, indent=indent)) - f.flush() - os.fsync(f.fileno()) - os.replace(tmp, path) - finally: - Path(tmp).unlink(missing_ok=True) diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 05865e6b9f0..78e991be3d1 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -188,10 +188,6 @@ class PeerManager(BaseModel): _states_at_start: Optional[Dict[str, PeerState]] = PrivateAttr(default=None) # Peers this process published its encryption bundle for. _bundle_published_for: Set[str] = PrivateAttr(default_factory=set) - # The policy in the version file this process last wrote. - _advertised_policy: Optional[EncryptionPolicy] = PrivateAttr(default=None) - # Peers already warned about an advertised downgrade, in this process. - _downgrade_warned: Set[str] = PrivateAttr(default_factory=set) # ========== Peer List Properties ========== @@ -317,31 +313,6 @@ def write_own_version(self) -> None: version_info = self.get_own_version() self.connection_router.write_version_file(version_info) write_local_version(self.syftbox_folder) - self._advertised_policy = version_info.encryption_policy - - def reload_policy(self) -> None: - """Take a policy another process recorded in the key file, and advertise it.""" - self.peer_store.reload_policy_from_key_file() - self._republish_version_if_policy_changed() - - def _republish_version_if_policy_changed(self) -> None: - """Write the version file again when the policy changed in memory. - - Loading a key file marked required makes this client required. Peers - must see that, or they send plaintext that this client rejects. A - process that never wrote the version file (syft-bg) compares with the - file on Drive, once. - """ - if self._advertised_policy is None: - published = self.connection_router.read_own_version_file() - if published is None or ( - published.encryption_policy != self.peer_store.encryption_policy - ): - self.write_own_version() - else: - self._advertised_policy = published.encryption_policy - elif self._advertised_policy != self.peer_store.encryption_policy: - self.write_own_version() def share_version_with_peer(self, peer_email: str) -> None: """Share version file with a peer so they can read it.""" @@ -359,7 +330,6 @@ def load_peer_version(self, peer_email: str) -> Optional[VersionInfo]: cached_peer = self.get_cached_peer(peer_email) if cached_peer: cached_peer.version = version_info - self._note_peer_policy(cached_peer) self._update_peer_schemas(peer_email, version_info) return version_info @@ -395,34 +365,10 @@ def load_peer_versions_parallel( peer = self.get_cached_peer(email) if peer: peer.version = version - self._note_peer_policy(peer) self._update_peer_schemas(email, version) return {email: version for email, version in results} - def _note_peer_policy(self, peer: Peer) -> None: - """Remember a peer that requires encryption; warn when it seems to stop. - - The version file is not signed, so the storage provider could write a - downgrade into it. The peer stays required until the user trusts a new - key for it. - """ - advertised = peer.encryption_policy - if advertised == EncryptionPolicy.REQUIRED: - self.peer_store.remember_required_peer(peer.email) - elif ( - advertised is not None - and self.peer_store.is_remembered_required(peer.email) - and peer.email not in self._downgrade_warned - ): - self._downgrade_warned.add(peer.email) - warnings.warn( - f"{peer.email} advertises encryption {advertised.value}, but it " - "required encryption before, so encryption stays on. If the peer " - "reset its keys, compare its new key fingerprint with it and run " - f"client.trust_peer_key({peer.email!r}, fingerprint)." - ) - def get_peer_version(self, peer_email: str) -> Optional[VersionInfo]: """Get cached version for peer, None if not loaded or not available.""" peer = self.get_cached_peer(peer_email) @@ -655,7 +601,6 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): # load_peers publishes and pins when the policy becomes known. version_info = self.connection_router.read_peer_version_file(peer_email) new_peer_obj.version = version_info - self._note_peer_policy(new_peer_obj) encrypted = pair_uses_encryption( self.peer_store.encryption_policy, self.peer_store.policy_of(new_peer_obj), @@ -871,7 +816,6 @@ def _warn_if_key_rotated(self, peer_email: str, published_bundle: dict) -> None: def _write_encryption_bundle_for_peer(self, peer_email: str) -> dict | None: """Write own encryption bundle for a peer, making keys first when needed.""" self.peer_store.ensure_keys() - self._republish_version_if_policy_changed() bundle = self.peer_store.get_public_bundle() bundle_json = json.dumps({BUNDLE_FILE_KEY: bundle}) self.connection_router.write_encryption_bundle(peer_email, bundle_json) diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index ec4e4c3a424..d73e28bb53e 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -6,7 +6,6 @@ import json import threading -import time import warnings from pathlib import Path @@ -14,7 +13,6 @@ from syft.sync.connections.connection_router import ConnectionRouter from syft.sync.connections.drive import mock_drive_service -from syft.sync.connections.drive.gdrive_transport import GDriveConnection from syft.sync.encryption_policy import ( EncryptionPolicy, EncryptionPolicyConflictError, @@ -24,12 +22,10 @@ ) from syft.sync.events.file_change_event import ( FileChangeEventsMessage, - FileChangeEventsMessageFileName, ) from syft.sync.peers.peer import Peer, PeerState from syft.sync.peers.peer_store import PeerStore from syft.sync.sync.datasite_watcher_syncer import DatasiteWatcherSyncer -from syft.sync.sync.held_messages import HeldMessages from syft.sync.syftbox_manager import SyftboxManager from syft.sync.version.version_info import VersionInfo from tests.unit.test_sync_manager import path_for_job @@ -76,19 +72,8 @@ def _keyed_pair( return alice, bob -def _held_proposals(manager) -> int: - """The number of proposed-change messages ``manager`` holds.""" - return len(HeldMessages(manager.syftbox_folder / ".cache" / "held_proposals.json")) - - -def _held_outbox(manager) -> int: - """The number of outbox messages ``manager`` holds.""" - return len(HeldMessages(manager.syftbox_folder / ".cache" / "held_outbox.json")) - - def _forget(store: PeerStore, email: str) -> None: """Make ``store`` forget what it knew of ``email``'s policy, as a new client.""" - store.forget_required_peer(email) store._last_known_policies.pop(email, None) @@ -219,87 +204,6 @@ def test_missing_version_file_created_with_own_policy(): assert b_conn.read_peer_version_file("a@example.com").encryption_policy == REQUIRED -def test_policy_change_in_memory_republished(): - """A key file marked required makes this client required: peers must see it.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - add_peers=False, - do_encryption_policy=REQUIRED, - ) - ds_manager.add_peer(do_manager.email, sync=False) - do_manager.load_peers() - do_manager.approve_peer_request(ds_manager.email) - - # Another process on the DS datasite wrote a required key file. - keys_path = ds_manager.peer_manager.peer_store._keys_path - PeerStore.create( - email=ds_manager.email, encryption_policy=REQUIRED, keys_path=keys_path - ) - ds_manager.load_peers() - - assert ds_manager.peer_manager.peer_store.encryption_policy == REQUIRED - advertised = do_manager.peer_manager.load_peer_version(ds_manager.email) - assert advertised.encryption_policy == REQUIRED - - -def _advertise_policy(manager, policy) -> None: - """Write the version file of ``manager`` with ``policy``, as a tamperer could.""" - version = manager.peer_manager.get_own_version().model_copy() - version.encryption_policy = policy - manager.peer_manager.connection_router.write_version_file(version) - - -def test_advertised_downgrade_of_required_peer_ignored(): - """The version file is not signed, so a required peer stays required.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - ds_encryption_policy=OPTIONAL, - do_encryption_policy=REQUIRED, - ) - grant_job_inbox_access(do_manager, ds_manager.email) - ds_manager.load_peers() - do_manager.load_peers() - - _advertise_policy(do_manager, OPTIONAL) - with pytest.warns(UserWarning, match="required encryption before"): - ds_manager.load_peers() - ds_store = ds_manager.peer_manager.peer_store - assert ds_store.peer_policy(do_manager.email) == REQUIRED - - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "content") - assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) - - -def test_required_peer_remembered_in_key_file(tmp_path): - keys_path = tmp_path / "crypto_keys.json" - store = PeerStore.create(email="alice@example.com", keys_path=keys_path) - store.ensure_keys() - store.add_peer( - Peer(email="bob@example.com", version=_version_with_policy(REQUIRED)) - ) - store.remember_required_peer("bob@example.com") - - later = PeerStore.create(email="alice@example.com", keys_path=keys_path) - later.add_peer( - Peer(email="bob@example.com", version=_version_with_policy(OPTIONAL)) - ) - assert later.peer_policy("bob@example.com") == REQUIRED - - -def test_trusted_key_change_clears_remembered_required_peer(): - """A peer can drop required only with a new key file, so a new identity key.""" - alice, bob = _keyed_pair(OPTIONAL, REQUIRED) - alice.remember_required_peer("bob@example.com") - alice.get_cached_peer("bob@example.com").version = _version_with_policy(OPTIONAL) - assert alice.peer_policy("bob@example.com") == REQUIRED - - reset_bob = PeerStore(email="bob@example.com") - reset_bob.generate_keys() - alice.set_peer_bundle( - "bob@example.com", reset_bob.get_public_bundle(), allow_key_change=True - ) - assert alice.peer_policy("bob@example.com") == OPTIONAL - - def _write_garbage_own_bundle(ds_manager, do_manager, monkeypatch) -> None: ds_manager.peer_manager.connection_router.write_encryption_bundle( do_manager.email, "not json" @@ -434,7 +338,7 @@ def test_store_not_requiring_encryption_creates_no_key_file(tmp_path, policy): def test_off_refused_when_key_file_requires_encryption(tmp_path): - """The files at rest of such a datasite are encrypted; off cannot read them.""" + """Files at rest of such a datasite are encrypted; off cannot read them.""" keys_path = tmp_path / "crypto_keys.json" with pytest.warns(UserWarning): PeerStore.create( @@ -516,44 +420,6 @@ def test_required_client_records_policy_in_existing_key_file(tmp_path): assert store.encryption_policy == REQUIRED -def test_new_required_key_file_checks_files_at_rest_once(tmp_path): - """An optional client before it wrote plaintext at rest without a key file.""" - keys_path = tmp_path / "crypto_keys.json" - PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) - store = PeerStore.create(email="alice@example.com", keys_path=keys_path) - assert store.needs_at_rest_encryption() - - -def test_switch_to_required_needs_at_rest_encryption_until_marked(tmp_path): - keys_path = tmp_path / "crypto_keys.json" - PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() - store = PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) - assert store.needs_at_rest_encryption() - - store.encrypt_files_at_rest_once(lambda: None) - reloaded = PeerStore.create(email="alice@example.com", keys_path=keys_path) - assert not reloaded.needs_at_rest_encryption() - - -def test_key_file_without_policy_checks_files_at_rest_once(tmp_path): - """An earlier client without encryption can have written plaintext at rest.""" - keys_path = tmp_path / "crypto_keys.json" - PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) - data = json.loads(keys_path.read_text()) - del data["encryption_policy"] - data.pop("at_rest_encrypted_at", None) - keys_path.write_text(json.dumps(data)) - - store = PeerStore.create(email="alice@example.com", keys_path=keys_path) - assert store.needs_at_rest_encryption() - - def _create(keys_path, policy=OPTIONAL) -> PeerStore: return PeerStore.create( email="alice@example.com", encryption_policy=policy, keys_path=keys_path @@ -616,148 +482,6 @@ def test_keys_from_other_process_keep_pin_held_in_memory(tmp_path): assert PeerStore.load_keys(keys_path).has_peer_bundle("bob@example.com") -def test_reload_reads_keys_only_for_a_required_key_file(tmp_path, monkeypatch): - keys_path = tmp_path / "crypto_keys.json" - store = PeerStore.create(email="alice@example.com", keys_path=keys_path) - store.ensure_keys() - loads = [] - original = PeerStore.load_keys.__func__ - monkeypatch.setattr( - PeerStore, - "load_keys", - classmethod(lambda cls, path: loads.append(path) or original(cls, path)), - ) - store.reload_policy_from_key_file() - assert loads == [] - - -def test_at_rest_pass_leaves_key_file_lock_free(tmp_path): - """A notebook can pin keys while syft-bg encrypts the files at rest.""" - from syft.sync.utils.locked_files import locked_file - - keys_path = tmp_path / "crypto_keys.json" - store = PeerStore.create(email="alice@example.com", keys_path=keys_path) - store.ensure_keys() - store.encryption_policy = REQUIRED - took_lock = [] - - def encrypt_files(): - def other_process(): - with locked_file(keys_path): - took_lock.append(True) - - thread = threading.Thread(target=other_process, daemon=True) - thread.start() - thread.join(timeout=5) - - store.encrypt_files_at_rest_once(encrypt_files) - assert took_lock == [True] - - -def test_write_at_rest_takes_required_policy_from_other_process(tmp_path): - """Every write at rest reads the key file, so none goes out as plaintext.""" - keys_path = tmp_path / "crypto_keys.json" - notebook = PeerStore.create(email="alice@example.com", keys_path=keys_path) - notebook.ensure_keys() - with pytest.warns(UserWarning): - PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) - assert _is_envelope(notebook.encrypt_for_self_if_needed(b"rolling state")) - - -def _plaintext_event_at_rest(manager, timestamp: float) -> None: - message = FileChangeEventsMessage( - events=[], - message_filepath=FileChangeEventsMessageFileName(timestamp=timestamp), - ) - manager.datasite_owner_syncer.connection_router.connection_for_eventlog().owner_write_raw_bytes_to_syftbox( - message.message_filepath.as_string(), message.as_compressed_data() - ) - - -def test_plaintext_at_rest_accepted_only_if_written_before_files_were_encrypted(): - """A process can write plaintext while another one encrypts the files at rest.""" - _, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - do_encryption_policy=REQUIRED, - ) - written_during_pass = time.time() - do_manager.sync() - router = do_manager.datasite_owner_syncer.connection_router - _plaintext_event_at_rest(do_manager, written_during_pass) - router.owner_get_all_accepted_events_messages() - - _plaintext_event_at_rest(do_manager, time.time() + 1) - with pytest.raises(ValueError, match="not encrypted"): - router.owner_get_all_accepted_events_messages() - - -def test_key_file_read_once_for_batch_of_files_at_rest(monkeypatch): - _, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - do_encryption_policy=REQUIRED, - ) - router = do_manager.datasite_owner_syncer.connection_router - for _ in range(3): - router.owner_write_events_message_to_syftbox(FileChangeEventsMessage(events=[])) - reloads = [] - original = PeerStore.reload_policy_from_key_file - monkeypatch.setattr( - PeerStore, - "reload_policy_from_key_file", - lambda self: reloads.append(1) or original(self), - ) - router.owner_get_all_accepted_events_messages() - assert len(reloads) == 1 - reloads.clear() - do_manager.datasite_owner_syncer.get_all_accepted_events_messages() - assert len(reloads) == 1 - - -def test_keyless_process_takes_remembered_peers_from_key_file(tmp_path): - keys_path = tmp_path / "crypto_keys.json" - notebook = _create(keys_path) - daemon = _create(keys_path) - daemon.ensure_keys() - notebook.reload_policy_from_key_file() - daemon.remember_required_peer("bob@example.com") - notebook.reload_policy_from_key_file() - assert notebook.is_remembered_required("bob@example.com") - - -def test_trusted_peer_stays_forgotten_in_other_process(tmp_path): - """syft-bg must not write back a peer that trust_peer_key cleared.""" - keys_path = tmp_path / "crypto_keys.json" - with pytest.warns(UserWarning): - notebook = _create(keys_path, REQUIRED) - daemon = _create(keys_path) - notebook.remember_required_peer("bob@example.com") - daemon.reload_policy_from_key_file() - assert daemon.is_remembered_required("bob@example.com") - - notebook.forget_required_peer("bob@example.com") - daemon.remember_required_peer("carol@example.com") - stored = json.loads(keys_path.read_text())["required_peers"] - assert stored == ["carol@example.com"] - daemon.reload_policy_from_key_file() - assert not daemon.is_remembered_required("bob@example.com") - - -def test_key_file_read_again_only_after_it_changes(tmp_path, monkeypatch): - keys_path = tmp_path / "crypto_keys.json" - store = _create(keys_path) - store.ensure_keys() - reads = [] - original = PeerStore._read_key_file - monkeypatch.setattr( - PeerStore, - "_read_key_file", - lambda self: reads.append(1) or original(self), - ) - for _ in range(3): - store.encrypt_for_self_if_needed(b"data") - assert len(reads) == 1 - - def test_concurrent_processes_make_one_key_pair(tmp_path): """A notebook and syft-bg that need keys at once must end with the same keys.""" keys_path = tmp_path / "crypto_keys.json" @@ -944,63 +668,6 @@ def test_client_required_after_peering_publishes_bundle(): assert any("after switch" in c for c in _accepted_contents(do_manager)) -def test_policy_from_other_process_taken_at_next_sync(): - """syft-bg starts required next to a notebook that started optional.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - grant_job_inbox_access(do_manager, ds_manager.email) - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "written while optional") - do_manager.sync() - - keys_path = do_manager.peer_manager.peer_store._keys_path - with pytest.warns(UserWarning): - PeerStore.create( - email=do_manager.email, encryption_policy=REQUIRED, keys_path=keys_path - ) - do_manager.sync() - - do_store = do_manager.peer_manager.peer_store - assert do_store.encryption_policy == REQUIRED - assert all(_is_envelope(raw) for raw in _raw_event_log(do_manager)) - assert any("written while optional" in c for c in _accepted_contents(do_manager)) - advertised = ds_manager.peer_manager.load_peer_version(do_manager.email) - assert advertised.encryption_policy == REQUIRED - - -def test_switch_advertised_by_process_that_never_wrote_version(): - """syft-bg does not write the version file, but must advertise required.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - do_manager.peer_manager._advertised_policy = None # as a fresh syft-bg - - keys_path = do_manager.peer_manager.peer_store._keys_path - with pytest.warns(UserWarning): - PeerStore.create( - email=do_manager.email, encryption_policy=REQUIRED, keys_path=keys_path - ) - do_manager.sync() - advertised = ds_manager.peer_manager.load_peer_version(do_manager.email) - assert advertised.encryption_policy == REQUIRED - - -def test_direct_event_write_encrypts_earlier_files_at_rest(): - """Files written while optional are encrypted before a direct write reads them.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - grant_job_inbox_access(do_manager, ds_manager.email) - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "written while optional") - do_manager.sync() - - keys_path = do_manager.peer_manager.peer_store._keys_path - with pytest.warns(UserWarning): - PeerStore.create( - email=do_manager.email, encryption_policy=REQUIRED, keys_path=keys_path - ) - syncer = do_manager.datasite_owner_syncer - syncer.queue_event_for_syftbox([], FileChangeEventsMessage(events=[])) - syncer.process_syftbox_events_queue() - assert all(_is_envelope(raw) for raw in _raw_event_log(do_manager)) - - def _raw_event_log(do_manager) -> list[bytes]: connection = ( do_manager.datasite_owner_syncer.connection_router.connection_for_eventlog() @@ -1008,22 +675,31 @@ def _raw_event_log(do_manager) -> list[bytes]: return connection.owner_download_all_raw_events_from_syftbox() -def test_owner_switching_to_required_encrypts_files_at_rest(): - """Plaintext written while optional is encrypted once, then read as usual.""" +def test_owner_switching_to_required_encrypts_new_files_at_rest(): + """Files written while optional stay plaintext, and are read as usual.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() grant_job_inbox_access(do_manager, ds_manager.email) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "before switch") do_manager.sync() do_manager.create_checkpoint() - assert not any(_is_envelope(raw) for raw in _raw_event_log(do_manager)) + before = len(_raw_event_log(do_manager)) + # A later login of the DO requires encryption. do_manager.peer_manager.peer_store.encryption_policy = REQUIRED - do_manager.peer_manager.peer_store.ensure_keys() + do_manager.peer_manager.write_own_version() + do_manager.load_peers() + ds_manager.load_peers() + do_manager.load_peers() + ds_manager._send_file_change(file_path, "after switch") do_manager.sync() - assert all(_is_envelope(raw) for raw in _raw_event_log(do_manager)) - assert any("before switch" in c for c in _accepted_contents(do_manager)) + envelopes = [_is_envelope(raw) for raw in _raw_event_log(do_manager)] + assert envelopes.count(False) == before + assert envelopes.count(True) == len(envelopes) - before > 0 + contents = _accepted_contents(do_manager) + assert any("before switch" in c for c in contents) + assert any("after switch" in c for c in contents) router = do_manager.datasite_owner_syncer.connection_router assert router.get_latest_checkpoint() is not None @@ -1076,7 +752,7 @@ def test_owner_drops_outbox_message_it_cannot_send(ds_policy, recipient): message = FileChangeEventsMessage(events=[]) syncer.outbox_queue.put((recipient or ds_manager.email, message)) syncer.process_syftbox_events_queue() - assert _held_outbox(do_manager) == 0 + assert syncer.outbox_queue.empty() def test_conflict_found_on_read_makes_peer_wait(): @@ -1134,47 +810,6 @@ def fail(self, peer_email): assert do_manager.email not in ds_manager.peer_manager._bundle_published_for -def test_add_peer_publishes_bundle_for_remembered_required_peer(monkeypatch): - """add_peer follows the same policy as other paths, not the raw version file.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - ds_encryption_policy=OPTIONAL, - do_encryption_policy=REQUIRED, - ) - ds_manager.load_peers() - _advertise_policy(do_manager, OPTIONAL) - writes = [] - original = ConnectionRouter.write_encryption_bundle - monkeypatch.setattr( - ConnectionRouter, - "write_encryption_bundle", - lambda self, peer_email, bundle_json: writes.append(peer_email) - or original(self, peer_email, bundle_json), - ) - with pytest.warns(UserWarning, match="required encryption before"): - ds_manager.add_peer(do_manager.email, force=True, sync=False) - assert writes == [do_manager.email] - - -def test_outbox_history_from_before_switch_stays_readable(): - """A client that turns required accepts plaintext written before it did.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - grant_job_inbox_access(do_manager, ds_manager.email) - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "history") - do_manager.sync() - - keys_path = ds_manager.peer_manager.peer_store._keys_path - with pytest.warns(UserWarning): - PeerStore.create( - email=ds_manager.email, encryption_policy=REQUIRED, keys_path=keys_path - ) - ds_manager.sync() - - outbox = do_manager.datasite_owner_syncer.connection_router.connection_for_outbox() - assert outbox.owner_get_rejected_outbox_messages(ds_manager.email) == [] - assert any("history" in c for c in _ds_cached_contents(ds_manager)) - - def test_owner_waits_on_envelope_from_unpinned_peer(): """The sync goes on; the message stays in the inbox until the pin exists.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( @@ -1196,7 +831,7 @@ def test_owner_waits_on_envelope_from_unpinned_peer(): # ========================================================================= -# Held sends +# Sends while encryption is pending # ========================================================================= @@ -1219,8 +854,12 @@ def test_send_reread_updates_peer_manager(): assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) -def test_send_before_peer_accepts_held_until_sync(capsys): - """Before the owner accepts, its policy is unknown, so the send waits.""" +def _queued_changes(manager) -> int: + return manager.datasite_watcher_syncer.queue.qsize() + + +def test_send_before_peer_accepts_waits_until_sync(capsys): + """Before the owner accepts, its policy is unknown, so the change waits.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, do_encryption_policy=REQUIRED, @@ -1228,12 +867,9 @@ def test_send_before_peer_accepts_held_until_sync(capsys): ds_manager.add_peer(do_manager.email, sync=False) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "held content") - assert _held_proposals(ds_manager) == 1 - assert "Held a message" in capsys.readouterr().out - # The held message is on disk, so a restart keeps it, and the hash cache - # records only what the owner received. - assert (ds_manager.syftbox_folder / ".cache" / "held_proposals.json").exists() + ds_manager._send_file_change(file_path, "early content") + assert _queued_changes(ds_manager) == 1 + assert "next sync" in capsys.readouterr().out cache = ds_manager.datasite_watcher_syncer.datasite_watcher_cache assert Path(file_path) not in cache.file_hashes @@ -1241,15 +877,14 @@ def test_send_before_peer_accepts_held_until_sync(capsys): do_manager.approve_peer_request(ds_manager.email) grant_job_inbox_access(do_manager, ds_manager.email) ds_manager.sync() - assert _held_proposals(ds_manager) == 0 + assert _queued_changes(ds_manager) == 0 assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) - do_manager.sync() - assert any("held content" in c for c in _accepted_contents(do_manager)) + assert any("early content" in c for c in _accepted_contents(do_manager)) -def test_send_after_accept_before_peer_load_held(): - """The owner accepted, but the scientist has no keys or pin yet: hold, then send.""" +def test_send_after_accept_before_peer_load_waits_until_sync(): + """The owner accepted, but the scientist has no keys or pin yet.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, do_encryption_policy=REQUIRED, @@ -1261,58 +896,21 @@ def test_send_after_accept_before_peer_load_held(): file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "after accept") - assert _held_proposals(ds_manager) == 1 + assert _queued_changes(ds_manager) == 1 ds_manager.sync() do_manager.sync() assert any("after accept" in c for c in _accepted_contents(do_manager)) -def test_two_held_changes_to_one_file_apply_in_order(): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - add_peers=False, - ) - ds_manager.add_peer(do_manager.email, sync=False) - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "first") - ds_manager._send_file_change(file_path, "second") - assert _held_proposals(ds_manager) == 2 - - do_manager.load_peers() - do_manager.approve_peer_request(ds_manager.email) - grant_job_inbox_access(do_manager, ds_manager.email) - ds_manager.sync() - do_manager.sync() - assert any("second" in c for c in _accepted_contents(do_manager)) - - -def test_held_hashes_read_once_per_queue_flush(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - reads = [] - original = DatasiteWatcherSyncer._held_hashes - monkeypatch.setattr( - DatasiteWatcherSyncer, - "_held_hashes", - lambda self: reads.append(1) or original(self), - ) - grant_job_inbox_access(do_manager, ds_manager.email) - watcher = ds_manager.datasite_watcher_syncer - for name in ("a.job", "b.job", "c.job"): - file_path = path_for_job(do_manager.email, ds_manager.email, name) - watcher.on_file_change(file_path, name, process_now=False) - watcher.process_file_changes_queue() - assert len(reads) == 1 - - def test_send_to_non_peer_raises(): - """A recipient that is not a peer never gets a policy, so holding would be forever.""" + """A recipient that is not a peer never gets a policy.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, ) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") with pytest.raises(PeerEncryptionPolicyUnknownError): ds_manager._send_file_change(file_path, "content") - assert _held_proposals(ds_manager) == 0 def test_changes_for_two_datasites_go_to_each(monkeypatch): @@ -1320,7 +918,7 @@ def test_changes_for_two_datasites_go_to_each(monkeypatch): sent = [] monkeypatch.setattr( DatasiteWatcherSyncer, - "_send_or_hold", + "_send_message", lambda self, recipient, message: sent.append( (recipient, {fc.datasite_email for fc in message.proposed_file_changes}) ) @@ -1351,13 +949,13 @@ def test_failure_for_one_recipient_still_sends_to_other(monkeypatch, error): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() sent = [] - def send_or_hold(self, recipient, message): + def send_message(self, recipient, message): if recipient == "other@example.com": raise error(recipient) sent.append(recipient) return True - monkeypatch.setattr(DatasiteWatcherSyncer, "_send_or_hold", send_or_hold) + monkeypatch.setattr(DatasiteWatcherSyncer, "_send_message", send_message) watcher = ds_manager.datasite_watcher_syncer watcher.on_file_change("other@example.com/a.txt", "a", process_now=False) watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) @@ -1366,40 +964,6 @@ def send_or_hold(self, recipient, message): assert sent == [do_manager.email] -def test_owner_flush_writes_no_held_file_when_nothing_waits(monkeypatch): - """Messages that go out at once do not pass through the held file.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - written = [] - original = HeldMessages._write - monkeypatch.setattr( - HeldMessages, - "_write", - lambda self, messages: written.append(len(messages)) - or original(self, messages), - ) - syncer = do_manager.datasite_owner_syncer - for _ in range(3): - syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) - syncer.process_syftbox_events_queue() - assert all(n == 0 for n in written) - - -def test_drive_error_while_sending_changes_holds_unsent(monkeypatch): - """The queue is already empty, so unsent changes go to the held file.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - - def fail(self, recipient, message): - raise RuntimeError("Drive error") - - monkeypatch.setattr(DatasiteWatcherSyncer, "_send_or_hold", fail) - watcher = ds_manager.datasite_watcher_syncer - watcher.on_file_change(f"{do_manager.email}/a.txt", "a", process_now=False) - watcher.on_file_change("other@example.com/b.txt", "b", process_now=False) - with pytest.raises(RuntimeError): - watcher.process_file_changes_queue() - assert _held_proposals(ds_manager) == 2 - - def test_policy_reread_once_per_peer_load(monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() syncer = do_manager.datasite_owner_syncer @@ -1435,65 +999,6 @@ def test_compaction_skips_peer_with_unknown_policy(monkeypatch): assert syncer.compact_outbox_if_needed(ds_manager.email, min_messages=1) == 0 -def test_owner_flush_keeps_only_unsent_messages_on_error(monkeypatch): - """A Drive error mid-flush neither loses queued messages nor resends sent ones.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - syncer = do_manager.datasite_owner_syncer - sent = [] - - def write_once_then_fail(self, recipient, message): - if sent: - raise RuntimeError("Drive error") - sent.append(recipient) - - monkeypatch.setattr( - ConnectionRouter, "owner_write_event_messages_to_outbox", write_once_then_fail - ) - for _ in range(2): - syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) - with pytest.raises(RuntimeError): - syncer.process_syftbox_events_queue() - assert sent == [ds_manager.email] - assert _held_outbox(do_manager) == 1 - - monkeypatch.undo() - syncer.process_syftbox_events_queue() - assert _held_outbox(do_manager) == 0 - - -def test_held_send_flush_keeps_only_unsent_messages_on_error(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - add_peers=False, - ) - ds_manager.add_peer(do_manager.email, sync=False) - for name in ("a.job", "b.job"): - file_path = path_for_job(do_manager.email, ds_manager.email, name) - ds_manager._send_file_change(file_path, name) - watcher = ds_manager.datasite_watcher_syncer - assert _held_proposals(ds_manager) == 2 - - do_manager.load_peers() - do_manager.approve_peer_request(ds_manager.email) - ds_manager.load_peers() - original = ConnectionRouter.watcher_send_proposed_file_changes_message - sent = [] - - def send_once_then_fail(self, recipient, message): - if sent: - raise RuntimeError("Drive error") - sent.append(recipient) - return original(self, recipient, message) - - monkeypatch.setattr( - ConnectionRouter, - "watcher_send_proposed_file_changes_message", - send_once_then_fail, - ) - with pytest.raises(RuntimeError): - watcher.send_pending_messages() - assert _held_proposals(ds_manager) == 1 - - def _unknown_policy(ds_manager, do_manager, monkeypatch): """Make the DS policy unknown to the DO; return a function that undoes it.""" peer = do_manager.peer_manager.get_cached_peer(ds_manager.email) @@ -1526,186 +1031,61 @@ def restore(): @pytest.mark.parametrize( "make_pending", [_unknown_policy, _unpinned_bundle], ids=["policy", "pin"] ) -def test_owner_holds_outbox_message_until_encryption_set_up(make_pending, monkeypatch): +def test_owner_sends_outbox_message_once_encryption_set_up(make_pending, monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=REQUIRED, ) syncer = do_manager.datasite_owner_syncer restore = make_pending(ds_manager, do_manager, monkeypatch) + written = [] + original = ConnectionRouter.owner_write_event_messages_to_outbox + + def write(self, recipient, message): + original(self, recipient, message) + written.append(recipient) + monkeypatch.setattr(ConnectionRouter, "owner_write_event_messages_to_outbox", write) syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) syncer.process_syftbox_events_queue() - assert _held_outbox(do_manager) == 1 - # The held message is on disk, so a restart keeps it. - assert (do_manager.syftbox_folder / ".cache" / "held_outbox.json").exists() + assert written == [] restore() + monkeypatch.setattr(ConnectionRouter, "owner_write_event_messages_to_outbox", write) syncer.process_syftbox_events_queue() - assert _held_outbox(do_manager) == 0 + assert written == [ds_manager.email] # ========================================================================= -# Rejected messages +# Plaintext refused # ========================================================================= -def test_rejected_inbox_message_resent_in_order(): - """A newer encrypted change waits until the rejected one is resent encrypted.""" +def test_plaintext_inbox_message_dropped_and_later_ones_read(): ds_manager, do_manager = _pair_with_stale_sender_view() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "first") - do_manager.sync() + ds_manager._send_file_change(file_path, "plain content") - # The sender learns the policy and sends a newer change before it resends. ds_manager.load_peers() - ds_manager._send_file_change(file_path, "second") - do_manager.sync() - assert not any("second" in c for c in _accepted_contents(do_manager)) - - ds_manager.sync() - assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) - do_manager.sync() + # The owner never applies the dropped change, so this one is to another file. + other_path = path_for_job(do_manager.email, ds_manager.email, "other.job") + ds_manager._send_file_change(other_path, "encrypted content") + with pytest.warns(UserWarning, match="not encrypted"): + do_manager.sync() contents = _accepted_contents(do_manager) - assert any("first" in c for c in contents) - assert any("second" in c for c in contents) + assert not any("plain content" in c for c in contents) + assert any("encrypted content" in c for c in contents) -def test_rejected_outbox_message_resent_in_order(monkeypatch): +def test_plaintext_outbox_message_skipped_and_later_ones_read(): ds_manager, do_manager = _required_ds_pair() - _owner_sends_with_stale_view(ds_manager, do_manager, "first") - ds_manager.sync() - assert not any("first" in c for c in _ds_cached_contents(ds_manager)) + _owner_sends_with_stale_view(ds_manager, do_manager, "plain content") - # The owner refreshes its view and answers a newer proposal, but has not - # resent the rejected message yet. do_manager.load_peers() - with monkeypatch.context() as m: - m.setattr( - ConnectionRouter, - "owner_resend_rejected_outbox_messages", - lambda self, recipient: 0, - ) - file_path = path_for_job(do_manager.email, ds_manager.email, "other.job") - ds_manager._send_file_change(file_path, "second") - do_manager.datasite_owner_syncer.sync([ds_manager.email]) - ds_manager.sync() - assert not any("second" in c for c in _ds_cached_contents(ds_manager)) - - do_manager.sync() - ds_manager.sync() + file_path = path_for_job(do_manager.email, ds_manager.email, "other.job") + ds_manager._send_file_change(file_path, "encrypted content") + do_manager.datasite_owner_syncer.sync([ds_manager.email]) + with pytest.warns(UserWarning, match="not encrypted"): + ds_manager.sync() contents = _ds_cached_contents(ds_manager) - assert any("first" in c for c in contents) - assert any("second" in c for c in contents) - - -def test_all_plaintext_inbox_messages_rejected_in_one_pass(): - ds_manager, do_manager = _pair_with_stale_sender_view() - for name in ("a.job", "b.job", "c.job"): - file_path = path_for_job(do_manager.email, ds_manager.email, name) - ds_manager._send_file_change(file_path, f"content {name}") - do_manager.sync() - - ds_manager.sync() - do_manager.sync() - contents = _accepted_contents(do_manager) - for name in ("a.job", "b.job", "c.job"): - assert any(f"content {name}" in c for c in contents) - - -def test_message_rejected_twice_keeps_one_suffix(): - """A notebook and syft-bg can both reject one message.""" - ds_manager, do_manager = _pair_with_stale_sender_view() - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "rejected twice") - do_manager.sync() - - ds_conn = ds_manager.datasite_watcher_syncer.connection_router.connection_for_send_message() - [(file_id, _, _)] = ds_conn.watcher_get_rejected_inbox_messages(do_manager.email) - do_conn = do_manager.datasite_owner_syncer.connection_router.connection_for_receive_message() - do_conn.mark_message_rejected(file_id) - - ds_manager.sync() - do_manager.sync() - assert any("rejected twice" in c for c in _accepted_contents(do_manager)) - - -def test_optional_pair_skips_rejected_message_lookup(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - calls = [] - original = GDriveConnection.watcher_get_rejected_inbox_messages - - def spy(self, recipient): - calls.append(recipient) - return original(self, recipient) - - monkeypatch.setattr(GDriveConnection, "watcher_get_rejected_inbox_messages", spy) - ds_manager.sync() - assert calls == [] - - -def test_rejected_message_lookup_does_not_list_whole_folder(monkeypatch): - ds_manager, do_manager = _required_ds_pair() - calls = [] - original = GDriveConnection.get_file_metadatas_from_folder - monkeypatch.setattr( - GDriveConnection, - "get_file_metadatas_from_folder", - lambda self, *a, **k: calls.append(a) or original(self, *a, **k), - ) - router = do_manager.datasite_owner_syncer.connection_router - router.owner_resend_rejected_outbox_messages(ds_manager.email) - assert calls == [] - - -def _outbox_with_files(names): - """The owner's outbox connection, with files ``names`` in its folder for the DS.""" - ds_manager, do_manager = _required_ds_pair() - outbox = do_manager.datasite_owner_syncer.connection_router.connection_for_outbox() - folder_id = outbox._get_own_datasite_outbox_id(ds_manager.email) - for name in names: - outbox.drive_service.files().create( - body={"name": name, "parents": [folder_id]} - ).execute() - return outbox, ds_manager.email - - -def test_rejected_lookup_handles_quotes_in_names(): - outbox, ds_email = _outbox_with_files(["a'b", "rejected_a'b", "rejected_c\\d"]) - rejected = outbox.owner_get_rejected_outbox_messages(ds_email) - assert {(r.filename, r.resent) for r in rejected} == { - ("a'b", True), - ("c\\d", False), - } - - -def test_rejected_lookup_reads_every_page(): - names = [f"rejected_msgv2_{i}_x.tar.gz" for i in range(101)] - outbox, ds_email = _outbox_with_files(names) - assert len(outbox.owner_get_rejected_outbox_messages(ds_email)) == 101 - - -def test_rejected_lookup_checks_resent_names_in_one_query(monkeypatch): - names = [f"rejected_msgv2_{i}_x.tar.gz" for i in range(3)] - outbox, ds_email = _outbox_with_files(names) - lists = [] - original = mock_drive_service.MockListRequest.execute - monkeypatch.setattr( - mock_drive_service.MockListRequest, - "execute", - lambda self: lists.append(self._q) or original(self), - ) - outbox.owner_get_rejected_outbox_messages(ds_email) - assert len(lists) == 2 - - -def test_resend_waits_while_peer_bundle_not_pinned(): - ds_manager, do_manager = _pair_with_stale_sender_view() - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "plain content") - do_manager.sync() - - peer = ds_manager.peer_manager.get_cached_peer(do_manager.email) - peer.version = _version_with_policy(REQUIRED) - peer.public_encryption_bundle = None - router = ds_manager.datasite_watcher_syncer.connection_router - assert router.watcher_resend_rejected_inbox_messages(do_manager.email) == 0 + assert not any("plain content" in c for c in contents) + assert any("encrypted content" in c for c in contents) diff --git a/tests/unit/test_peer_key_pinning.py b/tests/unit/test_peer_key_pinning.py index 363a2a713f0..1b743375c2d 100644 --- a/tests/unit/test_peer_key_pinning.py +++ b/tests/unit/test_peer_key_pinning.py @@ -339,7 +339,7 @@ def test_failed_key_file_write_leaves_old_file(tmp_path, monkeypatch): def crash(src, dst): raise OSError("disk full") - monkeypatch.setattr("syft.sync.utils.locked_files.os.replace", crash) + monkeypatch.setattr("syft.sync.peers.peer_store.os.replace", crash) with pytest.raises(OSError, match="disk full"): alice.set_peer_bundle(BOB, _store(BOB).get_public_bundle()) monkeypatch.undo() From 967df46cac8172354aea95380a83355adca899b6 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Thu, 8 Oct 2026 16:33:52 -0300 Subject: [PATCH 11/30] fix(sync): make encryption waits and drops visible - Warn when a message waits in memory for encryption, or is dropped - Warn when messages from a peer wait to be read - Refuse to sync when the key file became required after start --- docs/connections.md | 4 +-- syft/sync/connections/connection_router.py | 2 +- syft/sync/peers/peer_store.py | 24 ++++++++++++++ syft/sync/syftbox_manager.py | 1 + syft/sync/sync/datasite_owner_syncer.py | 11 +++++-- syft/sync/sync/datasite_watcher_syncer.py | 9 +++--- tests/unit/test_encryption_policy.py | 37 ++++++++++++++++------ 7 files changed, 68 insertions(+), 20 deletions(-) diff --git a/docs/connections.md b/docs/connections.md index e1510020583..74ed5c50a6a 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -60,9 +60,9 @@ Each client advertises an encryption policy in `SYFT_version.json`: A pair encrypts when one side is `required`. An `off` and a `required` client conflict: each side skips the other with a warning. -A client takes its policy at login. The key file records a `required` policy, so later logins (and syft-bg) stay `required`; restart syft-bg after a change. Files at rest written before the switch stay plaintext; new ones are encrypted. +A client takes its policy at login. The key file records a `required` policy, so later logins (and syft-bg) stay `required`. A process that started with another policy stops at its next sync with an error to restart it. Files at rest written before the switch stay plaintext; new ones are encrypted. -While a peer's policy or keys are not known yet, a change for it waits in memory, and the next sync sends it. +While a peer's policy or keys are not known yet, a change for it waits in memory, with a warning, and the next sync sends it. A change that waits is lost if the process exits. A pair that encrypts refuses plaintext. The receiver drops a plaintext message with a warning: the owner archives it unread, and a scientist skips it. diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index cd07f7be58c..fafa77aeb7d 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -286,7 +286,7 @@ def _decrypt_or_wait(self, peer_email: str, raw: bytes) -> bytes | None: return self.peer_store.decrypt_and_verify_if_needed(peer_email, raw) except (PeerEncryptionPendingError, EncryptionPolicyConflictError) as e: # A conflict found here is new since the peer filter of this sync. - logger.info(f"Messages from {peer_email} wait: {e}") + warnings.warn(f"Messages from {peer_email} wait: {e}") return None def watcher_get_outbox_file_metadatas( diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index f5a5a465ade..f940c20433c 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -413,6 +413,30 @@ def ensure_keys(self) -> None: for email, bundle in unsaved_pins: self._persist_peer_bundle(email, bundle) + def check_key_file_policy(self) -> None: + """Raise when the key file requires encryption and this process does not. + + The policy is read at startup only. Another process (a new login) can + make the datasite required later; this process must not go on + without encryption. + + Raises: + ValueError: when the key file records a required policy, and this + client is optional or off. + """ + if self.use_encryption or self._keys_path is None: + return + data = self._read_key_file() + if data is None: + return + recorded = data.get(ENCRYPTION_POLICY_KEY, EncryptionPolicy.REQUIRED.value) + if recorded == EncryptionPolicy.REQUIRED.value: + raise ValueError( + f"The key file {self._keys_path} requires encryption, but this " + f"process started with encryption {self.encryption_policy.value}. " + "Restart it (for example syft-bg) to use the new policy." + ) + def _adopt_key_file(self, loaded: "PeerStore") -> None: """Take the keys, pins and recorded policy of ``loaded``, a store read from our key file.""" for email, bundle in self._take_key_file(loaded): diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index 96edf3cbad5..67d33bb46ff 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -1016,6 +1016,7 @@ def sync( separate process may have updated the file. """ with self.sync_file_lock(): + self.peer_manager.peer_store.check_key_file_policy() self.load_peers(force_download=force_download_peer_state) if self.has_do_role: peer_emails = [peer.email for peer in self.peer_manager.approved_peers] diff --git a/syft/sync/sync/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py index b85d7f7b29e..3d970d26840 100644 --- a/syft/sync/sync/datasite_owner_syncer.py +++ b/syft/sync/sync/datasite_owner_syncer.py @@ -1,4 +1,5 @@ import logging +import warnings from pathlib import Path from uuid import uuid4 from functools import partial @@ -823,12 +824,16 @@ def _send_outbox_message( self.connection_router.owner_write_event_messages_to_outbox( recipient, message ) - except PeerEncryptionPendingError: + except PeerEncryptionPendingError as e: if self.connection_router.peer_store.should_hold(recipient): + warnings.warn( + f"An outbox message for {recipient} waits for the next sync: " + f"{e} It is held in memory, and lost if this process exits." + ) return False - logger.warning(f"Dropped an outbox message for {recipient}: not a peer") + warnings.warn(f"Dropped an outbox message for {recipient}: not a peer") except EncryptionPolicyConflictError as e: - logger.warning(f"Dropped an outbox message: {e}") + warnings.warn(f"Dropped an outbox message: {e}") return True def _download_outbox_message_with_new_connection( diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index a07b9e264c3..91ab269dbf0 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -1,4 +1,4 @@ -import logging +import warnings from pathlib import Path from concurrent.futures import ThreadPoolExecutor from queue import Queue @@ -23,8 +23,6 @@ DataSiteWatcherCacheConfig, ) -logger = logging.getLogger(__name__) - class DatasiteWatcherSyncerConfig(BaseModel): syftbox_folder: Path | None = None @@ -123,7 +121,10 @@ def process_file_changes_queue(self): error = error or e continue # The changes wait in the queue; the next sync sends them. - print(f"Changes for {recipient} wait for the next sync: {e}") + warnings.warn( + f"Changes for {recipient} wait for the next sync: {e} They " + "are held in memory, and lost if this process exits." + ) for item in items_by_recipient[recipient]: self.queue.put(item) except EncryptionPolicyConflictError as e: diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index d73e28bb53e..f0b424e568a 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -337,6 +337,21 @@ def test_store_not_requiring_encryption_creates_no_key_file(tmp_path, policy): assert not keys_path.exists() +@pytest.mark.parametrize("policy", [OPTIONAL, OFF]) +def test_sync_refuses_key_file_made_required_after_start(policy): + """The policy is read at startup; a running process must not go on without it.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=policy, + ) + keys_path = do_manager.peer_manager.peer_store._keys_path + with pytest.warns(UserWarning): + PeerStore.create( + email=do_manager.email, encryption_policy=REQUIRED, keys_path=keys_path + ) + with pytest.raises(ValueError, match="Restart"): + do_manager.sync() + + def test_off_refused_when_key_file_requires_encryption(tmp_path): """Files at rest of such a datasite are encrypted; off cannot read them.""" keys_path = tmp_path / "crypto_keys.json" @@ -751,7 +766,8 @@ def test_owner_drops_outbox_message_it_cannot_send(ds_policy, recipient): syncer = do_manager.datasite_owner_syncer message = FileChangeEventsMessage(events=[]) syncer.outbox_queue.put((recipient or ds_manager.email, message)) - syncer.process_syftbox_events_queue() + with pytest.warns(UserWarning, match="Dropped an outbox message"): + syncer.process_syftbox_events_queue() assert syncer.outbox_queue.empty() @@ -767,12 +783,11 @@ def test_conflict_found_on_read_makes_peer_wait(): # The owner now requires encryption, after the message was sent. do_manager.peer_manager.peer_store.encryption_policy = REQUIRED syncer = do_manager.datasite_owner_syncer - assert ( - syncer.pull_and_process_next_proposed_filechange( + with pytest.warns(UserWarning, match=f"Messages from {ds_manager.email} wait"): + message = syncer.pull_and_process_next_proposed_filechange( ds_manager.email, raise_on_none=False ) - is None - ) + assert message is None def test_current_published_bundle_not_written_again(monkeypatch): @@ -823,7 +838,8 @@ def test_owner_waits_on_envelope_from_unpinned_peer(): peer = do_manager.peer_manager.peer_store.get_cached_peer(ds_manager.email) peer.public_encryption_bundle = None - do_manager.datasite_owner_syncer.sync([ds_manager.email]) + with pytest.warns(UserWarning, match="wait"): + do_manager.datasite_owner_syncer.sync([ds_manager.email]) assert not any("pinned later" in c for c in _accepted_contents(do_manager)) do_manager.sync() @@ -858,7 +874,7 @@ def _queued_changes(manager) -> int: return manager.datasite_watcher_syncer.queue.qsize() -def test_send_before_peer_accepts_waits_until_sync(capsys): +def test_send_before_peer_accepts_waits_until_sync(): """Before the owner accepts, its policy is unknown, so the change waits.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, @@ -867,9 +883,9 @@ def test_send_before_peer_accepts_waits_until_sync(capsys): ds_manager.add_peer(do_manager.email, sync=False) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "early content") + with pytest.warns(UserWarning, match="lost if this process exits"): + ds_manager._send_file_change(file_path, "early content") assert _queued_changes(ds_manager) == 1 - assert "next sync" in capsys.readouterr().out cache = ds_manager.datasite_watcher_syncer.datasite_watcher_cache assert Path(file_path) not in cache.file_hashes @@ -1046,7 +1062,8 @@ def write(self, recipient, message): monkeypatch.setattr(ConnectionRouter, "owner_write_event_messages_to_outbox", write) syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) - syncer.process_syftbox_events_queue() + with pytest.warns(UserWarning, match="lost if this process exits"): + syncer.process_syftbox_events_queue() assert written == [] restore() From 935f0b9d1c8357049f052c31c5580796913deab5 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Thu, 8 Oct 2026 16:59:59 -0300 Subject: [PATCH 12/30] refactor(sync): address review standards issues - Split the send loop out of process_file_changes_queue - Remove unused PeerManagerConfig.use_encryption - Rename tests with filler words; fix a docstring and an import - Restore the original place of _with_pin and BUNDLE_FILE_KEY --- .../syft-enclave/tests/test_encryption.py | 4 ++-- packages/syft-enclave/tests/test_runner.py | 2 +- syft/sync/peers/peer_store.py | 22 +++++++++---------- syft/sync/sync/datasite_watcher_syncer.py | 16 +++++++++++++- syft/sync/version/peer_manager.py | 13 ++++------- tests/unit/test_encryption_policy.py | 6 ++--- 6 files changed, 36 insertions(+), 27 deletions(-) diff --git a/packages/syft-enclave/tests/test_encryption.py b/packages/syft-enclave/tests/test_encryption.py index 20681c201a3..2179e4c9f8d 100644 --- a/packages/syft-enclave/tests/test_encryption.py +++ b/packages/syft-enclave/tests/test_encryption.py @@ -9,6 +9,8 @@ from syft_enclaves import SyftEnclaveClient +from syft.sync.encryption_policy import EncryptionPolicy + def _simple_job_code() -> str: return ( @@ -71,8 +73,6 @@ def test_encrypted_message_to_enclave_roundtrips(): def test_quad_builder_applies_off_to_enclave(): - from syft.sync.encryption_policy import EncryptionPolicy - enclave, *_ = SyftEnclaveClient.quad_with_mock_drive_service_connection( enclave_encryption_policy=EncryptionPolicy.OFF ) diff --git a/packages/syft-enclave/tests/test_runner.py b/packages/syft-enclave/tests/test_runner.py index f302a2c3bef..5ac85bdf38e 100644 --- a/packages/syft-enclave/tests/test_runner.py +++ b/packages/syft-enclave/tests/test_runner.py @@ -144,7 +144,7 @@ def test_the_key_bundle_is_published_for_the_attestation_endpoint( # moments earlier, so the file the endpoint signs with is gone. store.save_keys.assert_called_once_with(written["keys_path"]) - def test_optional_enclave_binds_keys_it_makes_at_boot(self, monkeypatch): + def test_optional_enclave_binds_keys_made_at_boot(self, monkeypatch): """A peer can require encryption from an optional enclave later. The keys it then uses must be bound to the report too, so the enclave diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index f940c20433c..c0a7b933ca4 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -438,7 +438,7 @@ def check_key_file_policy(self) -> None: ) def _adopt_key_file(self, loaded: "PeerStore") -> None: - """Take the keys, pins and recorded policy of ``loaded``, a store read from our key file.""" + """Take the keys and pins of ``loaded``, a store read from our key file.""" for email, bundle in self._take_key_file(loaded): self._persist_peer_bundle(email, bundle) @@ -778,6 +778,16 @@ def _locked_key_file(path: Path) -> Iterator[None]: portalocker.unlock(f) +def _with_pin(data: dict, peer_email: str, bundle: dict) -> dict: + """``data`` from the key file, with ``bundle`` recorded as the pin of ``peer_email``.""" + bundles = data.get(PEER_BUNDLES_KEY) + if not isinstance(bundles, dict): + bundles = {} + bundles[peer_email] = bundle + data[PEER_BUNDLES_KEY] = bundles + return data + + def _write_key_file(path: Path, data: dict) -> None: """Replace the key file at ``path`` with ``data`` in one step. @@ -797,16 +807,6 @@ def _write_key_file(path: Path, data: dict) -> None: Path(tmp).unlink(missing_ok=True) -def _with_pin(data: dict, peer_email: str, bundle: dict) -> dict: - """``data`` from the key file, with ``bundle`` recorded as the pin of ``peer_email``.""" - bundles = data.get(PEER_BUNDLES_KEY) - if not isinstance(bundles, dict): - bundles = {} - bundles[peer_email] = bundle - data[PEER_BUNDLES_KEY] = bundles - return data - - def _peers_from_stored_pins(bundles: dict[str, dict]) -> List[Peer]: """Peers carrying the pins in the key file, minus any pin that no longer validates. diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index 91ab269dbf0..6346151965c 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -108,7 +108,22 @@ def process_file_changes_queue(self): items_by_recipient.setdefault(recipient, []).append( (relative_path, content) ) + self._send_by_recipient(changes_by_recipient, items_by_recipient) + def _send_by_recipient( + self, + changes_by_recipient: Dict[str, List[ProposedFileChange]], + items_by_recipient: Dict[str, list], + ) -> None: + """Send one message to each recipient; put pending ones back in the queue. + + ``items_by_recipient`` holds the queue items each message was built + from. The other recipients still get their changes when one fails. + + Raises: + PeerEncryptionPendingError: when a recipient is not a peer. + EncryptionPolicyConflictError: when the policies of a pair conflict. + """ error = None for recipient, changes in changes_by_recipient.items(): message = ProposedFileChangesMessage( @@ -128,7 +143,6 @@ def process_file_changes_queue(self): for item in items_by_recipient[recipient]: self.queue.put(item) except EncryptionPolicyConflictError as e: - # The other recipients still get their changes. error = error or e if error is not None: raise error diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 78e991be3d1..fcf20260350 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -48,6 +48,10 @@ logger = logging.getLogger(__name__) +# Key wrapping the DID document inside a published encryption bundle file. +BUNDLE_FILE_KEY = "public_encryption_bundle" + + class CompatAction(str, Enum): """What the caller is doing — used to phrase warning messages.""" @@ -56,10 +60,6 @@ class CompatAction(str, Enum): EXECUTE = "execute" -# Key wrapping the DID document inside a published encryption bundle file. -BUNDLE_FILE_KEY = "public_encryption_bundle" - - class PeerCompatibilityResult(BaseModel): """Outcome of a per-peer version compatibility check. @@ -132,11 +132,6 @@ class PeerManagerConfig(BaseModel): None # None: value is determined by the role (resolves to has_do_role) ) - @property - def use_encryption(self) -> bool: - """True when the policy is required.""" - return self.encryption_policy == EncryptionPolicy.REQUIRED - @property def default_crypto_keys_path(self) -> Path: """Default per-datasite encryption key file for this participant diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index f0b424e568a..11c3ea8ba10 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -284,7 +284,7 @@ def test_off_and_required_conflict(mine, theirs): alice.decrypt_and_verify_if_needed("bob@example.com", b"plain") -def test_resolution_follows_a_changed_peer_policy(): +def test_resolution_follows_changed_peer_policy(): """An enclave that reboots with another setting is picked up on the next load.""" store = _store_with_peer(OPTIONAL, OPTIONAL) assert not store.peer_uses_encryption("bob@example.com") @@ -365,7 +365,7 @@ def test_off_refused_when_key_file_requires_encryption(tmp_path): ) -def test_off_keeps_its_policy_over_optional_key_file(tmp_path): +def test_off_policy_wins_over_optional_key_file(tmp_path): keys_path = tmp_path / "crypto_keys.json" PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() store = PeerStore.create( @@ -758,7 +758,7 @@ def test_off_and_required_peers_skip_each_other(): [(OPTIONAL, "former@example.com"), (OFF, None)], ids=["former peer", "conflicting peer"], ) -def test_owner_drops_outbox_message_it_cannot_send(ds_policy, recipient): +def test_owner_drops_unsendable_outbox_message(ds_policy, recipient): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=ds_policy, do_encryption_policy=REQUIRED, From cbc7f7c5ad9a2d7c3bb96d53d12de6392bba7e95 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Wed, 14 Oct 2026 11:10:18 -0300 Subject: [PATCH 13/30] fix: update model configuration to forbid extra fields in PeerStore and PeerManagerConfig --- syft/sync/peers/peer_store.py | 4 ++-- syft/sync/version/peer_manager.py | 2 ++ tests/unit/test_encryption_policy.py | 30 ++++++++++++++++++++++------ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index cfd745bbff4..582f39583dd 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -21,8 +21,8 @@ EncryptionPolicy, EncryptionPolicyConflictError, PeerEncryptionPolicyUnknownError, - PeerOutdatedError, PeerKeysNotReadyError, + PeerOutdatedError, UnencryptedMessageError, pair_conflicts, pair_uses_encryption, @@ -75,7 +75,7 @@ class PeerStore(BaseModel): the key we encrypt to. """ - model_config = {"arbitrary_types_allowed": True} + model_config = {"arbitrary_types_allowed": True, "extra": "forbid"} email: str encryption_policy: EncryptionPolicy = EncryptionPolicy.OPTIONAL diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index fcf20260350..d9d5db3cd8f 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -114,6 +114,8 @@ def raise_on_skip(self, operation: str) -> None: class PeerManagerConfig(BaseModel): """Configuration for PeerManager.""" + model_config = ConfigDict(extra="forbid") + syftbox_folder: Path email: str = "" connection_configs: List[ConnectionConfig] = [] diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 11c3ea8ba10..b1a9f7cb8c0 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -10,23 +10,25 @@ from pathlib import Path import pytest +from pydantic import ValidationError from syft.sync.connections.connection_router import ConnectionRouter from syft.sync.connections.drive import mock_drive_service from syft.sync.encryption_policy import ( EncryptionPolicy, EncryptionPolicyConflictError, - PeerOutdatedError, PeerEncryptionPolicyUnknownError, PeerKeysNotReadyError, + PeerOutdatedError, ) from syft.sync.events.file_change_event import ( FileChangeEventsMessage, ) from syft.sync.peers.peer import Peer, PeerState from syft.sync.peers.peer_store import PeerStore -from syft.sync.sync.datasite_watcher_syncer import DatasiteWatcherSyncer from syft.sync.syftbox_manager import SyftboxManager +from syft.sync.sync.datasite_watcher_syncer import DatasiteWatcherSyncer +from syft.sync.version.peer_manager import PeerManagerConfig from syft.sync.version.version_info import VersionInfo from tests.unit.test_sync_manager import path_for_job from tests.unit.utils import grant_job_inbox_access @@ -149,6 +151,20 @@ def test_policy_defaults_to_optional(): assert PeerStore(email="alice@example.com").encryption_policy == OPTIONAL +@pytest.mark.parametrize( + "build", + [ + lambda: PeerStore(email="alice@example.com", use_encryption=True), + lambda: PeerManagerConfig(syftbox_folder=Path("/tmp"), use_encryption=True), + ], + ids=["PeerStore", "PeerManagerConfig"], +) +def test_removed_use_encryption_keyword_raises(build): + """The keyword gave a store without encryption when it was ignored.""" + with pytest.raises(ValidationError, match="use_encryption"): + build() + + @pytest.mark.parametrize( "value, expected", [(True, REQUIRED), (False, OPTIONAL), ("off", OFF), (OFF, OFF)], @@ -935,10 +951,12 @@ def test_changes_for_two_datasites_go_to_each(monkeypatch): monkeypatch.setattr( DatasiteWatcherSyncer, "_send_message", - lambda self, recipient, message: sent.append( - (recipient, {fc.datasite_email for fc in message.proposed_file_changes}) - ) - or True, + lambda self, recipient, message: ( + sent.append( + (recipient, {fc.datasite_email for fc in message.proposed_file_changes}) + ) + or True + ), ) watcher = ds_manager.datasite_watcher_syncer watcher.on_file_change(f"{do_manager.email}/a.txt", "a", process_now=False) From 650e93b3a1fea0b882d83ee3c15a534cfb8049c9 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Wed, 14 Oct 2026 12:27:24 -0300 Subject: [PATCH 14/30] fix: send the held job before the owner syncs; format docs and notebooks - Integration test: the DS syncs after approval to send the job it held - Apply prettier and nbstripout to files the branch changed --- docs/connections.md | 10 +-- .../1. DO-model-owner-gemma-shield.ipynb | 54 ++++++++-------- .../2. DO-benchmark-owner-gemma-shield.ipynb | 64 +++++++++---------- .../1. DO-model-owner-gemma-restrict.ipynb | 50 +++++++-------- ...2. DO-benchmark-owner-gemma-restrict.ipynb | 60 ++++++++--------- .../docs/tinfoil_troubleshooting.md | 32 +++++----- .../test_sync_manager.py | 6 +- 7 files changed, 139 insertions(+), 137 deletions(-) diff --git a/docs/connections.md b/docs/connections.md index 74ed5c50a6a..062771f8379 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -52,11 +52,11 @@ Syft uses Google Drive as its file-based transport layer. All peer-to-peer commu Each client advertises an encryption policy in `SYFT_version.json`: -| Policy | Set with | Effect | -| ---------- | ------------------------------ | -------------------------------------------- | -| `optional` | default, `encryption=False` | encrypt only with a peer that requires it | -| `required` | `encryption=True` | encrypt with every peer; refuse plaintext | -| `off` | `encryption="off"` | never encrypt | +| Policy | Set with | Effect | +| ---------- | --------------------------- | ----------------------------------------- | +| `optional` | default, `encryption=False` | encrypt only with a peer that requires it | +| `required` | `encryption=True` | encrypt with every peer; refuse plaintext | +| `off` | `encryption="off"` | never encrypt | A pair encrypts when one side is `required`. An `off` and a `required` client conflict: each side skips the other with a warning. diff --git a/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb b/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb index 8c86f71f45a..77f748ce981 100644 --- a/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb +++ b/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb @@ -266,7 +266,7 @@ { "cell_type": "code", "execution_count": null, - "id": "backup-key", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -276,7 +276,7 @@ }, { "cell_type": "markdown", - "id": "18", + "id": "19", "metadata": {}, "source": [ "### Step 1.2 — Attest the enclave's identity\n", @@ -287,7 +287,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -297,7 +297,7 @@ }, { "cell_type": "markdown", - "id": "20", + "id": "21", "metadata": {}, "source": [ "## Step 2 — Get the two models\n", @@ -314,7 +314,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "22", "metadata": {}, "source": [ "### Step 2.1 — Gemma 3 from Kaggle\n" @@ -323,7 +323,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -342,7 +342,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -357,7 +357,7 @@ }, { "cell_type": "markdown", - "id": "24", + "id": "25", "metadata": {}, "source": [ "### Step 2.2 — ShieldGemma 2B from Hugging Face\n", @@ -370,7 +370,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -395,7 +395,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "27", "metadata": {}, "source": [ "## Step 3 — Prepare the files for upload\n", @@ -409,7 +409,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -487,7 +487,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -506,7 +506,7 @@ }, { "cell_type": "markdown", - "id": "29", + "id": "30", "metadata": {}, "source": [ "## Step 4 — Upload the two private models\n", @@ -517,7 +517,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -543,7 +543,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -554,7 +554,7 @@ }, { "cell_type": "markdown", - "id": "32", + "id": "33", "metadata": {}, "source": [ "## Step 5 — Run `syft-restrict` on the inference code\n", @@ -568,7 +568,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -628,7 +628,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -648,7 +648,7 @@ }, { "cell_type": "markdown", - "id": "35", + "id": "36", "metadata": {}, "source": [ "### Step 5.1 — Approve the job\n", @@ -659,7 +659,7 @@ { "cell_type": "code", "execution_count": null, - "id": "36", + "id": "37", "metadata": {}, "outputs": [], "source": [ @@ -670,7 +670,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -680,7 +680,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "39", "metadata": {}, "outputs": [], "source": [ @@ -690,7 +690,7 @@ }, { "cell_type": "markdown", - "id": "39", + "id": "40", "metadata": {}, "source": [ "\"Wait\n" @@ -698,7 +698,7 @@ }, { "cell_type": "markdown", - "id": "40", + "id": "41", "metadata": {}, "source": [ "## Step 6 — Approve the final inference job\n", @@ -709,7 +709,7 @@ { "cell_type": "code", "execution_count": null, - "id": "41", + "id": "42", "metadata": {}, "outputs": [], "source": [ @@ -721,7 +721,7 @@ { "cell_type": "code", "execution_count": null, - "id": "42", + "id": "43", "metadata": {}, "outputs": [], "source": [ @@ -731,7 +731,7 @@ { "cell_type": "code", "execution_count": null, - "id": "43", + "id": "44", "metadata": {}, "outputs": [], "source": [ diff --git a/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb b/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb index 2362c060c92..0f8b1363f34 100644 --- a/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb +++ b/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb @@ -208,7 +208,7 @@ { "cell_type": "code", "execution_count": null, - "id": "backup-key", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -218,7 +218,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "16", "metadata": {}, "source": [ "### Step 1.2 — Attest the enclave's identity\n", @@ -229,7 +229,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -239,7 +239,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "18", "metadata": {}, "source": [ "## Step 2 — Prepare the benchmark\n", @@ -254,7 +254,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -320,7 +320,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -331,7 +331,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -344,7 +344,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -362,7 +362,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "23", "metadata": {}, "source": [ "## Step 3 — Upload the benchmark\n", @@ -373,7 +373,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -390,7 +390,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -400,7 +400,7 @@ }, { "cell_type": "markdown", - "id": "25", + "id": "26", "metadata": {}, "source": [ "\"Wait\n" @@ -408,7 +408,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "27", "metadata": {}, "source": [ "## Step 4 — Review the `syft-restrict` job\n", @@ -424,7 +424,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -436,7 +436,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -446,7 +446,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -456,7 +456,7 @@ }, { "cell_type": "markdown", - "id": "30", + "id": "31", "metadata": {}, "source": [ "### Step 4.1 — Read the certificate\n", @@ -467,7 +467,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -486,7 +486,7 @@ }, { "cell_type": "markdown", - "id": "32", + "id": "33", "metadata": {}, "source": [ "## Step 5 — Submit the inference job\n", @@ -503,7 +503,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -514,7 +514,7 @@ }, { "cell_type": "markdown", - "id": "34", + "id": "35", "metadata": {}, "source": [ "### Step 5.1 — Read the model owner's guardrail\n", @@ -525,7 +525,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -543,7 +543,7 @@ { "cell_type": "code", "execution_count": null, - "id": "36", + "id": "37", "metadata": {}, "outputs": [], "source": [ @@ -675,7 +675,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -703,7 +703,7 @@ }, { "cell_type": "markdown", - "id": "38", + "id": "39", "metadata": {}, "source": [ "## Step 6 — Approve the inference job\n", @@ -714,7 +714,7 @@ { "cell_type": "code", "execution_count": null, - "id": "39", + "id": "40", "metadata": {}, "outputs": [], "source": [ @@ -725,7 +725,7 @@ { "cell_type": "code", "execution_count": null, - "id": "40", + "id": "41", "metadata": {}, "outputs": [], "source": [ @@ -735,7 +735,7 @@ { "cell_type": "code", "execution_count": null, - "id": "41", + "id": "42", "metadata": {}, "outputs": [], "source": [ @@ -745,7 +745,7 @@ }, { "cell_type": "markdown", - "id": "42", + "id": "43", "metadata": {}, "source": [ "\"Wait\n" @@ -753,7 +753,7 @@ }, { "cell_type": "markdown", - "id": "43", + "id": "44", "metadata": {}, "source": [ "## Step 7 — View the results\n", @@ -766,7 +766,7 @@ { "cell_type": "code", "execution_count": null, - "id": "44", + "id": "45", "metadata": {}, "outputs": [], "source": [ @@ -779,7 +779,7 @@ { "cell_type": "code", "execution_count": null, - "id": "45", + "id": "46", "metadata": {}, "outputs": [], "source": [ diff --git a/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb b/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb index 9306122c5bd..e3ca1045837 100644 --- a/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb +++ b/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb @@ -218,7 +218,7 @@ { "cell_type": "code", "execution_count": null, - "id": "backup-key", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -228,7 +228,7 @@ }, { "cell_type": "markdown", - "id": "16", + "id": "17", "metadata": {}, "source": [ "### Step 1.2 — Attest the enclave's identity\n", @@ -239,7 +239,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -249,7 +249,7 @@ }, { "cell_type": "markdown", - "id": "18", + "id": "19", "metadata": {}, "source": [ "## Step 2 — Get the Gemma 3 model\n", @@ -259,7 +259,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "20", "metadata": {}, "source": [ "The Notebooks assumes, that gemma-3 model is present in your Google Drive, kindly upload the model to your Google Drive. You could use the kaggle API to download the model from Kaggle and upload it to your Google Drive. The command to download the model from Kaggle is below.\n", @@ -270,7 +270,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -289,7 +289,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -306,7 +306,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "23", "metadata": {}, "source": [ "## Step 3 — Prepare the files for upload\n", @@ -317,7 +317,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -359,7 +359,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -375,7 +375,7 @@ }, { "cell_type": "markdown", - "id": "25", + "id": "26", "metadata": {}, "source": [ "## Step 4 — Upload the private model\n", @@ -386,7 +386,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -403,7 +403,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -413,7 +413,7 @@ }, { "cell_type": "markdown", - "id": "28", + "id": "29", "metadata": {}, "source": [ "## Step 5 — Run `syft-restrict` on the inference code\n", @@ -427,7 +427,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -487,7 +487,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -507,7 +507,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "32", "metadata": {}, "source": [ "### Step 5.1 — Approve the job\n", @@ -518,7 +518,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -529,7 +529,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -539,7 +539,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -549,7 +549,7 @@ }, { "cell_type": "markdown", - "id": "35", + "id": "36", "metadata": {}, "source": [ "\"Wait\n" @@ -557,7 +557,7 @@ }, { "cell_type": "markdown", - "id": "36", + "id": "37", "metadata": {}, "source": [ "## Step 6 — Approve the final inference job\n", @@ -568,7 +568,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -580,7 +580,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "39", "metadata": {}, "outputs": [], "source": [ @@ -590,7 +590,7 @@ { "cell_type": "code", "execution_count": null, - "id": "39", + "id": "40", "metadata": {}, "outputs": [], "source": [ diff --git a/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb b/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb index b473721f56e..dca49868ff4 100644 --- a/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb +++ b/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb @@ -197,7 +197,7 @@ { "cell_type": "code", "execution_count": null, - "id": "backup-key", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -207,7 +207,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "16", "metadata": {}, "source": [ "### Step 1.2 — Attest the enclave's identity\n", @@ -218,7 +218,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -228,7 +228,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "18", "metadata": {}, "source": [ "## Step 2 — Prepare the benchmark\n", @@ -243,7 +243,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -309,7 +309,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -320,7 +320,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -333,7 +333,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -351,7 +351,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "23", "metadata": {}, "source": [ "## Step 3 — Upload the benchmark\n", @@ -362,7 +362,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -379,7 +379,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -389,7 +389,7 @@ }, { "cell_type": "markdown", - "id": "25", + "id": "26", "metadata": {}, "source": [ "\"Wait\n" @@ -397,7 +397,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "27", "metadata": {}, "source": [ "## Step 4 — Review the `syft-restrict` job\n", @@ -413,7 +413,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -425,7 +425,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -435,7 +435,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -445,7 +445,7 @@ }, { "cell_type": "markdown", - "id": "30", + "id": "31", "metadata": {}, "source": [ "### Step 4.1 — Read the certificate\n", @@ -456,7 +456,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -475,7 +475,7 @@ }, { "cell_type": "markdown", - "id": "32", + "id": "33", "metadata": {}, "source": [ "## Step 5 — Submit the inference job\n", @@ -489,7 +489,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -500,7 +500,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -578,7 +578,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -603,7 +603,7 @@ }, { "cell_type": "markdown", - "id": "36", + "id": "37", "metadata": {}, "source": [ "## Step 6 — Approve the inference job\n", @@ -614,7 +614,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -625,7 +625,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "39", "metadata": {}, "outputs": [], "source": [ @@ -635,7 +635,7 @@ { "cell_type": "code", "execution_count": null, - "id": "39", + "id": "40", "metadata": {}, "outputs": [], "source": [ @@ -645,7 +645,7 @@ }, { "cell_type": "markdown", - "id": "40", + "id": "41", "metadata": {}, "source": [ "\"Wait\n" @@ -653,7 +653,7 @@ }, { "cell_type": "markdown", - "id": "41", + "id": "42", "metadata": {}, "source": [ "## Step 7 — View the results\n", @@ -664,7 +664,7 @@ { "cell_type": "code", "execution_count": null, - "id": "42", + "id": "43", "metadata": {}, "outputs": [], "source": [ @@ -677,7 +677,7 @@ { "cell_type": "code", "execution_count": null, - "id": "43", + "id": "44", "metadata": {}, "outputs": [], "source": [ diff --git a/packages/syft-enclave/docs/tinfoil_troubleshooting.md b/packages/syft-enclave/docs/tinfoil_troubleshooting.md index 9d39723873d..a32a0d0501c 100644 --- a/packages/syft-enclave/docs/tinfoil_troubleshooting.md +++ b/packages/syft-enclave/docs/tinfoil_troubleshooting.md @@ -10,22 +10,22 @@ for what the attestation does and does not prove, see ## Symptoms -| Symptom | Cause and fix | -| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Firewall setup failed: creating docker network "default": operation is not permitted on predefined default network` | a network named `default` collides with Docker's predefined one. The canonical schema only reserves `shim-net`, so this **validates fine and fails on the host**. Name it anything else. | -| `FileNotFoundError: No usable temporary directory found in ['/tmp', ...]` and a crash-loop | containers run with a read-only rootfs, and `portalocker` (a syft dependency) calls `tempfile.gettempdir()` at import. Set `read_only: false`. | -| `2 validation errors for EnclaveSettings: email / data_owners Field required` — despite passing `--variable` | the shim only injects variables whose **key the measured config declares**. An undeclared `--variable` is silently dropped. Declare it as a bare name under `env:`. | -| The enclave hangs at "Building SyftEnclaveClient" | OAuth token refresh cannot reach `oauth2.googleapis.com`. See the egress note in `tinfoil-config.yml`: `allowlist` resolves hostnames to IPs once at boot and Google rotates them, so use `egress: open`. | -| `curl: (60) SSL certificate problem: self signed certificate` | expected. The enclave's TLS key is generated inside it and the report commits to that key, so there is no CA. Use `-k` (as `just tinfoil-attest` does) and get your trust from `just tinfoil-verify`. | -| `The server had an error while processing your request.` from the domain | the shim is up but your container is not serving on `upstream-port`. It has probably crashed — `just tinfoil-debug ` then `just tinfoil-logs`. | +| Symptom | Cause and fix | +| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Firewall setup failed: creating docker network "default": operation is not permitted on predefined default network` | a network named `default` collides with Docker's predefined one. The canonical schema only reserves `shim-net`, so this **validates fine and fails on the host**. Name it anything else. | +| `FileNotFoundError: No usable temporary directory found in ['/tmp', ...]` and a crash-loop | containers run with a read-only rootfs, and `portalocker` (a syft dependency) calls `tempfile.gettempdir()` at import. Set `read_only: false`. | +| `2 validation errors for EnclaveSettings: email / data_owners Field required` — despite passing `--variable` | the shim only injects variables whose **key the measured config declares**. An undeclared `--variable` is silently dropped. Declare it as a bare name under `env:`. | +| The enclave hangs at "Building SyftEnclaveClient" | OAuth token refresh cannot reach `oauth2.googleapis.com`. See the egress note in `tinfoil-config.yml`: `allowlist` resolves hostnames to IPs once at boot and Google rotates them, so use `egress: open`. | +| `curl: (60) SSL certificate problem: self signed certificate` | expected. The enclave's TLS key is generated inside it and the report commits to that key, so there is no CA. Use `-k` (as `just tinfoil-attest` does) and get your trust from `just tinfoil-verify`. | +| `The server had an error while processing your request.` from the domain | the shim is up but your container is not serving on `upstream-port`. It has probably crashed — `just tinfoil-debug ` then `just tinfoil-logs`. | | `ValueError: Serialization error: sender fingerprint mismatch` on a data owner's login | once a client encrypts, the keypair is persisted at `//private/crypto_keys.json`. A fresh `SYFTBOX_FOLDER` mints a new identity that cannot verify that account's own history on Drive. Point at the account's existing folder, or wipe its Drive state first. | -| `upstream port is not set` | the config's `shim.upstream-port` is missing; it is required | -| Config rejected: image must be a digest | `image:` uses a tag; it must be `repo@sha256:...` | -| A path 404s | it is not in `shim.paths`. `/.well-known/tinfoil-attestation` is exempt | -| `Tag vX.Y.Z already exists` | releases are permanent; pick the next version | -| `image_digest` check fails with a 400 from `github-proxy.tinfoil.sh` | expected — the proxy only serves `tinfoil.hash`; the verifier falls through to github.com | -| `MissingOptionalDependency: ... tinfoil` | `uv pip install "syft-enclave[tinfoil]"` | -| `measurement_match` fails | the running enclave is not the release you are verifying against — check the deployed tag with `just tinfoil-status` | -| Peer skipped with "published no attestation evidence" | the enclave booted outside a TEE, or with `attestation_provider=none` | +| `upstream port is not set` | the config's `shim.upstream-port` is missing; it is required | +| Config rejected: image must be a digest | `image:` uses a tag; it must be `repo@sha256:...` | +| A path 404s | it is not in `shim.paths`. `/.well-known/tinfoil-attestation` is exempt | +| `Tag vX.Y.Z already exists` | releases are permanent; pick the next version | +| `image_digest` check fails with a 400 from `github-proxy.tinfoil.sh` | expected — the proxy only serves `tinfoil.hash`; the verifier falls through to github.com | +| `MissingOptionalDependency: ... tinfoil` | `uv pip install "syft-enclave[tinfoil]"` | +| `measurement_match` fails | the running enclave is not the release you are verifying against — check the deployed tag with `just tinfoil-status` | +| Peer skipped with "published no attestation evidence" | the enclave booted outside a TEE, or with `attestation_provider=none` | Redeploying? Delete the accounts' syftboxes **first**, then redeploy — the enclave caches peer Drive folders at boot, so wiping state afterwards breaks the flow. diff --git a/tests/integration/without_unit_coverage/test_sync_manager.py b/tests/integration/without_unit_coverage/test_sync_manager.py index 4f1c01c6310..52e57dbad5a 100644 --- a/tests/integration/without_unit_coverage/test_sync_manager.py +++ b/tests/integration/without_unit_coverage/test_sync_manager.py @@ -40,7 +40,7 @@ def test_peer_request_blocks_sync_until_approved(): 2. DS submits a job 3. DO syncs - nothing should sync (peer not approved) 4. DO approves peer request - 5. DO syncs - job should now sync + 5. DS syncs (sends the held job), DO syncs - job should now sync """ # Create managers with Google Drive connection, no auto-add peers ds_manager, do_manager = SyftboxManager._pair_with_google_drive_testing_connection( @@ -91,7 +91,9 @@ def test_peer_request_blocks_sync_until_approved(): assert len(do_manager.peer_manager.approved_peers) == 1 assert do_manager.peer_manager.approved_peers[0].email == ds_manager.email - # Step 5: DO syncs again - now it should work + # Step 5: DS syncs, which sends the job it held while the DO policy was + # unknown. DO syncs again - now it should work + ds_manager.sync() do_manager.sync() # Verify: File synced and in cache From 3ca82ed970fe9c8e0860080ed70513f45ca0c3e3 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Wed, 14 Oct 2026 13:24:40 -0300 Subject: [PATCH 15/30] test: drop repeated encryption policy tests; fix debug enclave policy in README --- packages/syft-enclave/README.md | 2 +- packages/syft-enclave/tests/test_runner.py | 35 ++----------------- tests/unit/test_encryption_policy.py | 39 ++++++++-------------- 3 files changed, 16 insertions(+), 60 deletions(-) diff --git a/packages/syft-enclave/README.md b/packages/syft-enclave/README.md index e0993ee9ca1..807bc6437ff 100644 --- a/packages/syft-enclave/README.md +++ b/packages/syft-enclave/README.md @@ -69,7 +69,7 @@ just start-debug EMAIL my-vm n2d-standard-4 # override name / machine type just stop [name] # Teardown: Deletes the VM. ``` -Debug enclaves run with encryption off. Clients select encryption from the enclave policy, so they need no setting. +Debug enclaves set `use_encryption=false`, so their policy is `optional`: they encrypt only with a peer that requires encryption. Clients select encryption from the enclave policy, so they need no setting. ## GPU deployments diff --git a/packages/syft-enclave/tests/test_runner.py b/packages/syft-enclave/tests/test_runner.py index 5ac85bdf38e..d9c642f0b8c 100644 --- a/packages/syft-enclave/tests/test_runner.py +++ b/packages/syft-enclave/tests/test_runner.py @@ -131,7 +131,6 @@ def test_the_key_bundle_is_published_for_the_attestation_endpoint( client._rds.peer_manager.syftbox_folder = Path("/tmp/SyftBox_enclave") store = client._rds.peer_manager.peer_store store.email = "enclave@openmined.org" - store.use_encryption = True store.has_my_keys.return_value = True store.get_public_bundle.return_value = {"identity": "enclave@openmined.org"} @@ -143,37 +142,8 @@ def test_the_key_bundle_is_published_for_the_attestation_endpoint( # And the keys must be back on disk: fresh_state wiped the folder # moments earlier, so the file the endpoint signs with is gone. store.save_keys.assert_called_once_with(written["keys_path"]) - - def test_optional_enclave_binds_keys_made_at_boot(self, monkeypatch): - """A peer can require encryption from an optional enclave later. - - The keys it then uses must be bound to the report too, so the enclave - makes them before it attests. - """ - provider = MagicMock() - provider.kind = AttestationKind.TINFOIL - provider.collect.return_value = tinfoil_evidence(TINFOIL_DOC) - monkeypatch.setattr( - "syft_enclaves.runner.select_provider", lambda name, settings: provider - ) - calls = [] - monkeypatch.setattr( - "syft_enclaves.runner.write_public_bundle", - lambda bundle, keys_path, claims=None: calls.append(bundle), - ) - - client = _make_client() - client._rds.peer_manager.syftbox_folder = Path("/tmp/SyftBox_enclave") - store = client._rds.peer_manager.peer_store - store.email = "enclave@openmined.org" - store.use_encryption = False - store.has_my_keys.return_value = True - store.get_public_bundle.return_value = {"identity": "enclave@openmined.org"} - - EnclaveRunner(client=client, require_tee=True).init() - - store.ensure_keys.assert_called_once() - assert calls == [{"identity": "enclave@openmined.org"}] + # An optional enclave makes keys at boot too, so they are bound to the report. + store.ensure_keys.assert_called_once_with() def test_provider_gets_the_settings_object(self, monkeypatch): seen = {} @@ -215,7 +185,6 @@ def _client(self): client.data_owners = ["b@openmined.org", "a@openmined.org"] store = client._rds.peer_manager.peer_store store.email = "enclave@openmined.org" - store.use_encryption = True store.has_my_keys.return_value = True store.get_public_bundle.return_value = {"identity": "enclave@openmined.org"} return client diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index b1a9f7cb8c0..61ab7c89606 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -1,7 +1,8 @@ """Per-peer encryption negotiated from each side's advertised policy. -A client is ``optional`` (encrypt only when the peer requires it) or -``required``. A pair encrypts when either side requires it. +A client is ``off``, ``optional`` (encrypt only when the peer requires it) +or ``required``. A pair encrypts when either side requires it, and neither +side is ``off``. """ import json @@ -89,10 +90,6 @@ def _raw_inbox_message(do_manager, sender_email: str) -> bytes: return raw -def _is_envelope(data: bytes) -> bool: - return PeerStore.is_envelope(data) - - def _accepted_contents(do_manager) -> list[str]: return [ str(e.content) @@ -322,7 +319,7 @@ def test_optional_sender_refuses_peer_with_unknown_policy(): def test_required_sender_needs_no_peer_policy(): alice, _ = _keyed_pair(REQUIRED, OPTIONAL) alice.get_cached_peer("bob@example.com").version = None - assert _is_envelope(alice.encrypt_if_needed("bob@example.com", b"data")) + assert PeerStore.is_envelope(alice.encrypt_if_needed("bob@example.com", b"data")) @pytest.mark.parametrize( @@ -656,7 +653,7 @@ def test_envelope_before_keys_and_pin_is_not_ready(make_receiver): @pytest.mark.parametrize( "ds_policy, do_policy", - [(OPTIONAL, REQUIRED), (REQUIRED, OPTIONAL), (REQUIRED, REQUIRED)], + [(OPTIONAL, REQUIRED), (REQUIRED, OPTIONAL)], ) def test_negotiated_encryption_flow(ds_policy, do_policy): """The optional side complies with no manual configuration.""" @@ -671,7 +668,7 @@ def test_negotiated_encryption_flow(ds_policy, do_policy): file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "negotiated content") - assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) + assert PeerStore.is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) do_manager.sync() assert any("negotiated content" in c for c in _accepted_contents(do_manager)) @@ -725,7 +722,7 @@ def test_owner_switching_to_required_encrypts_new_files_at_rest(): ds_manager._send_file_change(file_path, "after switch") do_manager.sync() - envelopes = [_is_envelope(raw) for raw in _raw_event_log(do_manager)] + envelopes = [PeerStore.is_envelope(raw) for raw in _raw_event_log(do_manager)] assert envelopes.count(False) == before assert envelopes.count(True) == len(envelopes) - before > 0 contents = _accepted_contents(do_manager) @@ -745,7 +742,7 @@ def test_off_client_works_in_plaintext_with_optional_peer(): file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "plain content") - assert not _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) + assert not PeerStore.is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) do_manager.sync() assert any("plain content" in c for c in _accepted_contents(do_manager)) assert not ds_manager.peer_manager.peer_store.has_my_keys() @@ -883,7 +880,7 @@ def test_send_reread_updates_peer_manager(): file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "content") assert peer_manager._loaded_peer_versions[do_manager.email] is not None - assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) + assert PeerStore.is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) def _queued_changes(manager) -> int: @@ -910,7 +907,7 @@ def test_send_before_peer_accepts_waits_until_sync(): grant_job_inbox_access(do_manager, ds_manager.email) ds_manager.sync() assert _queued_changes(ds_manager) == 0 - assert _is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) + assert PeerStore.is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) do_manager.sync() assert any("early content" in c for c in _accepted_contents(do_manager)) @@ -955,7 +952,6 @@ def test_changes_for_two_datasites_go_to_each(monkeypatch): sent.append( (recipient, {fc.datasite_email for fc in message.proposed_file_changes}) ) - or True ), ) watcher = ds_manager.datasite_watcher_syncer @@ -972,12 +968,8 @@ def test_changes_for_two_datasites_go_to_each(monkeypatch): @pytest.mark.parametrize( "error", - [ - EncryptionPolicyConflictError, - PeerEncryptionPolicyUnknownError, - PeerKeysNotReadyError, - ], - ids=["conflict", "not a peer", "keys not ready"], + [EncryptionPolicyConflictError, PeerEncryptionPolicyUnknownError], + ids=["conflict", "not a peer"], ) def test_failure_for_one_recipient_still_sends_to_other(monkeypatch, error): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() @@ -987,7 +979,6 @@ def send_message(self, recipient, message): if recipient == "other@example.com": raise error(recipient) sent.append(recipient) - return True monkeypatch.setattr(DatasiteWatcherSyncer, "_send_message", send_message) watcher = ds_manager.datasite_watcher_syncer @@ -1024,11 +1015,7 @@ def test_compaction_skips_peer_with_unknown_policy(monkeypatch): ds_manager._send_file_change(file_path, content) do_manager.sync() - do_manager.peer_manager.get_cached_peer(ds_manager.email).version = None - _forget(do_manager.peer_manager.peer_store, ds_manager.email) - monkeypatch.setattr( - ConnectionRouter, "read_peer_version_file", lambda self, email: None - ) + _unknown_policy(ds_manager, do_manager, monkeypatch) syncer = do_manager.datasite_owner_syncer assert syncer.compact_outbox_if_needed(ds_manager.email, min_messages=1) == 0 From cffcb24cd4c491c335a93365467b33174f15d27a Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Tue, 20 Oct 2026 15:51:40 -0300 Subject: [PATCH 16/30] chore: retrigger checks From 7d3159cf2de897b012acdca526087e768c1cd9e1 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Fri, 23 Oct 2026 14:38:53 -0300 Subject: [PATCH 17/30] refactor: always create keys and keep only our identity in the key file Every client now creates its keys at login. The key file holds only the version, the email and the keys. The write is atomic and never replaces an existing file. Both sides publish their bundle at peering. Remove the lazy keys, the policy in the key file and the publish-later code. --- .../syft-enclave/src/syft_enclaves/runner.py | 41 ++- .../syft-enclave/tests/test_encryption.py | 2 +- packages/syft-enclave/tests/test_runner.py | 9 +- syft/sync/connections/connection_router.py | 4 - syft/sync/encryption_policy.py | 4 +- syft/sync/peers/peer_store.py | 267 ++++-------------- syft/sync/syftbox_manager.py | 1 - syft/sync/version/peer_manager.py | 60 +--- tests/unit/test_crypto_keys_version.py | 64 +++-- tests/unit/test_encryption.py | 47 +-- tests/unit/test_encryption_policy.py | 242 +--------------- tests/unit/test_sync_manager.py | 5 +- 12 files changed, 149 insertions(+), 597 deletions(-) diff --git a/packages/syft-enclave/src/syft_enclaves/runner.py b/packages/syft-enclave/src/syft_enclaves/runner.py index 09cd3a71e6a..6969be7daed 100644 --- a/packages/syft-enclave/src/syft_enclaves/runner.py +++ b/packages/syft-enclave/src/syft_enclaves/runner.py @@ -14,6 +14,7 @@ import logging import signal import time +from pathlib import Path from typing import Callable, Optional from syft.sync.peers.peer_store import datasite_crypto_keys_path @@ -127,6 +128,9 @@ def _on_initializing(self) -> None: "(local folder + Google Drive files) before init" ) self.client.delete_syftbox() + # The wipe took the key file with it. The keys live on in memory, + # so write them again: the endpoint signs nonces with that file. + self._save_keys() logger.info("State wipe complete — enclave starts with a clean slate") def _on_attesting(self) -> None: @@ -144,9 +148,6 @@ def _on_attesting(self) -> None: logger.info( "TEE detected (%s) — collecting attestation evidence", provider.kind.value ) - # An optional enclave makes keys too: a peer that requires encryption - # later uses them, and only keys that exist now are bound to the report. - self.client._rds.peer_manager.peer_store.ensure_keys() # The key bundle and claims first: on a target that commits to them in # the token, the digest has to exist before minting. claims = self._build_claims() @@ -178,7 +179,7 @@ def _build_claims(self) -> Optional[dict]: report already binds and serves it over the pinned connection. """ peer_store = self.client._rds.peer_manager.peer_store - bundle = peer_store.get_public_bundle() if peer_store.has_my_keys() else None + bundle = peer_store.get_public_bundle() claims = build_claims( email=self.client.email, data_owners=self.client.data_owners, @@ -197,33 +198,31 @@ def _publish_key_bundle(self, claims: Optional[dict] = None) -> None: """Expose our public key bundle on the attestation endpoint. A peer fetching the report over a pinned connection gets the bundle - from the same channel, which binds it to the hardware report. Skipped - when there are no keys, since then there is no bundle. + from the same channel, which binds it to the hardware report. """ peer_store = self.client._rds.peer_manager.peer_store - if not peer_store.has_my_keys(): - logger.info("No keys — no key bundle to publish") - return - peer_manager = self.client._rds.peer_manager - # The per-datasite default location; the enclave never overrides - # crypto_keys_path, and PeerManager does not carry the resolved value. - keys_path = datasite_crypto_keys_path( - peer_manager.syftbox_folder, peer_store.email - ) try: - # fresh_state wiped the syftbox folder a moment ago, taking the key - # file with it — the keys live on in memory, so persist them again - # before advertising where they are. Without this the endpoint - # serves a bundle it cannot sign a nonce with. - peer_store.save_keys(keys_path) write_public_bundle( - peer_store.get_public_bundle(), keys_path=keys_path, claims=claims + peer_store.get_public_bundle(), + keys_path=self._keys_path(), + claims=claims, ) except OSError as e: # Not fatal: peers can still fall back to the Drive-published # bundle, they just lose the attestation binding. logger.warning("Could not publish the public key bundle: %s", e) + def _keys_path(self) -> Path: + # The per-datasite default location; the enclave never overrides + # crypto_keys_path, and PeerManager does not carry the resolved value. + peer_manager = self.client._rds.peer_manager + return datasite_crypto_keys_path( + peer_manager.syftbox_folder, peer_manager.peer_store.email + ) + + def _save_keys(self) -> None: + self.client._rds.peer_manager.peer_store.save_keys(self._keys_path()) + def _on_peering(self) -> None: """Load peers and accept pending peer requests.""" self.client.load_peers() diff --git a/packages/syft-enclave/tests/test_encryption.py b/packages/syft-enclave/tests/test_encryption.py index 2179e4c9f8d..46bfd4afe2e 100644 --- a/packages/syft-enclave/tests/test_encryption.py +++ b/packages/syft-enclave/tests/test_encryption.py @@ -39,7 +39,7 @@ def test_encrypted_quad_keys_and_bundles(): store = client._rds.sync_engine._peer_store assert store is not None assert store.use_encryption is True - assert store.has_my_keys() is True + assert store.get_public_bundle() is not None # The enclave peers with all three; it should hold each of their bundles # after wiring, so it can encrypt to and verify from them. diff --git a/packages/syft-enclave/tests/test_runner.py b/packages/syft-enclave/tests/test_runner.py index d9c642f0b8c..8d214ca1f7f 100644 --- a/packages/syft-enclave/tests/test_runner.py +++ b/packages/syft-enclave/tests/test_runner.py @@ -131,7 +131,6 @@ def test_the_key_bundle_is_published_for_the_attestation_endpoint( client._rds.peer_manager.syftbox_folder = Path("/tmp/SyftBox_enclave") store = client._rds.peer_manager.peer_store store.email = "enclave@openmined.org" - store.has_my_keys.return_value = True store.get_public_bundle.return_value = {"identity": "enclave@openmined.org"} EnclaveRunner(client=client, require_tee=True).init() @@ -142,8 +141,6 @@ def test_the_key_bundle_is_published_for_the_attestation_endpoint( # And the keys must be back on disk: fresh_state wiped the folder # moments earlier, so the file the endpoint signs with is gone. store.save_keys.assert_called_once_with(written["keys_path"]) - # An optional enclave makes keys at boot too, so they are bound to the report. - store.ensure_keys.assert_called_once_with() def test_provider_gets_the_settings_object(self, monkeypatch): seen = {} @@ -185,7 +182,6 @@ def _client(self): client.data_owners = ["b@openmined.org", "a@openmined.org"] store = client._rds.peer_manager.peer_store store.email = "enclave@openmined.org" - store.has_my_keys.return_value = True store.get_public_bundle.return_value = {"identity": "enclave@openmined.org"} return client @@ -203,8 +199,9 @@ def test_the_key_bundle_exists_before_the_token_is_minted(self, monkeypatch): provider = self._provider(monkeypatch, binds_claims=True) client = self._client() order = [] - client._rds.peer_manager.peer_store.save_keys.side_effect = ( - lambda p: order.append("keys") + monkeypatch.setattr( + "syft_enclaves.runner.write_public_bundle", + lambda bundle, keys_path, claims=None: order.append("keys"), ) provider.collect.side_effect = lambda **kw: ( order.append("mint"), diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index d797f28ff9b..336178eec8b 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -416,10 +416,6 @@ def owner_remove_proposed_filechange_from_inbox( # ENCRYPTION BUNDLE EXCHANGE # ========================================================================= - def read_own_encryption_bundle(self, peer_email: str) -> str | None: - """Read the bundle this client published for ``peer_email``.""" - return self.transport.peering.read_own_key_bundle(peer_email) - def write_encryption_bundle(self, peer_email: str, bundle_json: str) -> None: """Write own encryption bundle for a peer.""" self.transport.peering.publish_key_bundle(peer_email, bundle_json) diff --git a/syft/sync/encryption_policy.py b/syft/sync/encryption_policy.py index f7e6a3c90a1..f23bce8c1f5 100644 --- a/syft/sync/encryption_policy.py +++ b/syft/sync/encryption_policy.py @@ -47,9 +47,9 @@ def __init__(self, peer_email: str): class PeerKeysNotReadyError(PeerEncryptionPendingError): - """The pair encrypts, but our keys or the pinned peer bundle are missing. + """The pair encrypts, but the peer bundle is missing. - ``load_peers`` makes the keys and pins the bundle. + ``load_peers`` reads the bundle. """ def __init__(self, peer_email: str): diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index b83b2454a4d..311cf002cff 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -7,12 +7,9 @@ import json import os import tempfile -import warnings -from contextlib import contextmanager from pathlib import Path -from typing import Iterator, List, Optional +from typing import List, Optional -import portalocker import syft_crypto_python as syc from pydantic import BaseModel, PrivateAttr @@ -36,14 +33,9 @@ PRIVATE_DIR_NAME = "private" CRYPTO_KEYS_FILENAME = "crypto_keys.json" -# Format of the crypto key file. Raise it when the layout of the file changes, -# and add a read path for every earlier version. A file with no version was -# written before the field, and is version 0. -CRYPTO_KEYS_VERSION = 1 -# Key in the crypto key file that records the policy. Every process that -# loads the file (a notebook and syft-bg) then uses the same policy. A file -# without it was written when only required clients had keys. -ENCRYPTION_POLICY_KEY = "encryption_policy" +# Format of the crypto key file. Raise it when the layout of the file changes. +# A file of any other version is refused. +CRYPTO_KEYS_VERSION = 2 def datasite_crypto_keys_path(syftbox_folder: Path | str, email: str) -> Path: @@ -61,9 +53,6 @@ class PeerStore(BaseModel): _private_keys: syc.SyftPrivateKeys | None = PrivateAttr(default=None) _peers: List[Peer] = PrivateAttr(default_factory=list) - # The private key file. None when the store was built in memory, e.g. in - # tests. - _keys_path: Path | None = PrivateAttr(default=None) # Peers whose version file was read again since the last ``set_peers``. _policy_rereads: set[str] = PrivateAttr(default_factory=set) # The last policy known of each peer in this process (see peer_policy). @@ -110,8 +99,8 @@ def encrypt_if_needed(self, email: str, data: bytes) -> bytes: Raises: PeerEncryptionPolicyUnknownError: when this client is optional and the policy of the peer is not known. - PeerKeysNotReadyError: when the pair encrypts, but our keys or the - peer bundle are missing. + PeerKeysNotReadyError: when the pair encrypts, but the peer bundle + is missing. EncryptionPolicyConflictError: when the policies of the pair conflict. """ error = self._cached_conflict_error(email) @@ -121,7 +110,7 @@ def encrypt_if_needed(self, email: str, data: bytes) -> bytes: raise PeerEncryptionPolicyUnknownError(email) if not self.peer_uses_encryption(email): return data - if not self.encryption_ready(email): + if not self.has_peer_bundle(email): raise PeerKeysNotReadyError(email) return self.encrypt(email, data) @@ -139,8 +128,8 @@ def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: the peer policy is not known. UnencryptedMessageError: when the pair encrypts and ``data`` is plaintext. - PeerKeysNotReadyError: when ``data`` is an envelope, but our keys - or the peer bundle are missing. + PeerKeysNotReadyError: when ``data`` is an envelope, but the peer + bundle is missing. EncryptionPolicyConflictError: when the policies of the pair conflict. """ error = self._cached_conflict_error(email) @@ -152,7 +141,7 @@ def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: if self.peer_uses_encryption(email): raise UnencryptedMessageError(email) return data - if not self.encryption_ready(email): + if not self.has_peer_bundle(email): raise PeerKeysNotReadyError(email) self.verify_message(email, data) return self.decrypt(email, data) @@ -203,10 +192,6 @@ def peer_policy_known(self, email: str) -> bool: """ return self.use_encryption or self.peer_policy(email) is not None - def encryption_ready(self, email: str) -> bool: - """Whether our keys and the bundle of ``email`` exist.""" - return self.has_my_keys() and self.has_peer_bundle(email) - def conflict_error(self, peer: Peer) -> EncryptionPolicyConflictError | None: """Why this client and ``peer`` cannot exchange messages, or None when they can. @@ -326,67 +311,6 @@ def _missing_bundle_reason(peer: Peer) -> tuple[str, str]: def generate_keys(self) -> None: self._private_keys = syc.SyftRecoveryKey.generate().derive_keys() - def ensure_keys(self) -> None: - """Make sure this client has keys: keep them, load the key file, or generate. - - An optional client has no keys until a peer requires encryption. New - keys are saved to the key file when the store has one. The lock covers - the check and the write, so two processes on one datasite (a notebook - and syft-bg) do not each make a key pair. - """ - if self.has_my_keys(): - return - if self._keys_path is None: - self.generate_keys() - return - with _locked_key_file(self._keys_path): - if self._keys_path.exists(): - self._adopt_key_file(self.load_keys(self._keys_path)) - else: - self.generate_keys() - _write_key_file(self._keys_path, self._key_file_data()) - - def check_key_file_policy(self) -> None: - """Raise when the key file requires encryption and this process does not. - - The policy is read at startup only. Another process (a new login) can - make the datasite required later; this process must not go on - without encryption. - - Raises: - ValueError: when the key file records a required policy, and this - client is optional or off. - """ - if self.use_encryption or self._keys_path is None: - return - data = self._read_key_file() - if data is None: - return - recorded = data.get(ENCRYPTION_POLICY_KEY, EncryptionPolicy.REQUIRED.value) - if recorded == EncryptionPolicy.REQUIRED.value: - raise ValueError( - f"The key file {self._keys_path} requires encryption, but this " - f"process started with encryption {self.encryption_policy.value}. " - "Restart it (for example syft-bg) to use the new policy." - ) - - def _adopt_key_file(self, loaded: "PeerStore") -> None: - """Take the keys of ``loaded``, a store read from our key file. - - The peers of ``loaded`` are taken only when this store has none. - """ - if loaded.email != self.email: - raise ValueError( - f"Encryption key file {self._keys_path} belongs to " - f"{loaded.email!r}, not {self.email!r}" - ) - self._private_keys = loaded._private_keys - if not self._peers: - self._peers = loaded._peers - - def has_my_keys(self) -> bool: - return self._private_keys is not None - @property def public_key(self) -> syc.SyftPublicKeyBundle: keys = self._ensure_private_keys() @@ -454,8 +378,8 @@ def decrypt_for_self(self, envelope: bytes) -> bytes: return syc.decrypt_message(self.email, keys, own_bundle, parsed) def encrypt_for_self_if_needed(self, data: bytes) -> bytes: - """Encrypt for self if encryption is enabled and keys are available.""" - if self.use_encryption and self.has_my_keys(): + """Encrypt for self if this client requires encryption.""" + if self.use_encryption: return self.encrypt_for_self(data) return data @@ -471,168 +395,85 @@ def decrypt_and_verify_for_self_if_needed(self, data: bytes) -> bytes: # ========== Persistence ========== - def _key_file_data(self) -> dict: - """The key file as this store would write it from memory.""" + def save_keys(self, path: Path | str) -> None: + """Write our keys to a new key file at ``path``. + + Raises: + FileExistsError: if a file exists at ``path``. + """ keys = self._ensure_private_keys() - return { + data = { "version": CRYPTO_KEYS_VERSION, "email": self.email, "keys_jwk": keys.to_jwks(), - "peer_bundles": { - peer.email: peer.public_encryption_bundle - for peer in self._peers - if peer.public_encryption_bundle is not None - }, - ENCRYPTION_POLICY_KEY: self.encryption_policy.value, } - - def _record_required_policy(self) -> None: - """Record a required policy in the key file, keeping what else it holds.""" - if self._keys_path is None or not self.use_encryption: - return - with _locked_key_file(self._keys_path): - data = self._read_key_file() or self._key_file_data() - if data.get(ENCRYPTION_POLICY_KEY) == EncryptionPolicy.REQUIRED.value: - return - data = {**data, ENCRYPTION_POLICY_KEY: EncryptionPolicy.REQUIRED.value} - _write_key_file(self._keys_path, data) - _warn_key_file_holds_data(self._keys_path) - - def save_keys(self, path: Path) -> None: - data = self._key_file_data() - path = Path(path) - with _locked_key_file(path): - _write_key_file(path, data) - self._keys_path = path - - def _read_key_file(self) -> dict | None: - """The key file as a dict, or None when missing, unreadable or another identity's.""" - if self._keys_path is None: - return None - try: - data = json.loads(self._keys_path.read_text()) - except (OSError, ValueError): - return None - if not isinstance(data, dict) or data.get("email") != self.email: - return None - return data - - @classmethod - def load_keys(cls, path: Path) -> "PeerStore": - data = json.loads(Path(path).read_text()) - # A file with no version was written before the field, and its layout is - # this client reads. A later version is refused: a user cannot rebuild a - # private key, so a wrong read loses the keys. - version = data.get("version", 0) - if version > CRYPTO_KEYS_VERSION: - raise ValueError( - f"The crypto key file at {path} has version {version}, and this " - f"client reads up to version {CRYPTO_KEYS_VERSION}. Install a " - "newer syft to use these keys." - ) - policy = data.get(ENCRYPTION_POLICY_KEY, EncryptionPolicy.REQUIRED.value) - store = cls(email=data["email"], encryption_policy=EncryptionPolicy(policy)) - store._private_keys = syc.SyftPrivateKeys.from_jwks(data["keys_jwk"]) - store._keys_path = Path(path) - for email, bundle_dict in data.get("peer_bundles", {}).items(): - store._peers.append(Peer(email=email, public_encryption_bundle=bundle_dict)) - return store + _create_key_file(Path(path), data) @classmethod def create( cls, email: str, + keys_path: Path | str, encryption_policy: EncryptionPolicy = EncryptionPolicy.OPTIONAL, - keys_path: Path | str | None = None, ) -> "PeerStore": - """Build a PeerStore for ``email`` with its per-datasite key file. + """Build a PeerStore for ``email`` with the keys in its key file. ``keys_path`` is the participant's own key file (``//private/crypto_keys.json``, see :func:`datasite_crypto_keys_path`). When the file exists, the store - loads its keys and peer bundles. A required policy in the file wins over an - optional ``encryption_policy``, so all processes on a datasite agree. - - Without a file, a required store generates keys and saves them. An - optional store has no keys until :meth:`ensure_keys`. + loads its keys. Else the store generates keys and writes the file. Scoping keys to a per-datasite file lets several identities run on one machine (e.g. two data owners in the same notebook) without colliding, and ties key lifetime to the datasite folder. Raises: - ValueError: if the policy is required but ``keys_path`` is missing, - or the existing key file belongs to a different identity. + ValueError: if the key file has another version or belongs to + another identity. + FileExistsError: if another process created the key file while + this store generated its keys. """ store = cls(email=email, encryption_policy=encryption_policy) - if keys_path is None: - if store.use_encryption: - raise ValueError("keys_path is required when encryption is required") - return store - store._keys_path = Path(keys_path) - if store._keys_path.exists(): - loaded = cls.load_keys(store._keys_path) - if encryption_policy == EncryptionPolicy.OFF and loaded.use_encryption: - raise _off_refused_error(store._keys_path) - if loaded.use_encryption: - store.encryption_policy = EncryptionPolicy.REQUIRED - store._adopt_key_file(loaded) - store._record_required_policy() - elif store.use_encryption: + path = Path(keys_path) + if path.exists(): + store._private_keys = _read_private_keys(path, email) + else: store.generate_keys() - store.save_keys(store._keys_path) - _warn_key_file_holds_data(store._keys_path) + store.save_keys(path) return store -def _off_refused_error(keys_path: Path) -> ValueError: - return ValueError( - f"The key file {keys_path} requires encryption: files of this " - "datasite at rest are encrypted. Encryption cannot be off for it. Use a " - "new SyftBox folder for a client with encryption off." - ) - - -def _warn_key_file_holds_data(keys_path: Path) -> None: - """Warn once, when a datasite starts to require encryption.""" - warnings.warn( - "This datasite now requires encryption, and every later session keeps " - "that setting. Its new files at rest are encrypted with the key in " - f"{keys_path}. Without that file, they cannot be read again: keep a " - "copy in a safe place." - ) +def _read_private_keys(path: Path, email: str) -> syc.SyftPrivateKeys: + data = json.loads(path.read_text()) + if data["version"] != CRYPTO_KEYS_VERSION: + raise ValueError( + f"The crypto key file at {path} has version {data['version']}, and " + f"this client reads only version {CRYPTO_KEYS_VERSION}." + ) + if data["email"] != email: + raise ValueError( + f"Encryption key file {path} belongs to {data['email']!r}, not {email!r}" + ) + return syc.SyftPrivateKeys.from_jwks(data["keys_jwk"]) -@contextmanager -def _locked_key_file(path: Path) -> Iterator[None]: - """Hold an exclusive cross-process lock on the key file at ``path``. +def _create_key_file(path: Path, data: dict) -> None: + """Write ``data`` to a new key file at ``path`` in one step. - The lock is on a sibling ``.lock`` file, as in ``PersistedDict``, so the - key file itself can be replaced while the lock is held. + The key file holds the only copy of the private keys. The data goes to a + temporary file in the same folder and reaches the disk. A hard link then + gives it the name of the key file. The link fails when that name exists, + so a write cut short leaves no partial file, and a second process cannot + replace the keys of the first. ``mkstemp`` makes the file readable by this + user only. """ path.parent.mkdir(parents=True, exist_ok=True) - with open(path.with_suffix(f"{path.suffix}.lock"), "a") as f: - portalocker.lock(f, portalocker.LOCK_EX) - try: - yield - finally: - portalocker.unlock(f) - - -def _write_key_file(path: Path, data: dict) -> None: - """Replace the key file at ``path`` with ``data`` in one step. - - The key file holds the only copy of the private keys, so a write cut short - must leave the old file in place. The data goes to a temporary file in the - same folder, reaches the disk, and then takes the name of the key file. - ``mkstemp`` creates the temporary file readable by this user only. - """ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") try: with os.fdopen(fd, "w") as f: f.write(json.dumps(data, indent=2)) f.flush() os.fsync(f.fileno()) - os.replace(tmp, path) + os.link(tmp, path) finally: - Path(tmp).unlink(missing_ok=True) + os.unlink(tmp) diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index 4fadb52a4c9..89597eba33f 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -1016,7 +1016,6 @@ def sync( separate process may have updated the file. """ with self.sync_file_lock(): - self.peer_manager.peer_store.check_key_file_policy() self.load_peers(force_download=force_download_peer_state) if self.has_do_role: peer_emails = [peer.email for peer in self.peer_manager.approved_peers] diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index f1292e7bc0e..03d77dfecfc 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -8,15 +8,13 @@ from concurrent.futures import ThreadPoolExecutor from enum import Enum from pathlib import Path -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional -from googleapiclient.errors import HttpError from pydantic import BaseModel, ConfigDict, PrivateAttr, model_validator from syft_migration import ProtocolSchema from syft.sync.connections.base_connection import ConnectionConfig from syft.sync.connections.connection_router import ConnectionRouter -from syft.sync.connections.drive.gdrive_retry import RETRYABLE_TRANSPORT_ERRORS from syft.sync.encryption_policy import ( EncryptionPolicy, pair_uses_encryption, @@ -171,8 +169,6 @@ class PeerManager(BaseModel): # Peer email -> state at the first load_peers(), for accepted peers and # waiting requests. None until that load. _states_at_start: Optional[Dict[str, PeerState]] = PrivateAttr(default=None) - # Peers this process published its encryption bundle for. - _bundle_published_for: Set[str] = PrivateAttr(default_factory=set) # ========== Peer List Properties ========== @@ -581,17 +577,17 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): if existing_peer_obj: new_peer_obj = existing_peer_obj - # The peer policy selects encryption for the pair. Before the peer - # accepts, its version file is not shared and the policy is unknown; - # load_peers publishes and reads bundles when the policy becomes known. + # Both sides publish their bundle at peering, so a pair can start to + # encrypt in a later session. The peer policy selects encryption for + # the pair. Before the peer accepts, its version file is not shared + # and the policy is unknown; load_peers reads the bundle once it is. + self._write_encryption_bundle_for_peer(peer_email) version_info = self.connection_router.read_peer_version_file(peer_email) new_peer_obj.version = version_info encrypted = pair_uses_encryption( self.peer_store.encryption_policy, self.peer_store.policy_of(new_peer_obj), ) - if encrypted: - self._write_encryption_bundle_for_peer(peer_email) error = self.peer_store.conflict_error(new_peer_obj) if error is not None: warnings.warn(f"{peer_email} is skipped: {error}") @@ -618,13 +614,11 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): print_peer_request_sent(peer_email) def _write_encryption_bundle_for_peer(self, peer_email: str) -> dict | None: - """Write own encryption bundle for a peer, making keys first when needed.""" - self.peer_store.ensure_keys() + """Write own encryption bundle for a peer.""" bundle = self.peer_store.get_public_bundle() bundle_json = json.dumps({"public_encryption_bundle": bundle}) self.connection_router.write_encryption_bundle(peer_email, bundle_json) self.connection_router.share_encryption_bundles_folder(peer_email) - self._bundle_published_for.add(peer_email) return bundle def _read_peer_encryption_bundle(self, peer_email: str) -> dict | None: @@ -694,48 +688,8 @@ def load_peers(self, force_download: bool = False): if p.state in (PeerState.ACCEPTED, PeerState.REQUESTED_BY_ME) and self.peer_store.peer_uses_encryption(p.email) ] - self._publish_bundle_to_new_encrypted_peers(encrypted) self._read_missing_peer_bundles(encrypted) - def _publish_bundle_to_new_encrypted_peers(self, peers: List[Peer]) -> None: - """Publish our bundle to each peer the pair encrypts with, once per process. - - ``add_peer`` publishes when the pair encrypts at peering. A pair can - start to encrypt later: an optional client learns the peer policy, or - this client becomes required after peering. - """ - for peer in peers: - if peer.email in self._bundle_published_for: - continue - try: - if self._own_bundle_is_published(peer.email): - self._bundle_published_for.add(peer.email) - else: - self._write_encryption_bundle_for_peer(peer.email) - except (HttpError, *RETRYABLE_TRANSPORT_ERRORS) as e: - warnings.warn( - f"Could not publish the encryption key for {peer.email}: {e}. " - "The next peer load tries again." - ) - - def _own_bundle_is_published(self, peer_email: str) -> bool: - """Whether the bundle published for ``peer_email`` is our current one.""" - self.peer_store.ensure_keys() - try: - published = self.connection_router.read_own_encryption_bundle(peer_email) - if published is None: - return False - payload = json.loads(published) - except ValueError: - # Not valid UTF-8 or JSON, so not ours as written: write it again. - return False - if not isinstance(payload, dict): - return False - return ( - payload.get("public_encryption_bundle") - == self.peer_store.get_public_bundle() - ) - def _read_published_bundle(self, peer_email: str) -> dict | None: """The bundle ``peer_email`` published for us, or None when it is missing or malformed.""" try: diff --git a/tests/unit/test_crypto_keys_version.py b/tests/unit/test_crypto_keys_version.py index f35cc42b661..f909156b4d0 100644 --- a/tests/unit/test_crypto_keys_version.py +++ b/tests/unit/test_crypto_keys_version.py @@ -1,27 +1,30 @@ -"""The crypto key file carries a version, and an unknown one stops the load. +"""The crypto key file carries a version, and any other version stops the load. A user cannot rebuild a private key, so delete-and-rebuild is not a recovery -here. If a newer client wrote the file, this client must refuse it rather than -read it wrong and lose the keys. +here. A client must refuse a file of another version rather than read it wrong +and lose the keys. """ import json import pytest -from syft.sync.encryption_policy import EncryptionPolicy from syft.sync.peers.peer_store import CRYPTO_KEYS_VERSION, PeerStore +EMAIL = "alice@example.com" + def _saved(tmp_path): - store = PeerStore( - email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED - ) - store.generate_keys() path = tmp_path / "crypto_keys.json" - store.save_keys(path) + PeerStore.create(email=EMAIL, keys_path=path) return path +def _set_version(path, version): + data = json.loads(path.read_text()) + data["version"] = version + path.write_text(json.dumps(data)) + + def test_a_saved_file_carries_the_version(tmp_path): data = json.loads(_saved(tmp_path).read_text()) assert data["version"] == CRYPTO_KEYS_VERSION @@ -29,26 +32,39 @@ def test_a_saved_file_carries_the_version(tmp_path): def test_a_saved_file_loads_back(tmp_path): path = _saved(tmp_path) - loaded = PeerStore.load_keys(path) - assert loaded.email == "alice@example.com" + on_disk = path.read_text() + PeerStore.create(email=EMAIL, keys_path=path) + assert path.read_text() == on_disk -def test_a_file_without_a_version_still_loads(tmp_path): - # Written before the version field existed. Those keys must keep working. +@pytest.mark.parametrize( + "version", + [CRYPTO_KEYS_VERSION - 1, CRYPTO_KEYS_VERSION + 1], + ids=["older", "newer"], +) +def test_a_file_of_another_version_is_refused(tmp_path, version): path = _saved(tmp_path) - data = json.loads(path.read_text()) - del data["version"] - path.write_text(json.dumps(data)) + _set_version(path, version) - loaded = PeerStore.load_keys(path) - assert loaded.email == "alice@example.com" + with pytest.raises(ValueError, match=f"version {version}"): + PeerStore.create(email=EMAIL, keys_path=path) -def test_a_file_from_a_newer_client_is_refused(tmp_path): +def test_a_file_of_another_identity_is_refused(tmp_path): path = _saved(tmp_path) - data = json.loads(path.read_text()) - data["version"] = CRYPTO_KEYS_VERSION + 1 - path.write_text(json.dumps(data)) - with pytest.raises(ValueError, match=str(CRYPTO_KEYS_VERSION + 1)): - PeerStore.load_keys(path) + with pytest.raises(ValueError, match="belongs to"): + PeerStore.create(email="bob@example.com", keys_path=path) + + +def test_saving_never_replaces_an_existing_file(tmp_path): + """A second process that generated keys at the same time must not win.""" + path = _saved(tmp_path) + on_disk = path.read_text() + other = PeerStore(email=EMAIL) + other.generate_keys() + + with pytest.raises(FileExistsError): + other.save_keys(path) + assert path.read_text() == on_disk + assert [p.name for p in tmp_path.iterdir()] == ["crypto_keys.json"] diff --git a/tests/unit/test_encryption.py b/tests/unit/test_encryption.py index 2f953cfc5ea..d012efbcf68 100644 --- a/tests/unit/test_encryption.py +++ b/tests/unit/test_encryption.py @@ -1,6 +1,6 @@ import copy +import json import pickle -import tempfile from pathlib import Path import pytest @@ -23,9 +23,7 @@ def test_key_generation(): ps = PeerStore( email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED ) - assert not ps.has_my_keys() ps.generate_keys() - assert ps.has_my_keys() assert ps.public_key is not None assert ps.get_public_bundle() is not None @@ -168,30 +166,18 @@ def test_try_decrypt_invalid_envelope(): bob.decrypt("alice@example.com", bad_data) -def test_save_and_load_keys(): - alice = PeerStore( - email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED - ) - alice.generate_keys() - bob = PeerStore( - email="bob@example.com", encryption_policy=EncryptionPolicy.REQUIRED - ) +def test_key_file_holds_identity_only(tmp_path): + keys_path = tmp_path / "crypto_keys.json" + alice = PeerStore.create(email="alice@example.com", keys_path=keys_path) + bob = PeerStore(email="bob@example.com") bob.generate_keys() - alice.add_peer(Peer(email="bob@example.com")) alice.set_peer_bundle("bob@example.com", bob.get_public_bundle()) - with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: - path = Path(f.name) - - alice.save_keys(path) - loaded = PeerStore.load_keys(path) - - assert loaded.email == "alice@example.com" - assert loaded.has_my_keys() - assert loaded.get_public_bundle() is not None - assert loaded.has_peer_bundle("bob@example.com") - path.unlink() + assert set(json.loads(keys_path.read_text())) == {"version", "email", "keys_jwk"} + reloaded = PeerStore.create(email="alice@example.com", keys_path=keys_path) + assert reloaded.get_public_bundle() == alice.get_public_bundle() + assert not reloaded.has_peer_bundle("bob@example.com") # ========================================================================= @@ -271,9 +257,6 @@ def test_no_encryption_backward_compat(): grant_job_inbox_access(do_manager, ds_manager.email) - assert not ds_manager._peer_store.has_my_keys() - assert not do_manager._peer_store.has_my_keys() - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "hello unencrypted") do_manager.sync() @@ -358,17 +341,6 @@ def test_self_encrypt_if_needed_without_encryption(): assert ps.decrypt_and_verify_for_self_if_needed(data) == data -def test_self_encrypt_if_needed_without_keys(): - """encrypt_for_self_if_needed is a no-op when keys are missing.""" - ps = PeerStore( - email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED - ) - - data = b"hello" - assert ps.encrypt_for_self_if_needed(data) == data - assert ps.decrypt_and_verify_for_self_if_needed(data) == data - - # ========================================================================= # At-rest encryption via mock drive: events, checkpoints, rolling state # ========================================================================= @@ -426,7 +398,6 @@ def test_encrypted_rolling_state_roundtrip(): FileChangeEvent, FileChangeEventsMessage, ) - from pathlib import Path from uuid import uuid4 ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 7a0c515d86b..54af6b84ca7 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -5,9 +5,6 @@ side is ``off``. """ -import json -import threading -import warnings from pathlib import Path import pytest @@ -221,34 +218,6 @@ def test_missing_version_file_created_with_own_policy(): assert b_conn.read_peer_version_file("a@example.com").encryption_policy == REQUIRED -def _write_garbage_own_bundle(ds_manager, do_manager, monkeypatch) -> None: - ds_manager.peer_manager.connection_router.write_encryption_bundle( - do_manager.email, "not json" - ) - - -def _make_own_bundle_undecodable(ds_manager, do_manager, monkeypatch) -> None: - def undecodable(self, peer_email): - raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") - - monkeypatch.setattr(ConnectionRouter, "read_own_encryption_bundle", undecodable) - - -@pytest.mark.parametrize( - "damage", - [_write_garbage_own_bundle, _make_own_bundle_undecodable], - ids=["not json", "not utf-8"], -) -def test_unreadable_own_bundle_is_written_again(damage, monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - do_encryption_policy=REQUIRED, - ) - damage(ds_manager, do_manager, monkeypatch) - ds_manager.peer_manager._bundle_published_for.clear() # as a new process - ds_manager.load_peers() - assert do_manager.email in ds_manager.peer_manager._bundle_published_for - - def test_malformed_peer_bundle_does_not_stop_peer_load(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, @@ -344,177 +313,22 @@ def test_encrypting_pair_without_keys_or_pin_not_ready(mine, theirs, with_keys): # ========================================================================= -@pytest.mark.parametrize("policy", [OPTIONAL, OFF]) -def test_store_not_requiring_encryption_creates_no_key_file(tmp_path, policy): +@pytest.mark.parametrize("policy", [OPTIONAL, OFF, REQUIRED]) +def test_every_policy_creates_key_file(tmp_path, policy): keys_path = tmp_path / "crypto_keys.json" store = PeerStore.create( email="alice@example.com", encryption_policy=policy, keys_path=keys_path ) - assert not store.has_my_keys() - assert not keys_path.exists() - - -@pytest.mark.parametrize("policy", [OPTIONAL, OFF]) -def test_sync_refuses_key_file_made_required_after_start(policy): - """The policy is read at startup; a running process must not go on without it.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - do_encryption_policy=policy, - ) - keys_path = do_manager.peer_manager.peer_store._keys_path - with pytest.warns(UserWarning): - PeerStore.create( - email=do_manager.email, encryption_policy=REQUIRED, keys_path=keys_path - ) - with pytest.raises(ValueError, match="Restart"): - do_manager.sync() - - -def test_off_refused_when_key_file_requires_encryption(tmp_path): - """Files at rest of such a datasite are encrypted; off cannot read them.""" - keys_path = tmp_path / "crypto_keys.json" - with pytest.warns(UserWarning): - PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) - with pytest.raises(ValueError, match="new SyftBox folder"): - PeerStore.create( - email="alice@example.com", encryption_policy=OFF, keys_path=keys_path - ) - - -def test_off_policy_wins_over_optional_key_file(tmp_path): - keys_path = tmp_path / "crypto_keys.json" - PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() - store = PeerStore.create( - email="alice@example.com", encryption_policy=OFF, keys_path=keys_path - ) - assert store.encryption_policy == OFF - - -def test_optional_store_creates_keys_when_needed(tmp_path): - keys_path = tmp_path / "crypto_keys.json" - store = PeerStore.create( - email="alice@example.com", encryption_policy=OPTIONAL, keys_path=keys_path - ) - store.ensure_keys() - assert store.has_my_keys() - reloaded = PeerStore.create(email="alice@example.com", keys_path=keys_path) - assert reloaded.has_my_keys() - assert reloaded.encryption_policy == OPTIONAL - - -def test_optional_store_loads_existing_keys_when_needed(tmp_path): - keys_path = tmp_path / "crypto_keys.json" - first = PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) - second = PeerStore.create( - email="alice@example.com", encryption_policy=OPTIONAL, keys_path=keys_path - ) - second.ensure_keys() - assert second.get_public_bundle() == first.get_public_bundle() - - -def test_required_policy_in_key_file_wins(tmp_path): - """syft-bg next to a required notebook loads the same key file and requires too.""" - keys_path = tmp_path / "crypto_keys.json" - PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) - daemon = PeerStore.create( - email="alice@example.com", encryption_policy=OPTIONAL, keys_path=keys_path - ) - assert daemon.encryption_policy == REQUIRED - - -def test_key_file_without_policy_reads_as_required(tmp_path): - """Before the field, only required clients wrote a key file.""" - keys_path = tmp_path / "crypto_keys.json" - PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) - data = json.loads(keys_path.read_text()) - del data["encryption_policy"] - keys_path.write_text(json.dumps(data)) - - store = PeerStore.create(email="alice@example.com", keys_path=keys_path) - assert store.encryption_policy == REQUIRED + assert reloaded.get_public_bundle() == store.get_public_bundle() -def test_required_client_records_policy_in_existing_key_file(tmp_path): - keys_path = tmp_path / "crypto_keys.json" - PeerStore.create(email="alice@example.com", keys_path=keys_path).ensure_keys() - PeerStore.create( - email="alice@example.com", encryption_policy=REQUIRED, keys_path=keys_path - ) - store = PeerStore.create(email="alice@example.com", keys_path=keys_path) - assert store.encryption_policy == REQUIRED - - -def _create(keys_path, policy=OPTIONAL) -> PeerStore: - return PeerStore.create( - email="alice@example.com", encryption_policy=policy, keys_path=keys_path - ) - - -def _make_optional_keys(keys_path) -> None: - _create(keys_path).ensure_keys() - - -def _first_required_login(keys_path) -> None: - with pytest.warns(UserWarning): - _create(keys_path, REQUIRED) - - -@pytest.mark.parametrize( - "before", [lambda keys_path: None, _make_optional_keys], ids=["first", "switch"] -) -def test_datasite_starting_to_require_warns_about_key_file(tmp_path, before): - keys_path = tmp_path / "crypto_keys.json" - before(keys_path) - with pytest.warns(UserWarning, match="keep a copy"): - _create(keys_path, REQUIRED) - - -@pytest.mark.parametrize( - "before, action", - [ - (_first_required_login, lambda keys_path: _create(keys_path, REQUIRED)), - (_first_required_login, lambda keys_path: _create(keys_path, OPTIONAL)), - (lambda keys_path: None, _make_optional_keys), - ], - ids=["later required login", "later optional login", "optional keys"], -) -def test_no_key_file_warning_without_switch_to_required(tmp_path, before, action): - keys_path = tmp_path / "crypto_keys.json" - before(keys_path) - with warnings.catch_warnings(): - warnings.simplefilter("error") - action(keys_path) - - -def test_concurrent_processes_make_one_key_pair(tmp_path): - """A notebook and syft-bg that need keys at once must end with the same keys.""" - keys_path = tmp_path / "crypto_keys.json" - stores = [ - PeerStore.create(email="alice@example.com", keys_path=keys_path) - for _ in range(8) - ] - barrier = threading.Barrier(len(stores)) - - def make_keys(store: PeerStore) -> None: - barrier.wait() - store.ensure_keys() - - threads = [threading.Thread(target=make_keys, args=(s,)) for s in stores] - for t in threads: - t.start() - for t in threads: - t.join() - - on_disk = PeerStore.load_keys(keys_path).get_public_bundle() - assert all(store.get_public_bundle() == on_disk for store in stores) +def test_plaintext_pair_publishes_bundles_at_peering(): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + ds_router = ds_manager.peer_manager.connection_router + do_router = do_manager.peer_manager.connection_router + assert ds_router.read_peer_encryption_bundle(do_manager.email) is not None + assert do_router.read_peer_encryption_bundle(ds_manager.email) is not None # ========================================================================= @@ -633,7 +447,7 @@ def test_client_required_after_peering_publishes_bundle(): ds_manager.load_peers() do_store = do_manager.peer_manager.peer_store - assert do_store.encryption_ready(ds_manager.email) + assert do_store.has_peer_bundle(ds_manager.email) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "after switch") do_manager.sync() @@ -689,7 +503,6 @@ def test_off_client_works_in_plaintext_with_optional_peer(): assert not PeerStore.is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) do_manager.sync() assert any("plain content" in c for c in _accepted_contents(do_manager)) - assert not ds_manager.peer_manager.peer_store.has_my_keys() def test_off_and_required_peers_skip_each_other(): @@ -741,41 +554,6 @@ def test_conflict_found_on_read_makes_peer_wait(): assert message is None -def test_current_published_bundle_not_written_again(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - do_encryption_policy=REQUIRED, - ) - ds_manager.load_peers() - writes = [] - monkeypatch.setattr( - ConnectionRouter, - "write_encryption_bundle", - lambda self, peer_email, bundle_json: writes.append(peer_email), - ) - ds_manager.peer_manager._bundle_published_for.clear() # as a new process - ds_manager.load_peers() - assert writes == [] - - -def test_drive_error_while_publishing_bundle_does_not_stop_sync(monkeypatch): - from googleapiclient.errors import HttpError - - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - do_encryption_policy=REQUIRED, - ) - - def fail(self, peer_email): - raise HttpError( - resp=type("R", (), {"status": 503, "reason": ""})(), content=b"" - ) - - monkeypatch.setattr(ConnectionRouter, "read_own_encryption_bundle", fail) - ds_manager.peer_manager._bundle_published_for.clear() # as a new process - with pytest.warns(UserWarning, match="publish"): - ds_manager.load_peers() - assert do_manager.email not in ds_manager.peer_manager._bundle_published_for - - def test_owner_waits_on_envelope_from_unpinned_peer(): """The sync goes on; the message stays in the inbox until the pin exists.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( diff --git a/tests/unit/test_sync_manager.py b/tests/unit/test_sync_manager.py index f97b2b8a22d..515be26a181 100644 --- a/tests/unit/test_sync_manager.py +++ b/tests/unit/test_sync_manager.py @@ -167,7 +167,7 @@ def test_sync_existing_datasite_state_do(): n_outbox = connection_ds.watcher_get_outbox_file_metadatas(do_manager.email, None) assert n_messages_in_cache >= 1 # At least 1 message with the 2 file changes - assert n_files_in_cache == 2 # 2 data files + assert n_files_in_cache == 3 # 2 data files + private/crypto_keys.json assert hashes_in_cache == 2 # 2 data files assert len(n_outbox) >= 1 @@ -203,7 +203,8 @@ def test_sync_existing_inbox_state_do(): n_files_in_cache = len(do_manager.datasite_owner_syncer.event_cache.file_connection) hashes_in_cache = len(do_manager.datasite_owner_syncer.event_cache.file_hashes) assert n_events_message_in_cache >= 1 # At least 1 message with 2 file changes - assert n_files_in_cache == 3 # 2 data files + root syft.pub.yaml + # 2 data files + root syft.pub.yaml + private/crypto_keys.json + assert n_files_in_cache == 4 assert hashes_in_cache == 3 # 2 data files + root syft.pub.yaml From fc9f3d0da64f5c333d92791452b5dfc3769eedda Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Fri, 23 Oct 2026 14:59:35 -0300 Subject: [PATCH 18/30] test: drop pin names and cases without own keys --- tests/unit/test_encryption_policy.py | 53 ++++++++-------------------- 1 file changed, 14 insertions(+), 39 deletions(-) diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 54af6b84ca7..69722bc8d5c 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -295,21 +295,15 @@ def test_required_sender_needs_no_peer_policy(): assert PeerStore.is_envelope(alice.encrypt_if_needed("bob@example.com", b"data")) -@pytest.mark.parametrize( - "mine, theirs, with_keys", - [(OPTIONAL, REQUIRED, False), (REQUIRED, OPTIONAL, True)], - ids=["no own keys", "no peer bundle"], -) -def test_encrypting_pair_without_keys_or_pin_not_ready(mine, theirs, with_keys): - store = _store_with_peer(mine, theirs) - if with_keys: - store.generate_keys() +def test_encrypting_pair_without_peer_bundle_not_ready(): + store = _store_with_peer(REQUIRED, OPTIONAL) + store.generate_keys() with pytest.raises(PeerKeysNotReadyError): store.encrypt_if_needed("bob@example.com", b"data") # ========================================================================= -# Keys and pins +# Keys and bundles # ========================================================================= @@ -377,29 +371,10 @@ def test_receiver_accepts_envelope_from_plaintext_peer(): assert bob.decrypt_and_verify_if_needed("alice@example.com", envelope) == b"secret" -def _unpinned_receiver() -> PeerStore: - _, bob = _keyed_pair(REQUIRED, OPTIONAL) - bob.get_cached_peer("alice@example.com").public_encryption_bundle = None - return bob - - -def _keyless_receiver() -> PeerStore: - bob = PeerStore(email="bob@example.com") - bob.add_peer( - Peer(email="alice@example.com", version=_version_with_policy(REQUIRED)) - ) - return bob - - -@pytest.mark.parametrize( - "make_receiver", - [_unpinned_receiver, _keyless_receiver], - ids=["no peer bundle", "no own keys"], -) -def test_envelope_before_keys_and_pin_is_not_ready(make_receiver): - alice, _ = _keyed_pair(REQUIRED, OPTIONAL) +def test_envelope_without_peer_bundle_is_not_ready(): + alice, bob = _keyed_pair(REQUIRED, OPTIONAL) envelope = alice.encrypt("bob@example.com", b"secret") - bob = make_receiver() + bob.get_cached_peer("alice@example.com").public_encryption_bundle = None with pytest.raises(PeerKeysNotReadyError): bob.decrypt_and_verify_if_needed("alice@example.com", envelope) @@ -420,7 +395,7 @@ def test_negotiated_encryption_flow(ds_policy, do_policy): do_encryption_policy=do_policy, ) grant_job_inbox_access(do_manager, ds_manager.email) - # One load_peers round each lets both sides see the policy and pin bundles. + # One load_peers round each lets both sides see the policy and read bundles. ds_manager.load_peers() do_manager.load_peers() @@ -554,8 +529,8 @@ def test_conflict_found_on_read_makes_peer_wait(): assert message is None -def test_owner_waits_on_envelope_from_unpinned_peer(): - """The sync goes on; the message stays in the inbox until the pin exists.""" +def test_owner_waits_on_envelope_from_peer_without_bundle(): + """The sync goes on; the message stays in the inbox until the bundle is read.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=REQUIRED, do_encryption_policy=OPTIONAL, @@ -563,16 +538,16 @@ def test_owner_waits_on_envelope_from_unpinned_peer(): grant_job_inbox_access(do_manager, ds_manager.email) do_manager.load_peers() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "pinned later") + ds_manager._send_file_change(file_path, "bundle later") peer = do_manager.peer_manager.peer_store.get_cached_peer(ds_manager.email) peer.public_encryption_bundle = None with pytest.warns(UserWarning, match="wait"): do_manager.datasite_owner_syncer.sync([ds_manager.email]) - assert not any("pinned later" in c for c in _accepted_contents(do_manager)) + assert not any("bundle later" in c for c in _accepted_contents(do_manager)) do_manager.sync() - assert any("pinned later" in c for c in _accepted_contents(do_manager)) + assert any("bundle later" in c for c in _accepted_contents(do_manager)) # ========================================================================= @@ -629,7 +604,7 @@ def test_send_before_peer_accepts_waits_until_sync(): def test_send_after_accept_before_peer_load_waits_until_sync(): - """The owner accepted, but the scientist has no keys or pin yet.""" + """The owner accepted, but the scientist has not read its bundle yet.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, do_encryption_policy=REQUIRED, From dbe88824b56c7c0b5af29e917c7c5429e5ac413d Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Fri, 23 Oct 2026 16:39:51 -0300 Subject: [PATCH 19/30] refactor: import an uploaded key file at login The enclave login takes a key_file. Login copies it to the key file path of the datasite before it builds the client. This replaces the crypto_keys_path override, so syft-bg finds the same keys. The Colab notebooks go back to one login cell that uploads or downloads the key file. --- .../1. DO-benchmark-owner-dbe.ipynb | 15 ++- .../2. DO-model-owner-dbe.ipynb | 15 ++- .../1. DO-model-owner-gemma-shield.ipynb | 84 +++++++---------- .../2. DO-benchmark-owner-gemma-shield.ipynb | 94 ++++++++----------- .../1. DO-model-owner-gemma-restrict.ipynb | 80 ++++++---------- ...2. DO-benchmark-owner-gemma-restrict.ipynb | 90 +++++++----------- .../docs/tinfoil_troubleshooting.md | 2 +- .../syft-enclave/src/syft_enclaves/login.py | 32 ++++--- .../syft-enclave/src/syft_enclaves/runner.py | 2 - packages/syft-enclave/tests/test_login.py | 36 +++++++ syft/sync/peers/peer_store.py | 25 +++++ syft/sync/syftbox_manager.py | 8 -- syft/sync/version/peer_manager.py | 14 +-- tests/unit/test_encryption.py | 45 ++++++++- 14 files changed, 288 insertions(+), 254 deletions(-) create mode 100644 packages/syft-enclave/tests/test_login.py diff --git a/notebooks/enclave/double-blind-eval-w-receipts/1. DO-benchmark-owner-dbe.ipynb b/notebooks/enclave/double-blind-eval-w-receipts/1. DO-benchmark-owner-dbe.ipynb index 3149af1c9c6..781e22595ea 100644 --- a/notebooks/enclave/double-blind-eval-w-receipts/1. DO-benchmark-owner-dbe.ipynb +++ b/notebooks/enclave/double-blind-eval-w-receipts/1. DO-benchmark-owner-dbe.ipynb @@ -102,13 +102,18 @@ "metadata": {}, "outputs": [], "source": [ - "CRYPTO_KEYS_PATH = None\n", - "# NOTE: Re-storing Private Key from local_file, if you have it, uncomment the following line and upload the file.\n", + "KEY_FILE = None\n", + "# To restore the encryption key of an earlier session, uncomment the next two lines and upload crypto_keys.json.\n", "# from google.colab import files; files.upload()\n", - "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", + "# KEY_FILE = \"crypto_keys.json\"\n", "\n", - "benchmark_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", - "print(f\" Benchmark owner : {benchmark_owner.email}\")" + "benchmark_owner = login_do(key_file=KEY_FILE)\n", + "print(f\" Benchmark owner : {benchmark_owner.email}\")\n", + "\n", + "# First login: back up the encryption key. It lives only on this runtime's filesystem.\n", + "if KEY_FILE is None:\n", + " from google.colab import files\n", + " files.download(str(benchmark_owner.syftbox_folder / benchmark_owner.email / \"private\" / \"crypto_keys.json\"))" ] }, { diff --git a/notebooks/enclave/double-blind-eval-w-receipts/2. DO-model-owner-dbe.ipynb b/notebooks/enclave/double-blind-eval-w-receipts/2. DO-model-owner-dbe.ipynb index 0af1b6c707a..afa20e163ad 100644 --- a/notebooks/enclave/double-blind-eval-w-receipts/2. DO-model-owner-dbe.ipynb +++ b/notebooks/enclave/double-blind-eval-w-receipts/2. DO-model-owner-dbe.ipynb @@ -100,13 +100,18 @@ "metadata": {}, "outputs": [], "source": [ - "CRYPTO_KEYS_PATH = None\n", - "# NOTE: Re-storing Private Key from local_file, if you have it, uncomment the following line and upload the file.\n", + "KEY_FILE = None\n", + "# To restore the encryption key of an earlier session, uncomment the next two lines and upload crypto_keys.json.\n", "# from google.colab import files; files.upload()\n", - "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", + "# KEY_FILE = \"crypto_keys.json\"\n", "\n", - "model_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", - "print(f\" Model owner : {model_owner.email}\")" + "model_owner = login_do(key_file=KEY_FILE)\n", + "print(f\" Model owner : {model_owner.email}\")\n", + "\n", + "# First login: back up the encryption key. It lives only on this runtime's filesystem.\n", + "if KEY_FILE is None:\n", + " from google.colab import files\n", + " files.download(str(model_owner.syftbox_folder / model_owner.email / \"private\" / \"crypto_keys.json\"))" ] }, { diff --git a/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb b/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb index 77f748ce981..fc2de0d163f 100644 --- a/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb +++ b/notebooks/enclave/gemma/colab-shield/1. DO-model-owner-gemma-shield.ipynb @@ -156,25 +156,18 @@ "metadata": {}, "outputs": [], "source": [ - "CRYPTO_KEYS_PATH = None\n", - "# NOTE: Re-storing Private Key from local_file, if you have it, uncomment the following line and upload the file.\n", + "KEY_FILE = None\n", + "# To restore the encryption key of an earlier session, uncomment the next two lines and upload crypto_keys.json.\n", "# from google.colab import files; files.upload()\n", - "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", + "# KEY_FILE = \"crypto_keys.json\"\n", "\n", - "model_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", + "model_owner = login_do(key_file=KEY_FILE)\n", "print(f\" Model owner : {model_owner.email}\")\n", "\n", - "# Back up the encryption key: it lives only on this runtime's filesystem. The\n", - "# client makes it when it first syncs with an enclave that requires encryption.\n", - "def backup_key():\n", - " if CRYPTO_KEYS_PATH is not None:\n", - " return\n", - " key_file = model_owner.syftbox_folder / model_owner.email / \"private\" / \"crypto_keys.json\"\n", - " if not key_file.exists():\n", - " print(f\"No encryption key yet. It appears at {key_file} after the enclave accepts.\")\n", - " return\n", + "# First login: back up the encryption key. It lives only on this runtime's filesystem.\n", + "if KEY_FILE is None:\n", " from google.colab import files\n", - " files.download(str(key_file))" + " files.download(str(model_owner.syftbox_folder / model_owner.email / \"private\" / \"crypto_keys.json\"))" ] }, { @@ -263,20 +256,9 @@ " print(f\" Connected to {peer_email}\")" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "18", - "metadata": {}, - "outputs": [], - "source": [ - "# The enclave accepted and the keys exist now: back up the key file.\n", - "backup_key()" - ] - }, { "cell_type": "markdown", - "id": "19", + "id": "18", "metadata": {}, "source": [ "### Step 1.2 — Attest the enclave's identity\n", @@ -287,7 +269,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -297,7 +279,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "20", "metadata": {}, "source": [ "## Step 2 — Get the two models\n", @@ -314,7 +296,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "21", "metadata": {}, "source": [ "### Step 2.1 — Gemma 3 from Kaggle\n" @@ -323,7 +305,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -342,7 +324,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -357,7 +339,7 @@ }, { "cell_type": "markdown", - "id": "25", + "id": "24", "metadata": {}, "source": [ "### Step 2.2 — ShieldGemma 2B from Hugging Face\n", @@ -370,7 +352,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -395,7 +377,7 @@ }, { "cell_type": "markdown", - "id": "27", + "id": "26", "metadata": {}, "source": [ "## Step 3 — Prepare the files for upload\n", @@ -409,7 +391,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -487,7 +469,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -506,7 +488,7 @@ }, { "cell_type": "markdown", - "id": "30", + "id": "29", "metadata": {}, "source": [ "## Step 4 — Upload the two private models\n", @@ -517,7 +499,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -543,7 +525,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -554,7 +536,7 @@ }, { "cell_type": "markdown", - "id": "33", + "id": "32", "metadata": {}, "source": [ "## Step 5 — Run `syft-restrict` on the inference code\n", @@ -568,7 +550,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -628,7 +610,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -648,7 +630,7 @@ }, { "cell_type": "markdown", - "id": "36", + "id": "35", "metadata": {}, "source": [ "### Step 5.1 — Approve the job\n", @@ -659,7 +641,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -670,7 +652,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "37", "metadata": {}, "outputs": [], "source": [ @@ -680,7 +662,7 @@ { "cell_type": "code", "execution_count": null, - "id": "39", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -690,7 +672,7 @@ }, { "cell_type": "markdown", - "id": "40", + "id": "39", "metadata": {}, "source": [ "\"Wait\n" @@ -698,7 +680,7 @@ }, { "cell_type": "markdown", - "id": "41", + "id": "40", "metadata": {}, "source": [ "## Step 6 — Approve the final inference job\n", @@ -709,7 +691,7 @@ { "cell_type": "code", "execution_count": null, - "id": "42", + "id": "41", "metadata": {}, "outputs": [], "source": [ @@ -721,7 +703,7 @@ { "cell_type": "code", "execution_count": null, - "id": "43", + "id": "42", "metadata": {}, "outputs": [], "source": [ @@ -731,7 +713,7 @@ { "cell_type": "code", "execution_count": null, - "id": "44", + "id": "43", "metadata": {}, "outputs": [], "source": [ diff --git a/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb b/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb index 0f8b1363f34..26100bb4280 100644 --- a/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb +++ b/notebooks/enclave/gemma/colab-shield/2. DO-benchmark-owner-gemma-shield.ipynb @@ -100,25 +100,18 @@ "metadata": {}, "outputs": [], "source": [ - "CRYPTO_KEYS_PATH = None\n", - "# NOTE: Re-storing Private Key from local_file, if you have it, uncomment the following line and upload the file.\n", + "KEY_FILE = None\n", + "# To restore the encryption key of an earlier session, uncomment the next two lines and upload crypto_keys.json.\n", "# from google.colab import files; files.upload()\n", - "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", + "# KEY_FILE = \"crypto_keys.json\"\n", "\n", - "benchmark_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", + "benchmark_owner = login_do(key_file=KEY_FILE)\n", "print(f\" Benchmark owner : {benchmark_owner.email}\")\n", "\n", - "# Back up the encryption key: it lives only on this runtime's filesystem. The\n", - "# client makes it when it first syncs with an enclave that requires encryption.\n", - "def backup_key():\n", - " if CRYPTO_KEYS_PATH is not None:\n", - " return\n", - " key_file = benchmark_owner.syftbox_folder / benchmark_owner.email / \"private\" / \"crypto_keys.json\"\n", - " if not key_file.exists():\n", - " print(f\"No encryption key yet. It appears at {key_file} after the enclave accepts.\")\n", - " return\n", + "# First login: back up the encryption key. It lives only on this runtime's filesystem.\n", + "if KEY_FILE is None:\n", " from google.colab import files\n", - " files.download(str(key_file))" + " files.download(str(benchmark_owner.syftbox_folder / benchmark_owner.email / \"private\" / \"crypto_keys.json\"))" ] }, { @@ -205,20 +198,9 @@ " print(f\" Connected to {peer_email}\")" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "15", - "metadata": {}, - "outputs": [], - "source": [ - "# The enclave accepted and the keys exist now: back up the key file.\n", - "backup_key()" - ] - }, { "cell_type": "markdown", - "id": "16", + "id": "15", "metadata": {}, "source": [ "### Step 1.2 — Attest the enclave's identity\n", @@ -229,7 +211,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -239,7 +221,7 @@ }, { "cell_type": "markdown", - "id": "18", + "id": "17", "metadata": {}, "source": [ "## Step 2 — Prepare the benchmark\n", @@ -254,7 +236,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -320,7 +302,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -331,7 +313,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -344,7 +326,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -362,7 +344,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "22", "metadata": {}, "source": [ "## Step 3 — Upload the benchmark\n", @@ -373,7 +355,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -390,7 +372,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -400,7 +382,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "25", "metadata": {}, "source": [ "\"Wait\n" @@ -408,7 +390,7 @@ }, { "cell_type": "markdown", - "id": "27", + "id": "26", "metadata": {}, "source": [ "## Step 4 — Review the `syft-restrict` job\n", @@ -424,7 +406,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -436,7 +418,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -446,7 +428,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -456,7 +438,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "30", "metadata": {}, "source": [ "### Step 4.1 — Read the certificate\n", @@ -467,7 +449,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -486,7 +468,7 @@ }, { "cell_type": "markdown", - "id": "33", + "id": "32", "metadata": {}, "source": [ "## Step 5 — Submit the inference job\n", @@ -503,7 +485,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -514,7 +496,7 @@ }, { "cell_type": "markdown", - "id": "35", + "id": "34", "metadata": {}, "source": [ "### Step 5.1 — Read the model owner's guardrail\n", @@ -525,7 +507,7 @@ { "cell_type": "code", "execution_count": null, - "id": "36", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -543,7 +525,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -675,7 +657,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "37", "metadata": {}, "outputs": [], "source": [ @@ -703,7 +685,7 @@ }, { "cell_type": "markdown", - "id": "39", + "id": "38", "metadata": {}, "source": [ "## Step 6 — Approve the inference job\n", @@ -714,7 +696,7 @@ { "cell_type": "code", "execution_count": null, - "id": "40", + "id": "39", "metadata": {}, "outputs": [], "source": [ @@ -725,7 +707,7 @@ { "cell_type": "code", "execution_count": null, - "id": "41", + "id": "40", "metadata": {}, "outputs": [], "source": [ @@ -735,7 +717,7 @@ { "cell_type": "code", "execution_count": null, - "id": "42", + "id": "41", "metadata": {}, "outputs": [], "source": [ @@ -745,7 +727,7 @@ }, { "cell_type": "markdown", - "id": "43", + "id": "42", "metadata": {}, "source": [ "\"Wait\n" @@ -753,7 +735,7 @@ }, { "cell_type": "markdown", - "id": "44", + "id": "43", "metadata": {}, "source": [ "## Step 7 — View the results\n", @@ -766,7 +748,7 @@ { "cell_type": "code", "execution_count": null, - "id": "45", + "id": "44", "metadata": {}, "outputs": [], "source": [ @@ -779,7 +761,7 @@ { "cell_type": "code", "execution_count": null, - "id": "46", + "id": "45", "metadata": {}, "outputs": [], "source": [ diff --git a/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb b/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb index e3ca1045837..eebe1432f10 100644 --- a/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb +++ b/notebooks/enclave/gemma/colab/1. DO-model-owner-gemma-restrict.ipynb @@ -108,25 +108,18 @@ "metadata": {}, "outputs": [], "source": [ - "CRYPTO_KEYS_PATH = None\n", - "# NOTE: Re-storing Private Key from local_file, if you have it, uncomment the following line and upload the file.\n", + "KEY_FILE = None\n", + "# To restore the encryption key of an earlier session, uncomment the next two lines and upload crypto_keys.json.\n", "# from google.colab import files; files.upload()\n", - "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", + "# KEY_FILE = \"crypto_keys.json\"\n", "\n", - "model_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", + "model_owner = login_do(key_file=KEY_FILE)\n", "print(f\" Model owner : {model_owner.email}\")\n", "\n", - "# Back up the encryption key: it lives only on this runtime's filesystem. The\n", - "# client makes it when it first syncs with an enclave that requires encryption.\n", - "def backup_key():\n", - " if CRYPTO_KEYS_PATH is not None:\n", - " return\n", - " key_file = model_owner.syftbox_folder / model_owner.email / \"private\" / \"crypto_keys.json\"\n", - " if not key_file.exists():\n", - " print(f\"No encryption key yet. It appears at {key_file} after the enclave accepts.\")\n", - " return\n", + "# First login: back up the encryption key. It lives only on this runtime's filesystem.\n", + "if KEY_FILE is None:\n", " from google.colab import files\n", - " files.download(str(key_file))" + " files.download(str(model_owner.syftbox_folder / model_owner.email / \"private\" / \"crypto_keys.json\"))" ] }, { @@ -215,20 +208,9 @@ " print(f\" Connected to {peer_email}\")" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "16", - "metadata": {}, - "outputs": [], - "source": [ - "# The enclave accepted and the keys exist now: back up the key file.\n", - "backup_key()" - ] - }, { "cell_type": "markdown", - "id": "17", + "id": "16", "metadata": {}, "source": [ "### Step 1.2 — Attest the enclave's identity\n", @@ -239,7 +221,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -249,7 +231,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "18", "metadata": {}, "source": [ "## Step 2 — Get the Gemma 3 model\n", @@ -259,7 +241,7 @@ }, { "cell_type": "markdown", - "id": "20", + "id": "19", "metadata": {}, "source": [ "The Notebooks assumes, that gemma-3 model is present in your Google Drive, kindly upload the model to your Google Drive. You could use the kaggle API to download the model from Kaggle and upload it to your Google Drive. The command to download the model from Kaggle is below.\n", @@ -270,7 +252,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -289,7 +271,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -306,7 +288,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "22", "metadata": {}, "source": [ "## Step 3 — Prepare the files for upload\n", @@ -317,7 +299,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -359,7 +341,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -375,7 +357,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "25", "metadata": {}, "source": [ "## Step 4 — Upload the private model\n", @@ -386,7 +368,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -403,7 +385,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -413,7 +395,7 @@ }, { "cell_type": "markdown", - "id": "29", + "id": "28", "metadata": {}, "source": [ "## Step 5 — Run `syft-restrict` on the inference code\n", @@ -427,7 +409,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -487,7 +469,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "30", "metadata": {}, "outputs": [], "source": [ @@ -507,7 +489,7 @@ }, { "cell_type": "markdown", - "id": "32", + "id": "31", "metadata": {}, "source": [ "### Step 5.1 — Approve the job\n", @@ -518,7 +500,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -529,7 +511,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -539,7 +521,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -549,7 +531,7 @@ }, { "cell_type": "markdown", - "id": "36", + "id": "35", "metadata": {}, "source": [ "\"Wait\n" @@ -557,7 +539,7 @@ }, { "cell_type": "markdown", - "id": "37", + "id": "36", "metadata": {}, "source": [ "## Step 6 — Approve the final inference job\n", @@ -568,7 +550,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "37", "metadata": {}, "outputs": [], "source": [ @@ -580,7 +562,7 @@ { "cell_type": "code", "execution_count": null, - "id": "39", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -590,7 +572,7 @@ { "cell_type": "code", "execution_count": null, - "id": "40", + "id": "39", "metadata": {}, "outputs": [], "source": [ diff --git a/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb b/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb index dca49868ff4..b7a84935153 100644 --- a/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb +++ b/notebooks/enclave/gemma/colab/2. DO-benchmark-owner-gemma-restrict.ipynb @@ -89,25 +89,18 @@ "metadata": {}, "outputs": [], "source": [ - "CRYPTO_KEYS_PATH = None\n", - "# NOTE: Re-storing Private Key from local_file, if you have it, uncomment the following line and upload the file.\n", + "KEY_FILE = None\n", + "# To restore the encryption key of an earlier session, uncomment the next two lines and upload crypto_keys.json.\n", "# from google.colab import files; files.upload()\n", - "# CRYPTO_KEYS_PATH = \"crypto_keys.json\"\n", + "# KEY_FILE = \"crypto_keys.json\"\n", "\n", - "benchmark_owner = login_do(crypto_keys_path=CRYPTO_KEYS_PATH)\n", + "benchmark_owner = login_do(key_file=KEY_FILE)\n", "print(f\" Benchmark owner : {benchmark_owner.email}\")\n", "\n", - "# Back up the encryption key: it lives only on this runtime's filesystem. The\n", - "# client makes it when it first syncs with an enclave that requires encryption.\n", - "def backup_key():\n", - " if CRYPTO_KEYS_PATH is not None:\n", - " return\n", - " key_file = benchmark_owner.syftbox_folder / benchmark_owner.email / \"private\" / \"crypto_keys.json\"\n", - " if not key_file.exists():\n", - " print(f\"No encryption key yet. It appears at {key_file} after the enclave accepts.\")\n", - " return\n", + "# First login: back up the encryption key. It lives only on this runtime's filesystem.\n", + "if KEY_FILE is None:\n", " from google.colab import files\n", - " files.download(str(key_file))" + " files.download(str(benchmark_owner.syftbox_folder / benchmark_owner.email / \"private\" / \"crypto_keys.json\"))" ] }, { @@ -194,20 +187,9 @@ " print(f\" Connected to {peer_email}\")" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "15", - "metadata": {}, - "outputs": [], - "source": [ - "# The enclave accepted and the keys exist now: back up the key file.\n", - "backup_key()" - ] - }, { "cell_type": "markdown", - "id": "16", + "id": "15", "metadata": {}, "source": [ "### Step 1.2 — Attest the enclave's identity\n", @@ -218,7 +200,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -228,7 +210,7 @@ }, { "cell_type": "markdown", - "id": "18", + "id": "17", "metadata": {}, "source": [ "## Step 2 — Prepare the benchmark\n", @@ -243,7 +225,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -309,7 +291,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -320,7 +302,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -333,7 +315,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -351,7 +333,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "22", "metadata": {}, "source": [ "## Step 3 — Upload the benchmark\n", @@ -362,7 +344,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -379,7 +361,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -389,7 +371,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "25", "metadata": {}, "source": [ "\"Wait\n" @@ -397,7 +379,7 @@ }, { "cell_type": "markdown", - "id": "27", + "id": "26", "metadata": {}, "source": [ "## Step 4 — Review the `syft-restrict` job\n", @@ -413,7 +395,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -425,7 +407,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -435,7 +417,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -445,7 +427,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "30", "metadata": {}, "source": [ "### Step 4.1 — Read the certificate\n", @@ -456,7 +438,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -475,7 +457,7 @@ }, { "cell_type": "markdown", - "id": "33", + "id": "32", "metadata": {}, "source": [ "## Step 5 — Submit the inference job\n", @@ -489,7 +471,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -500,7 +482,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -578,7 +560,7 @@ { "cell_type": "code", "execution_count": null, - "id": "36", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -603,7 +585,7 @@ }, { "cell_type": "markdown", - "id": "37", + "id": "36", "metadata": {}, "source": [ "## Step 6 — Approve the inference job\n", @@ -614,7 +596,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "37", "metadata": {}, "outputs": [], "source": [ @@ -625,7 +607,7 @@ { "cell_type": "code", "execution_count": null, - "id": "39", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -635,7 +617,7 @@ { "cell_type": "code", "execution_count": null, - "id": "40", + "id": "39", "metadata": {}, "outputs": [], "source": [ @@ -645,7 +627,7 @@ }, { "cell_type": "markdown", - "id": "41", + "id": "40", "metadata": {}, "source": [ "\"Wait\n" @@ -653,7 +635,7 @@ }, { "cell_type": "markdown", - "id": "42", + "id": "41", "metadata": {}, "source": [ "## Step 7 — View the results\n", @@ -664,7 +646,7 @@ { "cell_type": "code", "execution_count": null, - "id": "43", + "id": "42", "metadata": {}, "outputs": [], "source": [ @@ -677,7 +659,7 @@ { "cell_type": "code", "execution_count": null, - "id": "44", + "id": "43", "metadata": {}, "outputs": [], "source": [ diff --git a/packages/syft-enclave/docs/tinfoil_troubleshooting.md b/packages/syft-enclave/docs/tinfoil_troubleshooting.md index a32a0d0501c..83a4a124720 100644 --- a/packages/syft-enclave/docs/tinfoil_troubleshooting.md +++ b/packages/syft-enclave/docs/tinfoil_troubleshooting.md @@ -18,7 +18,7 @@ for what the attestation does and does not prove, see | The enclave hangs at "Building SyftEnclaveClient" | OAuth token refresh cannot reach `oauth2.googleapis.com`. See the egress note in `tinfoil-config.yml`: `allowlist` resolves hostnames to IPs once at boot and Google rotates them, so use `egress: open`. | | `curl: (60) SSL certificate problem: self signed certificate` | expected. The enclave's TLS key is generated inside it and the report commits to that key, so there is no CA. Use `-k` (as `just tinfoil-attest` does) and get your trust from `just tinfoil-verify`. | | `The server had an error while processing your request.` from the domain | the shim is up but your container is not serving on `upstream-port`. It has probably crashed — `just tinfoil-debug ` then `just tinfoil-logs`. | -| `ValueError: Serialization error: sender fingerprint mismatch` on a data owner's login | once a client encrypts, the keypair is persisted at `//private/crypto_keys.json`. A fresh `SYFTBOX_FOLDER` mints a new identity that cannot verify that account's own history on Drive. Point at the account's existing folder, or wipe its Drive state first. | +| `ValueError: Serialization error: sender fingerprint mismatch` on a data owner's login | the first login persists the keypair at `//private/crypto_keys.json`. A fresh `SYFTBOX_FOLDER` mints a new identity that cannot verify that account's own history on Drive. Point at the account's existing folder, or wipe its Drive state first. | | `upstream port is not set` | the config's `shim.upstream-port` is missing; it is required | | Config rejected: image must be a digest | `image:` uses a tag; it must be `repo@sha256:...` | | A path 404s | it is not in `shim.paths`. `/.well-known/tinfoil-attestation` is exempt | diff --git a/packages/syft-enclave/src/syft_enclaves/login.py b/packages/syft-enclave/src/syft_enclaves/login.py index 85fd9eb7ccc..11e655ee8a5 100644 --- a/packages/syft-enclave/src/syft_enclaves/login.py +++ b/packages/syft-enclave/src/syft_enclaves/login.py @@ -5,6 +5,7 @@ from syft.sync.environments.environment import Environment from syft.sync.login import _init_client_login, _resolve_login_params from syft.sync.login_utils import handle_potential_version_mismatches_on_login +from syft.sync.peers.peer_store import datasite_crypto_keys_path, import_key_file from syft.sync.utils.syftbox_utils import check_env from syft_rds import SyftRDSClientConfig @@ -21,7 +22,7 @@ def _login( has_do_role: bool, has_ds_role: bool, encryption: bool | str = False, - crypto_keys_path: str | Path | None = None, + key_file: str | Path | None = None, ) -> SyftEnclaveClient: """Shared login for enclave-flow participants. @@ -31,8 +32,9 @@ def _login( Keys are persisted per-datasite to ``//private/crypto_keys.json`` (a stable identity - across sessions; never synced to Drive). The client makes them when - ``encryption`` is set, or when a peer first requires encryption. + across sessions; never synced to Drive). The client makes them at the + first login. ``key_file`` restores the keys of an earlier session: it is + copied to that path before the client is built. """ env = check_env() email, token_path = _resolve_login_params(email, token_path) @@ -44,7 +46,6 @@ def _login( has_do_role=has_do_role, has_ds_role=has_ds_role, encryption=encryption, - crypto_keys_path=crypto_keys_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, ) else: @@ -54,12 +55,15 @@ def _login( has_ds_role=has_ds_role, token_path=Path(token_path) if token_path is not None else None, encryption=encryption, - crypto_keys_path=crypto_keys_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, ) - # Encryption keys (when enabled) are resolved inside from_config via the - # config's peer_manager_config. + if key_file is not None: + import_key_file( + key_file, + datasite_crypto_keys_path(config.sync.syftbox_folder, email), + email, + ) client = SyftEnclaveClient.from_config(config) # Reuses syft's login init: verifies the token authenticates as @@ -81,7 +85,7 @@ def login_do( load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, encryption: bool | str = False, - crypto_keys_path: str | Path | None = None, + key_file: str | Path | None = None, ) -> SyftEnclaveClient: """Log in a data owner for an enclave computation. @@ -89,6 +93,9 @@ def login_do( it, the client encrypts with each peer that requires it, such as an enclave. Set ``encryption="off"`` to never encrypt, e.g. against a debug enclave; a peer that requires encryption is then skipped. + + ``key_file`` is the ``crypto_keys.json`` of an earlier session, e.g. an + uploaded file on Colab. Without it, the first login makes new keys. """ return _login( email=email, @@ -99,7 +106,7 @@ def login_do( has_do_role=True, has_ds_role=True, encryption=encryption, - crypto_keys_path=crypto_keys_path, + key_file=key_file, ) @@ -110,7 +117,7 @@ def login_ds( load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, encryption: bool | str = False, - crypto_keys_path: str | Path | None = None, + key_file: str | Path | None = None, ) -> SyftEnclaveClient: """Log in a data scientist for an enclave computation. @@ -118,6 +125,9 @@ def login_ds( it, the client encrypts with each peer that requires it, such as an enclave. Set ``encryption="off"`` to never encrypt, e.g. against a debug enclave; a peer that requires encryption is then skipped. + + ``key_file`` is the ``crypto_keys.json`` of an earlier session, e.g. an + uploaded file on Colab. Without it, the first login makes new keys. """ return _login( email=email, @@ -128,5 +138,5 @@ def login_ds( has_do_role=False, has_ds_role=True, encryption=encryption, - crypto_keys_path=crypto_keys_path, + key_file=key_file, ) diff --git a/packages/syft-enclave/src/syft_enclaves/runner.py b/packages/syft-enclave/src/syft_enclaves/runner.py index 6969be7daed..4d1dfd6553f 100644 --- a/packages/syft-enclave/src/syft_enclaves/runner.py +++ b/packages/syft-enclave/src/syft_enclaves/runner.py @@ -213,8 +213,6 @@ def _publish_key_bundle(self, claims: Optional[dict] = None) -> None: logger.warning("Could not publish the public key bundle: %s", e) def _keys_path(self) -> Path: - # The per-datasite default location; the enclave never overrides - # crypto_keys_path, and PeerManager does not carry the resolved value. peer_manager = self.client._rds.peer_manager return datasite_crypto_keys_path( peer_manager.syftbox_folder, peer_manager.peer_store.email diff --git a/packages/syft-enclave/tests/test_login.py b/packages/syft-enclave/tests/test_login.py new file mode 100644 index 00000000000..7066928274f --- /dev/null +++ b/packages/syft-enclave/tests/test_login.py @@ -0,0 +1,36 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import syft.sync.login +from syft.sync.environments.environment import Environment +from syft.sync.peers.peer_store import PeerStore +from syft_enclaves import login as enclave_login + +EMAIL = "do@openmined.org" + + +def test_login_restores_uploaded_key_file(tmp_path, monkeypatch): + uploaded = tmp_path / "upload" / "crypto_keys.json" + earlier = PeerStore.create(email=EMAIL, keys_path=uploaded) + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + for module in (enclave_login, syft.sync.login): + monkeypatch.setattr(module, "check_env", lambda: Environment.JUPYTER) + monkeypatch.setattr( + enclave_login, + "handle_potential_version_mismatches_on_login", + lambda email, token_path: False, + ) + monkeypatch.setattr(enclave_login, "_init_client_login", lambda *a, **kw: None) + configs = [] + monkeypatch.setattr( + enclave_login.SyftEnclaveClient, + "from_config", + classmethod(lambda cls, config: configs.append(config) or MagicMock()), + ) + + enclave_login.login_do(EMAIL, token_path=tmp_path / "token.json", key_file=uploaded) + + keys_path = configs[0].sync.peer_manager_config.crypto_keys_path + restored = PeerStore.create(email=EMAIL, keys_path=keys_path) + assert restored.get_public_bundle() == earlier.get_public_bundle() diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 311cf002cff..15737a102f3 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -443,6 +443,31 @@ def create( return store +def import_key_file(source: Path | str, keys_path: Path | str, email: str) -> None: + """Copy the key file ``source`` of ``email`` to ``keys_path``. + + Use it to restore the keys of an earlier session, e.g. an uploaded key file + on Colab, before the client is built. When ``keys_path`` holds the same + keys already, nothing changes. + + Raises: + ValueError: if ``source`` has another version or belongs to another + identity. + FileExistsError: if ``keys_path`` holds other keys. + """ + source, keys_path = Path(source), Path(keys_path) + _read_private_keys(source, email) + data = json.loads(source.read_text()) + if keys_path.exists(): + if json.loads(keys_path.read_text()) != data: + raise FileExistsError( + f"{keys_path} holds other keys than {source}. Remove it to " + "import the file, or log in without the file." + ) + return + _create_key_file(keys_path, data) + + def _read_private_keys(path: Path, email: str) -> syc.SyftPrivateKeys: data = json.loads(path.read_text()) if data["version"] != CRYPTO_KEYS_VERSION: diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index 89597eba33f..955dd0eecb4 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -137,7 +137,6 @@ def for_colab( has_do_role: bool = False, encryption: bool | str = False, persist_owner_state: bool = True, - crypto_keys_path: Path | None = None, skip_peer_on_patch_version_diff: Optional[ bool ] = None, # None: value is determined by the role @@ -193,7 +192,6 @@ def for_colab( has_do_role=has_do_role, has_ds_role=has_ds_role, encryption_policy=EncryptionPolicy.coerce(encryption), - crypto_keys_path=crypto_keys_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, force_ignore_peer_version=force_ignore_peer_version, ) @@ -219,7 +217,6 @@ def for_jupyter( token_path: Path | None = None, encryption: bool | str = False, persist_owner_state: bool = True, - crypto_keys_path: Path | None = None, skip_peer_on_patch_version_diff: Optional[ bool ] = None, # None: value is determined by the role @@ -279,7 +276,6 @@ def for_jupyter( has_do_role=has_do_role, has_ds_role=has_ds_role, encryption_policy=EncryptionPolicy.coerce(encryption), - crypto_keys_path=crypto_keys_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, force_ignore_peer_version=force_ignore_peer_version, ) @@ -623,7 +619,6 @@ def for_colab( has_do_role: bool = False, encryption: bool | str = False, persist_owner_state: bool = True, - crypto_keys_path: Path | None = None, skip_peer_on_patch_version_diff: Optional[ bool ] = None, # None: value is determined by the role @@ -636,7 +631,6 @@ def for_colab( has_do_role=has_do_role, encryption=encryption, persist_owner_state=persist_owner_state, - crypto_keys_path=crypto_keys_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, force_ignore_peer_version=force_ignore_peer_version, ) @@ -652,7 +646,6 @@ def for_jupyter( token_path: Path | None = None, encryption: bool | str = False, persist_owner_state: bool = True, - crypto_keys_path: Path | None = None, skip_peer_on_patch_version_diff: Optional[ bool ] = None, # None: value is determined by the role @@ -668,7 +661,6 @@ def for_jupyter( token_path=token_path, encryption=encryption, persist_owner_state=persist_owner_state, - crypto_keys_path=crypto_keys_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, force_ignore_peer_version=force_ignore_peer_version, ) diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 03d77dfecfc..3f4de273bff 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -113,24 +113,16 @@ class PeerManagerConfig(BaseModel): has_do_role: bool = False has_ds_role: bool = False encryption_policy: EncryptionPolicy = EncryptionPolicy.OPTIONAL - # Explicit override for the encryption key file. When None, falls back to - # ``default_crypto_keys_path`` (the per-datasite location). - crypto_keys_path: Path | None = None skip_peer_on_patch_version_diff: Optional[bool] = ( None # None: value is determined by the role (resolves to has_do_role) ) @property - def default_crypto_keys_path(self) -> Path: - """Default per-datasite encryption key file for this participant + def crypto_keys_path(self) -> Path: + """Per-datasite encryption key file for this participant (``//private/crypto_keys.json``).""" return datasite_crypto_keys_path(self.syftbox_folder, self.email) - @property - def resolved_crypto_keys_path(self) -> Path: - """The explicit ``crypto_keys_path`` override if given, else the default.""" - return self.crypto_keys_path or self.default_crypto_keys_path - @model_validator(mode="after") def _default_skip_peer_on_patch(self) -> "PeerManagerConfig": if self.skip_peer_on_patch_version_diff is None: @@ -207,7 +199,7 @@ def from_config(cls, config: PeerManagerConfig, email: str = "") -> "PeerManager peer_store = PeerStore.create( email=resolved_email, encryption_policy=config.encryption_policy, - keys_path=config.resolved_crypto_keys_path, + keys_path=config.crypto_keys_path, ) connection_router = ConnectionRouter.from_configs( resolved_email, config.connection_configs diff --git a/tests/unit/test_encryption.py b/tests/unit/test_encryption.py index d012efbcf68..3677a7959e2 100644 --- a/tests/unit/test_encryption.py +++ b/tests/unit/test_encryption.py @@ -8,7 +8,7 @@ from syft.sync.encryption_policy import EncryptionPolicy from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.peers.peer import Peer, PeerState -from syft.sync.peers.peer_store import PeerStore +from syft.sync.peers.peer_store import PeerStore, import_key_file from syft.sync.syftbox_manager import SyftboxManager from tests.unit.test_sync_manager import path_for_job from tests.unit.utils import grant_job_inbox_access @@ -180,6 +180,49 @@ def test_key_file_holds_identity_only(tmp_path): assert not reloaded.has_peer_bundle("bob@example.com") +def _session_key_file(tmp_path, email="alice@example.com"): + path = tmp_path / "upload" / "crypto_keys.json" + return path, PeerStore.create(email=email, keys_path=path) + + +def test_imported_key_file_restores_keys(tmp_path): + uploaded, earlier = _session_key_file(tmp_path) + keys_path = tmp_path / "SyftBox" / "crypto_keys.json" + + import_key_file(uploaded, keys_path, "alice@example.com") + + restored = PeerStore.create(email="alice@example.com", keys_path=keys_path) + assert restored.get_public_bundle() == earlier.get_public_bundle() + + +def test_import_same_key_file_twice(tmp_path): + uploaded, _ = _session_key_file(tmp_path) + keys_path = tmp_path / "SyftBox" / "crypto_keys.json" + import_key_file(uploaded, keys_path, "alice@example.com") + + import_key_file(uploaded, keys_path, "alice@example.com") + + +def test_import_key_file_of_another_identity_refused(tmp_path): + uploaded, _ = _session_key_file(tmp_path, email="bob@example.com") + keys_path = tmp_path / "SyftBox" / "crypto_keys.json" + + with pytest.raises(ValueError, match="belongs to"): + import_key_file(uploaded, keys_path, "alice@example.com") + assert not keys_path.exists() + + +def test_import_never_replaces_other_keys(tmp_path): + uploaded, _ = _session_key_file(tmp_path) + keys_path = tmp_path / "SyftBox" / "crypto_keys.json" + PeerStore.create(email="alice@example.com", keys_path=keys_path) + on_disk = keys_path.read_text() + + with pytest.raises(FileExistsError): + import_key_file(uploaded, keys_path, "alice@example.com") + assert keys_path.read_text() == on_disk + + # ========================================================================= # Full DS→DO→DS flow with encryption via mock drive # ========================================================================= From 4a128ab208fb510d7ec9890c49bb5dee3fe7a8dd Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Fri, 23 Oct 2026 17:43:43 -0300 Subject: [PATCH 20/30] refactor: send nothing until the peer policy is known Remove the hold and retry code. A message that cannot go now is dropped with a warning. A DO inbox message that cannot be read stays in the inbox. The format of a message must match the pair, and a required client always decrypts its own files. --- syft/sync/connections/connection_router.py | 126 +++------ .../connections/drive/gdrive_transport.py | 8 +- syft/sync/encryption_policy.py | 45 +--- syft/sync/peers/peer_store.py | 109 +++----- syft/sync/syftbox_manager.py | 12 - .../sync/caches/datasite_watcher_cache.py | 22 +- syft/sync/sync/datasite_owner_syncer.py | 72 ++--- syft/sync/sync/datasite_watcher_syncer.py | 62 ++--- tests/unit/test_encryption.py | 10 + tests/unit/test_encryption_policy.py | 250 ++++-------------- tests/unit/test_peer_requests.py | 14 +- 11 files changed, 212 insertions(+), 518 deletions(-) diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index 336178eec8b..f04bac8acc3 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -1,6 +1,6 @@ import logging import warnings -from typing import TYPE_CHECKING, Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional from pydantic import BaseModel, PrivateAttr from syft_migration import MigratableObject, ProtocolSchema @@ -19,14 +19,13 @@ ) from syft.sync.encryption_policy import ( EncryptionPolicyConflictError, - PeerEncryptionPendingError, - UnencryptedMessageError, + MessageFormatError, ) from syft.sync.events.file_change_event import ( FileChangeEventsMessage, - FileChangeEventsMessageFileName, ) from syft.sync.messages.proposed_filechange import ProposedFileChangesMessage +from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.peers.peer import Peer, PeerState from syft.sync.peers.peer_store import PeerStore from syft.sync.transport.naming import PEERS_META_KEY @@ -53,14 +52,6 @@ class ConnectionRouter(BaseModel): # live map here (updated in place as peer version files load), so outgoing # messages downgrade to what each peer reads. _peer_schemas: Dict[str, ProtocolSchema] = PrivateAttr(default_factory=dict) - # Reads the version file of a peer and updates what depends on it (the - # peer, its policy, the protocol map). Set by SyftboxManager to - # ``PeerManager.load_peer_version``. - _version_loader: Optional[Callable[[str], object]] = PrivateAttr(default=None) - - def set_version_loader(self, version_loader: Callable[[str], object]) -> None: - self._version_loader = version_loader - # The transport jobs over connections[0], rebuilt if that connection is # replaced (tests swap in a mock connection with add_connection). _transport: Optional[Transport] = PrivateAttr(default=None) @@ -162,26 +153,9 @@ def _downgrade_for_peer( target = client_registry.schema_for_protocol_version(protocol_version) return client_migration_service.migrate_to_schema(message, target) - def _refresh_unknown_peer(self, peer_email: str) -> None: - """Read the version file of ``peer_email`` again when its policy is not known. - - The peer can have accepted since the last load. It runs at most once - per peer load, and before a message is converted for the peer. - """ - if self.peer_store.peer_policy_known(peer_email): - return - peer = self.peer_store.get_cached_peer(peer_email) - if peer is None or not self.peer_store.take_policy_reread(peer_email): - return - if self._version_loader is not None: - self._version_loader(peer_email) - else: - peer.version = self.read_peer_version_file(peer_email) - def watcher_send_proposed_file_changes_message( self, recipient: str, proposed_file_changes_message: ProposedFileChangesMessage ): - self._refresh_unknown_peer(recipient) proposed_file_changes_message = self._downgrade_for_peer( proposed_file_changes_message, recipient ) @@ -193,11 +167,15 @@ def watcher_send_proposed_file_changes_message( def owner_get_next_proposed_filechange_message( self, sender_email: str ) -> ProposedFileChangesMessage | None: - """The next message from ``sender_email`` that can be read, or None. + """The next message from ``sender_email``, or None when the inbox is empty. - A plaintext message to a pair that encrypts is archived unread, with a - warning. None also means the messages of this peer wait (see - ``_decrypt_or_wait``). + A message whose format does not match the pair is archived unread, + with a warning. + + Raises: + SyftPeerNotReadyError: when the message cannot be read yet. It + stays in the inbox. + EncryptionPolicyConflictError: when the policies of the pair conflict. """ connection = self.connection_for_receive_message() while True: @@ -208,17 +186,13 @@ def owner_get_next_proposed_filechange_message( return None raw_data, file_id = result try: - decrypted = self._decrypt_or_wait(sender_email, raw_data) - except UnencryptedMessageError as e: - warnings.warn(f"Archived message file {file_id} unread: {e}") - msg = ProposedFileChangesMessage.from_compressed_data(raw_data) - msg.platform_id = file_id - connection.owner_remove_proposed_filechange_message_from_inbox( - msg, sender_email + decrypted = self.peer_store.decrypt_and_verify_if_needed( + sender_email, raw_data ) + except MessageFormatError as e: + warnings.warn(f"Archived message file {file_id} unread: {e}") + connection.owner_archive_inbox_file(file_id, sender_email) continue - if decrypted is None: - return None msg = ProposedFileChangesMessage.from_compressed_data(decrypted) msg.platform_id = file_id return msg @@ -226,7 +200,6 @@ def owner_get_next_proposed_filechange_message( def owner_write_event_messages_to_outbox( self, recipient_email: str, events_message: FileChangeEventsMessage ): - self._refresh_unknown_peer(recipient_email) events_message = self._downgrade_for_peer(events_message, recipient_email) data = events_message.as_compressed_data() data = self.peer_store.encrypt_if_needed(recipient_email, data) @@ -236,62 +209,34 @@ def owner_write_event_messages_to_outbox( def watcher_get_events_messages( self, peer_email: str, since_timestamp: float | None ) -> List[FileChangeEventsMessage]: - """The outbox messages of ``peer_email``, up to the first that cannot be read yet.""" + """The outbox messages of ``peer_email``, without the skipped ones.""" connection = self.connection_for_datasite_watcher() - messages = [] - for meta in connection.watcher_get_outbox_file_metadatas( + raw_list = connection.watcher_download_raw_events_from_outbox( peer_email, since_timestamp - ): - message = self.watcher_read_events_message(connection, peer_email, meta) - if message is None: - break - messages.append(message) - return messages + ) + messages = [ + self.watcher_read_events_message(peer_email, raw) for raw in raw_list + ] + return [m for m in messages if m is not None] def watcher_read_events_message( - self, - connection: SyftboxPlatformConnection, - peer_email: str, - file_meta: dict, + self, peer_email: str, raw: bytes ) -> FileChangeEventsMessage | None: - """Download, verify and decrypt one outbox events message. + """Verify, decrypt and parse one outbox message of ``peer_email``. - ``file_meta`` holds its ``file_id`` and ``file_name``. Returns None - when the message cannot be read yet (see ``_decrypt_or_wait``). A - plaintext message to a pair that encrypts is skipped with a warning: - an empty message takes its place, so later messages apply. + Returns None when the message is skipped, with a warning: its format + does not match the pair, or it cannot be read now. """ - raw = connection.download_file(file_meta["file_id"]) try: - raw = self._decrypt_or_wait(peer_email, raw) - except UnencryptedMessageError as e: - warnings.warn(f"Skipped outbox message {file_meta['file_name']}: {e}") - return FileChangeEventsMessage( - events=[], - message_filepath=FileChangeEventsMessageFileName.from_string( - file_meta["file_name"] - ), - ) - if raw is None: + data = self.peer_store.decrypt_and_verify_if_needed(peer_email, raw) + except ( + MessageFormatError, + SyftPeerNotReadyError, + EncryptionPolicyConflictError, + ) as e: + warnings.warn(f"Skipped an outbox message of {peer_email}: {e}") return None - return FileChangeEventsMessage.from_compressed_data(raw) - - def _decrypt_or_wait(self, peer_email: str, raw: bytes) -> bytes | None: - """Verify and decrypt an inbox or outbox message from ``peer_email``. - - Returns None when the message cannot be read yet; the messages of this - peer then wait, so they keep their order. - - Raises: - UnencryptedMessageError: when the pair encrypts and ``raw`` is - plaintext. - """ - try: - return self.peer_store.decrypt_and_verify_if_needed(peer_email, raw) - except (PeerEncryptionPendingError, EncryptionPolicyConflictError) as e: - # A conflict found here is new since the peer filter of this sync. - warnings.warn(f"Messages from {peer_email} wait: {e}") - return None + return FileChangeEventsMessage.from_compressed_data(data) def watcher_get_outbox_file_metadatas( self, peer_email: str, since_timestamp: float | None @@ -457,7 +402,6 @@ def owner_upload_collection_files( ) -> None: """Upload collection files, encrypting each file if encryption is enabled.""" if recipient_email and self.peer_store: - self._refresh_unknown_peer(recipient_email) files = { name: self.peer_store.encrypt_if_needed(recipient_email, data) for name, data in files.items() diff --git a/syft/sync/connections/drive/gdrive_transport.py b/syft/sync/connections/drive/gdrive_transport.py index 82bfe576bfa..065da9fb78c 100644 --- a/syft/sync/connections/drive/gdrive_transport.py +++ b/syft/sync/connections/drive/gdrive_transport.py @@ -855,14 +855,18 @@ def owner_remove_proposed_filechange_message_from_inbox( raise ValueError( f"Event {fname} not found in inbox, event should already be created for this type of connection" ) + self.owner_archive_inbox_file(gdrive_id, sender_email) + + def owner_archive_inbox_file(self, file_id: str, sender_email: str) -> None: + """Move the inbox file ``file_id`` from ``sender_email`` to its archive folder.""" file_info = execute_with_retries( - self.drive_service.files().get(fileId=gdrive_id, fields="parents") + self.drive_service.files().get(fileId=file_id, fields="parents") ) previous_parents = ",".join(file_info.get("parents", [])) archive_folder_id = self.owner_get_archive_folder_id(sender_email) execute_with_retries( self.drive_service.files().update( - fileId=gdrive_id, + fileId=file_id, addParents=archive_folder_id, removeParents=previous_parents, fields="id, parents", diff --git a/syft/sync/encryption_policy.py b/syft/sync/encryption_policy.py index f23bce8c1f5..114bda0e2c6 100644 --- a/syft/sync/encryption_policy.py +++ b/syft/sync/encryption_policy.py @@ -30,36 +30,6 @@ def coerce(cls, value: "bool | str | EncryptionPolicy") -> "EncryptionPolicy": return cls(value) -class PeerEncryptionPendingError(ValueError): - """A message to a peer cannot go yet. A later sync sends it.""" - - -class PeerEncryptionPolicyUnknownError(PeerEncryptionPendingError): - """The policy of a peer is not known, so the client cannot select a format.""" - - def __init__(self, peer_email: str): - super().__init__( - f"The encryption policy of {peer_email} is not known. The peer has " - "not shared its version file yet. Wait until the peer accepts the " - "peer request, then send again." - ) - self.peer_email = peer_email - - -class PeerKeysNotReadyError(PeerEncryptionPendingError): - """The pair encrypts, but the peer bundle is missing. - - ``load_peers`` reads the bundle. - """ - - def __init__(self, peer_email: str): - super().__init__( - f"Encryption with {peer_email} is not set up yet. The next sync " - "sets it up and sends the message." - ) - self.peer_email = peer_email - - class EncryptionPolicyConflictError(ValueError): """One side of the pair has encryption off and the other requires it.""" @@ -84,14 +54,15 @@ def __init__(self, peer_email: str): ) -class UnencryptedMessageError(ValueError): - """A client that requires encryption received plaintext.""" +class MessageFormatError(ValueError): + """A message is plaintext in a pair that encrypts, or the other way around.""" - def __init__(self, peer_email: str): - super().__init__( - f"A message from {peer_email} is not encrypted, and the pair " - "requires encryption." - ) + def __init__(self, peer_email: str, encrypted: bool): + if encrypted: + detail = "is encrypted, and the pair does not encrypt" + else: + detail = "is not encrypted, and the pair encrypts" + super().__init__(f"A message from {peer_email} {detail}.") self.peer_email = peer_email diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 15737a102f3..930c68721a7 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -16,10 +16,8 @@ from syft.sync.encryption_policy import ( EncryptionPolicy, EncryptionPolicyConflictError, - PeerEncryptionPolicyUnknownError, - PeerKeysNotReadyError, PeerOutdatedError, - UnencryptedMessageError, + MessageFormatError, pair_conflicts, pair_uses_encryption, ) @@ -53,8 +51,6 @@ class PeerStore(BaseModel): _private_keys: syc.SyftPrivateKeys | None = PrivateAttr(default=None) _peers: List[Peer] = PrivateAttr(default_factory=list) - # Peers whose version file was read again since the last ``set_peers``. - _policy_rereads: set[str] = PrivateAttr(default_factory=set) # The last policy known of each peer in this process (see peer_policy). _last_known_policies: dict[str, EncryptionPolicy] = PrivateAttr( default_factory=dict @@ -97,52 +93,36 @@ def encrypt_if_needed(self, email: str, data: bytes) -> bytes: """Encrypt ``data`` for ``email`` when the pair encrypts. Raises: - PeerEncryptionPolicyUnknownError: when this client is optional and - the policy of the peer is not known. - PeerKeysNotReadyError: when the pair encrypts, but the peer bundle - is missing. + SyftPeerNotReadyError: when the policy of the peer is not known, or + the pair encrypts and the peer bundle is missing. EncryptionPolicyConflictError: when the policies of the pair conflict. """ - error = self._cached_conflict_error(email) - if error is not None: - raise error - if not self.peer_policy_known(email): - raise PeerEncryptionPolicyUnknownError(email) + self._raise_unless_can_exchange(email) if not self.peer_uses_encryption(email): return data - if not self.has_peer_bundle(email): - raise PeerKeysNotReadyError(email) return self.encrypt(email, data) def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: - """Verify and decrypt an envelope from ``email``; pass plaintext through. + """Verify and decrypt a message from ``email`` when the pair encrypts. - When the pair encrypts, plaintext is refused, so that a peer or the - storage provider cannot downgrade the pair. While the peer policy is - not known, plaintext waits, as a send does. When the pair does not - encrypt, an envelope is still accepted: the peer can have started to - require encryption after the last load. + The format of ``data`` must match the pair. Plaintext in a pair that + encrypts is refused, so that a peer or the storage provider cannot + downgrade the pair. Raises: - PeerEncryptionPolicyUnknownError: when ``data`` is plaintext and - the peer policy is not known. - UnencryptedMessageError: when the pair encrypts and ``data`` is - plaintext. - PeerKeysNotReadyError: when ``data`` is an envelope, but the peer - bundle is missing. + SyftPeerNotReadyError: when the policy of the peer is not known, or + the pair encrypts and the peer bundle is missing. + MessageFormatError: when ``data`` is plaintext and the pair + encrypts, or ``data`` is an envelope and the pair does not. EncryptionPolicyConflictError: when the policies of the pair conflict. """ - error = self._cached_conflict_error(email) - if error is not None: - raise error - if not self.is_envelope(data): - if not self.peer_policy_known(email): - raise PeerEncryptionPolicyUnknownError(email) - if self.peer_uses_encryption(email): - raise UnencryptedMessageError(email) + self._raise_unless_can_exchange(email) + encrypts = self.peer_uses_encryption(email) + encrypted = self.is_envelope(data) + if encrypted != encrypts: + raise MessageFormatError(email, encrypted) + if not encrypts: return data - if not self.has_peer_bundle(email): - raise PeerKeysNotReadyError(email) self.verify_message(email, data) return self.decrypt(email, data) @@ -185,13 +165,6 @@ def policy_of(self, peer: Peer) -> Optional[EncryptionPolicy]: self._last_known_policies[peer.email] = policy return policy - def peer_policy_known(self, email: str) -> bool: - """Whether this client can select the format for ``email``. - - A required client encrypts with every peer, so it needs no peer policy. - """ - return self.use_encryption or self.peer_policy(email) is not None - def conflict_error(self, peer: Peer) -> EncryptionPolicyConflictError | None: """Why this client and ``peer`` cannot exchange messages, or None when they can. @@ -204,11 +177,26 @@ def conflict_error(self, peer: Peer) -> EncryptionPolicyConflictError | None: return EncryptionPolicyConflictError(peer.email) return None - def _cached_conflict_error( - self, email: str - ) -> EncryptionPolicyConflictError | None: + def _raise_unless_can_exchange(self, email: str) -> None: + """Raise unless the policy of ``email`` is known and does not conflict. + + Raises: + EncryptionPolicyConflictError: when the policies of the pair conflict. + SyftPeerNotReadyError: when the policy of the peer is not known. + """ peer = self.get_cached_peer(email) - return None if peer is None else self.conflict_error(peer) + error = None if peer is None else self.conflict_error(peer) + if error is not None: + raise error + if peer is None or self.policy_of(peer) is None: + raise SyftPeerNotReadyError( + email, + cause=f"The encryption policy of {email} is not known yet.", + remedy=( + "Peer with them and wait until they accept. Then run " + "client.sync() and send again." + ), + ) def peer_uses_encryption(self, email: str) -> bool: """Whether the pair with ``email`` encrypts. False when the peer policy is not known.""" @@ -226,22 +214,6 @@ def add_peer(self, peer: Peer) -> None: def set_peers(self, peers: List[Peer]) -> None: self._peers = peers - self._policy_rereads.clear() - - def should_hold(self, recipient: str) -> bool: - """Whether a message to ``recipient`` waits while encryption is pending. - - Only a peer waits. Any other recipient never gets a policy, so a - message to it would wait forever. - """ - return self.get_cached_peer(recipient) is not None - - def take_policy_reread(self, email: str) -> bool: - """True once per peer load: the caller may read the version file again.""" - if email in self._policy_rereads: - return False - self._policy_rereads.add(email) - return True # ========== Ensure helpers ========== @@ -384,11 +356,8 @@ def encrypt_for_self_if_needed(self, data: bytes) -> bytes: return data def decrypt_and_verify_for_self_if_needed(self, data: bytes) -> bytes: - """Verify and decrypt an envelope this client wrote for itself; pass plaintext through. - - Files written while this client was optional stay plaintext. - """ - if self.is_envelope(data): + """Verify and decrypt data for self if this client requires encryption.""" + if self.use_encryption: self.verify_message_from_self(data) return self.decrypt_for_self(data) return data diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index 955dd0eecb4..ebd202c505c 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -568,7 +568,6 @@ def from_config(cls, config: SyftboxManagerConfig): # Every router that sends peer-directed messages downgrades them to the # peer's negotiated syft protocol, so each one gets the live map. manager_res._set_peer_schemas(peer_manager.live_peer_schemas("syft")) - manager_res._set_version_loader(peer_manager.load_peer_version) return manager_res @@ -584,17 +583,6 @@ def _set_peer_schemas(self, peer_schemas) -> None: peer_schemas ) - def _set_version_loader(self, version_loader) -> None: - """Wire PeerManager's version reload into all routers that send.""" - if self.datasite_owner_syncer: - self.datasite_owner_syncer.connection_router.set_version_loader( - version_loader - ) - if self.datasite_watcher_syncer: - self.datasite_watcher_syncer.connection_router.set_version_loader( - version_loader - ) - def _set_peer_store(self, peer_store) -> None: """Wire shared peer_store into all connection routers.""" from syft.sync.peers.peer_store import PeerStore diff --git a/syft/sync/sync/caches/datasite_watcher_cache.py b/syft/sync/sync/caches/datasite_watcher_cache.py index bdffe7e565b..3d9b96865b8 100644 --- a/syft/sync/sync/caches/datasite_watcher_cache.py +++ b/syft/sync/sync/caches/datasite_watcher_cache.py @@ -241,14 +241,12 @@ def sync_down_parallel( self, peer_email: str, executor: ThreadPoolExecutor, - download_fn: Callable[[dict], FileChangeEventsMessage | None], + download_fn: Callable[[str], FileChangeEventsMessage | None], ) -> int: """Sync with parallel file downloads. Returns the number of events applied. - ``download_fn`` gets the metadata of one file. It returns None for a - message that cannot be read yet. Messages from its timestamp on are not - applied, and the cursor stays before it, so they apply in order when it - can be read. + ``download_fn`` gets the id of one file. It returns None for a message + that it skips. """ peer_timestamp = self.last_event_timestamp_per_peer.get(peer_email) @@ -264,16 +262,14 @@ def sync_down_parallel( return 0 # Download all files in parallel - downloaded_messages = list(executor.map(download_fn, file_metadatas)) + file_ids = [m["file_id"] for m in file_metadatas] + downloaded_messages = [ + m for m in executor.map(download_fn, file_ids) if m is not None + ] - # Apply in timestamp order, up to the first message that cannot be read + # Apply in timestamp order event_count = 0 - by_timestamp = sorted( - zip(file_metadatas, downloaded_messages), key=lambda x: x[0]["timestamp"] - ) - for _, event_message in by_timestamp: - if event_message is None: - break + for event_message in sorted(downloaded_messages, key=lambda x: x.timestamp): self.apply_event_message(event_message, peer_email) self.last_event_timestamp_per_peer[peer_email] = event_message.timestamp event_count += len(event_message.events) diff --git a/syft/sync/sync/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py index 1d27bc7814b..1465d4af2d5 100644 --- a/syft/sync/sync/datasite_owner_syncer.py +++ b/syft/sync/sync/datasite_owner_syncer.py @@ -21,10 +21,8 @@ DataSiteOwnerEventCacheConfig, ) from syft.sync.connections.connection_router import ConnectionRouter -from syft.sync.encryption_policy import ( - EncryptionPolicyConflictError, - PeerEncryptionPendingError, -) +from syft.sync.encryption_policy import EncryptionPolicyConflictError +from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.sync.caches.datasite_owner_cache import DataSiteOwnerEventCache from syft.sync.sync.collection_spec import CollectionSyncSpec from syft.sync.callback_mixin import BaseModelCallbackMixin @@ -232,14 +230,17 @@ def sync( # first, pull existing state for peer_email in peer_emails: proposed_count = 0 - while True: - msg = self.pull_and_process_next_proposed_filechange( - peer_email, raise_on_none=False - ) - if msg is None: - # no new message, we are done - break - proposed_count += len(msg.proposed_file_changes) + try: + while True: + msg = self.pull_and_process_next_proposed_filechange( + peer_email, raise_on_none=False + ) + if msg is None: + # no new message, we are done + break + proposed_count += len(msg.proposed_file_changes) + except (SyftPeerNotReadyError, EncryptionPolicyConflictError) as e: + warnings.warn(f"Messages from {peer_email} stay in the inbox: {e}") if proposed_count: print( f"Received {proposed_count} proposed change(s) " @@ -881,39 +882,17 @@ def process_syftbox_events_queue(self): self.connection_router.owner_write_events_message_to_syftbox( file_change_events_message ) - # A message that waits is not cleared: its paths stay pending, and a - # later sync sends their current state. Later messages to the same - # recipient wait too, so the order stays the same. - waiting_for = set() while not self.outbox_queue.empty(): recipient, message = self.outbox_queue.get() - if recipient in self._deferred_peer_emails or recipient in waiting_for: + if recipient in self._deferred_peer_emails: continue - if self._send_outbox_message(recipient, message): - self._clear_delivered(recipient, message) - else: - waiting_for.add(recipient) - - def _send_outbox_message( - self, recipient: str, message: FileChangeEventsMessage - ) -> bool: - """Write one outbox message. - - Returns False when the message waits for encryption with a peer, or - for a policy conflict to end. Returns True when it is written, or - dropped because ``recipient`` is not a peer. - """ - try: - self.connection_router.owner_write_event_messages_to_outbox( - recipient, message - ) - except (PeerEncryptionPendingError, EncryptionPolicyConflictError) as e: - if not self.connection_router.peer_store.should_hold(recipient): - warnings.warn(f"Dropped an outbox message for {recipient}: not a peer") - return True - warnings.warn(f"Changes for {recipient} wait for a later sync: {e}") - return False - return True + try: + self.connection_router.owner_write_event_messages_to_outbox( + recipient, message + ) + except (SyftPeerNotReadyError, EncryptionPolicyConflictError) as e: + warnings.warn(f"Dropped an outbox message for {recipient}: {e}") + self._clear_delivered(recipient, message) def _download_outbox_message_with_new_connection( self, file_id: str @@ -934,13 +913,10 @@ def compact_outbox_if_needed( deletes the originals. Returns the number of source messages compacted (0 if below threshold, - if no outbox folder, or if peer encryption is on or the peer policy is - not known — DO can't read peer-encrypted bytes back to merge). + if no outbox folder, or if peer encryption is on — DO can't read + peer-encrypted bytes back to merge). """ - peer_store = self.connection_router.peer_store - if not peer_store.peer_policy_known( - recipient_email - ) or peer_store.peer_uses_encryption(recipient_email): + if self.connection_router.peer_store.peer_uses_encryption(recipient_email): logger.debug( f"outbox compaction for {recipient_email} skipped — " f"peer encryption is not supported yet" diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index 6346151965c..81eb8d4fbdb 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -9,11 +9,9 @@ from syft.sync.connections.base_connection import ConnectionConfig from syft.sync.connections.connection_router import ConnectionRouter from syft.sync.callback_mixin import BaseModelCallbackMixin -from syft.sync.encryption_policy import ( - EncryptionPolicyConflictError, - PeerEncryptionPendingError, -) +from syft.sync.encryption_policy import EncryptionPolicyConflictError from syft.sync.events.file_change_event import FileChangeEventsMessage +from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.messages.proposed_filechange import ( ProposedFileChange, ProposedFileChangesMessage, @@ -81,7 +79,6 @@ def get_proposed_file_change_object( def process_file_changes_queue(self): # One message per datasite: each change goes to the owner of its path. changes_by_recipient: Dict[str, List[ProposedFileChange]] = {} - items_by_recipient: Dict[str, list] = {} while not self.queue.empty(): relative_path, content = self.queue.get() @@ -105,47 +102,24 @@ def process_file_changes_queue(self): path_in_datasite, content, datasite_email=recipient ) changes_by_recipient.setdefault(recipient, []).append(file_change) - items_by_recipient.setdefault(recipient, []).append( - (relative_path, content) - ) - self._send_by_recipient(changes_by_recipient, items_by_recipient) + self._send_by_recipient(changes_by_recipient) def _send_by_recipient( - self, - changes_by_recipient: Dict[str, List[ProposedFileChange]], - items_by_recipient: Dict[str, list], + self, changes_by_recipient: Dict[str, List[ProposedFileChange]] ) -> None: - """Send one message to each recipient; put pending ones back in the queue. + """Send one message to each recipient. - ``items_by_recipient`` holds the queue items each message was built - from. The other recipients still get their changes when one fails. - - Raises: - PeerEncryptionPendingError: when a recipient is not a peer. - EncryptionPolicyConflictError: when the policies of a pair conflict. + A recipient that cannot receive now is skipped with a warning, and its + changes are dropped. The other recipients still get their changes. """ - error = None for recipient, changes in changes_by_recipient.items(): message = ProposedFileChangesMessage( sender_email=self.email, proposed_file_changes=changes ) try: self._send_message(recipient, message) - except PeerEncryptionPendingError as e: - if not self.connection_router.peer_store.should_hold(recipient): - error = error or e - continue - # The changes wait in the queue; the next sync sends them. - warnings.warn( - f"Changes for {recipient} wait for the next sync: {e} They " - "are held in memory, and lost if this process exits." - ) - for item in items_by_recipient[recipient]: - self.queue.put(item) - except EncryptionPolicyConflictError as e: - error = error or e - if error is not None: - raise error + except (SyftPeerNotReadyError, EncryptionPolicyConflictError) as e: + warnings.warn(f"Dropped the changes for {recipient}: {e}") def _record_sent_hashes(self, message: ProposedFileChangesMessage) -> None: hashes = self.datasite_watcher_cache.file_hashes @@ -162,9 +136,8 @@ def _send_message( """Send ``message`` and record the hashes it sent. Raises: - PeerEncryptionPendingError: while encryption with ``recipient`` is - pending: its policy is unknown, or the pair encrypts and the - keys or the peer bundle are missing. + SyftPeerNotReadyError: when the policy of ``recipient`` is not + known, or the pair encrypts and the peer bundle is missing. EncryptionPolicyConflictError: when the policies of the pair conflict. """ self.connection_router.watcher_send_proposed_file_changes_message( @@ -183,17 +156,16 @@ def on_file_change( # --- Pulling from datasite outboxes --- def download_events_message_with_new_connection( - self, file_meta: dict, peer_email: str + self, file_id: str, peer_email: str ) -> FileChangeEventsMessage | None: """Download from outbox using a new connection (thread-safe). - Returns None when the message cannot be read yet (see + Returns None when the message is skipped (see ``ConnectionRouter.watcher_read_events_message``). """ connection = self.connection_router.connection_for_parallel_download() - return self.connection_router.watcher_read_events_message( - connection, peer_email, file_meta - ) + raw = connection.download_file(file_id) + return self.connection_router.watcher_read_events_message(peer_email, raw) def download_collection_file_with_new_connection( self, file_id: str, owner_email: str @@ -212,9 +184,9 @@ def sync_down(self, peer_emails: list[str]): event_count = self.datasite_watcher_cache.sync_down_parallel( peer_email, self._executor, - lambda meta, + lambda fid, pe=peer_email: self.download_events_message_with_new_connection( - meta, pe + fid, pe ), ) if event_count: diff --git a/tests/unit/test_encryption.py b/tests/unit/test_encryption.py index 3677a7959e2..21db1ae4f89 100644 --- a/tests/unit/test_encryption.py +++ b/tests/unit/test_encryption.py @@ -384,6 +384,16 @@ def test_self_encrypt_if_needed_without_encryption(): assert ps.decrypt_and_verify_for_self_if_needed(data) == data +def test_required_self_decrypt_refuses_plaintext(): + ps = PeerStore( + email="alice@example.com", encryption_policy=EncryptionPolicy.REQUIRED + ) + ps.generate_keys() + + with pytest.raises(ValueError, match="SYC envelope"): + ps.decrypt_and_verify_for_self_if_needed(b"hello") + + # ========================================================================= # At-rest encryption via mock drive: events, checkpoints, rolling state # ========================================================================= diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 69722bc8d5c..bdc25148d80 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -15,23 +15,18 @@ from syft.sync.encryption_policy import ( EncryptionPolicy, EncryptionPolicyConflictError, - PeerEncryptionPolicyUnknownError, - PeerKeysNotReadyError, - PeerOutdatedError, + MessageFormatError, ) from syft.sync.events.file_change_event import ( FileChangeEventsMessage, ) +from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.peers.peer import Peer from syft.sync.peers.peer_store import PeerStore from syft.sync.syftbox_manager import SyftboxManager from syft.sync.sync.datasite_watcher_syncer import DatasiteWatcherSyncer from syft.sync.version.peer_manager import PeerManagerConfig from syft.sync.version.version_info import VersionInfo -from tests.unit.test_persisted_cache import ( - _assert_ds_receives_after_notebook_sync, - _pair_with_unsent_do_file, -) from tests.unit.test_sync_manager import path_for_job from tests.unit.utils import grant_job_inbox_access @@ -130,14 +125,13 @@ def _required_ds_pair(): return ds_manager, do_manager -def _owner_sends_with_stale_view(ds_manager, do_manager, content: str) -> None: - """The owner handles a proposal and answers while it sees the DS as optional.""" - stale = do_manager.peer_manager.get_cached_peer(ds_manager.email) - stale.version = _version_with_policy(OPTIONAL) - _forget(do_manager.peer_manager.peer_store, ds_manager.email) +def _owner_answers_in_plaintext(ds_manager, do_manager, content, monkeypatch): + """The owner handles a proposal and answers in plaintext, as a broken client would.""" file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, content) - do_manager.datasite_owner_syncer.sync([ds_manager.email]) + with monkeypatch.context() as m: + m.setattr(PeerStore, "encrypt_if_needed", lambda self, email, data: data) + do_manager.datasite_owner_syncer.sync([ds_manager.email]) # ========================================================================= @@ -193,7 +187,7 @@ def test_outdated_peer_skipped_with_upgrade_warning(): ds_manager.load_peers() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - with pytest.raises(PeerOutdatedError, match="upgrade"): + with pytest.warns(UserWarning, match="Dropped the changes.*upgrade"): ds_manager._send_file_change(file_path, "content") with pytest.warns(UserWarning, match="upgrade"): ds_manager.sync() @@ -282,23 +276,18 @@ def test_resolution_follows_changed_peer_policy(): assert not store.peer_uses_encryption("bob@example.com") -def test_optional_sender_refuses_peer_with_unknown_policy(): - store = PeerStore(email="alice@example.com", encryption_policy=OPTIONAL) +@pytest.mark.parametrize("policy", [OPTIONAL, REQUIRED, OFF]) +def test_sender_refuses_peer_with_unknown_policy(policy): + store = PeerStore(email="alice@example.com", encryption_policy=policy) store.add_peer(Peer(email="bob@example.com")) - with pytest.raises(PeerEncryptionPolicyUnknownError): + with pytest.raises(SyftPeerNotReadyError, match="not known"): store.encrypt_if_needed("bob@example.com", b"data") -def test_required_sender_needs_no_peer_policy(): - alice, _ = _keyed_pair(REQUIRED, OPTIONAL) - alice.get_cached_peer("bob@example.com").version = None - assert PeerStore.is_envelope(alice.encrypt_if_needed("bob@example.com", b"data")) - - def test_encrypting_pair_without_peer_bundle_not_ready(): store = _store_with_peer(REQUIRED, OPTIONAL) store.generate_keys() - with pytest.raises(PeerKeysNotReadyError): + with pytest.raises(SyftPeerNotReadyError): store.encrypt_if_needed("bob@example.com", b"data") @@ -342,11 +331,10 @@ def test_receiver_rejects_plaintext_when_pair_encrypts(alice_policy, bob_policy) bob.decrypt_and_verify_if_needed("alice@example.com", b"plain") -def test_plaintext_from_peer_with_unknown_policy_waits(): - """The receive path waits like the send path while the policy is unknown.""" +def test_message_from_peer_with_unknown_policy_refused(): _, bob = _keyed_pair(OPTIONAL, OPTIONAL) bob.get_cached_peer("alice@example.com").version = None - with pytest.raises(PeerEncryptionPolicyUnknownError): + with pytest.raises(SyftPeerNotReadyError, match="not known"): bob.decrypt_and_verify_if_needed("alice@example.com", b"plain") @@ -363,19 +351,19 @@ def test_receiver_accepts_plaintext_in_plaintext_pair(): assert bob.decrypt_and_verify_if_needed("alice@example.com", b"plain") == b"plain" -def test_receiver_accepts_envelope_from_plaintext_peer(): +def test_receiver_rejects_envelope_when_pair_does_not_encrypt(): alice, bob = _keyed_pair(REQUIRED, OPTIONAL) envelope = alice.encrypt("bob@example.com", b"secret") - # Bob now sees Alice as optional, e.g. after she restarted without encryption. bob.get_cached_peer("alice@example.com").version = _version_with_policy(OPTIONAL) - assert bob.decrypt_and_verify_if_needed("alice@example.com", envelope) == b"secret" + with pytest.raises(MessageFormatError, match="is encrypted"): + bob.decrypt_and_verify_if_needed("alice@example.com", envelope) def test_envelope_without_peer_bundle_is_not_ready(): alice, bob = _keyed_pair(REQUIRED, OPTIONAL) envelope = alice.encrypt("bob@example.com", b"secret") bob.get_cached_peer("alice@example.com").public_encryption_bundle = None - with pytest.raises(PeerKeysNotReadyError): + with pytest.raises(SyftPeerNotReadyError): bob.decrypt_and_verify_if_needed("alice@example.com", envelope) @@ -429,42 +417,6 @@ def test_client_required_after_peering_publishes_bundle(): assert any("after switch" in c for c in _accepted_contents(do_manager)) -def _raw_event_log(do_manager) -> list[bytes]: - connection = ( - do_manager.datasite_owner_syncer.connection_router.connection_for_eventlog() - ) - return connection.owner_download_all_raw_events_from_syftbox() - - -def test_owner_switching_to_required_encrypts_new_files_at_rest(): - """Files written while optional stay plaintext, and are read as usual.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - grant_job_inbox_access(do_manager, ds_manager.email) - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "before switch") - do_manager.sync() - do_manager.create_checkpoint() - before = len(_raw_event_log(do_manager)) - - # A later login of the DO requires encryption. - do_manager.peer_manager.peer_store.encryption_policy = REQUIRED - do_manager.peer_manager.write_own_version() - do_manager.load_peers() - ds_manager.load_peers() - do_manager.load_peers() - ds_manager._send_file_change(file_path, "after switch") - do_manager.sync() - - envelopes = [PeerStore.is_envelope(raw) for raw in _raw_event_log(do_manager)] - assert envelopes.count(False) == before - assert envelopes.count(True) == len(envelopes) - before > 0 - contents = _accepted_contents(do_manager) - assert any("before switch" in c for c in contents) - assert any("after switch" in c for c in contents) - router = do_manager.datasite_owner_syncer.connection_router - assert router.get_latest_checkpoint() is not None - - def test_off_client_works_in_plaintext_with_optional_peer(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=OFF, @@ -490,7 +442,7 @@ def test_off_and_required_peers_skip_each_other(): ds_manager.load_peers() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - with pytest.raises(EncryptionPolicyConflictError): + with pytest.warns(UserWarning, match="Dropped the changes"): ds_manager._send_file_change(file_path, "content") with pytest.warns(UserWarning, match="skipped"): do_manager.sync() @@ -510,7 +462,7 @@ def test_owner_drops_outbox_message_to_non_peer(): assert syncer.outbox_queue.empty() -def test_conflict_found_on_read_makes_peer_wait(): +def test_conflict_found_on_read_leaves_message_in_inbox(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=OFF, do_encryption_policy=OPTIONAL, @@ -521,15 +473,13 @@ def test_conflict_found_on_read_makes_peer_wait(): # The owner now requires encryption, after the message was sent. do_manager.peer_manager.peer_store.encryption_policy = REQUIRED - syncer = do_manager.datasite_owner_syncer - with pytest.warns(UserWarning, match=f"Messages from {ds_manager.email} wait"): - message = syncer.pull_and_process_next_proposed_filechange( - ds_manager.email, raise_on_none=False - ) - assert message is None + match = f"Messages from {ds_manager.email} stay in the inbox" + with pytest.warns(UserWarning, match=match): + do_manager.datasite_owner_syncer.sync([ds_manager.email]) + assert _raw_inbox_message(do_manager, ds_manager.email) -def test_owner_waits_on_envelope_from_peer_without_bundle(): +def test_owner_leaves_envelope_from_peer_without_bundle_in_inbox(): """The sync goes on; the message stays in the inbox until the bundle is read.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=REQUIRED, @@ -542,7 +492,7 @@ def test_owner_waits_on_envelope_from_peer_without_bundle(): peer = do_manager.peer_manager.peer_store.get_cached_peer(ds_manager.email) peer.public_encryption_bundle = None - with pytest.warns(UserWarning, match="wait"): + with pytest.warns(UserWarning, match="stay in the inbox"): do_manager.datasite_owner_syncer.sync([ds_manager.email]) assert not any("bundle later" in c for c in _accepted_contents(do_manager)) @@ -551,85 +501,41 @@ def test_owner_waits_on_envelope_from_peer_without_bundle(): # ========================================================================= -# Sends while encryption is pending +# Sends that cannot go # ========================================================================= -def test_send_reread_updates_peer_manager(): - """The re-read also refreshes what PeerManager derives from the version.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - ds_encryption_policy=OPTIONAL, - do_encryption_policy=REQUIRED, - ) - grant_job_inbox_access(do_manager, ds_manager.email) - ds_manager.load_peers() - peer_manager = ds_manager.peer_manager - peer_manager.get_cached_peer(do_manager.email).version = None - _forget(peer_manager.peer_store, do_manager.email) - peer_manager._loaded_peer_versions[do_manager.email] = None - - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "content") - assert peer_manager._loaded_peer_versions[do_manager.email] is not None - assert PeerStore.is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) - - def _queued_changes(manager) -> int: return manager.datasite_watcher_syncer.queue.qsize() -def test_send_before_peer_accepts_waits_until_sync(): - """Before the owner accepts, its policy is unknown, so the change waits.""" +@pytest.mark.parametrize("do_policy", [OPTIONAL, REQUIRED]) +def test_send_before_owner_accepts_is_dropped(do_policy): + """Before the owner accepts, its policy is not known, so nothing is sent.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, - do_encryption_policy=REQUIRED, + do_encryption_policy=do_policy, ) ds_manager.add_peer(do_manager.email, sync=False) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - with pytest.warns(UserWarning, match="lost if this process exits"): + with pytest.warns(UserWarning, match="Dropped the changes.*not known"): ds_manager._send_file_change(file_path, "early content") - assert _queued_changes(ds_manager) == 1 - cache = ds_manager.datasite_watcher_syncer.datasite_watcher_cache - assert Path(file_path) not in cache.file_hashes - - do_manager.load_peers() - do_manager.approve_peer_request(ds_manager.email) - grant_job_inbox_access(do_manager, ds_manager.email) - ds_manager.sync() assert _queued_changes(ds_manager) == 0 - assert PeerStore.is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) - do_manager.sync() - assert any("early content" in c for c in _accepted_contents(do_manager)) - -def test_send_after_accept_before_peer_load_waits_until_sync(): - """The owner accepted, but the scientist has not read its bundle yet.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - add_peers=False, - do_encryption_policy=REQUIRED, - ) - ds_manager.add_peer(do_manager.email, sync=False) do_manager.load_peers() do_manager.approve_peer_request(ds_manager.email) grant_job_inbox_access(do_manager, ds_manager.email) - - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "after accept") - assert _queued_changes(ds_manager) == 1 - - ds_manager.sync() do_manager.sync() - assert any("after accept" in c for c in _accepted_contents(do_manager)) + assert not any("early content" in c for c in _accepted_contents(do_manager)) -def test_send_to_non_peer_raises(): - """A recipient that is not a peer never gets a policy.""" +def test_send_to_non_peer_dropped(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, ) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - with pytest.raises(PeerEncryptionPolicyUnknownError): + with pytest.warns(UserWarning, match="Dropped the changes.*not known"): ds_manager._send_file_change(file_path, "content") @@ -657,10 +563,12 @@ def test_changes_for_two_datasites_go_to_each(monkeypatch): ) +def _not_ready(recipient: str) -> SyftPeerNotReadyError: + return SyftPeerNotReadyError(recipient, cause="No bundle.", remedy="Sync.") + + @pytest.mark.parametrize( - "error", - [EncryptionPolicyConflictError, PeerEncryptionPolicyUnknownError], - ids=["conflict", "not a peer"], + "error", [EncryptionPolicyConflictError, _not_ready], ids=["conflict", "not ready"] ) def test_failure_for_one_recipient_still_sends_to_other(monkeypatch, error): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() @@ -675,74 +583,27 @@ def send_message(self, recipient, message): watcher = ds_manager.datasite_watcher_syncer watcher.on_file_change("other@example.com/a.txt", "a", process_now=False) watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) - with pytest.raises(error): + with pytest.warns(UserWarning, match="Dropped the changes for other@example.com"): watcher.process_file_changes_queue() assert sent == [do_manager.email] - - -def test_policy_reread_once_per_peer_load(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - syncer = do_manager.datasite_owner_syncer - do_manager.peer_manager.get_cached_peer(ds_manager.email).version = None - _forget(do_manager.peer_manager.peer_store, ds_manager.email) - reads = [] - monkeypatch.setattr( - ConnectionRouter, - "read_peer_version_file", - lambda self, email: reads.append(email), - ) - for _ in range(3): - syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) - syncer.process_syftbox_events_queue() - syncer.process_syftbox_events_queue() - assert reads == [ds_manager.email] - - -def test_compaction_skips_peer_with_unknown_policy(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - grant_job_inbox_access(do_manager, ds_manager.email) - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - for content in ("one", "two"): - ds_manager._send_file_change(file_path, content) - do_manager.sync() - - _unknown_policy(ds_manager, do_manager, monkeypatch) - syncer = do_manager.datasite_owner_syncer - assert syncer.compact_outbox_if_needed(ds_manager.email, min_messages=1) == 0 - - -def _unknown_policy(ds_manager, do_manager, monkeypatch) -> None: - """Make the DS policy unknown to the DO.""" - do_manager.peer_manager.get_cached_peer(ds_manager.email).version = None - _forget(do_manager.peer_manager.peer_store, ds_manager.email) - monkeypatch.setattr( - ConnectionRouter, "read_peer_version_file", lambda self, email: None - ) + assert _queued_changes(ds_manager) == 0 @pytest.mark.parametrize( - "error", - [ - PeerEncryptionPolicyUnknownError, - PeerKeysNotReadyError, - EncryptionPolicyConflictError, - ], - ids=["policy", "keys", "conflict"], + "error", [EncryptionPolicyConflictError, _not_ready], ids=["conflict", "not ready"] ) -def test_waiting_outbox_message_survives_process_exit(monkeypatch, error): - """The paths stay pending on disk, so the next process sends them.""" - ds_manager, do_manager = _pair_with_unsent_do_file() - do_manager_b = do_manager._copy() +def test_owner_drops_outbox_message_it_cannot_send(monkeypatch, error): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - def wait(self, recipient, message): + def fail(self, recipient, message): raise error(recipient) - with monkeypatch.context() as m: - m.setattr(ConnectionRouter, "owner_write_event_messages_to_outbox", wait) - with pytest.warns(UserWarning, match="wait for a later sync"): - do_manager_b.sync(auto_checkpoint=False) - - _assert_ds_receives_after_notebook_sync(ds_manager, do_manager) + monkeypatch.setattr(ConnectionRouter, "owner_write_event_messages_to_outbox", fail) + syncer = do_manager.datasite_owner_syncer + syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) + with pytest.warns(UserWarning, match="Dropped an outbox message"): + syncer.process_syftbox_events_queue() + assert syncer.outbox_queue.empty() # ========================================================================= @@ -766,11 +627,10 @@ def test_plaintext_inbox_message_dropped_and_later_ones_read(): assert any("encrypted content" in c for c in contents) -def test_plaintext_outbox_message_skipped_and_later_ones_read(): +def test_plaintext_outbox_message_skipped_and_later_ones_read(monkeypatch): ds_manager, do_manager = _required_ds_pair() - _owner_sends_with_stale_view(ds_manager, do_manager, "plain content") + _owner_answers_in_plaintext(ds_manager, do_manager, "plain content", monkeypatch) - do_manager.load_peers() file_path = path_for_job(do_manager.email, ds_manager.email, "other.job") ds_manager._send_file_change(file_path, "encrypted content") do_manager.datasite_owner_syncer.sync([ds_manager.email]) diff --git a/tests/unit/test_peer_requests.py b/tests/unit/test_peer_requests.py index 70dbc02a2ff..9dcbe956bdb 100644 --- a/tests/unit/test_peer_requests.py +++ b/tests/unit/test_peer_requests.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from syft.sync.syftbox_manager import SyftboxManager from tests.unit.test_sync_manager import path_for_job from tests.unit.utils import grant_job_inbox_access @@ -11,10 +13,10 @@ def test_peer_request_blocks_sync_until_approved(): Workflow: 1. DS adds DO as peer (creates peer request) - 2. DS submits a job + 2. DS submits a job - it is dropped (DO policy not known) 3. DO syncs - nothing should sync (peer not approved) 4. DO approves peer request - 5. DS syncs (sends the held job), DO syncs - job should now sync + 5. DS syncs and submits again, DO syncs - job should now sync """ # Create managers with mock drive service connection, no auto-add peers # Note: email1 becomes DO, email2 becomes DS @@ -36,7 +38,8 @@ def test_peer_request_blocks_sync_until_approved(): # Step 2: DS submits a file to their job folder job_file_path = path_for_job(do_manager.email, ds_manager.email) job_content = "print('Hello from DS')" - ds_manager._send_file_change(job_file_path, job_content) + with pytest.warns(UserWarning, match="Dropped the changes"): + ds_manager._send_file_change(job_file_path, job_content) # Step 3: DO syncs WITHOUT accepting - nothing should sync do_manager.sync() @@ -54,9 +57,10 @@ def test_peer_request_blocks_sync_until_approved(): assert len(do_manager.peer_manager.approved_peers) == 1 assert do_manager.peer_manager.approved_peers[0].email == ds_manager.email - # Step 5: DS syncs, which sends the job it held while the DO policy was - # unknown. DO syncs again - now it should work + # Step 5: DS syncs, which reads the DO policy, and submits again. DO syncs + # again - now it should work ds_manager.sync() + ds_manager._send_file_change(job_file_path, job_content) do_manager.sync() # Verify: File synced and in cache From 0059cae648a78e40c7672e394f4fbee6802bb1b8 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Fri, 23 Oct 2026 18:18:14 -0300 Subject: [PATCH 21/30] refactor: take the encryption policy from login only The policy of a client is fixed when it is built. Test clients get their policy in the config, and nothing replaces the peer store later. syft-bg takes the policy in init, so it writes the datasite with the same policy as the notebook. --- packages/syft-bg/docs/python-api.md | 4 ++ packages/syft-bg/src/syft_bg/api/api.py | 8 +++ .../syft-bg/src/syft_bg/approve/config.py | 2 + .../src/syft_bg/approve/orchestrator.py | 4 ++ packages/syft-bg/src/syft_bg/cli/commands.py | 12 +++- .../src/syft_bg/common/syft_bg_config.py | 9 +++ packages/syft-bg/src/syft_bg/sync/config.py | 2 + .../syft-bg/src/syft_bg/sync/orchestrator.py | 4 ++ .../syft-enclave/src/syft_enclaves/client.py | 12 ++-- .../syft-enclave/src/syft_enclaves/utils.py | 7 ++ packages/syft-rds/src/syft_rds/__init__.py | 2 + syft/sync/peers/peer_store.py | 6 +- syft/sync/syftbox_manager.py | 33 ++-------- syft/sync/version/peer_manager.py | 4 +- tests/unit/syft_bg/test_init.py | 21 ++++++ .../syft_bg/test_orchestrator_encryption.py | 64 +++++++++++++++++++ tests/unit/test_encryption_policy.py | 35 ++++------ 17 files changed, 164 insertions(+), 65 deletions(-) create mode 100644 tests/unit/syft_bg/test_orchestrator_encryption.py diff --git a/packages/syft-bg/docs/python-api.md b/packages/syft-bg/docs/python-api.md index b9dea4a74e5..2b5a1604fbe 100644 --- a/packages/syft-bg/docs/python-api.md +++ b/packages/syft-bg/docs/python-api.md @@ -12,6 +12,10 @@ syft_bg.init(do_email="you@example.com") Creates the config file. Optionally pass `syftbox_root` and `token_path`. +Pass `encryption` with the same value as the notebook's `login_do` +(`True`, `False` or `"off"`). syft-bg writes the same datasite as the +notebook, so the two must use the same policy. + ## Ensure running ```python diff --git a/packages/syft-bg/src/syft_bg/api/api.py b/packages/syft-bg/src/syft_bg/api/api.py index d1e53e39604..e2a8b44eb42 100644 --- a/packages/syft-bg/src/syft_bg/api/api.py +++ b/packages/syft-bg/src/syft_bg/api/api.py @@ -10,6 +10,7 @@ PolicyRunner, PolicyState, ) +from syft_rds import EncryptionPolicy from syft_rds.apis import ApiArg from syft_bg.api.results import ApiResult, InstallationResult, StatusResult @@ -55,7 +56,13 @@ def init( syftbox_root: str | Path | None = None, token_path: str | Path | None = None, settings: dict[str, dict] | None = None, + encryption: bool | str = False, ) -> None: + """Write the syft-bg config for ``do_email``. + + ``encryption`` takes the same values as ``login_do``, and it must match + the login of the notebook: both write the same datasite. + """ token_kwargs: dict = {} if token_path is not None: # A single OAuth token is used for both the Gmail and Drive APIs. @@ -65,6 +72,7 @@ def init( config = SyftBgConfig( do_email=do_email, syftbox_root=syftbox_root, + encryption_policy=EncryptionPolicy.coerce(encryption), **token_kwargs, ) diff --git a/packages/syft-bg/src/syft_bg/approve/config.py b/packages/syft-bg/src/syft_bg/approve/config.py index 3c88816c6a0..ae007300467 100644 --- a/packages/syft-bg/src/syft_bg/approve/config.py +++ b/packages/syft-bg/src/syft_bg/approve/config.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field +from syft_rds import EncryptionPolicy from syft_rds.apis.models import FileEntry from syft_bg.common.config import get_default_paths @@ -33,6 +34,7 @@ class AutoApproveConfig(BaseModel): do_email: Optional[str] = None syftbox_root: Optional[Path] = None + encryption_policy: Optional[EncryptionPolicy] = None drive_token_path: Path = Field( default_factory=lambda: get_default_paths().drive_token ) diff --git a/packages/syft-bg/src/syft_bg/approve/orchestrator.py b/packages/syft-bg/src/syft_bg/approve/orchestrator.py index cff3fd664ac..3f91d73fb3e 100644 --- a/packages/syft-bg/src/syft_bg/approve/orchestrator.py +++ b/packages/syft-bg/src/syft_bg/approve/orchestrator.py @@ -68,6 +68,8 @@ def from_config( raise ValueError("Config missing 'do_email' field") if not config.syftbox_root: raise ValueError("Config missing 'syftbox_root' field") + if config.encryption_policy is None: + raise ValueError("Config missing 'encryption_policy' field") # Wait for sync to seed the cache before building the RDS client — # its nested SyftboxManager.from_config triggers @@ -86,6 +88,7 @@ def from_config( rds_config = SyftRDSClientConfig.for_colab( email=config.do_email, has_do_role=True, + encryption=config.encryption_policy, skip_peer_on_patch_version_diff=config.skip_peer_on_patch_version_diff, force_ignore_peer_version=config.force_ignore_peer_version, ) @@ -94,6 +97,7 @@ def from_config( email=config.do_email, has_do_role=True, token_path=config.drive_token_path, + encryption=config.encryption_policy, skip_peer_on_patch_version_diff=config.skip_peer_on_patch_version_diff, force_ignore_peer_version=config.force_ignore_peer_version, ) diff --git a/packages/syft-bg/src/syft_bg/cli/commands.py b/packages/syft-bg/src/syft_bg/cli/commands.py index ca6d9320b46..33f5361c669 100644 --- a/packages/syft-bg/src/syft_bg/cli/commands.py +++ b/packages/syft-bg/src/syft_bg/cli/commands.py @@ -238,7 +238,14 @@ def run_foreground(service: str, once: bool): type=click.Path(exists=True), help="Path to the OAuth token file.", ) -def init(email: str, syftbox_root: str | None, token_path: str | None): +@click.option( + "--encryption", + type=click.Choice(["off", "optional", "required"]), + default="optional", + show_default=True, + help="Encryption policy. Use the same policy as the notebook login.", +) +def init(email: str, syftbox_root: str | None, token_path: str | None, encryption: str): """Initialize syft-bg configuration. Sets up the config file with the data owner email and optional @@ -249,6 +256,8 @@ def init(email: str, syftbox_root: str | None, token_path: str | None): syft-bg init -e alice@uni.edu syft-bg init -e alice@uni.edu -r ~/syftbox -t ~/token.json + + syft-bg init -e alice@uni.edu --encryption required """ from syft_bg.api.api import init as api_init @@ -256,6 +265,7 @@ def init(email: str, syftbox_root: str | None, token_path: str | None): do_email=email, syftbox_root=syftbox_root, token_path=token_path, + encryption=encryption, ) diff --git a/packages/syft-bg/src/syft_bg/common/syft_bg_config.py b/packages/syft-bg/src/syft_bg/common/syft_bg_config.py index 2e789375601..c56753722c5 100644 --- a/packages/syft-bg/src/syft_bg/common/syft_bg_config.py +++ b/packages/syft-bg/src/syft_bg/common/syft_bg_config.py @@ -6,6 +6,7 @@ import yaml from pydantic import BaseModel, ConfigDict, Field, model_validator +from syft_rds import EncryptionPolicy from syft_bg.approve.config import AutoApproveConfig from syft_bg.common.config import get_default_paths @@ -24,6 +25,9 @@ class SyftBgConfig(BaseModel): do_email: str | None = None syftbox_root: str | Path | None = None + # Must match the policy the notebook logs in with: both write the same + # datasite, at rest and to the same peers. + encryption_policy: EncryptionPolicy = EncryptionPolicy.OPTIONAL credentials_path: Path = Field( default_factory=lambda: get_default_paths().credentials ) @@ -63,6 +67,11 @@ def _merge_common_into_services(self) -> None: ): if hasattr(service_config, "do_email") and service_config.do_email is None: service_config.do_email = self.do_email + if ( + hasattr(service_config, "encryption_policy") + and service_config.encryption_policy is None + ): + service_config.encryption_policy = self.encryption_policy if ( hasattr(service_config, "syftbox_root") and service_config.syftbox_root is None diff --git a/packages/syft-bg/src/syft_bg/sync/config.py b/packages/syft-bg/src/syft_bg/sync/config.py index 5cb867fab7c..6598b4ccfa2 100644 --- a/packages/syft-bg/src/syft_bg/sync/config.py +++ b/packages/syft-bg/src/syft_bg/sync/config.py @@ -4,6 +4,7 @@ from typing import Optional from pydantic import BaseModel, Field +from syft_rds import EncryptionPolicy from syft_bg.common.config import get_default_paths @@ -14,6 +15,7 @@ class SyncConfig(BaseModel): retry_backoff: float = 2.0 do_email: Optional[str] = None syftbox_root: Optional[Path] = None + encryption_policy: Optional[EncryptionPolicy] = None drive_token_path: Path = Field( default_factory=lambda: get_default_paths().drive_token ) diff --git a/packages/syft-bg/src/syft_bg/sync/orchestrator.py b/packages/syft-bg/src/syft_bg/sync/orchestrator.py index e4f4195ae03..f0b49e14f0b 100644 --- a/packages/syft-bg/src/syft_bg/sync/orchestrator.py +++ b/packages/syft-bg/src/syft_bg/sync/orchestrator.py @@ -41,6 +41,8 @@ def from_config(cls, config: SyncConfig) -> SyncOrchestrator: raise ValueError("SyncConfig missing 'do_email'") if not config.syftbox_root: raise ValueError("SyncConfig missing 'syftbox_root'") + if config.encryption_policy is None: + raise ValueError("SyncConfig missing 'encryption_policy'") from syft_rds import ( Environment, @@ -53,6 +55,7 @@ def from_config(cls, config: SyncConfig) -> SyncOrchestrator: rds_config = SyftRDSClientConfig.for_colab( email=config.do_email, has_do_role=True, + encryption=config.encryption_policy, skip_peer_on_patch_version_diff=config.skip_peer_on_patch_version_diff, force_ignore_peer_version=config.force_ignore_peer_version, ) @@ -61,6 +64,7 @@ def from_config(cls, config: SyncConfig) -> SyncOrchestrator: email=config.do_email, has_do_role=True, token_path=config.drive_token_path, + encryption=config.encryption_policy, skip_peer_on_patch_version_diff=config.skip_peer_on_patch_version_diff, force_ignore_peer_version=config.force_ignore_peer_version, ) diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index 3f4a1b5e0b4..4efd0a7aa5c 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -874,12 +874,16 @@ def quad_with_mock_drive_service_connection( Returns: Tuple of (enclave, do1, do2, ds) """ + policies = [EncryptionPolicy.coerce(encryption)] * 4 + if enclave_encryption_policy is not None: + policies[0] = enclave_encryption_policy configs = create_configs( enclave_email, do1_email, do2_email, ds_email, use_in_memory_cache, + policies, enclave_persist_owner_state, ) clients = create_clients(configs) @@ -894,14 +898,6 @@ def quad_with_mock_drive_service_connection( setup_connections(managers) setup_callbacks(managers) write_versions(managers) - # Initialize encryption AFTER connections/versions but BEFORE peering, - # so the bundle exchange in wire_peers() picks up each client's keys - # (mirrors SyftboxManager.pair_with_mock_drive_service_connection). - policies = [EncryptionPolicy.coerce(encryption)] * len(managers) - if enclave_encryption_policy is not None: - policies[0] = enclave_encryption_policy - for manager, policy in zip(managers, policies): - manager._apply_encryption_policy_for_testing(policy) wire_peers(managers) return clients diff --git a/packages/syft-enclave/src/syft_enclaves/utils.py b/packages/syft-enclave/src/syft_enclaves/utils.py index 5412000c5c2..f2fc73036ce 100644 --- a/packages/syft-enclave/src/syft_enclaves/utils.py +++ b/packages/syft-enclave/src/syft_enclaves/utils.py @@ -12,31 +12,38 @@ def create_configs( do2_email, ds_email, use_in_memory_cache, + policies, enclave_persist_owner_state=False, ) -> tuple: + """Configs of the enclave, DO1, DO2 and DS, with ``policies`` in that order.""" + enclave_policy, do1_policy, do2_policy, ds_policy = policies enclave_config = SyftRDSClientConfig._base_config_for_testing( email=enclave_email, has_do_role=True, has_ds_role=True, use_in_memory_cache=use_in_memory_cache, persist_owner_state=enclave_persist_owner_state, + encryption_policy=enclave_policy, ) do1_config = SyftRDSClientConfig._base_config_for_testing( email=do1_email, has_do_role=True, has_ds_role=True, use_in_memory_cache=use_in_memory_cache, + encryption_policy=do1_policy, ) do2_config = SyftRDSClientConfig._base_config_for_testing( email=do2_email, has_do_role=True, has_ds_role=True, use_in_memory_cache=use_in_memory_cache, + encryption_policy=do2_policy, ) ds_config = SyftRDSClientConfig._base_config_for_testing( email=ds_email, has_ds_role=True, use_in_memory_cache=use_in_memory_cache, + encryption_policy=ds_policy, ) return enclave_config, do1_config, do2_config, ds_config diff --git a/packages/syft-rds/src/syft_rds/__init__.py b/packages/syft-rds/src/syft_rds/__init__.py index 95b1dbeb213..3a3976e7806 100644 --- a/packages/syft-rds/src/syft_rds/__init__.py +++ b/packages/syft-rds/src/syft_rds/__init__.py @@ -13,6 +13,7 @@ from syft.sync.utils.path_filters import is_normal_syncable_path from syft.sync.environments.environment import Environment from syft.sync.utils.syftbox_utils import check_env +from syft.sync.encryption_policy import EncryptionPolicy __all__ = [ "SyftRDSClient", @@ -25,6 +26,7 @@ "is_normal_syncable_path", "Environment", "check_env", + "EncryptionPolicy", ] # Last, so the imports stay at the top. Nothing here logs at import time. diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 930c68721a7..0f6e4671d3d 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -11,7 +11,7 @@ from typing import List, Optional import syft_crypto_python as syc -from pydantic import BaseModel, PrivateAttr +from pydantic import BaseModel, Field, PrivateAttr from syft.sync.encryption_policy import ( EncryptionPolicy, @@ -47,7 +47,9 @@ class PeerStore(BaseModel): model_config = {"arbitrary_types_allowed": True, "extra": "forbid"} email: str - encryption_policy: EncryptionPolicy = EncryptionPolicy.OPTIONAL + encryption_policy: EncryptionPolicy = Field( + default=EncryptionPolicy.OPTIONAL, frozen=True + ) _private_keys: syc.SyftPrivateKeys | None = PrivateAttr(default=None) _peers: List[Peer] = PrivateAttr(default_factory=list) diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index ebd202c505c..e12fe067135 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -27,7 +27,6 @@ from syft.sync.peers.peer import Peer, PeerNotReadyError from syft.sync.peers.peer_list import PeerList from syft.sync.encryption_policy import EncryptionPolicy -from syft.sync.peers.peer_store import PeerStore from syft.sync.platforms.base_platform import BasePlatform from syft.sync.sync.caches.datasite_owner_cache import ( DataSiteOwnerEventCacheConfig, @@ -303,6 +302,7 @@ def _base_config_for_testing( persist_owner_state: bool = True, check_versions: bool = False, collection_specs: list["CollectionSyncSpec"] | None = None, + encryption_policy: EncryptionPolicy = EncryptionPolicy.OPTIONAL, ): # No collection specs by default: the generic sync engine knows nothing # about datasets. syft-rds registers its dataset spec at initialization @@ -351,6 +351,7 @@ def _base_config_for_testing( force_ignore_peer_version=not check_versions, has_do_role=has_do_role, has_ds_role=has_ds_role, + encryption_policy=encryption_policy, ) return cls( @@ -784,6 +785,7 @@ def pair_with_mock_drive_service_connection( Returns: Tuple of (ds_manager, do_manager) """ + default_policy = EncryptionPolicy.coerce(encryption) # Create configs using the existing base config generator do_config = SyftboxManagerConfig._base_config_for_testing( email=email1, @@ -794,6 +796,7 @@ def pair_with_mock_drive_service_connection( persist_owner_state=persist_owner_state, check_versions=check_versions, collection_specs=collection_specs, + encryption_policy=do_encryption_policy or default_policy, ) ds_config = SyftboxManagerConfig._base_config_for_testing( @@ -805,6 +808,7 @@ def pair_with_mock_drive_service_connection( persist_owner_state=persist_owner_state, check_versions=check_versions, collection_specs=collection_specs, + encryption_policy=ds_encryption_policy or default_policy, ) # Create managers from configs @@ -836,13 +840,6 @@ def pair_with_mock_drive_service_connection( ds_manager.peer_manager.write_own_version() do_manager.peer_manager.write_own_version() - default_policy = EncryptionPolicy.coerce(encryption) - for manager, policy in ( - (ds_manager, ds_encryption_policy or default_policy), - (do_manager, do_encryption_policy or default_policy), - ): - manager._apply_encryption_policy_for_testing(policy) - if add_peers: # DS creates peer request ds_manager.add_peer(do_manager.email, sync=False) @@ -855,26 +852,6 @@ def pair_with_mock_drive_service_connection( return ds_manager, do_manager - def _apply_encryption_policy_for_testing(self, policy: EncryptionPolicy) -> None: - """Give a mock-Drive client ``policy``, before it peers.""" - if policy == EncryptionPolicy.REQUIRED: - self._init_encrypted_peer_store() - elif policy == EncryptionPolicy.OFF: - self.peer_manager.peer_store.encryption_policy = policy - self.peer_manager.write_own_version() - - def _init_encrypted_peer_store(self) -> None: - """Replace the peer store with an in-memory one that requires encryption. - - The version file is written again, so that peers read the new policy. - """ - peer_store = PeerStore( - email=self.email, encryption_policy=EncryptionPolicy.REQUIRED - ) - peer_store.generate_keys() - self._set_peer_store(peer_store) - self.peer_manager.write_own_version() - def add_peer( self, peer_email: str, diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 3f4de273bff..bac8716c8d9 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -274,9 +274,7 @@ def get_own_version(self) -> VersionInfo: """Get current client's version info.""" if self._own_version is None: self._own_version = VersionInfo.current() - # The store can change after the first call: tests and the enclave - # replace it, and a key file can make it required. - self._own_version.encryption_policy = self.peer_store.encryption_policy + self._own_version.encryption_policy = self.peer_store.encryption_policy return self._own_version def write_own_version(self) -> None: diff --git a/tests/unit/syft_bg/test_init.py b/tests/unit/syft_bg/test_init.py index 996742f1dfc..0f31e41291a 100644 --- a/tests/unit/syft_bg/test_init.py +++ b/tests/unit/syft_bg/test_init.py @@ -5,6 +5,8 @@ from pathlib import Path from unittest.mock import patch +from syft_rds import EncryptionPolicy + from syft_bg.api.api import init from syft_bg.api.utils import move_token_to_syftbg_dir from syft_bg.common.config import get_default_paths @@ -67,3 +69,22 @@ def test_sets_both_gmail_and_drive_token_path(self, temp_dir): canonical_token_path = temp_dir / "syftbg" / "token.json" assert config.drive_token_path == canonical_token_path assert config.gmail_token_path == canonical_token_path + + +class TestInitEncryption: + """api.init(encryption=...) sets the policy of every service that syncs.""" + + def test_default_is_optional(self, temp_dir): + with _patched_paths(temp_dir) as patched: + init(do_email="alice@test.com") + config = SyftBgConfig.from_path(patched.config) + + assert config.sync.encryption_policy == EncryptionPolicy.OPTIONAL + + def test_required_reaches_sync_and_approve(self, temp_dir): + with _patched_paths(temp_dir) as patched: + init(do_email="alice@test.com", encryption=True) + config = SyftBgConfig.from_path(patched.config) + + assert config.sync.encryption_policy == EncryptionPolicy.REQUIRED + assert config.approve.encryption_policy == EncryptionPolicy.REQUIRED diff --git a/tests/unit/syft_bg/test_orchestrator_encryption.py b/tests/unit/syft_bg/test_orchestrator_encryption.py new file mode 100644 index 00000000000..89a7d9a7553 --- /dev/null +++ b/tests/unit/syft_bg/test_orchestrator_encryption.py @@ -0,0 +1,64 @@ +"""The syft-bg orchestrators build their client with the configured policy.""" + +from unittest.mock import MagicMock + +import pytest +import syft_rds +from syft_rds import EncryptionPolicy + +from syft_bg.approve.config import AutoApproveConfig +from syft_bg.approve.orchestrator import ApprovalOrchestrator +from syft_bg.sync.config import SyncConfig +from syft_bg.sync.orchestrator import SyncOrchestrator + + +def _sync(temp_dir, policy): + config = SyncConfig( + do_email="do@test.com", + syftbox_root=temp_dir, + encryption_policy=policy, + sync_state_path=temp_dir / "sync_state.json", + ) + return SyncOrchestrator, config + + +def _approve(temp_dir, policy): + config = AutoApproveConfig( + do_email="do@test.com", syftbox_root=temp_dir, encryption_policy=policy + ) + return ApprovalOrchestrator, config + + +@pytest.fixture +def client_kwargs(monkeypatch): + """Capture the keyword arguments the orchestrator builds its client with.""" + seen = {} + monkeypatch.setattr(syft_rds, "check_env", lambda: syft_rds.Environment.JUPYTER) + monkeypatch.setattr( + syft_rds.SyftRDSClientConfig, + "for_jupyter", + classmethod(lambda cls, **kwargs: seen.update(kwargs)), + ) + monkeypatch.setattr( + syft_rds.SyftRDSClient, + "from_config", + classmethod(lambda cls, config: MagicMock()), + ) + monkeypatch.setattr( + ApprovalOrchestrator, "_wait_for_sync_ready", staticmethod(lambda **kw: None) + ) + return seen + + +@pytest.mark.parametrize("build", [_sync, _approve], ids=["sync", "approve"]) +def test_orchestrator_uses_configured_policy(temp_dir, client_kwargs, build): + orchestrator, config = build(temp_dir, EncryptionPolicy.REQUIRED) + orchestrator.from_config(config) + assert client_kwargs["encryption"] == EncryptionPolicy.REQUIRED + + +@pytest.mark.parametrize("build", [_sync, _approve], ids=["sync", "approve"]) +def test_orchestrator_refuses_missing_policy(temp_dir, client_kwargs, build): + orchestrator, config = build(temp_dir, None) + with pytest.raises(ValueError, match="encryption_policy"): + orchestrator.from_config(config) diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index bdc25148d80..29ab1192ab3 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -143,6 +143,12 @@ def test_policy_defaults_to_optional(): assert PeerStore(email="alice@example.com").encryption_policy == OPTIONAL +def test_policy_cannot_change_after_login(): + store = PeerStore(email="alice@example.com", encryption_policy=OPTIONAL) + with pytest.raises(ValidationError, match="frozen"): + store.encryption_policy = REQUIRED + + @pytest.mark.parametrize( "build", [ @@ -398,25 +404,6 @@ def test_negotiated_encryption_flow(ds_policy, do_policy): assert len(_ds_cached_contents(ds_manager)) > 0 -def test_client_required_after_peering_publishes_bundle(): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - grant_job_inbox_access(do_manager, ds_manager.email) - - # A later login of the DS requires encryption. - ds_manager.peer_manager.peer_store.encryption_policy = REQUIRED - ds_manager.peer_manager.write_own_version() - ds_manager.load_peers() - do_manager.load_peers() - ds_manager.load_peers() - - do_store = do_manager.peer_manager.peer_store - assert do_store.has_peer_bundle(ds_manager.email) - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "after switch") - do_manager.sync() - assert any("after switch" in c for c in _accepted_contents(do_manager)) - - def test_off_client_works_in_plaintext_with_optional_peer(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=OFF, @@ -464,15 +451,17 @@ def test_owner_drops_outbox_message_to_non_peer(): def test_conflict_found_on_read_leaves_message_in_inbox(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - ds_encryption_policy=OFF, - do_encryption_policy=OPTIONAL, + ds_encryption_policy=OPTIONAL, + do_encryption_policy=REQUIRED, ) grant_job_inbox_access(do_manager, ds_manager.email) + ds_manager.load_peers() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") ds_manager._send_file_change(file_path, "content") - # The owner now requires encryption, after the message was sent. - do_manager.peer_manager.peer_store.encryption_policy = REQUIRED + # The scientist logs in again with encryption off, after the message was sent. + peer = do_manager.peer_manager.get_cached_peer(ds_manager.email) + peer.version = _version_with_policy(OFF) match = f"Messages from {ds_manager.email} stay in the inbox" with pytest.warns(UserWarning, match=match): do_manager.datasite_owner_syncer.sync([ds_manager.email]) From 015cd42b62de772b89e76588115d9ab4843fae3d Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Fri, 23 Oct 2026 20:34:59 -0300 Subject: [PATCH 22/30] fix: raise when changes cannot be sent and check the published bundle A DS send raises when the peer policy or bundle is not known, so a job submission no longer reports success for a dropped job. The first peer load raises when a bundle this client published carries other keys. syft-bg init takes a required encryption policy. Remove the dead queue call in sync, poll instead of sleep in the Drive test, and fix the docs. --- docs/connections.md | 8 +-- .../ai_audit/internal/DO_Tutorial_V3.ipynb | 1 + .../ai_audit/internal/test_email_e2e.ipynb | 1 + .../internal/test_multiple_clients.ipynb | 2 + .../ai_audit/internal/test_syft_bg.ipynb | 8 ++- packages/syft-bg/docs/python-api.md | 6 +- packages/syft-bg/src/syft_bg/api/api.py | 2 +- .../src/syft_bg/approve/orchestrator.py | 4 +- packages/syft-bg/src/syft_bg/cli/commands.py | 9 +-- packages/syft-bg/src/syft_bg/cli/init/flow.py | 22 ++++++++ .../src/syft_bg/common/syft_bg_config.py | 6 +- .../syft-bg/src/syft_bg/sync/orchestrator.py | 4 +- .../tests/test_version_negotiation.py | 16 ++++-- syft/sync/login_utils.py | 6 +- syft/sync/syftbox_manager.py | 2 - syft/sync/sync/datasite_watcher_syncer.py | 18 ++++-- syft/sync/version/peer_manager.py | 41 +++++++++++++- tests/integration/syft_bg/test_cli.py | 14 ++++- .../test_sync_manager.py | 55 +++++++++++++------ tests/unit/syft_bg/test_init.py | 32 +++++++++-- tests/unit/test_encryption_policy.py | 30 +++++++--- tests/unit/test_peer_requests.py | 10 ++-- 22 files changed, 223 insertions(+), 74 deletions(-) diff --git a/docs/connections.md b/docs/connections.md index 49493ab1fb3..ee5e86fce1f 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -60,13 +60,13 @@ Each client advertises an encryption policy in `SYFT_version.json`: A pair encrypts when one side is `required`. An `off` and a `required` client conflict: each side skips the other with a warning. -A client takes its policy at login. The key file records a `required` policy, so later logins (and syft-bg) stay `required`. A process that started with another policy stops at its next sync with an error to restart it. Files at rest written before the switch stay plaintext; new ones are encrypted. +A client takes its policy at login, and keeps it until the process exits. syft-bg takes its policy in `syft_bg.init(encryption=...)`; use the same value as the notebook login, because both write the same datasite. Files at rest are read with the current policy, so a datasite keeps one policy. -While a peer's policy or keys are not known yet, a change for it waits, with a warning, and a later sync sends it. An owner's change stays pending on disk. A scientist's change waits in memory, and is lost if the process exits. +Nothing is sent to a peer until its policy is known, that is, until it accepts and shares its version file. A scientist's change for a peer whose policy or bundle is not known is dropped, and the send raises. An owner's change for that peer is dropped with a warning. An owner keeps a message it cannot read yet in the inbox, with a warning. -A pair that encrypts refuses plaintext. The receiver drops a plaintext message with a warning: the owner archives it unread, and a scientist skips it. +The format of a message must match the pair: plaintext in a pair that encrypts is refused, and so is an envelope in a pair that does not. The owner archives such a message unread, and a scientist skips it, each with a warning. -A peer whose version file has no policy runs an outdated syft. It is skipped with a warning to upgrade, and a send to it raises. +A peer whose version file has no policy runs an outdated syft. It is skipped with a warning to upgrade. ## Peer Requests diff --git a/notebooks/ai_audit/internal/DO_Tutorial_V3.ipynb b/notebooks/ai_audit/internal/DO_Tutorial_V3.ipynb index 85a19a2343c..03977b5bc02 100644 --- a/notebooks/ai_audit/internal/DO_Tutorial_V3.ipynb +++ b/notebooks/ai_audit/internal/DO_Tutorial_V3.ipynb @@ -858,6 +858,7 @@ "\n", "syft_bg.init(\n", " do_email=DO_EMAIL,\n", + " encryption=False,\n", " token_path=str(TOKEN_PATH),\n", " settings=settings,\n", ")\n" diff --git a/notebooks/ai_audit/internal/test_email_e2e.ipynb b/notebooks/ai_audit/internal/test_email_e2e.ipynb index 02ba57a88e7..9b5bc7e2d6b 100644 --- a/notebooks/ai_audit/internal/test_email_e2e.ipynb +++ b/notebooks/ai_audit/internal/test_email_e2e.ipynb @@ -195,6 +195,7 @@ "source": [ "syft_bg.init(\n", " do_email=DO_EMAIL,\n", + " encryption=False,\n", " token_path=do_token_path,\n", " settings={\n", " \"email_approve\": {\"gcp_project_id\": GCP_PROJECT_ID},\n", diff --git a/notebooks/ai_audit/internal/test_multiple_clients.ipynb b/notebooks/ai_audit/internal/test_multiple_clients.ipynb index e5bd55d4c0a..fe9597c7f92 100644 --- a/notebooks/ai_audit/internal/test_multiple_clients.ipynb +++ b/notebooks/ai_audit/internal/test_multiple_clients.ipynb @@ -228,6 +228,7 @@ "source": [ "syft_bg.init(\n", " do_email=DO_EMAIL,\n", + " encryption=False,\n", " token_path=str(DO_TOKEN),\n", " settings={\n", " \"email_approve\": {\"gcp_project_id\": GCP_PROJECT_ID},\n", @@ -363,6 +364,7 @@ "syft_bg.reset()\n", "syft_bg.init(\n", " do_email=DO_EMAIL,\n", + " encryption=False,\n", " token_path=str(DO_TOKEN),\n", " settings={\n", " \"email_approve\": {\"gcp_project_id\": GCP_PROJECT_ID},\n", diff --git a/notebooks/ai_audit/internal/test_syft_bg.ipynb b/notebooks/ai_audit/internal/test_syft_bg.ipynb index 12c78b367c9..2685a17c540 100644 --- a/notebooks/ai_audit/internal/test_syft_bg.ipynb +++ b/notebooks/ai_audit/internal/test_syft_bg.ipynb @@ -217,6 +217,7 @@ "source": [ "syft_bg.init(\n", " do_email=DO_EMAIL,\n", + " encryption=False,\n", " token_path=do_token_path,\n", " settings={\n", " \"email_approve\": {\"gcp_project_id\": GCP_PROJECT_ID},\n", @@ -282,7 +283,7 @@ "outputs": [], "source": [ "syft_bg.reset()\n", - "syft_bg.init(do_email=DO_EMAIL, token_path=do_token_path)\n", + "syft_bg.init(do_email=DO_EMAIL, encryption=False, token_path=do_token_path)\n", "\n", "syft_bg.ensure_running(services=[\"email_approve\"])\n", "time.sleep(3) # wait for subprocess to fail\n", @@ -309,7 +310,7 @@ "outputs": [], "source": [ "syft_bg.reset()\n", - "syft_bg.init(do_email=DO_EMAIL, token_path=do_token_path,\n", + "syft_bg.init(do_email=DO_EMAIL, encryption=False, token_path=do_token_path,\n", " settings={\"email_approve\": {\"gcp_project_id\": GCP_PROJECT_ID}})\n", "\n", "SERVICES = [\"sync\", \"notify\", \"approve\"]\n", @@ -447,6 +448,7 @@ "syft_bg.reset()\n", "syft_bg.init(\n", " do_email=DO_EMAIL,\n", + " encryption=False,\n", " token_path=do_token_path,\n", " settings={\n", " \"email_approve\": {\"gcp_project_id\": GCP_PROJECT_ID},\n", @@ -679,7 +681,7 @@ "bad_token = Path(tempfile.mktemp(suffix=\".json\"))\n", "bad_token.write_text(json.dumps({\"invalid\": True}))\n", "\n", - "syft_bg.init(do_email=DO_EMAIL, token_path=str(bad_token),\n", + "syft_bg.init(do_email=DO_EMAIL, encryption=False, token_path=str(bad_token),\n", " settings={\"email_approve\": {\"gcp_project_id\": GCP_PROJECT_ID}})\n", "\n", "syft_bg.ensure_running(services=[\"sync\"])\n", diff --git a/packages/syft-bg/docs/python-api.md b/packages/syft-bg/docs/python-api.md index 2b5a1604fbe..be1a63f6b25 100644 --- a/packages/syft-bg/docs/python-api.md +++ b/packages/syft-bg/docs/python-api.md @@ -7,12 +7,12 @@ import syft_bg ## Init ```python -syft_bg.init(do_email="you@example.com") +syft_bg.init(do_email="you@example.com", encryption=False) ``` Creates the config file. Optionally pass `syftbox_root` and `token_path`. -Pass `encryption` with the same value as the notebook's `login_do` +`encryption` is required. Pass the same value as the notebook's `login_do` (`True`, `False` or `"off"`). syft-bg writes the same datasite as the notebook, so the two must use the same policy. @@ -102,7 +102,7 @@ syft_bg.logs("notify") # last 50 lines of notify service l import syft_bg # Create config -syft_bg.init(do_email="you@example.com") +syft_bg.init(do_email="you@example.com", encryption=False) # Start services syft_bg.ensure_running(["notify", "approve"]) diff --git a/packages/syft-bg/src/syft_bg/api/api.py b/packages/syft-bg/src/syft_bg/api/api.py index e2a8b44eb42..c4d24e3a2b9 100644 --- a/packages/syft-bg/src/syft_bg/api/api.py +++ b/packages/syft-bg/src/syft_bg/api/api.py @@ -53,10 +53,10 @@ def init( do_email: str, + encryption: bool | str, syftbox_root: str | Path | None = None, token_path: str | Path | None = None, settings: dict[str, dict] | None = None, - encryption: bool | str = False, ) -> None: """Write the syft-bg config for ``do_email``. diff --git a/packages/syft-bg/src/syft_bg/approve/orchestrator.py b/packages/syft-bg/src/syft_bg/approve/orchestrator.py index 3f91d73fb3e..bd9e8b1e1fd 100644 --- a/packages/syft-bg/src/syft_bg/approve/orchestrator.py +++ b/packages/syft-bg/src/syft_bg/approve/orchestrator.py @@ -69,7 +69,9 @@ def from_config( if not config.syftbox_root: raise ValueError("Config missing 'syftbox_root' field") if config.encryption_policy is None: - raise ValueError("Config missing 'encryption_policy' field") + raise ValueError( + "Config missing 'encryption_policy' field: run syft-bg init with an encryption policy" + ) # Wait for sync to seed the cache before building the RDS client — # its nested SyftboxManager.from_config triggers diff --git a/packages/syft-bg/src/syft_bg/cli/commands.py b/packages/syft-bg/src/syft_bg/cli/commands.py index 33f5361c669..bddb1904060 100644 --- a/packages/syft-bg/src/syft_bg/cli/commands.py +++ b/packages/syft-bg/src/syft_bg/cli/commands.py @@ -241,8 +241,7 @@ def run_foreground(service: str, once: bool): @click.option( "--encryption", type=click.Choice(["off", "optional", "required"]), - default="optional", - show_default=True, + required=True, help="Encryption policy. Use the same policy as the notebook login.", ) def init(email: str, syftbox_root: str | None, token_path: str | None, encryption: str): @@ -253,11 +252,9 @@ def init(email: str, syftbox_root: str | None, token_path: str | None, encryptio Examples: - syft-bg init -e alice@uni.edu - - syft-bg init -e alice@uni.edu -r ~/syftbox -t ~/token.json + syft-bg init -e alice@uni.edu --encryption optional - syft-bg init -e alice@uni.edu --encryption required + syft-bg init -e alice@uni.edu --encryption required -r ~/syftbox -t ~/token.json """ from syft_bg.api.api import init as api_init diff --git a/packages/syft-bg/src/syft_bg/cli/init/flow.py b/packages/syft-bg/src/syft_bg/cli/init/flow.py index 253c49d048c..755e18bf69c 100644 --- a/packages/syft-bg/src/syft_bg/cli/init/flow.py +++ b/packages/syft-bg/src/syft_bg/cli/init/flow.py @@ -7,6 +7,8 @@ from syft_bg.cli.init.exceptions import InitFlowError from syft_bg.common.config import get_default_paths +from syft_rds import EncryptionPolicy + from syft_bg.common.syft_bg_config import SyftBgConfig @@ -21,6 +23,7 @@ class UserPassedConfig: # Core settings email: str | None = None syftbox_root: str | None = None + encryption: str | None = None # Control flags yes: bool = False # Auto-confirm config update @@ -116,6 +119,25 @@ def _resolve_common_settings(config: UserPassedConfig, result: SyftBgConfig) -> "SyftBox root directory", default=default_syftbox ) + if config.encryption is not None: + result.encryption_policy = EncryptionPolicy(config.encryption) + elif config.quiet: + if result.encryption_policy is None: + raise InitFlowError( + "--encryption is required in quiet mode\n" + "Usage: syft-bg init --email user@example.com --encryption " + "optional --quiet" + ) + else: + current = result.encryption_policy + result.encryption_policy = EncryptionPolicy( + click.prompt( + "Encryption policy (use the same as the notebook login)", + type=click.Choice([p.value for p in EncryptionPolicy]), + default=current.value if current is not None else None, + ) + ) + # def _setup_auth( # user_passed_config: UserPassedConfig, diff --git a/packages/syft-bg/src/syft_bg/common/syft_bg_config.py b/packages/syft-bg/src/syft_bg/common/syft_bg_config.py index c56753722c5..c28fc14cea9 100644 --- a/packages/syft-bg/src/syft_bg/common/syft_bg_config.py +++ b/packages/syft-bg/src/syft_bg/common/syft_bg_config.py @@ -25,9 +25,9 @@ class SyftBgConfig(BaseModel): do_email: str | None = None syftbox_root: str | Path | None = None - # Must match the policy the notebook logs in with: both write the same - # datasite, at rest and to the same peers. - encryption_policy: EncryptionPolicy = EncryptionPolicy.OPTIONAL + # Set by init. Must match the policy the notebook logs in with: both + # write the same datasite, at rest and to the same peers. + encryption_policy: EncryptionPolicy | None = None credentials_path: Path = Field( default_factory=lambda: get_default_paths().credentials ) diff --git a/packages/syft-bg/src/syft_bg/sync/orchestrator.py b/packages/syft-bg/src/syft_bg/sync/orchestrator.py index f0b49e14f0b..0daa623923a 100644 --- a/packages/syft-bg/src/syft_bg/sync/orchestrator.py +++ b/packages/syft-bg/src/syft_bg/sync/orchestrator.py @@ -42,7 +42,9 @@ def from_config(cls, config: SyncConfig) -> SyncOrchestrator: if not config.syftbox_root: raise ValueError("SyncConfig missing 'syftbox_root'") if config.encryption_policy is None: - raise ValueError("SyncConfig missing 'encryption_policy'") + raise ValueError( + "SyncConfig missing 'encryption_policy': run syft-bg init with an encryption policy" + ) from syft_rds import ( Environment, diff --git a/packages/syft-rds/tests/test_version_negotiation.py b/packages/syft-rds/tests/test_version_negotiation.py index f2cad3a2479..992ce6b0038 100644 --- a/packages/syft-rds/tests/test_version_negotiation.py +++ b/packages/syft-rds/tests/test_version_negotiation.py @@ -10,6 +10,7 @@ from syft_job.config import protocol_dir_name from syft_job.migrations.registry import JOB_PROTOCOL_VERSION from syft_rds import SyftRDSClient +from syft.sync.peers.exceptions import SyftPeerNotReadyError def build_client_version(version: str) -> VersionInfo: @@ -71,12 +72,15 @@ def test_job_submission_allowed_with_force(self): job_name="test.fail.job", ) - ds.submit_python_job( - user=do.email, - code_path=test_py_path, - job_name="test.force.job", - force_submission=True, - ) + # Force skips the version gate, but the DO policy is not known before it + # accepts, so the send raises. The job is written locally. + with pytest.raises(SyftPeerNotReadyError, match="not known"): + ds.submit_python_job( + user=do.email, + code_path=test_py_path, + job_name="test.force.job", + force_submission=True, + ) job_dir = ( ds.syftbox_folder diff --git a/syft/sync/login_utils.py b/syft/sync/login_utils.py index 86e5a0ad33b..1d97190d5ed 100644 --- a/syft/sync/login_utils.py +++ b/syft/sync/login_utils.py @@ -46,9 +46,9 @@ def _handle_version_incompatible( if choice == "1": print( f"Continuing with v{SYFT_VERSION}. Local and remote data are kept.\n" - "Encryption keys are the one exception. A key file from a newer " - "client is refused, because a private key cannot be rebuilt. Install " - "that client to use those keys.\n" + "Encryption keys are the one exception. A key file of another " + "version is refused, because a private key cannot be rebuilt. Install " + "the client that wrote it to use those keys.\n" ) return True if choice == "2": diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index e12fe067135..dd19aa4d9a1 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -1005,8 +1005,6 @@ def sync( peer_emails = self.peer_manager.without_encryption_conflicts( peer_emails ) - # Changes that waited for encryption with their recipient. - self.datasite_watcher_syncer.process_file_changes_queue() self.datasite_watcher_syncer.sync_down(peer_emails) def load_peers(self, force_download: bool = False): diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index 81eb8d4fbdb..5567209e4ce 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -1,4 +1,3 @@ -import warnings from pathlib import Path from concurrent.futures import ThreadPoolExecutor from queue import Queue @@ -105,13 +104,20 @@ def process_file_changes_queue(self): self._send_by_recipient(changes_by_recipient) def _send_by_recipient( - self, changes_by_recipient: Dict[str, List[ProposedFileChange]] + self, changes_by_recipient: dict[str, list[ProposedFileChange]] ) -> None: """Send one message to each recipient. - A recipient that cannot receive now is skipped with a warning, and its - changes are dropped. The other recipients still get their changes. + The changes for a recipient that cannot receive now are dropped. The + other recipients still get their changes, and then the first error is + raised. + + Raises: + SyftPeerNotReadyError: when the policy of a recipient is not known, + or the pair encrypts and the peer bundle is missing. + EncryptionPolicyConflictError: when the policies of a pair conflict. """ + error = None for recipient, changes in changes_by_recipient.items(): message = ProposedFileChangesMessage( sender_email=self.email, proposed_file_changes=changes @@ -119,7 +125,9 @@ def _send_by_recipient( try: self._send_message(recipient, message) except (SyftPeerNotReadyError, EncryptionPolicyConflictError) as e: - warnings.warn(f"Dropped the changes for {recipient}: {e}") + error = error or e + if error is not None: + raise error def _record_sent_hashes(self, message: ProposedFileChangesMessage) -> None: hashes = self.datasite_watcher_cache.file_hashes diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index bac8716c8d9..5a3e952fcc5 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -662,13 +662,22 @@ def load_peers(self, force_download: bool = False): email, PeerState.ACCEPTED.value ) - if self._states_at_start is None: + first_load = self._states_at_start is None + if first_load: self._states_at_start = { p.email: p.state for p in peers if p.state in (PeerState.ACCEPTED, PeerState.REQUESTED_BY_PEER) } self.peer_store.set_peers(peers) + if first_load: + self._check_published_bundles( + [ + p.email + for p in peers + if p.state in (PeerState.ACCEPTED, PeerState.REQUESTED_BY_ME) + ] + ) self.load_peer_versions_parallel([peer.email for peer in peers]) # Peer policies are known only after the versions load. @@ -680,6 +689,34 @@ def load_peers(self, force_download: bool = False): ] self._read_missing_peer_bundles(encrypted) + def _check_published_bundles(self, peer_emails: list[str]) -> None: + """Raise when a bundle this client published differs from its keys. + + A runtime without the key file of the account makes new keys, and + peers keep the bundle of the earlier keys. The keys do not change + while the process runs, so the first peer load checks once. + + Raises: + ValueError: when a published bundle carries other keys. + """ + own_bundle = self.peer_store.get_public_bundle() + published = self._executor.map(self._read_own_published_bundle, peer_emails) + for peer_email, bundle in zip(peer_emails, published): + if bundle is not None and bundle != own_bundle: + raise ValueError( + f"The encryption key published for {peer_email} is not the " + "key of this client: this account already has a key. Log in " + "again with the key file of this account." + ) + + def _read_own_published_bundle(self, peer_email: str) -> dict | None: + """The bundle this client published for ``peer_email``, or None (thread-safe).""" + connection = self.connection_router.connection_for_version_read(create_new=True) + bundle_json = connection.read_own_encryption_bundle(peer_email) + if bundle_json is None: + return None + return json.loads(bundle_json)["public_encryption_bundle"] + def _read_published_bundle(self, peer_email: str) -> dict | None: """The bundle ``peer_email`` published for us, or None when it is missing or malformed.""" try: @@ -688,7 +725,7 @@ def _read_published_bundle(self, peer_email: str) -> dict | None: warnings.warn(f"Refusing the encryption key of {peer_email}: {e}") return None - def _read_missing_peer_bundles(self, peers: List[Peer]) -> None: + def _read_missing_peer_bundles(self, peers: list[Peer]) -> None: """Read the published bundle of every peer we hold none for.""" for peer in peers: if self.peer_store.has_peer_bundle(peer.email): diff --git a/tests/integration/syft_bg/test_cli.py b/tests/integration/syft_bg/test_cli.py index bf4f4b3d21b..c1bdf000bb6 100644 --- a/tests/integration/syft_bg/test_cli.py +++ b/tests/integration/syft_bg/test_cli.py @@ -27,13 +27,16 @@ class TestInitCommand: def test_init_basic(self, mock_api_init): """Should pass email to api.init.""" runner = CliRunner() - result = runner.invoke(init, ["-e", "alice@uni.edu"]) + result = runner.invoke( + init, ["-e", "alice@uni.edu", "--encryption", "optional"] + ) assert result.exit_code == 0 mock_api_init.assert_called_once_with( do_email="alice@uni.edu", syftbox_root=None, token_path=None, + encryption="optional", ) @patch("syft_bg.api.api.init") @@ -50,6 +53,15 @@ def test_init_all_options(self, mock_api_init): # unless file exists. Test the basic flow instead. mock_api_init.assert_not_called() # file doesn't exist + @patch("syft_bg.api.api.init") + def test_init_requires_encryption(self, mock_api_init): + """Should error when the encryption policy is not provided.""" + runner = CliRunner() + result = runner.invoke(init, ["-e", "alice@uni.edu"]) + + assert result.exit_code != 0 + mock_api_init.assert_not_called() + @patch("syft_bg.api.api.init") def test_init_requires_email(self, mock_api_init): """Should error when email not provided.""" diff --git a/tests/integration/without_unit_coverage/test_sync_manager.py b/tests/integration/without_unit_coverage/test_sync_manager.py index 52e57dbad5a..99388e6232c 100644 --- a/tests/integration/without_unit_coverage/test_sync_manager.py +++ b/tests/integration/without_unit_coverage/test_sync_manager.py @@ -5,10 +5,12 @@ cannot be adequately tested with mocked connections. """ +from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.syftbox_manager import SyftboxManager +from syft.sync.utils.waiting import poll_until import os +import time from pathlib import Path -from time import sleep import pytest @@ -28,6 +30,17 @@ token_path_do = CREDENTIALS_DIR / FILE_DO token_path_ds = CREDENTIALS_DIR / FILE_DS +# Drive lists a new file some time after the write. +POLL_TIMEOUT = 60 +POLL_INTERVAL = 1 + + +def _poll(check, what: str): + """Call ``check`` until it returns a value that is not None.""" + result = poll_until(check, time.monotonic() + POLL_TIMEOUT, POLL_INTERVAL) + assert result is not None, f"Timed out after {POLL_TIMEOUT}s waiting for {what}" + return result + @pytest.mark.flaky(reruns=3, reruns_delay=2) @pytest.mark.usefixtures("setup_delete_syftboxes") @@ -37,10 +50,10 @@ def test_peer_request_blocks_sync_until_approved(): Workflow: 1. DS adds DO as peer (creates peer request) - 2. DS submits a job + 2. DS submits a job - the send raises (DO policy not known) 3. DO syncs - nothing should sync (peer not approved) 4. DO approves peer request - 5. DS syncs (sends the held job), DO syncs - job should now sync + 5. DS syncs and submits again, DO syncs - job should now sync """ # Create managers with Google Drive connection, no auto-add peers ds_manager, do_manager = SyftboxManager._pair_with_google_drive_testing_connection( @@ -59,11 +72,12 @@ def test_peer_request_blocks_sync_until_approved(): # Step 1: DS makes peer request by adding DO ds_manager.add_peer(do_manager.email) - # Wait for sync - sleep(1) - # Verify: DO sees this as a pending request - do_manager.load_peers() + def request_seen(): + do_manager.load_peers() + return do_manager.peer_manager.requested_by_peer_peers or None + + _poll(request_seen, "the peer request") assert len(do_manager.peer_manager.requested_by_peer_peers) == 1 assert len(do_manager.peer_manager.approved_peers) == 0 assert do_manager.peer_manager.requested_by_peer_peers[0].email == ds_manager.email @@ -71,10 +85,8 @@ def test_peer_request_blocks_sync_until_approved(): # Step 2: DS submits a simple job job_file_path = f"{do_manager.email}/test.job" job_content = "print('Hello from DS')" - ds_manager._send_file_change(job_file_path, job_content) - - # Wait for message to be sent - sleep(1) + with pytest.raises(SyftPeerNotReadyError): + ds_manager._send_file_change(job_file_path, job_content) # Step 3: DO syncs WITHOUT accepting - nothing should sync do_manager.sync() @@ -91,13 +103,24 @@ def test_peer_request_blocks_sync_until_approved(): assert len(do_manager.peer_manager.approved_peers) == 1 assert do_manager.peer_manager.approved_peers[0].email == ds_manager.email - # Step 5: DS syncs, which sends the job it held while the DO policy was - # unknown. DO syncs again - now it should work - ds_manager.sync() - do_manager.sync() + # Step 5: DS syncs, which reads the DO policy, and submits again. DO syncs + # again - now it should work + def resent(): + ds_manager.sync() + try: + ds_manager._send_file_change(job_file_path, job_content) + except SyftPeerNotReadyError: + return None # The DS does not see the DO version file yet. + return True + + _poll(resent, "the DO policy") + + def job_received(): + do_manager.sync() + return do_cache.file_hashes or None # Verify: File synced and in cache - assert len(do_cache.file_hashes) > 0, "Cache should have content after approval" + _poll(job_received, "the job at the DO") # Verify: File is tracked in cache with correct path (stored as PosixPath) expected_cache_path = Path("test.job") diff --git a/tests/unit/syft_bg/test_init.py b/tests/unit/syft_bg/test_init.py index 0f31e41291a..df44ca0b957 100644 --- a/tests/unit/syft_bg/test_init.py +++ b/tests/unit/syft_bg/test_init.py @@ -5,9 +5,12 @@ from pathlib import Path from unittest.mock import patch +import pytest + from syft_rds import EncryptionPolicy from syft_bg.api.api import init +from syft_bg.cli.init import InitFlowError, UserPassedConfig, run_init_flow from syft_bg.api.utils import move_token_to_syftbg_dir from syft_bg.common.config import get_default_paths from syft_bg.common.syft_bg_config import SyftBgConfig @@ -62,7 +65,7 @@ def test_sets_both_gmail_and_drive_token_path(self, temp_dir): token_source.write_text("{}") with _patched_paths(temp_dir) as patched: - init(do_email="alice@test.com", token_path=token_source) + init(do_email="alice@test.com", token_path=token_source, encryption=False) config = SyftBgConfig.from_path(patched.config) @@ -74,12 +77,9 @@ def test_sets_both_gmail_and_drive_token_path(self, temp_dir): class TestInitEncryption: """api.init(encryption=...) sets the policy of every service that syncs.""" - def test_default_is_optional(self, temp_dir): - with _patched_paths(temp_dir) as patched: + def test_policy_is_required(self, temp_dir): + with _patched_paths(temp_dir), pytest.raises(TypeError, match="encryption"): init(do_email="alice@test.com") - config = SyftBgConfig.from_path(patched.config) - - assert config.sync.encryption_policy == EncryptionPolicy.OPTIONAL def test_required_reaches_sync_and_approve(self, temp_dir): with _patched_paths(temp_dir) as patched: @@ -88,3 +88,23 @@ def test_required_reaches_sync_and_approve(self, temp_dir): assert config.sync.encryption_policy == EncryptionPolicy.REQUIRED assert config.approve.encryption_policy == EncryptionPolicy.REQUIRED + + +class TestInitFlowEncryption: + """The interactive init flow sets the policy; quiet mode needs it.""" + + def _run_quiet(self, temp_dir, **kwargs): + with _patched_paths(temp_dir) as patched: + with patch("syft_bg.cli.init.flow.get_default_paths", return_value=patched): + run_init_flow( + UserPassedConfig(email="alice@test.com", quiet=True, **kwargs) + ) + return SyftBgConfig.from_path(patched.config) + + def test_quiet_mode_requires_policy(self, temp_dir): + with pytest.raises(InitFlowError, match="--encryption"): + self._run_quiet(temp_dir) + + def test_quiet_mode_sets_policy(self, temp_dir): + config = self._run_quiet(temp_dir, encryption="required") + assert config.sync.encryption_policy == EncryptionPolicy.REQUIRED diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 29ab1192ab3..862cfac9788 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -5,6 +5,7 @@ side is ``off``. """ +import json from pathlib import Path import pytest @@ -16,6 +17,7 @@ EncryptionPolicy, EncryptionPolicyConflictError, MessageFormatError, + PeerOutdatedError, ) from syft.sync.events.file_change_event import ( FileChangeEventsMessage, @@ -193,7 +195,7 @@ def test_outdated_peer_skipped_with_upgrade_warning(): ds_manager.load_peers() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - with pytest.warns(UserWarning, match="Dropped the changes.*upgrade"): + with pytest.raises(PeerOutdatedError, match="upgrade"): ds_manager._send_file_change(file_path, "content") with pytest.warns(UserWarning, match="upgrade"): ds_manager.sync() @@ -320,6 +322,20 @@ def test_plaintext_pair_publishes_bundles_at_peering(): assert do_router.read_peer_encryption_bundle(ds_manager.email) is not None +def test_first_peer_load_refuses_bundle_of_other_keys(): + """A new runtime without the key file has new keys; peers hold the old bundle.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + earlier = PeerStore(email=do_manager.email) + earlier.generate_keys() + do_manager.peer_manager.connection_router.write_encryption_bundle( + ds_manager.email, + json.dumps({"public_encryption_bundle": earlier.get_public_bundle()}), + ) + do_manager.peer_manager._states_at_start = None # as a new process + with pytest.raises(ValueError, match="already has a key"): + do_manager.load_peers() + + # ========================================================================= # Receive path # ========================================================================= @@ -429,7 +445,7 @@ def test_off_and_required_peers_skip_each_other(): ds_manager.load_peers() file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - with pytest.warns(UserWarning, match="Dropped the changes"): + with pytest.raises(EncryptionPolicyConflictError): ds_manager._send_file_change(file_path, "content") with pytest.warns(UserWarning, match="skipped"): do_manager.sync() @@ -499,7 +515,7 @@ def _queued_changes(manager) -> int: @pytest.mark.parametrize("do_policy", [OPTIONAL, REQUIRED]) -def test_send_before_owner_accepts_is_dropped(do_policy): +def test_send_before_owner_accepts_raises(do_policy): """Before the owner accepts, its policy is not known, so nothing is sent.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, @@ -508,7 +524,7 @@ def test_send_before_owner_accepts_is_dropped(do_policy): ds_manager.add_peer(do_manager.email, sync=False) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - with pytest.warns(UserWarning, match="Dropped the changes.*not known"): + with pytest.raises(SyftPeerNotReadyError, match="not known"): ds_manager._send_file_change(file_path, "early content") assert _queued_changes(ds_manager) == 0 @@ -519,12 +535,12 @@ def test_send_before_owner_accepts_is_dropped(do_policy): assert not any("early content" in c for c in _accepted_contents(do_manager)) -def test_send_to_non_peer_dropped(): +def test_send_to_non_peer_raises(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, ) file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - with pytest.warns(UserWarning, match="Dropped the changes.*not known"): + with pytest.raises(SyftPeerNotReadyError, match="not known"): ds_manager._send_file_change(file_path, "content") @@ -572,7 +588,7 @@ def send_message(self, recipient, message): watcher = ds_manager.datasite_watcher_syncer watcher.on_file_change("other@example.com/a.txt", "a", process_now=False) watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) - with pytest.warns(UserWarning, match="Dropped the changes for other@example.com"): + with pytest.raises((EncryptionPolicyConflictError, SyftPeerNotReadyError)): watcher.process_file_changes_queue() assert sent == [do_manager.email] assert _queued_changes(ds_manager) == 0 diff --git a/tests/unit/test_peer_requests.py b/tests/unit/test_peer_requests.py index 9dcbe956bdb..3ce239fc408 100644 --- a/tests/unit/test_peer_requests.py +++ b/tests/unit/test_peer_requests.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.syftbox_manager import SyftboxManager from tests.unit.test_sync_manager import path_for_job @@ -13,7 +14,7 @@ def test_peer_request_blocks_sync_until_approved(): Workflow: 1. DS adds DO as peer (creates peer request) - 2. DS submits a job - it is dropped (DO policy not known) + 2. DS submits a job - the send raises (DO policy not known) 3. DO syncs - nothing should sync (peer not approved) 4. DO approves peer request 5. DS syncs and submits again, DO syncs - job should now sync @@ -38,7 +39,7 @@ def test_peer_request_blocks_sync_until_approved(): # Step 2: DS submits a file to their job folder job_file_path = path_for_job(do_manager.email, ds_manager.email) job_content = "print('Hello from DS')" - with pytest.warns(UserWarning, match="Dropped the changes"): + with pytest.raises(SyftPeerNotReadyError, match="not known"): ds_manager._send_file_change(job_file_path, job_content) # Step 3: DO syncs WITHOUT accepting - nothing should sync @@ -91,9 +92,10 @@ def test_peer_request_rejection(): ds_manager.add_peer(do_manager.email) do_manager.load_peers() - # DS sends a job + # DS sends a job; the DO policy is not known before it accepts job_file_path = path_for_job(do_manager.email, ds_manager.email) - ds_manager._send_file_change(job_file_path, "print('test')") + with pytest.raises(SyftPeerNotReadyError): + ds_manager._send_file_change(job_file_path, "print('test')") # DO rejects the peer request do_manager.reject_peer_request(ds_manager.email) From 9c8ce4227349640007c8140a3bc638c1a81f3349 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Fri, 23 Oct 2026 21:00:56 -0300 Subject: [PATCH 23/30] refactor: remove the outdated peer handling A peer whose version file has no encryption policy now has a policy that is not known, like any other peer. The version file a client shares always carries its own version. --- docs/connections.md | 2 +- syft/sync/connections/connection_router.py | 2 +- .../connections/drive/drive_connections.py | 4 +- .../connections/drive/gdrive_transport.py | 10 ++--- syft/sync/encryption_policy.py | 11 ----- syft/sync/peers/peer.py | 5 --- syft/sync/peers/peer_store.py | 10 ++--- syft/sync/transport/protocols.py | 4 +- syft/sync/version/peer_manager.py | 2 - syft/sync/version/version_info.py | 2 +- tests/unit/test_drive_connections.py | 2 +- tests/unit/test_encryption_policy.py | 42 +++---------------- 12 files changed, 18 insertions(+), 78 deletions(-) diff --git a/docs/connections.md b/docs/connections.md index ee5e86fce1f..a88dd3f0c90 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -66,7 +66,7 @@ Nothing is sent to a peer until its policy is known, that is, until it accepts a The format of a message must match the pair: plaintext in a pair that encrypts is refused, and so is an envelope in a pair that does not. The owner archives such a message unread, and a scientist skips it, each with a warning. -A peer whose version file has no policy runs an outdated syft. It is skipped with a warning to upgrade. +A peer whose version file has no policy has a policy that is not known: nothing is exchanged with it. ## Peer Requests diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index f04bac8acc3..bbe2f409b9b 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -458,7 +458,7 @@ def read_peer_version_file(self, peer_email: str) -> Optional["VersionInfo"]: return self.transport.peering.read_peer_version(peer_email) def share_version_file_with_peer( - self, peer_email: str, version_info: Optional["VersionInfo"] = None + self, peer_email: str, version_info: "VersionInfo" ) -> None: """Share version file with a peer so they can read it.""" self.transport.peering.grant_version_read(peer_email, version_info) diff --git a/syft/sync/connections/drive/drive_connections.py b/syft/sync/connections/drive/drive_connections.py index 5579c42dcde..5733086996b 100644 --- a/syft/sync/connections/drive/drive_connections.py +++ b/syft/sync/connections/drive/drive_connections.py @@ -250,9 +250,7 @@ def read_own_version(self) -> VersionInfo | None: def read_peer_version(self, peer: str) -> VersionInfo | None: return self._conn.read_peer_version_file(peer) - def grant_version_read( - self, peer: str, version_info: VersionInfo | None = None - ) -> None: + def grant_version_read(self, peer: str, version_info: VersionInfo) -> None: self._conn.share_version_file_with_peer(peer, version_info) diff --git a/syft/sync/connections/drive/gdrive_transport.py b/syft/sync/connections/drive/gdrive_transport.py index 065da9fb78c..8171a964661 100644 --- a/syft/sync/connections/drive/gdrive_transport.py +++ b/syft/sync/connections/drive/gdrive_transport.py @@ -1929,20 +1929,16 @@ def read_peer_version_file(self, peer_email: str) -> Optional["VersionInfo"]: return None def share_version_file_with_peer( - self, peer_email: str, version_info: Optional["VersionInfo"] = None + self, peer_email: str, version_info: "VersionInfo" ) -> None: """Share the version file with a peer so they can read it. A missing file is created from ``version_info``, the version of this - client. Without it, the file says only what any client of this release - says, and not the encryption policy of this client. + client. """ file_id = self._get_version_file_id() if file_id is None: - # Version file doesn't exist yet, create it first - from syft.sync.version.version_info import VersionInfo - - self.write_version_file(version_info or VersionInfo.current()) + self.write_version_file(version_info) file_id = self._get_version_file_id() if file_id: diff --git a/syft/sync/encryption_policy.py b/syft/sync/encryption_policy.py index 114bda0e2c6..f97f8790a33 100644 --- a/syft/sync/encryption_policy.py +++ b/syft/sync/encryption_policy.py @@ -43,17 +43,6 @@ def __init__(self, peer_email: str, message: str | None = None): self.peer_email = peer_email -class PeerOutdatedError(EncryptionPolicyConflictError): - """The peer runs a syft that predates encryption negotiation.""" - - def __init__(self, peer_email: str): - super().__init__( - peer_email, - f"{peer_email} runs an outdated syft: its version file has no " - "encryption policy. Ask them to upgrade syft.", - ) - - class MessageFormatError(ValueError): """A message is plaintext in a pair that encrypts, or the other way around.""" diff --git a/syft/sync/peers/peer.py b/syft/sync/peers/peer.py index e3222786769..29313f01372 100644 --- a/syft/sync/peers/peer.py +++ b/syft/sync/peers/peer.py @@ -38,11 +38,6 @@ def encryption_policy(self) -> Optional[EncryptionPolicy]: return None return self.version.encryption_policy - @property - def is_outdated(self) -> bool: - """Whether the peer runs a syft that predates encryption negotiation.""" - return self.version is not None and self.version.encryption_policy is None - @property def is_approved(self) -> bool: """Returns True if peer is accepted""" diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 0f6e4671d3d..a56bd2a9689 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -16,7 +16,6 @@ from syft.sync.encryption_policy import ( EncryptionPolicy, EncryptionPolicyConflictError, - PeerOutdatedError, MessageFormatError, pair_conflicts, pair_uses_encryption, @@ -53,7 +52,7 @@ class PeerStore(BaseModel): _private_keys: syc.SyftPrivateKeys | None = PrivateAttr(default=None) _peers: List[Peer] = PrivateAttr(default_factory=list) - # The last policy known of each peer in this process (see peer_policy). + # The last policy known of each peer in this process (see policy_of). _last_known_policies: dict[str, EncryptionPolicy] = PrivateAttr( default_factory=dict ) @@ -162,7 +161,7 @@ def policy_of(self, peer: Peer) -> Optional[EncryptionPolicy]: applies. """ policy = peer.encryption_policy - if policy is None and not peer.is_outdated: + if policy is None: return self._last_known_policies.get(peer.email) self._last_known_policies[peer.email] = policy return policy @@ -170,11 +169,8 @@ def policy_of(self, peer: Peer) -> Optional[EncryptionPolicy]: def conflict_error(self, peer: Peer) -> EncryptionPolicyConflictError | None: """Why this client and ``peer`` cannot exchange messages, or None when they can. - That is when one side has encryption off and the other requires it, - or when the peer runs an outdated syft. + That is when one side has encryption off and the other requires it. """ - if peer.is_outdated: - return PeerOutdatedError(peer.email) if pair_conflicts(self.encryption_policy, self.policy_of(peer)): return EncryptionPolicyConflictError(peer.email) return None diff --git a/syft/sync/transport/protocols.py b/syft/sync/transport/protocols.py index 46c60c17a7f..0880dc01b03 100644 --- a/syft/sync/transport/protocols.py +++ b/syft/sync/transport/protocols.py @@ -291,9 +291,7 @@ def read_peer_version(self, peer: str) -> VersionInfo | None: """``peer``'s published version document, or None.""" @abstractmethod - def grant_version_read( - self, peer: str, version_info: VersionInfo | None = None - ) -> None: + def grant_version_read(self, peer: str, version_info: VersionInfo) -> None: """Let ``peer`` read my version document. When I have no document yet, publish ``version_info`` first. diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 5a3e952fcc5..4f76f725c39 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -481,8 +481,6 @@ def without_encryption_conflicts(self, peer_emails: List[str]) -> List[str]: """``peer_emails`` without the peers whose encryption policy conflicts. Such a pair cannot exchange messages, so it is skipped with a warning. - A peer that runs an outdated syft is skipped too, with a warning to - upgrade. ``ignore_peer_version`` does not apply: it is not a version question. """ result = [] diff --git a/syft/sync/version/version_info.py b/syft/sync/version/version_info.py index 1aa073b813b..32e36e1453d 100644 --- a/syft/sync/version/version_info.py +++ b/syft/sync/version/version_info.py @@ -216,7 +216,7 @@ class VersionInfoV2(VersionInfoV1): extra: dict[str, Any] = Field(default_factory=dict) # Peers read it to select encryption for the pair. None in a file from a - # syft that predates it: such a peer is outdated and must upgrade. + # syft without it: the policy of that peer is not known. encryption_policy: Optional[EncryptionPolicy] = None @classmethod diff --git a/tests/unit/test_drive_connections.py b/tests/unit/test_drive_connections.py index deea1c09775..c4114559d4b 100644 --- a/tests/unit/test_drive_connections.py +++ b/tests/unit/test_drive_connections.py @@ -245,7 +245,7 @@ def test_peering_publishes_a_version_the_peer_can_read(pair): version = VersionInfo.current() owner.peering.publish_version(version) - owner.peering.grant_version_read(DS) + owner.peering.grant_version_read(DS, version) assert owner.peering.read_own_version() == version assert scientist.peering.read_peer_version(DO) == version diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 862cfac9788..ba9b5e358bd 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -17,7 +17,6 @@ EncryptionPolicy, EncryptionPolicyConflictError, MessageFormatError, - PeerOutdatedError, ) from syft.sync.events.file_change_event import ( FileChangeEventsMessage, @@ -73,11 +72,6 @@ def _keyed_pair( return alice, bob -def _forget(store: PeerStore, email: str) -> None: - """Make ``store`` forget what it knew of ``email``'s policy, as a new client.""" - store._last_known_policies.pop(email, None) - - def _raw_inbox_message(do_manager, sender_email: str) -> bytes: connection = do_manager.datasite_owner_syncer.connection_router raw, _ = ( @@ -112,8 +106,6 @@ def _pair_with_stale_sender_view(): do_manager.load_peers() stale = ds_manager.peer_manager.get_cached_peer(do_manager.email) stale.version = _version_with_policy(OPTIONAL) - # A client that never saw the owner as required. - _forget(ds_manager.peer_manager.peer_store, do_manager.email) return ds_manager, do_manager @@ -173,34 +165,6 @@ def test_encryption_setting_coerced_to_policy(value, expected): assert EncryptionPolicy.coerce(value) == expected -def test_version_info_without_policy_field_has_no_policy(): - """A version file from a client that predates the field says nothing.""" - data = VersionInfo.current().model_dump(mode="json") - data.pop("encryption_policy") - assert VersionInfo.model_validate(data).encryption_policy is None - - -def _advertise_as_outdated_client(manager) -> None: - """Write the version file of ``manager`` without the policy field.""" - outdated = manager.peer_manager.get_own_version().model_copy() - outdated.encryption_policy = None - manager.peer_manager.connection_router.write_version_file(outdated) - - -def test_outdated_peer_skipped_with_upgrade_warning(): - """A peer without a policy in its version file runs an outdated syft.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - grant_job_inbox_access(do_manager, ds_manager.email) - _advertise_as_outdated_client(do_manager) - ds_manager.load_peers() - - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - with pytest.raises(PeerOutdatedError, match="upgrade"): - ds_manager._send_file_change(file_path, "content") - with pytest.warns(UserWarning, match="upgrade"): - ds_manager.sync() - - def test_own_version_advertises_policy(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=OPTIONAL, @@ -292,6 +256,12 @@ def test_sender_refuses_peer_with_unknown_policy(policy): store.encrypt_if_needed("bob@example.com", b"data") +def test_peer_version_without_policy_is_not_known(): + store = _store_with_peer(OPTIONAL, None) + with pytest.raises(SyftPeerNotReadyError, match="not known"): + store.encrypt_if_needed("bob@example.com", b"data") + + def test_encrypting_pair_without_peer_bundle_not_ready(): store = _store_with_peer(REQUIRED, OPTIONAL) store.generate_keys() From 47bf16d0d4e3d39eaf39240ab3b8a1bd434ae7e8 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Fri, 23 Oct 2026 21:43:58 -0300 Subject: [PATCH 24/30] refactor: always require encryption in the enclave and type the key file The enclave no longer has a use_encryption setting: it always requires encryption. A published bundle that does not parse now raises. The key file is a frozen model that is checked when it is read. --- .../docker/inference_server.py | 2 +- .../scripts/local_server.py | 2 +- .../src/enclave_model_api/__main__.py | 2 - .../src/enclave_model_api/server.py | 14 +--- .../tests/test_inference_server.py | 3 +- packages/syft-enclave/README.md | 2 +- .../src/syft_enclaves/__main__.py | 3 +- .../syft-enclave/src/syft_enclaves/client.py | 20 ++--- .../src/syft_enclaves/settings.py | 8 -- .../syft-enclave/tests/test_enclave_jobs.py | 6 +- .../syft-enclave/tests/test_encryption.py | 26 ++++-- packages/syft-enclave/tests/test_settings.py | 7 -- syft/sync/peers/peer_store.py | 84 +++++++++++-------- syft/sync/version/peer_manager.py | 22 ++--- tests/unit/test_crypto_keys_version.py | 15 ++++ tests/unit/test_encryption_policy.py | 5 +- 16 files changed, 113 insertions(+), 108 deletions(-) diff --git a/packages/enclave-model-api-example/docker/inference_server.py b/packages/enclave-model-api-example/docker/inference_server.py index cad573a4b60..b73b6d8c1c5 100644 --- a/packages/enclave-model-api-example/docker/inference_server.py +++ b/packages/enclave-model-api-example/docker/inference_server.py @@ -48,4 +48,4 @@ ), ) service.start_polling() -app.include_router(build_router(service, use_encryption=settings.use_encryption)) +app.include_router(build_router(service)) diff --git a/packages/enclave-model-api-example/scripts/local_server.py b/packages/enclave-model-api-example/scripts/local_server.py index b5676054772..a1fec698822 100644 --- a/packages/enclave-model-api-example/scripts/local_server.py +++ b/packages/enclave-model-api-example/scripts/local_server.py @@ -46,7 +46,7 @@ ) service.start_polling() uvicorn.run( - create_app(service, use_encryption=settings.use_encryption), + create_app(service), host="0.0.0.0", port=8080, ) diff --git a/packages/enclave-model-api-example/src/enclave_model_api/__main__.py b/packages/enclave-model-api-example/src/enclave_model_api/__main__.py index b8b307b2967..e3a4c42d04e 100644 --- a/packages/enclave-model-api-example/src/enclave_model_api/__main__.py +++ b/packages/enclave-model-api-example/src/enclave_model_api/__main__.py @@ -48,7 +48,6 @@ def main() -> None: f"Enclave settings — email={settings.email} data_owners={settings.data_owners} " f"token_path={settings.token_path} poll_interval={settings.poll_interval}s " f"require_tee={settings.require_tee} fresh_state={settings.fresh_state} " - f"use_encryption={settings.use_encryption} " f"persist_owner_state={settings.persist_owner_state}" ) logger.info( @@ -62,7 +61,6 @@ def main() -> None: email=settings.email, token_path=settings.token_path, data_owners=settings.data_owners, - encryption=settings.use_encryption, persist_owner_state=settings.persist_owner_state, ) logger.info("SyftEnclaveClient ready") diff --git a/packages/enclave-model-api-example/src/enclave_model_api/server.py b/packages/enclave-model-api-example/src/enclave_model_api/server.py index e4cc0d86983..ff0373a35a9 100644 --- a/packages/enclave-model-api-example/src/enclave_model_api/server.py +++ b/packages/enclave-model-api-example/src/enclave_model_api/server.py @@ -20,9 +20,7 @@ class InferResponse(BaseModel): stats: dict -def build_router( - service: InferenceService, use_encryption: bool | None = None -) -> APIRouter: +def build_router(service: InferenceService) -> APIRouter: router = APIRouter() @router.post("/infer", response_model=InferResponse) @@ -44,21 +42,15 @@ def model_status() -> dict: "mock": not getattr(service.backend, "requires_weights", True), "weights_present": service.weights_present, "model_loaded": service.loaded, - # Whether the enclave encrypts all Drive communication. Data owners - # must use the SAME setting or peering/sync silently fails, so the - # notebook reads this to match login_do(encryption=...) automatically. - "use_encryption": use_encryption, } return router -def create_app( - service: InferenceService, use_encryption: bool | None = None -) -> FastAPI: +def create_app(service: InferenceService) -> FastAPI: """Standalone app for tests and local runs (docker combines with attestation).""" app = FastAPI(title="Syft Enclave Inference", version="0.1.0") - app.include_router(build_router(service, use_encryption=use_encryption)) + app.include_router(build_router(service)) @app.get("/health") def health() -> dict: diff --git a/packages/enclave-model-api-example/tests/test_inference_server.py b/packages/enclave-model-api-example/tests/test_inference_server.py index 5866ea319de..062617df0dc 100644 --- a/packages/enclave-model-api-example/tests/test_inference_server.py +++ b/packages/enclave-model-api-example/tests/test_inference_server.py @@ -104,7 +104,7 @@ def test_inference_server_full_lifecycle(tmp_path): weights_dir=weights_dir, logs_dir=logs_dir, ) - client = TestClient(create_app(service, use_encryption=True)) + client = TestClient(create_app(service)) # Weights not synced yet status = client.get("/model-status").json() @@ -113,7 +113,6 @@ def test_inference_server_full_lifecycle(tmp_path): "mock": False, "weights_present": False, "model_loaded": False, - "use_encryption": True, } assert client.post("/infer", json={"query": "hi"}).status_code == 503 diff --git a/packages/syft-enclave/README.md b/packages/syft-enclave/README.md index 807bc6437ff..354c7d7a0e0 100644 --- a/packages/syft-enclave/README.md +++ b/packages/syft-enclave/README.md @@ -69,7 +69,7 @@ just start-debug EMAIL my-vm n2d-standard-4 # override name / machine type just stop [name] # Teardown: Deletes the VM. ``` -Debug enclaves set `use_encryption=false`, so their policy is `optional`: they encrypt only with a peer that requires encryption. Clients select encryption from the enclave policy, so they need no setting. +An enclave always requires encryption, debug enclaves too. Clients select encryption from the enclave policy, so they need no setting. ## GPU deployments diff --git a/packages/syft-enclave/src/syft_enclaves/__main__.py b/packages/syft-enclave/src/syft_enclaves/__main__.py index c0efc79631e..66b8b57df14 100644 --- a/packages/syft-enclave/src/syft_enclaves/__main__.py +++ b/packages/syft-enclave/src/syft_enclaves/__main__.py @@ -46,7 +46,7 @@ def main() -> None: f"Enclave settings — email={settings.email} data_owners={settings.data_owners} " f"token_path={settings.token_path} poll_interval={settings.poll_interval}s " f"require_tee={settings.require_tee} fresh_state={settings.fresh_state} " - f"use_encryption={settings.use_encryption} receipts={settings.receipts} " + f"receipts={settings.receipts} " f"attestation_provider={settings.attestation_provider} " f"tinfoil_repo={settings.tinfoil_repo} " f"tinfoil_release_tag={settings.tinfoil_release_tag} " @@ -58,7 +58,6 @@ def main() -> None: email=settings.email, token_path=settings.token_path, data_owners=settings.data_owners, - encryption=settings.use_encryption, persist_owner_state=settings.persist_owner_state, ) if settings.receipts: diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index 4efd0a7aa5c..6f887878878 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -811,16 +811,16 @@ def for_enclave( email: str, token_path: Path | str | None = None, data_owners: list[str] | None = None, - encryption: bool | str = False, persist_owner_state: bool = False, ) -> "SyftEnclaveClient": """Build an enclave client backed by a real Google Drive connection. + + An enclave always requires encryption with every peer. + Args: email: The enclave datasite's email address. token_path: Path to a pre-authorized Google Drive OAuth token. data_owners: Emails whose approval gates every job on this enclave. - encryption: Require end-to-end drive encryption with every peer. - Without it, the enclave encrypts with each peer that requires it. persist_owner_state: Keep the owner-only state used to restore this datasite later (event log, rolling state, checkpoints). Off by default - an enclave gets ephemeral keys and wipes its @@ -831,7 +831,7 @@ def for_enclave( has_ds_role=True, has_do_role=True, token_path=Path(token_path) if token_path is not None else None, - encryption=encryption, + encryption=EncryptionPolicy.REQUIRED, persist_owner_state=persist_owner_state, ) @@ -850,7 +850,6 @@ def quad_with_mock_drive_service_connection( use_in_memory_cache: bool = True, encryption: bool | str = False, enclave_persist_owner_state: bool = False, - enclave_encryption_policy: EncryptionPolicy | None = None, ) -> tuple[ "SyftEnclaveClient", "SyftEnclaveClient", @@ -866,17 +865,16 @@ def quad_with_mock_drive_service_connection( - DS: peers with DO1, DO2, Enclave Args: - encryption: Require end-to-end drive encryption on all four clients. + encryption: Policy of the DOs and the DS. The enclave always + requires encryption. enclave_persist_owner_state: Whether the enclave keeps restorable owner state. Off by default, as in production. - enclave_encryption_policy: Policy of the enclave; overrides - ``encryption`` for the enclave only. Returns: Tuple of (enclave, do1, do2, ds) """ - policies = [EncryptionPolicy.coerce(encryption)] * 4 - if enclave_encryption_policy is not None: - policies[0] = enclave_encryption_policy + policies = [EncryptionPolicy.REQUIRED] + [ + EncryptionPolicy.coerce(encryption) + ] * 3 configs = create_configs( enclave_email, do1_email, diff --git a/packages/syft-enclave/src/syft_enclaves/settings.py b/packages/syft-enclave/src/syft_enclaves/settings.py index 8ef7f0ddcde..e938617e291 100644 --- a/packages/syft-enclave/src/syft_enclaves/settings.py +++ b/packages/syft-enclave/src/syft_enclaves/settings.py @@ -150,14 +150,6 @@ def _split_data_owners(cls, v: object) -> object: "owner can see from the release whether receipts are on." ), ) - use_encryption: bool = Field( - default=True, - description=( - "Require end-to-end drive encryption with every peer. Enabled by " - "default. When false, the enclave encrypts with each peer that " - "requires it. Clients read this policy and need no setting." - ), - ) persist_owner_state: bool = Field( default=False, description=( diff --git a/packages/syft-enclave/tests/test_enclave_jobs.py b/packages/syft-enclave/tests/test_enclave_jobs.py index c72e710d07d..25ddedd9e7d 100644 --- a/packages/syft-enclave/tests/test_enclave_jobs.py +++ b/packages/syft-enclave/tests/test_enclave_jobs.py @@ -7,7 +7,6 @@ from pathlib import Path import pytest -from syft.sync.encryption_policy import EncryptionPolicy os.environ["PRE_SYNC"] = "false" @@ -77,12 +76,11 @@ def create_tmp_code_file(code: str): @pytest.mark.parametrize( "encryption_kwargs", [ - {"encryption": False}, {"encryption": True}, # Only the enclave requires encryption; the other clients comply. - {"enclave_encryption_policy": EncryptionPolicy.REQUIRED}, + {"encryption": False}, ], - ids=["plaintext", "encrypted", "negotiated"], + ids=["encrypted", "negotiated"], ) def test_enclave_job_distribution(encryption_kwargs): """Test full flow: DS submits job to enclave, enclave distributes to DOs.""" diff --git a/packages/syft-enclave/tests/test_encryption.py b/packages/syft-enclave/tests/test_encryption.py index 46bfd4afe2e..1fb7c53afbe 100644 --- a/packages/syft-enclave/tests/test_encryption.py +++ b/packages/syft-enclave/tests/test_encryption.py @@ -7,9 +7,8 @@ os.environ["PRE_SYNC"] = "false" -from syft_enclaves import SyftEnclaveClient - from syft.sync.encryption_policy import EncryptionPolicy +from syft_enclaves import SyftEnclaveClient def _simple_job_code() -> str: @@ -72,9 +71,22 @@ def test_encrypted_message_to_enclave_roundtrips(): assert "simple_job" in [j.name for j in enclave.jobs] -def test_quad_builder_applies_off_to_enclave(): - enclave, *_ = SyftEnclaveClient.quad_with_mock_drive_service_connection( - enclave_encryption_policy=EncryptionPolicy.OFF +def test_quad_enclave_requires_encryption_with_optional_peers(): + enclave, do1, *_ = SyftEnclaveClient.quad_with_mock_drive_service_connection() + enclave_store = enclave._rds.peer_manager.peer_store + assert enclave_store.encryption_policy == EncryptionPolicy.REQUIRED + assert ( + do1._rds.peer_manager.peer_store.encryption_policy == EncryptionPolicy.OPTIONAL + ) + + +def test_for_enclave_requires_encryption(monkeypatch): + configs = [] + monkeypatch.setattr( + SyftEnclaveClient, + "from_config", + classmethod(lambda cls, config, data_owners=None: configs.append(config)), ) - store = enclave._rds.peer_manager.peer_store - assert store.encryption_policy == EncryptionPolicy.OFF + SyftEnclaveClient.for_enclave(email="enclave@openmined.org") + policy = configs[0].sync.peer_manager_config.encryption_policy + assert policy == EncryptionPolicy.REQUIRED diff --git a/packages/syft-enclave/tests/test_settings.py b/packages/syft-enclave/tests/test_settings.py index cc1a2292526..78f6cd2559f 100644 --- a/packages/syft-enclave/tests/test_settings.py +++ b/packages/syft-enclave/tests/test_settings.py @@ -38,7 +38,6 @@ def test_defaults_applied_when_required_fields_set(required_env): assert settings.require_tee is False assert settings.log_level == "INFO" assert settings.fresh_state is True # default: always start with a clean slate - assert settings.use_encryption is True # default: encryption on assert settings.persist_owner_state is False # default: an enclave never restores @@ -110,12 +109,6 @@ def test_fresh_state_can_be_disabled_via_env(required_env): assert settings.fresh_state is False -def test_use_encryption_can_be_disabled_via_env(required_env): - required_env.setenv("SYFT_ENCLAVE_USE_ENCRYPTION", "false") - settings = EnclaveSettings(_env_file=None) - assert settings.use_encryption is False - - def test_persist_owner_state_can_be_enabled_via_env(required_env): required_env.setenv("SYFT_ENCLAVE_PERSIST_OWNER_STATE", "true") settings = EnclaveSettings(_env_file=None) diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index a56bd2a9689..5becc22bbcb 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -4,14 +4,13 @@ Shared between PeerManager and all ConnectionRouter instances. """ -import json import os import tempfile from pathlib import Path -from typing import List, Optional +from typing import Any, List, Optional import syft_crypto_python as syc -from pydantic import BaseModel, Field, PrivateAttr +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr from syft.sync.encryption_policy import ( EncryptionPolicy, @@ -35,6 +34,37 @@ CRYPTO_KEYS_VERSION = 2 +class _KeyFile(BaseModel): + """The private key file of one identity. It holds the only copy of its keys.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + version: int + email: str + keys_jwk: dict[str, Any] + + @classmethod + def read(cls, path: Path, email: str) -> "_KeyFile": + """Read the key file of ``email`` at ``path``. + + Raises: + ValueError: if the file has another shape or version, or belongs to + another identity. + """ + key_file = cls.model_validate_json(path.read_text()) + if key_file.version != CRYPTO_KEYS_VERSION: + raise ValueError( + f"The crypto key file at {path} has version {key_file.version}, " + f"and this client reads only version {CRYPTO_KEYS_VERSION}." + ) + if key_file.email != email: + raise ValueError( + f"Encryption key file {path} belongs to {key_file.email!r}, " + f"not {email!r}" + ) + return key_file + + def datasite_crypto_keys_path(syftbox_folder: Path | str, email: str) -> Path: """Per-datasite key file: ``//private/crypto_keys.json``.""" return Path(syftbox_folder) / email / PRIVATE_DIR_NAME / CRYPTO_KEYS_FILENAME @@ -369,12 +399,10 @@ def save_keys(self, path: Path | str) -> None: FileExistsError: if a file exists at ``path``. """ keys = self._ensure_private_keys() - data = { - "version": CRYPTO_KEYS_VERSION, - "email": self.email, - "keys_jwk": keys.to_jwks(), - } - _create_key_file(Path(path), data) + key_file = _KeyFile( + version=CRYPTO_KEYS_VERSION, email=self.email, keys_jwk=keys.to_jwks() + ) + _create_key_file(Path(path), key_file) @classmethod def create( @@ -395,15 +423,16 @@ def create( ties key lifetime to the datasite folder. Raises: - ValueError: if the key file has another version or belongs to - another identity. + ValueError: if the key file has another shape or version, or belongs + to another identity. FileExistsError: if another process created the key file while this store generated its keys. """ store = cls(email=email, encryption_policy=encryption_policy) path = Path(keys_path) if path.exists(): - store._private_keys = _read_private_keys(path, email) + key_file = _KeyFile.read(path, email) + store._private_keys = syc.SyftPrivateKeys.from_jwks(key_file.keys_jwk) else: store.generate_keys() store.save_keys(path) @@ -418,39 +447,24 @@ def import_key_file(source: Path | str, keys_path: Path | str, email: str) -> No keys already, nothing changes. Raises: - ValueError: if ``source`` has another version or belongs to another - identity. + ValueError: if ``source`` has another shape or version, or belongs to + another identity. FileExistsError: if ``keys_path`` holds other keys. """ source, keys_path = Path(source), Path(keys_path) - _read_private_keys(source, email) - data = json.loads(source.read_text()) + key_file = _KeyFile.read(source, email) if keys_path.exists(): - if json.loads(keys_path.read_text()) != data: + if _KeyFile.read(keys_path, email) != key_file: raise FileExistsError( f"{keys_path} holds other keys than {source}. Remove it to " "import the file, or log in without the file." ) return - _create_key_file(keys_path, data) - - -def _read_private_keys(path: Path, email: str) -> syc.SyftPrivateKeys: - data = json.loads(path.read_text()) - if data["version"] != CRYPTO_KEYS_VERSION: - raise ValueError( - f"The crypto key file at {path} has version {data['version']}, and " - f"this client reads only version {CRYPTO_KEYS_VERSION}." - ) - if data["email"] != email: - raise ValueError( - f"Encryption key file {path} belongs to {data['email']!r}, not {email!r}" - ) - return syc.SyftPrivateKeys.from_jwks(data["keys_jwk"]) + _create_key_file(keys_path, key_file) -def _create_key_file(path: Path, data: dict) -> None: - """Write ``data`` to a new key file at ``path`` in one step. +def _create_key_file(path: Path, key_file: _KeyFile) -> None: + """Write ``key_file`` to a new file at ``path`` in one step. The key file holds the only copy of the private keys. The data goes to a temporary file in the same folder and reaches the disk. A hard link then @@ -463,7 +477,7 @@ def _create_key_file(path: Path, data: dict) -> None: fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") try: with os.fdopen(fd, "w") as f: - f.write(json.dumps(data, indent=2)) + f.write(key_file.model_dump_json(indent=2)) f.flush() os.fsync(f.fileno()) os.link(tmp, path) diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 4f76f725c39..0bcce09455e 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -582,7 +582,7 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): peer_bundle = None if encrypted and is_accepting: - peer_bundle = self._read_published_bundle(peer_email) + peer_bundle = self._read_peer_encryption_bundle(peer_email) self.connection_router.update_peer_state( peer_email, new_state.value, public_encryption_bundle=peer_bundle @@ -610,12 +610,16 @@ def _write_encryption_bundle_for_peer(self, peer_email: str) -> dict | None: return bundle def _read_peer_encryption_bundle(self, peer_email: str) -> dict | None: - """Read a peer's encryption bundle if available.""" + """The bundle ``peer_email`` published for us, or None when it has not published one. + + Raises: + ValueError: when the published bundle file is not valid JSON. + KeyError: when it holds no bundle. + """ bundle_json = self.connection_router.read_peer_encryption_bundle(peer_email) if not bundle_json: return None - data = json.loads(bundle_json) - return data.get("public_encryption_bundle") + return json.loads(bundle_json)["public_encryption_bundle"] def load_peers(self, force_download: bool = False): """Load peers: from JSON (accepted + requested_by_me) + new requests from folder scan. @@ -715,20 +719,12 @@ def _read_own_published_bundle(self, peer_email: str) -> dict | None: return None return json.loads(bundle_json)["public_encryption_bundle"] - def _read_published_bundle(self, peer_email: str) -> dict | None: - """The bundle ``peer_email`` published for us, or None when it is missing or malformed.""" - try: - return self._read_peer_encryption_bundle(peer_email) - except ValueError as e: - warnings.warn(f"Refusing the encryption key of {peer_email}: {e}") - return None - def _read_missing_peer_bundles(self, peers: list[Peer]) -> None: """Read the published bundle of every peer we hold none for.""" for peer in peers: if self.peer_store.has_peer_bundle(peer.email): continue - bundle = self._read_published_bundle(peer.email) + bundle = self._read_peer_encryption_bundle(peer.email) if bundle: self.peer_store.set_peer_bundle(peer.email, bundle) self.connection_router.update_peer_state( diff --git a/tests/unit/test_crypto_keys_version.py b/tests/unit/test_crypto_keys_version.py index f909156b4d0..218445e19da 100644 --- a/tests/unit/test_crypto_keys_version.py +++ b/tests/unit/test_crypto_keys_version.py @@ -50,6 +50,21 @@ def test_a_file_of_another_version_is_refused(tmp_path, version): PeerStore.create(email=EMAIL, keys_path=path) +@pytest.mark.parametrize( + "change", + [lambda d: d.pop("keys_jwk"), lambda d: d.update(peer_bundles={})], + ids=["missing field", "extra field"], +) +def test_a_file_of_another_shape_is_refused(tmp_path, change): + path = _saved(tmp_path) + data = json.loads(path.read_text()) + change(data) + path.write_text(json.dumps(data)) + + with pytest.raises(ValueError, match="_KeyFile"): + PeerStore.create(email=EMAIL, keys_path=path) + + def test_a_file_of_another_identity_is_refused(tmp_path): path = _saved(tmp_path) diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index ba9b5e358bd..738f872b036 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -184,7 +184,7 @@ def test_missing_version_file_created_with_own_policy(): assert b_conn.read_peer_version_file("a@example.com").encryption_policy == REQUIRED -def test_malformed_peer_bundle_does_not_stop_peer_load(): +def test_malformed_peer_bundle_raises_on_peer_load(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( add_peers=False, do_encryption_policy=REQUIRED, @@ -195,9 +195,8 @@ def test_malformed_peer_bundle_does_not_stop_peer_load(): do_manager.peer_manager.connection_router.write_encryption_bundle( ds_manager.email, "not json" ) - with pytest.warns(UserWarning, match="Refusing"): + with pytest.raises(ValueError): ds_manager.load_peers() - assert not ds_manager.peer_manager.peer_store.has_peer_bundle(do_manager.email) # ========================================================================= From 0679db982bd6aa0caae0ec41ae6f50ed0daf91d5 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Fri, 23 Oct 2026 21:54:37 -0300 Subject: [PATCH 25/30] refactor: send queued DS changes as one message, as on dev Remove the grouping of DS changes per DO from the encryption work. The next commit adds it again as its own fix. --- syft/sync/sync/datasite_watcher_syncer.py | 67 +++++------------------ tests/unit/test_encryption_policy.py | 47 ---------------- 2 files changed, 14 insertions(+), 100 deletions(-) diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index 5567209e4ce..0432237a8e8 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -1,16 +1,14 @@ from pathlib import Path from concurrent.futures import ThreadPoolExecutor from queue import Queue -from typing import Dict, List +from typing import List from pydantic import BaseModel, Field, PrivateAttr from syft.sync.connections.base_connection import ConnectionConfig from syft.sync.connections.connection_router import ConnectionRouter from syft.sync.callback_mixin import BaseModelCallbackMixin -from syft.sync.encryption_policy import EncryptionPolicyConflictError from syft.sync.events.file_change_event import FileChangeEventsMessage -from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.messages.proposed_filechange import ( ProposedFileChange, ProposedFileChangesMessage, @@ -76,8 +74,7 @@ def get_proposed_file_change_object( ) def process_file_changes_queue(self): - # One message per datasite: each change goes to the owner of its path. - changes_by_recipient: Dict[str, List[ProposedFileChange]] = {} + file_changes = [] while not self.queue.empty(): relative_path, content = self.queue.get() @@ -100,58 +97,22 @@ def process_file_changes_queue(self): file_change = self.get_proposed_file_change_object( path_in_datasite, content, datasite_email=recipient ) - changes_by_recipient.setdefault(recipient, []).append(file_change) - self._send_by_recipient(changes_by_recipient) - - def _send_by_recipient( - self, changes_by_recipient: dict[str, list[ProposedFileChange]] - ) -> None: - """Send one message to each recipient. - - The changes for a recipient that cannot receive now are dropped. The - other recipients still get their changes, and then the first error is - raised. - - Raises: - SyftPeerNotReadyError: when the policy of a recipient is not known, - or the pair encrypts and the peer bundle is missing. - EncryptionPolicyConflictError: when the policies of a pair conflict. - """ - error = None - for recipient, changes in changes_by_recipient.items(): - message = ProposedFileChangesMessage( - sender_email=self.email, proposed_file_changes=changes - ) - try: - self._send_message(recipient, message) - except (SyftPeerNotReadyError, EncryptionPolicyConflictError) as e: - error = error or e - if error is not None: - raise error - - def _record_sent_hashes(self, message: ProposedFileChangesMessage) -> None: - hashes = self.datasite_watcher_cache.file_hashes - for fc in message.proposed_file_changes: - path_key = Path(fc.datasite_email) / fc.path_in_datasite - if fc.is_deleted: - hashes.pop(path_key, None) - else: - hashes[path_key] = fc.new_hash - - def _send_message( - self, recipient: str, message: ProposedFileChangesMessage - ) -> None: - """Send ``message`` and record the hashes it sent. + file_changes.append(file_change) - Raises: - SyftPeerNotReadyError: when the policy of ``recipient`` is not - known, or the pair encrypts and the peer bundle is missing. - EncryptionPolicyConflictError: when the policies of the pair conflict. - """ + message = ProposedFileChangesMessage( + sender_email=self.email, proposed_file_changes=file_changes + ) self.connection_router.watcher_send_proposed_file_changes_message( recipient, message ) - self._record_sent_hashes(message) + + # Update local cache with new hashes + for fc in file_changes: + path_key = Path(fc.datasite_email) / fc.path_in_datasite + if fc.is_deleted: + self.datasite_watcher_cache.file_hashes.pop(path_key, None) + else: + self.datasite_watcher_cache.file_hashes[path_key] = fc.new_hash def on_file_change( self, relative_path: Path | str, content: str | None = None, process_now=True diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 738f872b036..0111ffee74c 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -25,7 +25,6 @@ from syft.sync.peers.peer import Peer from syft.sync.peers.peer_store import PeerStore from syft.sync.syftbox_manager import SyftboxManager -from syft.sync.sync.datasite_watcher_syncer import DatasiteWatcherSyncer from syft.sync.version.peer_manager import PeerManagerConfig from syft.sync.version.version_info import VersionInfo from tests.unit.test_sync_manager import path_for_job @@ -513,56 +512,10 @@ def test_send_to_non_peer_raises(): ds_manager._send_file_change(file_path, "content") -def test_changes_for_two_datasites_go_to_each(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - sent = [] - monkeypatch.setattr( - DatasiteWatcherSyncer, - "_send_message", - lambda self, recipient, message: ( - sent.append( - (recipient, {fc.datasite_email for fc in message.proposed_file_changes}) - ) - ), - ) - watcher = ds_manager.datasite_watcher_syncer - watcher.on_file_change(f"{do_manager.email}/a.txt", "a", process_now=False) - watcher.on_file_change("other@example.com/b.txt", "b", process_now=False) - watcher.process_file_changes_queue() - assert sorted(sent) == sorted( - [ - (do_manager.email, {do_manager.email}), - ("other@example.com", {"other@example.com"}), - ] - ) - - def _not_ready(recipient: str) -> SyftPeerNotReadyError: return SyftPeerNotReadyError(recipient, cause="No bundle.", remedy="Sync.") -@pytest.mark.parametrize( - "error", [EncryptionPolicyConflictError, _not_ready], ids=["conflict", "not ready"] -) -def test_failure_for_one_recipient_still_sends_to_other(monkeypatch, error): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() - sent = [] - - def send_message(self, recipient, message): - if recipient == "other@example.com": - raise error(recipient) - sent.append(recipient) - - monkeypatch.setattr(DatasiteWatcherSyncer, "_send_message", send_message) - watcher = ds_manager.datasite_watcher_syncer - watcher.on_file_change("other@example.com/a.txt", "a", process_now=False) - watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) - with pytest.raises((EncryptionPolicyConflictError, SyftPeerNotReadyError)): - watcher.process_file_changes_queue() - assert sent == [do_manager.email] - assert _queued_changes(ds_manager) == 0 - - @pytest.mark.parametrize( "error", [EncryptionPolicyConflictError, _not_ready], ids=["conflict", "not ready"] ) From ec1122ff82d0d2bd7c6d10841bb1b0edd67ceede Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Fri, 23 Oct 2026 21:56:28 -0300 Subject: [PATCH 26/30] fix: send queued DS changes to each DO in its own message All queued changes went in one message to the last recipient, so one DO got the changes for the datasite of another DO. --- syft/sync/sync/datasite_watcher_syncer.py | 67 ++++++++++++++++++----- tests/unit/test_encryption_policy.py | 48 ++++++++++++++++ 2 files changed, 101 insertions(+), 14 deletions(-) diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index 0432237a8e8..5567209e4ce 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -1,14 +1,16 @@ from pathlib import Path from concurrent.futures import ThreadPoolExecutor from queue import Queue -from typing import List +from typing import Dict, List from pydantic import BaseModel, Field, PrivateAttr from syft.sync.connections.base_connection import ConnectionConfig from syft.sync.connections.connection_router import ConnectionRouter from syft.sync.callback_mixin import BaseModelCallbackMixin +from syft.sync.encryption_policy import EncryptionPolicyConflictError from syft.sync.events.file_change_event import FileChangeEventsMessage +from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.messages.proposed_filechange import ( ProposedFileChange, ProposedFileChangesMessage, @@ -74,7 +76,8 @@ def get_proposed_file_change_object( ) def process_file_changes_queue(self): - file_changes = [] + # One message per datasite: each change goes to the owner of its path. + changes_by_recipient: Dict[str, List[ProposedFileChange]] = {} while not self.queue.empty(): relative_path, content = self.queue.get() @@ -97,22 +100,58 @@ def process_file_changes_queue(self): file_change = self.get_proposed_file_change_object( path_in_datasite, content, datasite_email=recipient ) - file_changes.append(file_change) + changes_by_recipient.setdefault(recipient, []).append(file_change) + self._send_by_recipient(changes_by_recipient) + + def _send_by_recipient( + self, changes_by_recipient: dict[str, list[ProposedFileChange]] + ) -> None: + """Send one message to each recipient. + + The changes for a recipient that cannot receive now are dropped. The + other recipients still get their changes, and then the first error is + raised. + + Raises: + SyftPeerNotReadyError: when the policy of a recipient is not known, + or the pair encrypts and the peer bundle is missing. + EncryptionPolicyConflictError: when the policies of a pair conflict. + """ + error = None + for recipient, changes in changes_by_recipient.items(): + message = ProposedFileChangesMessage( + sender_email=self.email, proposed_file_changes=changes + ) + try: + self._send_message(recipient, message) + except (SyftPeerNotReadyError, EncryptionPolicyConflictError) as e: + error = error or e + if error is not None: + raise error + + def _record_sent_hashes(self, message: ProposedFileChangesMessage) -> None: + hashes = self.datasite_watcher_cache.file_hashes + for fc in message.proposed_file_changes: + path_key = Path(fc.datasite_email) / fc.path_in_datasite + if fc.is_deleted: + hashes.pop(path_key, None) + else: + hashes[path_key] = fc.new_hash - message = ProposedFileChangesMessage( - sender_email=self.email, proposed_file_changes=file_changes - ) + def _send_message( + self, recipient: str, message: ProposedFileChangesMessage + ) -> None: + """Send ``message`` and record the hashes it sent. + + Raises: + SyftPeerNotReadyError: when the policy of ``recipient`` is not + known, or the pair encrypts and the peer bundle is missing. + EncryptionPolicyConflictError: when the policies of the pair conflict. + """ self.connection_router.watcher_send_proposed_file_changes_message( recipient, message ) - - # Update local cache with new hashes - for fc in file_changes: - path_key = Path(fc.datasite_email) / fc.path_in_datasite - if fc.is_deleted: - self.datasite_watcher_cache.file_hashes.pop(path_key, None) - else: - self.datasite_watcher_cache.file_hashes[path_key] = fc.new_hash + self._record_sent_hashes(message) def on_file_change( self, relative_path: Path | str, content: str | None = None, process_now=True diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 0111ffee74c..76d57504e63 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -512,10 +512,58 @@ def test_send_to_non_peer_raises(): ds_manager._send_file_change(file_path, "content") +def test_changes_for_two_datasites_go_to_each(monkeypatch): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + sent = [] + monkeypatch.setattr( + ConnectionRouter, + "watcher_send_proposed_file_changes_message", + lambda self, recipient, message: ( + sent.append( + (recipient, {fc.datasite_email for fc in message.proposed_file_changes}) + ) + ), + ) + watcher = ds_manager.datasite_watcher_syncer + watcher.on_file_change(f"{do_manager.email}/a.txt", "a", process_now=False) + watcher.on_file_change("other@example.com/b.txt", "b", process_now=False) + watcher.process_file_changes_queue() + assert sorted(sent) == sorted( + [ + (do_manager.email, {do_manager.email}), + ("other@example.com", {"other@example.com"}), + ] + ) + + def _not_ready(recipient: str) -> SyftPeerNotReadyError: return SyftPeerNotReadyError(recipient, cause="No bundle.", remedy="Sync.") +@pytest.mark.parametrize( + "error", [EncryptionPolicyConflictError, _not_ready], ids=["conflict", "not ready"] +) +def test_failure_for_one_recipient_still_sends_to_other(monkeypatch, error): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + sent = [] + + def send_message(self, recipient, message): + if recipient == "other@example.com": + raise error(recipient) + sent.append(recipient) + + monkeypatch.setattr( + ConnectionRouter, "watcher_send_proposed_file_changes_message", send_message + ) + watcher = ds_manager.datasite_watcher_syncer + watcher.on_file_change("other@example.com/a.txt", "a", process_now=False) + watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) + with pytest.raises((EncryptionPolicyConflictError, SyftPeerNotReadyError)): + watcher.process_file_changes_queue() + assert sent == [do_manager.email] + assert _queued_changes(ds_manager) == 0 + + @pytest.mark.parametrize( "error", [EncryptionPolicyConflictError, _not_ready], ids=["conflict", "not ready"] ) From 644c07a1b300ad0683b9c1158c1c35a00dd35524 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Fri, 23 Oct 2026 22:02:15 -0300 Subject: [PATCH 27/30] test: remove the test for the removed use_encryption keyword --- tests/unit/test_encryption_policy.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 76d57504e63..8f14c8c2f6d 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -6,7 +6,6 @@ """ import json -from pathlib import Path import pytest from pydantic import ValidationError @@ -25,7 +24,6 @@ from syft.sync.peers.peer import Peer from syft.sync.peers.peer_store import PeerStore from syft.sync.syftbox_manager import SyftboxManager -from syft.sync.version.peer_manager import PeerManagerConfig from syft.sync.version.version_info import VersionInfo from tests.unit.test_sync_manager import path_for_job from tests.unit.utils import grant_job_inbox_access @@ -142,20 +140,6 @@ def test_policy_cannot_change_after_login(): store.encryption_policy = REQUIRED -@pytest.mark.parametrize( - "build", - [ - lambda: PeerStore(email="alice@example.com", use_encryption=True), - lambda: PeerManagerConfig(syftbox_folder=Path("/tmp"), use_encryption=True), - ], - ids=["PeerStore", "PeerManagerConfig"], -) -def test_removed_use_encryption_keyword_raises(build): - """The keyword gave a store without encryption when it was ignored.""" - with pytest.raises(ValidationError, match="use_encryption"): - build() - - @pytest.mark.parametrize( "value, expected", [(True, REQUIRED), (False, OPTIONAL), ("off", OFF), (OFF, OFF)], From 9fafee7308d3901bf3b17f2396c57a1698854ed6 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Sat, 24 Oct 2026 09:40:58 -0300 Subject: [PATCH 28/30] refactor: remove the off encryption policy A client is now optional or required, and a pair cannot conflict. The encryption setting at login is a bool again. --- docs/connections.md | 3 +- packages/syft-bg/docs/python-api.md | 2 +- packages/syft-bg/src/syft_bg/api/api.py | 4 +- .../src/syft_bg/approve/orchestrator.py | 5 +- packages/syft-bg/src/syft_bg/cli/commands.py | 4 +- .../syft-bg/src/syft_bg/sync/orchestrator.py | 5 +- .../syft-enclave/src/syft_enclaves/client.py | 4 +- .../syft-enclave/src/syft_enclaves/login.py | 12 +-- packages/syft-rds/src/syft_rds/login.py | 6 +- syft/sync/connections/connection_router.py | 3 - syft/sync/encryption_policy.py | 42 +------- syft/sync/login.py | 6 +- syft/sync/peers/peer_store.py | 25 +---- syft/sync/syftbox_manager.py | 22 ++-- syft/sync/sync/datasite_owner_syncer.py | 5 +- syft/sync/sync/datasite_watcher_syncer.py | 5 +- syft/sync/version/peer_manager.py | 19 ---- tests/integration/syft_bg/test_cli.py | 2 +- .../syft_bg/test_orchestrator_encryption.py | 2 +- tests/unit/test_encryption_policy.py | 100 ++---------------- 20 files changed, 57 insertions(+), 219 deletions(-) diff --git a/docs/connections.md b/docs/connections.md index a88dd3f0c90..502a2298b08 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -56,9 +56,8 @@ Each client advertises an encryption policy in `SYFT_version.json`: | ---------- | --------------------------- | ----------------------------------------- | | `optional` | default, `encryption=False` | encrypt only with a peer that requires it | | `required` | `encryption=True` | encrypt with every peer; refuse plaintext | -| `off` | `encryption="off"` | never encrypt | -A pair encrypts when one side is `required`. An `off` and a `required` client conflict: each side skips the other with a warning. +A pair encrypts when one side is `required`. A client takes its policy at login, and keeps it until the process exits. syft-bg takes its policy in `syft_bg.init(encryption=...)`; use the same value as the notebook login, because both write the same datasite. Files at rest are read with the current policy, so a datasite keeps one policy. diff --git a/packages/syft-bg/docs/python-api.md b/packages/syft-bg/docs/python-api.md index be1a63f6b25..e874e284ad6 100644 --- a/packages/syft-bg/docs/python-api.md +++ b/packages/syft-bg/docs/python-api.md @@ -13,7 +13,7 @@ syft_bg.init(do_email="you@example.com", encryption=False) Creates the config file. Optionally pass `syftbox_root` and `token_path`. `encryption` is required. Pass the same value as the notebook's `login_do` -(`True`, `False` or `"off"`). syft-bg writes the same datasite as the +(`True` or `False`). syft-bg writes the same datasite as the notebook, so the two must use the same policy. ## Ensure running diff --git a/packages/syft-bg/src/syft_bg/api/api.py b/packages/syft-bg/src/syft_bg/api/api.py index c4d24e3a2b9..d5750d5c6e0 100644 --- a/packages/syft-bg/src/syft_bg/api/api.py +++ b/packages/syft-bg/src/syft_bg/api/api.py @@ -53,7 +53,7 @@ def init( do_email: str, - encryption: bool | str, + encryption: bool, syftbox_root: str | Path | None = None, token_path: str | Path | None = None, settings: dict[str, dict] | None = None, @@ -72,7 +72,7 @@ def init( config = SyftBgConfig( do_email=do_email, syftbox_root=syftbox_root, - encryption_policy=EncryptionPolicy.coerce(encryption), + encryption_policy=EncryptionPolicy.from_bool(encryption), **token_kwargs, ) diff --git a/packages/syft-bg/src/syft_bg/approve/orchestrator.py b/packages/syft-bg/src/syft_bg/approve/orchestrator.py index bd9e8b1e1fd..5d0f2495a21 100644 --- a/packages/syft-bg/src/syft_bg/approve/orchestrator.py +++ b/packages/syft-bg/src/syft_bg/approve/orchestrator.py @@ -80,6 +80,7 @@ def from_config( cls._wait_for_sync_ready(label="Approve") from syft_rds import ( + EncryptionPolicy, Environment, SyftRDSClient, SyftRDSClientConfig, @@ -90,7 +91,7 @@ def from_config( rds_config = SyftRDSClientConfig.for_colab( email=config.do_email, has_do_role=True, - encryption=config.encryption_policy, + encryption=config.encryption_policy == EncryptionPolicy.REQUIRED, skip_peer_on_patch_version_diff=config.skip_peer_on_patch_version_diff, force_ignore_peer_version=config.force_ignore_peer_version, ) @@ -99,7 +100,7 @@ def from_config( email=config.do_email, has_do_role=True, token_path=config.drive_token_path, - encryption=config.encryption_policy, + encryption=config.encryption_policy == EncryptionPolicy.REQUIRED, skip_peer_on_patch_version_diff=config.skip_peer_on_patch_version_diff, force_ignore_peer_version=config.force_ignore_peer_version, ) diff --git a/packages/syft-bg/src/syft_bg/cli/commands.py b/packages/syft-bg/src/syft_bg/cli/commands.py index bddb1904060..9da46ab396e 100644 --- a/packages/syft-bg/src/syft_bg/cli/commands.py +++ b/packages/syft-bg/src/syft_bg/cli/commands.py @@ -240,7 +240,7 @@ def run_foreground(service: str, once: bool): ) @click.option( "--encryption", - type=click.Choice(["off", "optional", "required"]), + type=click.Choice(["optional", "required"]), required=True, help="Encryption policy. Use the same policy as the notebook login.", ) @@ -262,7 +262,7 @@ def init(email: str, syftbox_root: str | None, token_path: str | None, encryptio do_email=email, syftbox_root=syftbox_root, token_path=token_path, - encryption=encryption, + encryption=encryption == "required", ) diff --git a/packages/syft-bg/src/syft_bg/sync/orchestrator.py b/packages/syft-bg/src/syft_bg/sync/orchestrator.py index 0daa623923a..dca0a92eb08 100644 --- a/packages/syft-bg/src/syft_bg/sync/orchestrator.py +++ b/packages/syft-bg/src/syft_bg/sync/orchestrator.py @@ -47,6 +47,7 @@ def from_config(cls, config: SyncConfig) -> SyncOrchestrator: ) from syft_rds import ( + EncryptionPolicy, Environment, SyftRDSClient, SyftRDSClientConfig, @@ -57,7 +58,7 @@ def from_config(cls, config: SyncConfig) -> SyncOrchestrator: rds_config = SyftRDSClientConfig.for_colab( email=config.do_email, has_do_role=True, - encryption=config.encryption_policy, + encryption=config.encryption_policy == EncryptionPolicy.REQUIRED, skip_peer_on_patch_version_diff=config.skip_peer_on_patch_version_diff, force_ignore_peer_version=config.force_ignore_peer_version, ) @@ -66,7 +67,7 @@ def from_config(cls, config: SyncConfig) -> SyncOrchestrator: email=config.do_email, has_do_role=True, token_path=config.drive_token_path, - encryption=config.encryption_policy, + encryption=config.encryption_policy == EncryptionPolicy.REQUIRED, skip_peer_on_patch_version_diff=config.skip_peer_on_patch_version_diff, force_ignore_peer_version=config.force_ignore_peer_version, ) diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index 6f887878878..89d27b379e6 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -848,7 +848,7 @@ def quad_with_mock_drive_service_connection( do2_email: str | None = None, ds_email: str | None = None, use_in_memory_cache: bool = True, - encryption: bool | str = False, + encryption: bool = False, enclave_persist_owner_state: bool = False, ) -> tuple[ "SyftEnclaveClient", @@ -873,7 +873,7 @@ def quad_with_mock_drive_service_connection( Tuple of (enclave, do1, do2, ds) """ policies = [EncryptionPolicy.REQUIRED] + [ - EncryptionPolicy.coerce(encryption) + EncryptionPolicy.from_bool(encryption) ] * 3 configs = create_configs( enclave_email, diff --git a/packages/syft-enclave/src/syft_enclaves/login.py b/packages/syft-enclave/src/syft_enclaves/login.py index 11e655ee8a5..eb36b4ff7d4 100644 --- a/packages/syft-enclave/src/syft_enclaves/login.py +++ b/packages/syft-enclave/src/syft_enclaves/login.py @@ -21,7 +21,7 @@ def _login( skip_peer_on_patch_version_diff: bool | None, has_do_role: bool, has_ds_role: bool, - encryption: bool | str = False, + encryption: bool = False, key_file: str | Path | None = None, ) -> SyftEnclaveClient: """Shared login for enclave-flow participants. @@ -84,15 +84,14 @@ def login_do( sync: bool = True, load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, - encryption: bool | str = False, + encryption: bool = False, key_file: str | Path | None = None, ) -> SyftEnclaveClient: """Log in a data owner for an enclave computation. Set ``encryption=True`` to require encryption with every peer. Without it, the client encrypts with each peer that requires it, such as an - enclave. Set ``encryption="off"`` to never encrypt, e.g. against a debug - enclave; a peer that requires encryption is then skipped. + enclave. ``key_file`` is the ``crypto_keys.json`` of an earlier session, e.g. an uploaded file on Colab. Without it, the first login makes new keys. @@ -116,15 +115,14 @@ def login_ds( sync: bool = True, load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, - encryption: bool | str = False, + encryption: bool = False, key_file: str | Path | None = None, ) -> SyftEnclaveClient: """Log in a data scientist for an enclave computation. Set ``encryption=True`` to require encryption with every peer. Without it, the client encrypts with each peer that requires it, such as an - enclave. Set ``encryption="off"`` to never encrypt, e.g. against a debug - enclave; a peer that requires encryption is then skipped. + enclave. ``key_file`` is the ``crypto_keys.json`` of an earlier session, e.g. an uploaded file on Colab. Without it, the first login makes new keys. diff --git a/packages/syft-rds/src/syft_rds/login.py b/packages/syft-rds/src/syft_rds/login.py index 0614daf70d6..ec53ff2fc9a 100644 --- a/packages/syft-rds/src/syft_rds/login.py +++ b/packages/syft-rds/src/syft_rds/login.py @@ -31,7 +31,7 @@ def _login( skip_peer_on_patch_version_diff: bool | None, has_do_role: bool, has_ds_role: bool, - encryption: bool | str = False, + encryption: bool = False, ) -> SyftRDSClient: """Shared RDS login. @@ -77,7 +77,7 @@ def login_do( sync: bool = True, load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, - encryption: bool | str = False, + encryption: bool = False, ) -> SyftRDSClient: """Log in as a Data Owner and return a self-contained RDS client.""" return _login( @@ -99,7 +99,7 @@ def login_ds( sync: bool = True, load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, - encryption: bool | str = False, + encryption: bool = False, ) -> SyftRDSClient: """Log in as a Data Scientist and return a self-contained RDS client.""" return _login( diff --git a/syft/sync/connections/connection_router.py b/syft/sync/connections/connection_router.py index bbe2f409b9b..4a46a246753 100644 --- a/syft/sync/connections/connection_router.py +++ b/syft/sync/connections/connection_router.py @@ -18,7 +18,6 @@ SyftboxPlatformConnection, ) from syft.sync.encryption_policy import ( - EncryptionPolicyConflictError, MessageFormatError, ) from syft.sync.events.file_change_event import ( @@ -175,7 +174,6 @@ def owner_get_next_proposed_filechange_message( Raises: SyftPeerNotReadyError: when the message cannot be read yet. It stays in the inbox. - EncryptionPolicyConflictError: when the policies of the pair conflict. """ connection = self.connection_for_receive_message() while True: @@ -232,7 +230,6 @@ def watcher_read_events_message( except ( MessageFormatError, SyftPeerNotReadyError, - EncryptionPolicyConflictError, ) as e: warnings.warn(f"Skipped an outbox message of {peer_email}: {e}") return None diff --git a/syft/sync/encryption_policy.py b/syft/sync/encryption_policy.py index f97f8790a33..0af8571d482 100644 --- a/syft/sync/encryption_policy.py +++ b/syft/sync/encryption_policy.py @@ -6,41 +6,19 @@ class EncryptionPolicy(str, Enum): """What a client asks of the encryption between it and a peer. - - ``OFF``: never encrypt. - ``OPTIONAL``: encrypt only with a peer that requires it. - ``REQUIRED``: encrypt with every peer, and refuse plaintext. - A pair encrypts when one or both sides require it. An ``OFF`` and a - ``REQUIRED`` client conflict, and do not exchange messages. + A pair encrypts when one or both sides require it. """ - OFF = "off" OPTIONAL = "optional" REQUIRED = "required" @classmethod - def coerce(cls, value: "bool | str | EncryptionPolicy") -> "EncryptionPolicy": - """The policy for an ``encryption`` setting. - - ``True`` is ``REQUIRED`` and ``False`` is ``OPTIONAL``. A policy or - its name (``"off"``) selects that policy. - """ - if isinstance(value, bool): - return cls.REQUIRED if value else cls.OPTIONAL - return cls(value) - - -class EncryptionPolicyConflictError(ValueError): - """One side of the pair has encryption off and the other requires it.""" - - def __init__(self, peer_email: str, message: str | None = None): - super().__init__( - message - or f"Encryption with {peer_email} is off on one side and required on " - "the other, so this pair cannot exchange messages. Set the same " - "policy on both sides, or use optional on one of them." - ) - self.peer_email = peer_email + def from_bool(cls, encryption: bool) -> "EncryptionPolicy": + """The policy for an ``encryption`` setting: ``True`` is ``REQUIRED``.""" + return cls.REQUIRED if encryption else cls.OPTIONAL class MessageFormatError(ValueError): @@ -58,15 +36,5 @@ def __init__(self, peer_email: str, encrypted: bool): def pair_uses_encryption( mine: EncryptionPolicy, theirs: EncryptionPolicy | None ) -> bool: - """Whether a pair with these policies encrypts. ``theirs`` is None when unknown. - - A conflicting pair (see ``pair_conflicts``) does not encrypt. - """ - if EncryptionPolicy.OFF in (mine, theirs): - return False + """Whether a pair with these policies encrypts. ``theirs`` is None when unknown.""" return EncryptionPolicy.REQUIRED in (mine, theirs) - - -def pair_conflicts(mine: EncryptionPolicy, theirs: EncryptionPolicy | None) -> bool: - """Whether one side has encryption off and the other requires it.""" - return {mine, theirs} == {EncryptionPolicy.OFF, EncryptionPolicy.REQUIRED} diff --git a/syft/sync/login.py b/syft/sync/login.py index 94828413f70..090bc0b0e7c 100644 --- a/syft/sync/login.py +++ b/syft/sync/login.py @@ -100,7 +100,7 @@ def login( token_path: str | Path | None = None, skip_peer_on_patch_version_diff: bool | None = None, # None: value is determined by the role - encryption: bool | str = False, # True: required; "off": never encrypt + encryption: bool = False, # True: required ): return login_ds( email, @@ -119,7 +119,7 @@ def login_ds( token_path: str | Path | None = None, skip_peer_on_patch_version_diff: bool | None = None, # None: value is determined by the role - encryption: bool | str = False, # True: required; "off": never encrypt + encryption: bool = False, # True: required ): env = check_env() email, token_path = _resolve_login_params(email, token_path) @@ -152,7 +152,7 @@ def login_do( token_path: str | Path | None = None, skip_peer_on_patch_version_diff: bool | None = None, # None: value is determined by the role - encryption: bool | str = False, # True: required; "off": never encrypt + encryption: bool = False, # True: required ): env = check_env() email, token_path = _resolve_login_params(email, token_path) diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index 5becc22bbcb..f0f38e7d9d7 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -14,9 +14,7 @@ from syft.sync.encryption_policy import ( EncryptionPolicy, - EncryptionPolicyConflictError, MessageFormatError, - pair_conflicts, pair_uses_encryption, ) from syft.sync.peers.exceptions import SyftPeerNotReadyError @@ -126,9 +124,8 @@ def encrypt_if_needed(self, email: str, data: bytes) -> bytes: Raises: SyftPeerNotReadyError: when the policy of the peer is not known, or the pair encrypts and the peer bundle is missing. - EncryptionPolicyConflictError: when the policies of the pair conflict. """ - self._raise_unless_can_exchange(email) + self._raise_unless_policy_known(email) if not self.peer_uses_encryption(email): return data return self.encrypt(email, data) @@ -145,9 +142,8 @@ def decrypt_and_verify_if_needed(self, email: str, data: bytes) -> bytes: the pair encrypts and the peer bundle is missing. MessageFormatError: when ``data`` is plaintext and the pair encrypts, or ``data`` is an envelope and the pair does not. - EncryptionPolicyConflictError: when the policies of the pair conflict. """ - self._raise_unless_can_exchange(email) + self._raise_unless_policy_known(email) encrypts = self.peer_uses_encryption(email) encrypted = self.is_envelope(data) if encrypted != encrypts: @@ -196,26 +192,13 @@ def policy_of(self, peer: Peer) -> Optional[EncryptionPolicy]: self._last_known_policies[peer.email] = policy return policy - def conflict_error(self, peer: Peer) -> EncryptionPolicyConflictError | None: - """Why this client and ``peer`` cannot exchange messages, or None when they can. - - That is when one side has encryption off and the other requires it. - """ - if pair_conflicts(self.encryption_policy, self.policy_of(peer)): - return EncryptionPolicyConflictError(peer.email) - return None - - def _raise_unless_can_exchange(self, email: str) -> None: - """Raise unless the policy of ``email`` is known and does not conflict. + def _raise_unless_policy_known(self, email: str) -> None: + """Raise unless the policy of ``email`` is known. Raises: - EncryptionPolicyConflictError: when the policies of the pair conflict. SyftPeerNotReadyError: when the policy of the peer is not known. """ peer = self.get_cached_peer(email) - error = None if peer is None else self.conflict_error(peer) - if error is not None: - raise error if peer is None or self.policy_of(peer) is None: raise SyftPeerNotReadyError( email, diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index dd19aa4d9a1..8a349415a82 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -134,7 +134,7 @@ def for_colab( email: str, has_ds_role: bool = False, has_do_role: bool = False, - encryption: bool | str = False, + encryption: bool = False, persist_owner_state: bool = True, skip_peer_on_patch_version_diff: Optional[ bool @@ -190,7 +190,7 @@ def for_colab( connection_configs=connection_configs, has_do_role=has_do_role, has_ds_role=has_ds_role, - encryption_policy=EncryptionPolicy.coerce(encryption), + encryption_policy=EncryptionPolicy.from_bool(encryption), skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, force_ignore_peer_version=force_ignore_peer_version, ) @@ -214,7 +214,7 @@ def for_jupyter( has_ds_role: bool = False, has_do_role: bool = False, token_path: Path | None = None, - encryption: bool | str = False, + encryption: bool = False, persist_owner_state: bool = True, skip_peer_on_patch_version_diff: Optional[ bool @@ -274,7 +274,7 @@ def for_jupyter( connection_configs=connection_configs, has_do_role=has_do_role, has_ds_role=has_ds_role, - encryption_policy=EncryptionPolicy.coerce(encryption), + encryption_policy=EncryptionPolicy.from_bool(encryption), skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, force_ignore_peer_version=force_ignore_peer_version, ) @@ -606,7 +606,7 @@ def for_colab( email: str, has_ds_role: bool = False, has_do_role: bool = False, - encryption: bool | str = False, + encryption: bool = False, persist_owner_state: bool = True, skip_peer_on_patch_version_diff: Optional[ bool @@ -633,7 +633,7 @@ def for_jupyter( has_ds_role: bool = False, has_do_role: bool = False, token_path: Path | None = None, - encryption: bool | str = False, + encryption: bool = False, persist_owner_state: bool = True, skip_peer_on_patch_version_diff: Optional[ bool @@ -757,7 +757,7 @@ def pair_with_mock_drive_service_connection( use_in_memory_cache: bool = True, persist_owner_state: bool = True, check_versions: bool = False, - encryption: bool | str = False, + encryption: bool = False, collection_specs: list["CollectionSyncSpec"] | None = None, ds_encryption_policy: EncryptionPolicy | None = None, do_encryption_policy: EncryptionPolicy | None = None, @@ -785,7 +785,7 @@ def pair_with_mock_drive_service_connection( Returns: Tuple of (ds_manager, do_manager) """ - default_policy = EncryptionPolicy.coerce(encryption) + default_policy = EncryptionPolicy.from_bool(encryption) # Create configs using the existing base config generator do_config = SyftboxManagerConfig._base_config_for_testing( email=email1, @@ -982,9 +982,6 @@ def sync( ignore_peer_version=ignore_peer_version, ) ) - compatible_emails = self.peer_manager.without_encryption_conflicts( - compatible_emails - ) self.datasite_owner_syncer.sync( compatible_emails, deferred_peer_emails=[ @@ -1002,9 +999,6 @@ def sync( if self.has_ds_role: peer_emails = [peer.email for peer in self.peer_manager.syncable_peers] self.peer_manager.warn_if_all_peers_incompatible(peer_emails) - peer_emails = self.peer_manager.without_encryption_conflicts( - peer_emails - ) self.datasite_watcher_syncer.sync_down(peer_emails) def load_peers(self, force_download: bool = False): diff --git a/syft/sync/sync/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py index 1465d4af2d5..dc687302078 100644 --- a/syft/sync/sync/datasite_owner_syncer.py +++ b/syft/sync/sync/datasite_owner_syncer.py @@ -21,7 +21,6 @@ DataSiteOwnerEventCacheConfig, ) from syft.sync.connections.connection_router import ConnectionRouter -from syft.sync.encryption_policy import EncryptionPolicyConflictError from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.sync.caches.datasite_owner_cache import DataSiteOwnerEventCache from syft.sync.sync.collection_spec import CollectionSyncSpec @@ -239,7 +238,7 @@ def sync( # no new message, we are done break proposed_count += len(msg.proposed_file_changes) - except (SyftPeerNotReadyError, EncryptionPolicyConflictError) as e: + except SyftPeerNotReadyError as e: warnings.warn(f"Messages from {peer_email} stay in the inbox: {e}") if proposed_count: print( @@ -890,7 +889,7 @@ def process_syftbox_events_queue(self): self.connection_router.owner_write_event_messages_to_outbox( recipient, message ) - except (SyftPeerNotReadyError, EncryptionPolicyConflictError) as e: + except SyftPeerNotReadyError as e: warnings.warn(f"Dropped an outbox message for {recipient}: {e}") self._clear_delivered(recipient, message) diff --git a/syft/sync/sync/datasite_watcher_syncer.py b/syft/sync/sync/datasite_watcher_syncer.py index 5567209e4ce..0e846750958 100644 --- a/syft/sync/sync/datasite_watcher_syncer.py +++ b/syft/sync/sync/datasite_watcher_syncer.py @@ -8,7 +8,6 @@ from syft.sync.connections.base_connection import ConnectionConfig from syft.sync.connections.connection_router import ConnectionRouter from syft.sync.callback_mixin import BaseModelCallbackMixin -from syft.sync.encryption_policy import EncryptionPolicyConflictError from syft.sync.events.file_change_event import FileChangeEventsMessage from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.messages.proposed_filechange import ( @@ -115,7 +114,6 @@ def _send_by_recipient( Raises: SyftPeerNotReadyError: when the policy of a recipient is not known, or the pair encrypts and the peer bundle is missing. - EncryptionPolicyConflictError: when the policies of a pair conflict. """ error = None for recipient, changes in changes_by_recipient.items(): @@ -124,7 +122,7 @@ def _send_by_recipient( ) try: self._send_message(recipient, message) - except (SyftPeerNotReadyError, EncryptionPolicyConflictError) as e: + except SyftPeerNotReadyError as e: error = error or e if error is not None: raise error @@ -146,7 +144,6 @@ def _send_message( Raises: SyftPeerNotReadyError: when the policy of ``recipient`` is not known, or the pair encrypts and the peer bundle is missing. - EncryptionPolicyConflictError: when the policies of the pair conflict. """ self.connection_router.watcher_send_proposed_file_changes_message( recipient, message diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 0bcce09455e..0990611af03 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -477,22 +477,6 @@ def get_compatible_peer_emails_for_syncing( compatible.append(email) return compatible - def without_encryption_conflicts(self, peer_emails: List[str]) -> List[str]: - """``peer_emails`` without the peers whose encryption policy conflicts. - - Such a pair cannot exchange messages, so it is skipped with a warning. - ``ignore_peer_version`` does not apply: it is not a version question. - """ - result = [] - for email in peer_emails: - peer = self.peer_store.get_cached_peer(email) - error = None if peer is None else self.peer_store.conflict_error(peer) - if error is None: - result.append(email) - else: - warnings.warn(f"{email} is skipped: {error}") - return result - def warn_if_all_peers_incompatible(self, peer_emails: List[str]) -> None: """Warn if all connected peers are incompatible (used by DS during sync).""" if self.suppress_version_warnings: @@ -576,9 +560,6 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): self.peer_store.encryption_policy, self.peer_store.policy_of(new_peer_obj), ) - error = self.peer_store.conflict_error(new_peer_obj) - if error is not None: - warnings.warn(f"{peer_email} is skipped: {error}") peer_bundle = None if encrypted and is_accepting: diff --git a/tests/integration/syft_bg/test_cli.py b/tests/integration/syft_bg/test_cli.py index c1bdf000bb6..0bf09b8df70 100644 --- a/tests/integration/syft_bg/test_cli.py +++ b/tests/integration/syft_bg/test_cli.py @@ -36,7 +36,7 @@ def test_init_basic(self, mock_api_init): do_email="alice@uni.edu", syftbox_root=None, token_path=None, - encryption="optional", + encryption=False, ) @patch("syft_bg.api.api.init") diff --git a/tests/unit/syft_bg/test_orchestrator_encryption.py b/tests/unit/syft_bg/test_orchestrator_encryption.py index 89a7d9a7553..3330fbda735 100644 --- a/tests/unit/syft_bg/test_orchestrator_encryption.py +++ b/tests/unit/syft_bg/test_orchestrator_encryption.py @@ -54,7 +54,7 @@ def client_kwargs(monkeypatch): def test_orchestrator_uses_configured_policy(temp_dir, client_kwargs, build): orchestrator, config = build(temp_dir, EncryptionPolicy.REQUIRED) orchestrator.from_config(config) - assert client_kwargs["encryption"] == EncryptionPolicy.REQUIRED + assert client_kwargs["encryption"] is True @pytest.mark.parametrize("build", [_sync, _approve], ids=["sync", "approve"]) diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 8f14c8c2f6d..8b2f4e0fe23 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -14,7 +14,6 @@ from syft.sync.connections.drive import mock_drive_service from syft.sync.encryption_policy import ( EncryptionPolicy, - EncryptionPolicyConflictError, MessageFormatError, ) from syft.sync.events.file_change_event import ( @@ -28,7 +27,6 @@ from tests.unit.test_sync_manager import path_for_job from tests.unit.utils import grant_job_inbox_access -OFF = EncryptionPolicy.OFF OPTIONAL = EncryptionPolicy.OPTIONAL REQUIRED = EncryptionPolicy.REQUIRED @@ -140,12 +138,9 @@ def test_policy_cannot_change_after_login(): store.encryption_policy = REQUIRED -@pytest.mark.parametrize( - "value, expected", - [(True, REQUIRED), (False, OPTIONAL), ("off", OFF), (OFF, OFF)], -) -def test_encryption_setting_coerced_to_policy(value, expected): - assert EncryptionPolicy.coerce(value) == expected +@pytest.mark.parametrize("value, expected", [(True, REQUIRED), (False, OPTIONAL)]) +def test_encryption_setting_maps_to_policy(value, expected): + assert EncryptionPolicy.from_bool(value) == expected def test_own_version_advertises_policy(): @@ -194,9 +189,6 @@ def test_malformed_peer_bundle_raises_on_peer_load(): (OPTIONAL, REQUIRED, True), (REQUIRED, OPTIONAL, True), (REQUIRED, REQUIRED, True), - (OFF, OFF, False), - (OFF, OPTIONAL, False), - (OPTIONAL, OFF, False), ], ) def test_peer_encryption_resolved_from_both_policies(mine, theirs, encrypted): @@ -204,20 +196,6 @@ def test_peer_encryption_resolved_from_both_policies(mine, theirs, encrypted): assert store.peer_uses_encryption("bob@example.com") is encrypted -@pytest.mark.parametrize( - "mine, theirs", [(OFF, REQUIRED), (REQUIRED, OFF)], ids=["off", "required"] -) -def test_off_and_required_conflict(mine, theirs): - """Neither side gives way, so the pair cannot exchange messages.""" - alice, bob = _keyed_pair(mine, theirs) - peer = alice.get_cached_peer("bob@example.com") - assert isinstance(alice.conflict_error(peer), EncryptionPolicyConflictError) - with pytest.raises(EncryptionPolicyConflictError): - alice.encrypt_if_needed("bob@example.com", b"data") - with pytest.raises(EncryptionPolicyConflictError): - alice.decrypt_and_verify_if_needed("bob@example.com", b"plain") - - def test_resolution_follows_changed_peer_policy(): """An enclave that reboots with another setting is picked up on the next load.""" store = _store_with_peer(OPTIONAL, OPTIONAL) @@ -230,7 +208,7 @@ def test_resolution_follows_changed_peer_policy(): assert not store.peer_uses_encryption("bob@example.com") -@pytest.mark.parametrize("policy", [OPTIONAL, REQUIRED, OFF]) +@pytest.mark.parametrize("policy", [OPTIONAL, REQUIRED]) def test_sender_refuses_peer_with_unknown_policy(policy): store = PeerStore(email="alice@example.com", encryption_policy=policy) store.add_peer(Peer(email="bob@example.com")) @@ -256,7 +234,7 @@ def test_encrypting_pair_without_peer_bundle_not_ready(): # ========================================================================= -@pytest.mark.parametrize("policy", [OPTIONAL, OFF, REQUIRED]) +@pytest.mark.parametrize("policy", [OPTIONAL, REQUIRED]) def test_every_policy_creates_key_file(tmp_path, policy): keys_path = tmp_path / "crypto_keys.json" store = PeerStore.create( @@ -372,39 +350,6 @@ def test_negotiated_encryption_flow(ds_policy, do_policy): assert len(_ds_cached_contents(ds_manager)) > 0 -def test_off_client_works_in_plaintext_with_optional_peer(): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - ds_encryption_policy=OFF, - do_encryption_policy=OPTIONAL, - ) - grant_job_inbox_access(do_manager, ds_manager.email) - assert ds_manager.peer_manager.get_own_version().encryption_policy == OFF - - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "plain content") - assert not PeerStore.is_envelope(_raw_inbox_message(do_manager, ds_manager.email)) - do_manager.sync() - assert any("plain content" in c for c in _accepted_contents(do_manager)) - - -def test_off_and_required_peers_skip_each_other(): - """Both syncs go on without the other side, and say why.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - ds_encryption_policy=OFF, - do_encryption_policy=REQUIRED, - ) - grant_job_inbox_access(do_manager, ds_manager.email) - ds_manager.load_peers() - - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - with pytest.raises(EncryptionPolicyConflictError): - ds_manager._send_file_change(file_path, "content") - with pytest.warns(UserWarning, match="skipped"): - do_manager.sync() - with pytest.warns(UserWarning, match="skipped"): - ds_manager.sync() - - def test_owner_drops_outbox_message_to_non_peer(): _, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( do_encryption_policy=REQUIRED, @@ -417,25 +362,6 @@ def test_owner_drops_outbox_message_to_non_peer(): assert syncer.outbox_queue.empty() -def test_conflict_found_on_read_leaves_message_in_inbox(): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( - ds_encryption_policy=OPTIONAL, - do_encryption_policy=REQUIRED, - ) - grant_job_inbox_access(do_manager, ds_manager.email) - ds_manager.load_peers() - file_path = path_for_job(do_manager.email, ds_manager.email, "my.job") - ds_manager._send_file_change(file_path, "content") - - # The scientist logs in again with encryption off, after the message was sent. - peer = do_manager.peer_manager.get_cached_peer(ds_manager.email) - peer.version = _version_with_policy(OFF) - match = f"Messages from {ds_manager.email} stay in the inbox" - with pytest.warns(UserWarning, match=match): - do_manager.datasite_owner_syncer.sync([ds_manager.email]) - assert _raw_inbox_message(do_manager, ds_manager.email) - - def test_owner_leaves_envelope_from_peer_without_bundle_in_inbox(): """The sync goes on; the message stays in the inbox until the bundle is read.""" ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( @@ -524,16 +450,13 @@ def _not_ready(recipient: str) -> SyftPeerNotReadyError: return SyftPeerNotReadyError(recipient, cause="No bundle.", remedy="Sync.") -@pytest.mark.parametrize( - "error", [EncryptionPolicyConflictError, _not_ready], ids=["conflict", "not ready"] -) -def test_failure_for_one_recipient_still_sends_to_other(monkeypatch, error): +def test_failure_for_one_recipient_still_sends_to_other(monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() sent = [] def send_message(self, recipient, message): if recipient == "other@example.com": - raise error(recipient) + raise _not_ready(recipient) sent.append(recipient) monkeypatch.setattr( @@ -542,20 +465,17 @@ def send_message(self, recipient, message): watcher = ds_manager.datasite_watcher_syncer watcher.on_file_change("other@example.com/a.txt", "a", process_now=False) watcher.on_file_change(f"{do_manager.email}/b.txt", "b", process_now=False) - with pytest.raises((EncryptionPolicyConflictError, SyftPeerNotReadyError)): + with pytest.raises(SyftPeerNotReadyError): watcher.process_file_changes_queue() assert sent == [do_manager.email] assert _queued_changes(ds_manager) == 0 -@pytest.mark.parametrize( - "error", [EncryptionPolicyConflictError, _not_ready], ids=["conflict", "not ready"] -) -def test_owner_drops_outbox_message_it_cannot_send(monkeypatch, error): +def test_owner_drops_outbox_message_it_cannot_send(monkeypatch): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() def fail(self, recipient, message): - raise error(recipient) + raise _not_ready(recipient) monkeypatch.setattr(ConnectionRouter, "owner_write_event_messages_to_outbox", fail) syncer = do_manager.datasite_owner_syncer From 2ee3c48108f1551dfb3469663e5debd1efab9f8f Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Sat, 24 Oct 2026 11:36:33 -0300 Subject: [PATCH 29/30] fix: keep keys after a wipe and raise when a DO send cannot go Every login takes a key_file. delete_syftbox writes the keys back. A DO send that cannot go raises, and the change stays pending. Re-running syft-bg init updates the policy of every service. from_bool refuses a value that is not a bool, and reading our own bundle creates no folder. --- docs/connections.md | 2 +- packages/syft-bg/src/syft_bg/cli/init/flow.py | 3 +- packages/syft-enclave/docs/dev.md | 2 +- .../docs/tinfoil_troubleshooting.md | 32 ++++++------ .../syft-enclave/src/syft_enclaves/client.py | 2 +- .../syft-enclave/src/syft_enclaves/login.py | 14 +++--- .../syft-enclave/src/syft_enclaves/runner.py | 6 --- packages/syft-enclave/tests/test_runner.py | 3 -- packages/syft-rds/src/syft_rds/login.py | 12 ++++- .../syft-rds/tests/test_login_key_file.py | 39 +++++++++++++++ .../connections/drive/gdrive_transport.py | 30 ++++++++--- syft/sync/encryption_policy.py | 11 +++- syft/sync/login.py | 36 ++++++++++--- syft/sync/syftbox_manager.py | 9 ++-- syft/sync/sync/datasite_owner_syncer.py | 11 ++-- tests/unit/syft_bg/test_init.py | 6 +++ tests/unit/test_drive_connections.py | 6 +++ tests/unit/test_encryption_policy.py | 50 +++++++++++++------ tests/unit/test_login_key_file.py | 38 ++++++++++++++ 19 files changed, 234 insertions(+), 78 deletions(-) create mode 100644 packages/syft-rds/tests/test_login_key_file.py create mode 100644 tests/unit/test_login_key_file.py diff --git a/docs/connections.md b/docs/connections.md index 502a2298b08..58b6988924d 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -61,7 +61,7 @@ A pair encrypts when one side is `required`. A client takes its policy at login, and keeps it until the process exits. syft-bg takes its policy in `syft_bg.init(encryption=...)`; use the same value as the notebook login, because both write the same datasite. Files at rest are read with the current policy, so a datasite keeps one policy. -Nothing is sent to a peer until its policy is known, that is, until it accepts and shares its version file. A scientist's change for a peer whose policy or bundle is not known is dropped, and the send raises. An owner's change for that peer is dropped with a warning. An owner keeps a message it cannot read yet in the inbox, with a warning. +Nothing is sent to a peer until its policy is known, that is, until it accepts and shares its version file. A scientist's change for a peer whose policy or bundle is not known is dropped, and the send raises. An owner's send to that peer raises, and the change stays pending, so a later sync sends it. An owner keeps a message it cannot read yet in the inbox, with a warning. The format of a message must match the pair: plaintext in a pair that encrypts is refused, and so is an envelope in a pair that does not. The owner archives such a message unread, and a scientist skips it, each with a warning. diff --git a/packages/syft-bg/src/syft_bg/cli/init/flow.py b/packages/syft-bg/src/syft_bg/cli/init/flow.py index 755e18bf69c..cc401e12beb 100644 --- a/packages/syft-bg/src/syft_bg/cli/init/flow.py +++ b/packages/syft-bg/src/syft_bg/cli/init/flow.py @@ -80,7 +80,8 @@ def _load_existing_config(config: UserPassedConfig, config_path: Path) -> SyftBg if not config.quiet: click.echo(f"Found existing config at {config_path}") - return SyftBgConfig.from_path(config_path) + # Without the merge, so the services keep inheriting the top-level fields. + return SyftBgConfig.from_path(config_path, merge=False) def _resolve_common_settings(config: UserPassedConfig, result: SyftBgConfig) -> None: diff --git a/packages/syft-enclave/docs/dev.md b/packages/syft-enclave/docs/dev.md index b7e759a04df..ec803217d5a 100644 --- a/packages/syft-enclave/docs/dev.md +++ b/packages/syft-enclave/docs/dev.md @@ -25,7 +25,7 @@ just start-debug # cpu (default) just hardware=gpu start-debug # gpu (a3-highgpu-1g, 1x H100) — see docs/terraform_cs.md (Confidential Spaces) "GPU deployments" ``` -Debug enclaves run with encryption off. Clients select encryption from the enclave policy, so they need no setting. +An enclave always requires encryption, debug enclaves too. Clients select encryption from the enclave policy, so they need no setting. ## Building & pushing a new Docker Image diff --git a/packages/syft-enclave/docs/tinfoil_troubleshooting.md b/packages/syft-enclave/docs/tinfoil_troubleshooting.md index 83a4a124720..148bf6271f7 100644 --- a/packages/syft-enclave/docs/tinfoil_troubleshooting.md +++ b/packages/syft-enclave/docs/tinfoil_troubleshooting.md @@ -10,22 +10,22 @@ for what the attestation does and does not prove, see ## Symptoms -| Symptom | Cause and fix | -| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Firewall setup failed: creating docker network "default": operation is not permitted on predefined default network` | a network named `default` collides with Docker's predefined one. The canonical schema only reserves `shim-net`, so this **validates fine and fails on the host**. Name it anything else. | -| `FileNotFoundError: No usable temporary directory found in ['/tmp', ...]` and a crash-loop | containers run with a read-only rootfs, and `portalocker` (a syft dependency) calls `tempfile.gettempdir()` at import. Set `read_only: false`. | -| `2 validation errors for EnclaveSettings: email / data_owners Field required` — despite passing `--variable` | the shim only injects variables whose **key the measured config declares**. An undeclared `--variable` is silently dropped. Declare it as a bare name under `env:`. | -| The enclave hangs at "Building SyftEnclaveClient" | OAuth token refresh cannot reach `oauth2.googleapis.com`. See the egress note in `tinfoil-config.yml`: `allowlist` resolves hostnames to IPs once at boot and Google rotates them, so use `egress: open`. | -| `curl: (60) SSL certificate problem: self signed certificate` | expected. The enclave's TLS key is generated inside it and the report commits to that key, so there is no CA. Use `-k` (as `just tinfoil-attest` does) and get your trust from `just tinfoil-verify`. | -| `The server had an error while processing your request.` from the domain | the shim is up but your container is not serving on `upstream-port`. It has probably crashed — `just tinfoil-debug ` then `just tinfoil-logs`. | +| Symptom | Cause and fix | +| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Firewall setup failed: creating docker network "default": operation is not permitted on predefined default network` | a network named `default` collides with Docker's predefined one. The canonical schema only reserves `shim-net`, so this **validates fine and fails on the host**. Name it anything else. | +| `FileNotFoundError: No usable temporary directory found in ['/tmp', ...]` and a crash-loop | containers run with a read-only rootfs, and `portalocker` (a syft dependency) calls `tempfile.gettempdir()` at import. Set `read_only: false`. | +| `2 validation errors for EnclaveSettings: email / data_owners Field required` — despite passing `--variable` | the shim only injects variables whose **key the measured config declares**. An undeclared `--variable` is silently dropped. Declare it as a bare name under `env:`. | +| The enclave hangs at "Building SyftEnclaveClient" | OAuth token refresh cannot reach `oauth2.googleapis.com`. See the egress note in `tinfoil-config.yml`: `allowlist` resolves hostnames to IPs once at boot and Google rotates them, so use `egress: open`. | +| `curl: (60) SSL certificate problem: self signed certificate` | expected. The enclave's TLS key is generated inside it and the report commits to that key, so there is no CA. Use `-k` (as `just tinfoil-attest` does) and get your trust from `just tinfoil-verify`. | +| `The server had an error while processing your request.` from the domain | the shim is up but your container is not serving on `upstream-port`. It has probably crashed — `just tinfoil-debug ` then `just tinfoil-logs`. | | `ValueError: Serialization error: sender fingerprint mismatch` on a data owner's login | the first login persists the keypair at `//private/crypto_keys.json`. A fresh `SYFTBOX_FOLDER` mints a new identity that cannot verify that account's own history on Drive. Point at the account's existing folder, or wipe its Drive state first. | -| `upstream port is not set` | the config's `shim.upstream-port` is missing; it is required | -| Config rejected: image must be a digest | `image:` uses a tag; it must be `repo@sha256:...` | -| A path 404s | it is not in `shim.paths`. `/.well-known/tinfoil-attestation` is exempt | -| `Tag vX.Y.Z already exists` | releases are permanent; pick the next version | -| `image_digest` check fails with a 400 from `github-proxy.tinfoil.sh` | expected — the proxy only serves `tinfoil.hash`; the verifier falls through to github.com | -| `MissingOptionalDependency: ... tinfoil` | `uv pip install "syft-enclave[tinfoil]"` | -| `measurement_match` fails | the running enclave is not the release you are verifying against — check the deployed tag with `just tinfoil-status` | -| Peer skipped with "published no attestation evidence" | the enclave booted outside a TEE, or with `attestation_provider=none` | +| `upstream port is not set` | the config's `shim.upstream-port` is missing; it is required | +| Config rejected: image must be a digest | `image:` uses a tag; it must be `repo@sha256:...` | +| A path 404s | it is not in `shim.paths`. `/.well-known/tinfoil-attestation` is exempt | +| `Tag vX.Y.Z already exists` | releases are permanent; pick the next version | +| `image_digest` check fails with a 400 from `github-proxy.tinfoil.sh` | expected — the proxy only serves `tinfoil.hash`; the verifier falls through to github.com | +| `MissingOptionalDependency: ... tinfoil` | `uv pip install "syft-enclave[tinfoil]"` | +| `measurement_match` fails | the running enclave is not the release you are verifying against — check the deployed tag with `just tinfoil-status` | +| Peer skipped with "published no attestation evidence" | the enclave booted outside a TEE, or with `attestation_provider=none` | Redeploying? Delete the accounts' syftboxes **first**, then redeploy — the enclave caches peer Drive folders at boot, so wiping state afterwards breaks the flow. diff --git a/packages/syft-enclave/src/syft_enclaves/client.py b/packages/syft-enclave/src/syft_enclaves/client.py index 89d27b379e6..afae74f85c7 100644 --- a/packages/syft-enclave/src/syft_enclaves/client.py +++ b/packages/syft-enclave/src/syft_enclaves/client.py @@ -831,7 +831,7 @@ def for_enclave( has_ds_role=True, has_do_role=True, token_path=Path(token_path) if token_path is not None else None, - encryption=EncryptionPolicy.REQUIRED, + encryption=True, persist_owner_state=persist_owner_state, ) diff --git a/packages/syft-enclave/src/syft_enclaves/login.py b/packages/syft-enclave/src/syft_enclaves/login.py index eb36b4ff7d4..9f2abb553d5 100644 --- a/packages/syft-enclave/src/syft_enclaves/login.py +++ b/packages/syft-enclave/src/syft_enclaves/login.py @@ -3,9 +3,12 @@ from pathlib import Path from syft.sync.environments.environment import Environment -from syft.sync.login import _init_client_login, _resolve_login_params +from syft.sync.login import ( + _import_key_file, + _init_client_login, + _resolve_login_params, +) from syft.sync.login_utils import handle_potential_version_mismatches_on_login -from syft.sync.peers.peer_store import datasite_crypto_keys_path, import_key_file from syft.sync.utils.syftbox_utils import check_env from syft_rds import SyftRDSClientConfig @@ -58,12 +61,7 @@ def _login( skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, ) - if key_file is not None: - import_key_file( - key_file, - datasite_crypto_keys_path(config.sync.syftbox_folder, email), - email, - ) + _import_key_file(key_file, config.sync.syftbox_folder, email) client = SyftEnclaveClient.from_config(config) # Reuses syft's login init: verifies the token authenticates as diff --git a/packages/syft-enclave/src/syft_enclaves/runner.py b/packages/syft-enclave/src/syft_enclaves/runner.py index 4d1dfd6553f..9a39f1df501 100644 --- a/packages/syft-enclave/src/syft_enclaves/runner.py +++ b/packages/syft-enclave/src/syft_enclaves/runner.py @@ -128,9 +128,6 @@ def _on_initializing(self) -> None: "(local folder + Google Drive files) before init" ) self.client.delete_syftbox() - # The wipe took the key file with it. The keys live on in memory, - # so write them again: the endpoint signs nonces with that file. - self._save_keys() logger.info("State wipe complete — enclave starts with a clean slate") def _on_attesting(self) -> None: @@ -218,9 +215,6 @@ def _keys_path(self) -> Path: peer_manager.syftbox_folder, peer_manager.peer_store.email ) - def _save_keys(self) -> None: - self.client._rds.peer_manager.peer_store.save_keys(self._keys_path()) - def _on_peering(self) -> None: """Load peers and accept pending peer requests.""" self.client.load_peers() diff --git a/packages/syft-enclave/tests/test_runner.py b/packages/syft-enclave/tests/test_runner.py index 8d214ca1f7f..ba3579898c4 100644 --- a/packages/syft-enclave/tests/test_runner.py +++ b/packages/syft-enclave/tests/test_runner.py @@ -138,9 +138,6 @@ def test_the_key_bundle_is_published_for_the_attestation_endpoint( assert written["bundle"] == {"identity": "enclave@openmined.org"} # The endpoint needs the private key location to answer a nonce. assert written["keys_path"].name == "crypto_keys.json" - # And the keys must be back on disk: fresh_state wiped the folder - # moments earlier, so the file the endpoint signs with is gone. - store.save_keys.assert_called_once_with(written["keys_path"]) def test_provider_gets_the_settings_object(self, monkeypatch): seen = {} diff --git a/packages/syft-rds/src/syft_rds/login.py b/packages/syft-rds/src/syft_rds/login.py index ec53ff2fc9a..aa51cbcf25d 100644 --- a/packages/syft-rds/src/syft_rds/login.py +++ b/packages/syft-rds/src/syft_rds/login.py @@ -14,7 +14,11 @@ from pathlib import Path from syft.sync.environments.environment import Environment -from syft.sync.login import _init_client_login, _resolve_login_params +from syft.sync.login import ( + _import_key_file, + _init_client_login, + _resolve_login_params, +) from syft.sync.login_utils import handle_potential_version_mismatches_on_login from syft.sync.utils.syftbox_utils import check_env @@ -32,6 +36,7 @@ def _login( has_do_role: bool, has_ds_role: bool, encryption: bool = False, + key_file: str | Path | None = None, ) -> SyftRDSClient: """Shared RDS login. @@ -60,6 +65,7 @@ def _login( encryption=encryption, ) + _import_key_file(key_file, config.sync.syftbox_folder, email) client = SyftRDSClient.from_config(config) _init_client_login( client.sync_engine, @@ -78,6 +84,7 @@ def login_do( load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, encryption: bool = False, + key_file: str | Path | None = None, ) -> SyftRDSClient: """Log in as a Data Owner and return a self-contained RDS client.""" return _login( @@ -87,6 +94,7 @@ def login_do( load_peers=load_peers, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, encryption=encryption, + key_file=key_file, has_do_role=True, has_ds_role=False, ) @@ -100,6 +108,7 @@ def login_ds( load_peers: bool = True, skip_peer_on_patch_version_diff: bool | None = None, encryption: bool = False, + key_file: str | Path | None = None, ) -> SyftRDSClient: """Log in as a Data Scientist and return a self-contained RDS client.""" return _login( @@ -109,6 +118,7 @@ def login_ds( load_peers=load_peers, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, encryption=encryption, + key_file=key_file, has_do_role=False, has_ds_role=True, ) diff --git a/packages/syft-rds/tests/test_login_key_file.py b/packages/syft-rds/tests/test_login_key_file.py new file mode 100644 index 00000000000..8f1df8c74a2 --- /dev/null +++ b/packages/syft-rds/tests/test_login_key_file.py @@ -0,0 +1,39 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +import syft.sync.login +from syft.sync.environments.environment import Environment +from syft.sync.peers.peer_store import PeerStore +from syft_rds import login as rds_login + +EMAIL = "do@openmined.org" + + +@pytest.mark.parametrize("login", [rds_login.login_do, rds_login.login_ds]) +def test_login_restores_uploaded_key_file(tmp_path, monkeypatch, login): + uploaded = tmp_path / "upload" / "crypto_keys.json" + earlier = PeerStore.create(email=EMAIL, keys_path=uploaded) + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + for module in (rds_login, syft.sync.login): + monkeypatch.setattr(module, "check_env", lambda: Environment.JUPYTER) + monkeypatch.setattr( + rds_login, + "handle_potential_version_mismatches_on_login", + lambda email, token_path: False, + ) + monkeypatch.setattr(rds_login, "_init_client_login", lambda *a, **kw: None) + configs = [] + monkeypatch.setattr( + rds_login.SyftRDSClient, + "from_config", + classmethod(lambda cls, config: configs.append(config) or MagicMock()), + ) + + login(EMAIL, token_path=tmp_path / "token.json", key_file=uploaded) + + keys_path = configs[0].sync.peer_manager_config.crypto_keys_path + restored = PeerStore.create(email=EMAIL, keys_path=keys_path) + assert restored.get_public_bundle() == earlier.get_public_bundle() diff --git a/syft/sync/connections/drive/gdrive_transport.py b/syft/sync/connections/drive/gdrive_transport.py index 8171a964661..0640734137d 100644 --- a/syft/sync/connections/drive/gdrive_transport.py +++ b/syft/sync/connections/drive/gdrive_transport.py @@ -2393,16 +2393,25 @@ def delete_rolling_state(self) -> None: # ENCRYPTION BUNDLE METHODS # ========================================================================= - def _get_or_create_encryption_bundles_folder_id(self) -> str: - """Get or create the encryption bundles folder for this user.""" + def _find_encryption_bundles_folder_id(self) -> str | None: + """The id of the encryption bundles folder of this user, or None when it does not exist.""" if self._encryption_bundles_folder_id: return self._encryption_bundles_folder_id folder_name = GdriveEncryptionBundlesFolder(email=self.email).as_string() - syftbox_folder_id = self.get_syftbox_folder_id() - folder_id = self._find_folder_by_name(folder_name, parent_id=syftbox_folder_id) + folder_id = self._find_folder_by_name( + folder_name, parent_id=self.get_syftbox_folder_id() + ) + if folder_id: + self._encryption_bundles_folder_id = folder_id + return folder_id + + def _get_or_create_encryption_bundles_folder_id(self) -> str: + """Get or create the encryption bundles folder for this user.""" + folder_id = self._find_encryption_bundles_folder_id() if not folder_id: - folder_id = self.create_folder(folder_name, syftbox_folder_id) - self._encryption_bundles_folder_id = folder_id + folder_name = GdriveEncryptionBundlesFolder(email=self.email).as_string() + folder_id = self.create_folder(folder_name, self.get_syftbox_folder_id()) + self._encryption_bundles_folder_id = folder_id return folder_id def _encryption_bundle_filename(self, owner_email: str, peer_email: str) -> str: @@ -2443,8 +2452,13 @@ def share_encryption_bundles_folder(self, peer_email: str) -> None: self.add_permission(folder_id, peer_email, write=False) def read_own_encryption_bundle(self, peer_email: str) -> str | None: - """Read the bundle this client published for ``peer_email``, or None.""" - folder_id = self._get_or_create_encryption_bundles_folder_id() + """Read the bundle this client published for ``peer_email``, or None. + + A read never creates the bundles folder: without it, nothing is published. + """ + folder_id = self._find_encryption_bundles_folder_id() + if folder_id is None: + return None filename = self._encryption_bundle_filename(self.email, peer_email) query = f"name='{filename}' and '{folder_id}' in parents and trashed=false" results = execute_with_retries( diff --git a/syft/sync/encryption_policy.py b/syft/sync/encryption_policy.py index 0af8571d482..138759f509f 100644 --- a/syft/sync/encryption_policy.py +++ b/syft/sync/encryption_policy.py @@ -17,7 +17,16 @@ class EncryptionPolicy(str, Enum): @classmethod def from_bool(cls, encryption: bool) -> "EncryptionPolicy": - """The policy for an ``encryption`` setting: ``True`` is ``REQUIRED``.""" + """The policy for an ``encryption`` setting: ``True`` is ``REQUIRED``. + + Raises: + TypeError: if ``encryption`` is not a bool. + """ + if not isinstance(encryption, bool): + raise TypeError( + f"encryption must be True or False, got {encryption!r} " + f"({type(encryption).__name__})" + ) return cls.REQUIRED if encryption else cls.OPTIONAL diff --git a/syft/sync/login.py b/syft/sync/login.py index 090bc0b0e7c..480b071465e 100644 --- a/syft/sync/login.py +++ b/syft/sync/login.py @@ -2,7 +2,8 @@ from syft.sync.utils.syftbox_utils import check_env from syft.sync.environments.environment import Environment -from syft.sync.syftbox_manager import SyftboxManager +from syft.sync.peers.peer_store import datasite_crypto_keys_path, import_key_file +from syft.sync.syftbox_manager import SyftboxManager, SyftboxManagerConfig from syft.sync.utils.print_utils import ( print_client_connected, print_client_connecting, @@ -93,6 +94,19 @@ def _resolve_login_params( return email, token_path +def _import_key_file( + key_file: str | Path | None, syftbox_folder: Path, email: str +) -> None: + """Copy ``key_file``, the key file of an earlier session, into the datasite of ``email``. + + Call it before the client is built, so that the client loads these keys. + """ + if key_file is not None: + import_key_file( + key_file, datasite_crypto_keys_path(syftbox_folder, email), email + ) + + def login( email: str | None = None, sync: bool = True, @@ -101,6 +115,7 @@ def login( skip_peer_on_patch_version_diff: bool | None = None, # None: value is determined by the role encryption: bool = False, # True: required + key_file: str | Path | None = None, # crypto_keys.json of an earlier session ): return login_ds( email, @@ -109,6 +124,7 @@ def login( token_path, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, encryption=encryption, + key_file=key_file, ) @@ -120,6 +136,7 @@ def login_ds( skip_peer_on_patch_version_diff: bool | None = None, # None: value is determined by the role encryption: bool = False, # True: required + key_file: str | Path | None = None, # crypto_keys.json of an earlier session ): env = check_env() email, token_path = _resolve_login_params(email, token_path) @@ -127,20 +144,22 @@ def login_ds( repair_pending = handle_potential_version_mismatches_on_login(email, token_path) if env == Environment.COLAB: - client = SyftboxManager.for_colab( + config = SyftboxManagerConfig.for_colab( email=email, has_ds_role=True, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, encryption=encryption, ) else: - client = SyftboxManager.for_jupyter( + config = SyftboxManagerConfig.for_jupyter( email=email, has_ds_role=True, - token_path=token_path, + token_path=Path(token_path) if token_path is not None else None, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, encryption=encryption, ) + _import_key_file(key_file, config.syftbox_folder, email) + client = SyftboxManager.from_config(config) return _init_client_login(client, sync, load_peers, repair_pending) @@ -153,6 +172,7 @@ def login_do( skip_peer_on_patch_version_diff: bool | None = None, # None: value is determined by the role encryption: bool = False, # True: required + key_file: str | Path | None = None, # crypto_keys.json of an earlier session ): env = check_env() email, token_path = _resolve_login_params(email, token_path) @@ -160,19 +180,21 @@ def login_do( repair_pending = handle_potential_version_mismatches_on_login(email, token_path) if env == Environment.COLAB: - client = SyftboxManager.for_colab( + config = SyftboxManagerConfig.for_colab( email=email, has_do_role=True, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, encryption=encryption, ) else: - client = SyftboxManager.for_jupyter( + config = SyftboxManagerConfig.for_jupyter( email=email, has_do_role=True, - token_path=token_path, + token_path=Path(token_path) if token_path is not None else None, skip_peer_on_patch_version_diff=skip_peer_on_patch_version_diff, encryption=encryption, ) + _import_key_file(key_file, config.syftbox_folder, email) + client = SyftboxManager.from_config(config) return _init_client_login(client, sync, load_peers, repair_pending) diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index 8a349415a82..99246e19b44 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -26,6 +26,7 @@ from syft.sync.job_file_change_handler import JobFileChangeHandler from syft.sync.peers.peer import Peer, PeerNotReadyError from syft.sync.peers.peer_list import PeerList +from syft.sync.peers.peer_store import datasite_crypto_keys_path from syft.sync.encryption_policy import EncryptionPolicy from syft.sync.platforms.base_platform import BasePlatform from syft.sync.sync.caches.datasite_owner_cache import ( @@ -1239,6 +1240,11 @@ def delete_syftbox( # Delete local syftbox folder and cache directories self._delete_local_dirs() + # The folder held the key file. The client keeps its keys, so they go + # back to disk: the next login must load the keys that peers know. + self.peer_manager.peer_store.save_keys( + datasite_crypto_keys_path(self.syftbox_folder, self.email) + ) print( f"{_ANSI_RED}Done. If you are also running syft-bg, make sure to call " @@ -1258,9 +1264,6 @@ def _delete_local_dirs(self): for d in dirs_to_delete: if d.exists(): shutil.rmtree(d) - # Encryption keys live in //private/crypto_keys.json, - # so deleting the syftbox folder above already removes them — a fresh state - # regenerates a new identity. # ========================================================================= # CHECKPOINT METHODS diff --git a/syft/sync/sync/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py index dc687302078..46ff3c04c8f 100644 --- a/syft/sync/sync/datasite_owner_syncer.py +++ b/syft/sync/sync/datasite_owner_syncer.py @@ -885,12 +885,11 @@ def process_syftbox_events_queue(self): recipient, message = self.outbox_queue.get() if recipient in self._deferred_peer_emails: continue - try: - self.connection_router.owner_write_event_messages_to_outbox( - recipient, message - ) - except SyftPeerNotReadyError as e: - warnings.warn(f"Dropped an outbox message for {recipient}: {e}") + # A send that cannot go raises before the clear, so the paths stay + # pending and a later sync sends them. + self.connection_router.owner_write_event_messages_to_outbox( + recipient, message + ) self._clear_delivered(recipient, message) def _download_outbox_message_with_new_connection( diff --git a/tests/unit/syft_bg/test_init.py b/tests/unit/syft_bg/test_init.py index df44ca0b957..0d8277feba1 100644 --- a/tests/unit/syft_bg/test_init.py +++ b/tests/unit/syft_bg/test_init.py @@ -108,3 +108,9 @@ def test_quiet_mode_requires_policy(self, temp_dir): def test_quiet_mode_sets_policy(self, temp_dir): config = self._run_quiet(temp_dir, encryption="required") assert config.sync.encryption_policy == EncryptionPolicy.REQUIRED + + def test_new_policy_reaches_services(self, temp_dir): + self._run_quiet(temp_dir, encryption="optional") + config = self._run_quiet(temp_dir, encryption="required") + assert config.sync.encryption_policy == EncryptionPolicy.REQUIRED + assert config.approve.encryption_policy == EncryptionPolicy.REQUIRED diff --git a/tests/unit/test_drive_connections.py b/tests/unit/test_drive_connections.py index c4114559d4b..e8988e0afbf 100644 --- a/tests/unit/test_drive_connections.py +++ b/tests/unit/test_drive_connections.py @@ -226,6 +226,12 @@ def test_peering_keeps_the_peer_registry(pair): assert peering.read_peer_registry(force_download=True)[DS]["state"] == "accepted" +def test_reading_own_key_bundle_creates_no_folder(pair): + owner = pair[0].as_transport() + assert owner.peering.read_own_key_bundle(DS) is None + assert pair[0]._find_encryption_bundles_folder_id() is None + + def test_peering_publishes_a_key_bundle_both_sides_can_read(pair): do_conn, ds_conn = pair owner, scientist = do_conn.as_transport(), ds_conn.as_transport() diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 8b2f4e0fe23..4efb5ab4a00 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -1,8 +1,7 @@ """Per-peer encryption negotiated from each side's advertised policy. -A client is ``off``, ``optional`` (encrypt only when the peer requires it) -or ``required``. A pair encrypts when either side requires it, and neither -side is ``off``. +A client is ``optional`` (encrypt only when the peer requires it) or +``required``. A pair encrypts when either side requires it. """ import json @@ -21,9 +20,13 @@ ) from syft.sync.peers.exceptions import SyftPeerNotReadyError from syft.sync.peers.peer import Peer -from syft.sync.peers.peer_store import PeerStore +from syft.sync.peers.peer_store import PeerStore, datasite_crypto_keys_path from syft.sync.syftbox_manager import SyftboxManager from syft.sync.version.version_info import VersionInfo +from tests.unit.test_persisted_cache import ( + _assert_ds_receives_after_notebook_sync, + _pair_with_unsent_do_file, +) from tests.unit.test_sync_manager import path_for_job from tests.unit.utils import grant_job_inbox_access @@ -143,6 +146,12 @@ def test_encryption_setting_maps_to_policy(value, expected): assert EncryptionPolicy.from_bool(value) == expected +@pytest.mark.parametrize("value", [OPTIONAL, "optional", 1, None]) +def test_encryption_setting_must_be_bool(value): + with pytest.raises(TypeError, match="True or False"): + EncryptionPolicy.from_bool(value) + + def test_own_version_advertises_policy(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( ds_encryption_policy=OPTIONAL, @@ -244,6 +253,17 @@ def test_every_policy_creates_key_file(tmp_path, policy): assert reloaded.get_public_bundle() == store.get_public_bundle() +def test_delete_syftbox_keeps_keys_on_disk(): + """The client keeps its keys after the wipe, so the next login must find them.""" + _, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + store = do_manager.peer_manager.peer_store + do_manager.delete_syftbox(verbose=False, broadcast_delete_events=False) + + keys_path = datasite_crypto_keys_path(do_manager.syftbox_folder, do_manager.email) + reloaded = PeerStore.create(email=do_manager.email, keys_path=keys_path) + assert reloaded.get_public_bundle() == store.get_public_bundle() + + def test_plaintext_pair_publishes_bundles_at_peering(): ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() ds_router = ds_manager.peer_manager.connection_router @@ -350,16 +370,15 @@ def test_negotiated_encryption_flow(ds_policy, do_policy): assert len(_ds_cached_contents(ds_manager)) > 0 -def test_owner_drops_outbox_message_to_non_peer(): +def test_owner_send_to_non_peer_raises(): _, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( do_encryption_policy=REQUIRED, ) syncer = do_manager.datasite_owner_syncer message = FileChangeEventsMessage(events=[]) syncer.outbox_queue.put(("former@example.com", message)) - with pytest.warns(UserWarning, match="Dropped an outbox message"): + with pytest.raises(SyftPeerNotReadyError, match="not known"): syncer.process_syftbox_events_queue() - assert syncer.outbox_queue.empty() def test_owner_leaves_envelope_from_peer_without_bundle_in_inbox(): @@ -471,18 +490,19 @@ def send_message(self, recipient, message): assert _queued_changes(ds_manager) == 0 -def test_owner_drops_outbox_message_it_cannot_send(monkeypatch): - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() +def test_unsent_owner_change_stays_pending(monkeypatch): + """The send raises, and the next sync sends the change.""" + ds_manager, do_manager = _pair_with_unsent_do_file() def fail(self, recipient, message): raise _not_ready(recipient) - monkeypatch.setattr(ConnectionRouter, "owner_write_event_messages_to_outbox", fail) - syncer = do_manager.datasite_owner_syncer - syncer.outbox_queue.put((ds_manager.email, FileChangeEventsMessage(events=[]))) - with pytest.warns(UserWarning, match="Dropped an outbox message"): - syncer.process_syftbox_events_queue() - assert syncer.outbox_queue.empty() + with monkeypatch.context() as m: + m.setattr(ConnectionRouter, "owner_write_event_messages_to_outbox", fail) + with pytest.raises(SyftPeerNotReadyError): + do_manager.sync(auto_checkpoint=False) + + _assert_ds_receives_after_notebook_sync(ds_manager, do_manager) # ========================================================================= diff --git a/tests/unit/test_login_key_file.py b/tests/unit/test_login_key_file.py new file mode 100644 index 00000000000..c05272a469f --- /dev/null +++ b/tests/unit/test_login_key_file.py @@ -0,0 +1,38 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +import syft.sync.login as syft_login +from syft.sync.environments.environment import Environment +from syft.sync.peers.peer_store import PeerStore +from syft.sync.syftbox_manager import SyftboxManager + +EMAIL = "do@openmined.org" + + +@pytest.mark.parametrize("login", [syft_login.login_do, syft_login.login_ds]) +def test_login_restores_uploaded_key_file(tmp_path, monkeypatch, login): + uploaded = tmp_path / "upload" / "crypto_keys.json" + earlier = PeerStore.create(email=EMAIL, keys_path=uploaded) + + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + monkeypatch.setattr(syft_login, "check_env", lambda: Environment.JUPYTER) + monkeypatch.setattr( + syft_login, + "handle_potential_version_mismatches_on_login", + lambda email, token_path: False, + ) + monkeypatch.setattr(syft_login, "_init_client_login", lambda *a, **kw: None) + configs = [] + monkeypatch.setattr( + SyftboxManager, + "from_config", + classmethod(lambda cls, config: configs.append(config) or MagicMock()), + ) + + login(EMAIL, token_path=tmp_path / "token.json", key_file=uploaded) + + keys_path = configs[0].peer_manager_config.crypto_keys_path + restored = PeerStore.create(email=EMAIL, keys_path=keys_path) + assert restored.get_public_bundle() == earlier.get_public_bundle() From 52e54a1eced39b4d83742aaeec6b33ff9020a6b2 Mon Sep 17 00:00:00 2001 From: Pedro Werneck Date: Sat, 24 Oct 2026 12:50:46 -0300 Subject: [PATCH 30/30] fix: check the published bundle only for pairs that encrypt A pair that does not encrypt never uses the bundle, so new keys on a new runtime are fine for it. The check runs after the peer versions load. --- syft/sync/version/peer_manager.py | 11 +++-------- tests/unit/test_encryption_policy.py | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 0990611af03..6d0036c243c 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -653,14 +653,6 @@ def load_peers(self, force_download: bool = False): if p.state in (PeerState.ACCEPTED, PeerState.REQUESTED_BY_PEER) } self.peer_store.set_peers(peers) - if first_load: - self._check_published_bundles( - [ - p.email - for p in peers - if p.state in (PeerState.ACCEPTED, PeerState.REQUESTED_BY_ME) - ] - ) self.load_peer_versions_parallel([peer.email for peer in peers]) # Peer policies are known only after the versions load. @@ -670,6 +662,9 @@ def load_peers(self, force_download: bool = False): if p.state in (PeerState.ACCEPTED, PeerState.REQUESTED_BY_ME) and self.peer_store.peer_uses_encryption(p.email) ] + if first_load: + # Only a pair that encrypts uses the bundle we published. + self._check_published_bundles([p.email for p in encrypted]) self._read_missing_peer_bundles(encrypted) def _check_published_bundles(self, peer_emails: list[str]) -> None: diff --git a/tests/unit/test_encryption_policy.py b/tests/unit/test_encryption_policy.py index 4efb5ab4a00..65e77fbd202 100644 --- a/tests/unit/test_encryption_policy.py +++ b/tests/unit/test_encryption_policy.py @@ -272,9 +272,8 @@ def test_plaintext_pair_publishes_bundles_at_peering(): assert do_router.read_peer_encryption_bundle(ds_manager.email) is not None -def test_first_peer_load_refuses_bundle_of_other_keys(): - """A new runtime without the key file has new keys; peers hold the old bundle.""" - ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() +def _publish_bundle_of_other_keys(do_manager, ds_manager) -> None: + """As a new runtime without the key file: Drive holds the bundle of older keys.""" earlier = PeerStore(email=do_manager.email) earlier.generate_keys() do_manager.peer_manager.connection_router.write_encryption_bundle( @@ -282,10 +281,24 @@ def test_first_peer_load_refuses_bundle_of_other_keys(): json.dumps({"public_encryption_bundle": earlier.get_public_bundle()}), ) do_manager.peer_manager._states_at_start = None # as a new process + + +def test_first_peer_load_refuses_bundle_of_other_keys(): + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection( + do_encryption_policy=REQUIRED, + ) + _publish_bundle_of_other_keys(do_manager, ds_manager) with pytest.raises(ValueError, match="already has a key"): do_manager.load_peers() +def test_bundle_of_other_keys_ignored_when_pair_does_not_encrypt(): + """No peer uses the bundle, so new keys on a new runtime are fine.""" + ds_manager, do_manager = SyftboxManager.pair_with_mock_drive_service_connection() + _publish_bundle_of_other_keys(do_manager, ds_manager) + do_manager.load_peers() + + # ========================================================================= # Receive path # =========================================================================