diff --git a/docs/connections.md b/docs/connections.md
index b6c0d482d09..49493ab1fb3 100644
--- a/docs/connections.md
+++ b/docs/connections.md
@@ -48,6 +48,26 @@ 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 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, 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.
+
+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.
+
## 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/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..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
@@ -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))"
]
},
{
@@ -257,9 +264,20 @@
]
},
{
- "cell_type": "markdown",
+ "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",
+ "metadata": {},
"source": [
"### Step 1.2 — Attest the enclave's identity\n",
"\n",
@@ -269,7 +287,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "19",
+ "id": "20",
"metadata": {},
"outputs": [],
"source": [
@@ -279,7 +297,7 @@
},
{
"cell_type": "markdown",
- "id": "20",
+ "id": "21",
"metadata": {},
"source": [
"## Step 2 — Get the two models\n",
@@ -296,7 +314,7 @@
},
{
"cell_type": "markdown",
- "id": "21",
+ "id": "22",
"metadata": {},
"source": [
"### Step 2.1 — Gemma 3 from Kaggle\n"
@@ -305,7 +323,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "22",
+ "id": "23",
"metadata": {},
"outputs": [],
"source": [
@@ -324,7 +342,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "23",
+ "id": "24",
"metadata": {},
"outputs": [],
"source": [
@@ -339,7 +357,7 @@
},
{
"cell_type": "markdown",
- "id": "24",
+ "id": "25",
"metadata": {},
"source": [
"### Step 2.2 — ShieldGemma 2B from Hugging Face\n",
@@ -352,7 +370,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "25",
+ "id": "26",
"metadata": {},
"outputs": [],
"source": [
@@ -377,7 +395,7 @@
},
{
"cell_type": "markdown",
- "id": "26",
+ "id": "27",
"metadata": {},
"source": [
"## Step 3 — Prepare the files for upload\n",
@@ -391,7 +409,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "27",
+ "id": "28",
"metadata": {},
"outputs": [],
"source": [
@@ -469,7 +487,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "28",
+ "id": "29",
"metadata": {},
"outputs": [],
"source": [
@@ -488,7 +506,7 @@
},
{
"cell_type": "markdown",
- "id": "29",
+ "id": "30",
"metadata": {},
"source": [
"## Step 4 — Upload the two private models\n",
@@ -499,7 +517,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "30",
+ "id": "31",
"metadata": {},
"outputs": [],
"source": [
@@ -525,7 +543,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "31",
+ "id": "32",
"metadata": {},
"outputs": [],
"source": [
@@ -536,7 +554,7 @@
},
{
"cell_type": "markdown",
- "id": "32",
+ "id": "33",
"metadata": {},
"source": [
"## Step 5 — Run `syft-restrict` on the inference code\n",
@@ -550,7 +568,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "33",
+ "id": "34",
"metadata": {},
"outputs": [],
"source": [
@@ -610,7 +628,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "34",
+ "id": "35",
"metadata": {},
"outputs": [],
"source": [
@@ -630,7 +648,7 @@
},
{
"cell_type": "markdown",
- "id": "35",
+ "id": "36",
"metadata": {},
"source": [
"### Step 5.1 — Approve the job\n",
@@ -641,7 +659,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "36",
+ "id": "37",
"metadata": {},
"outputs": [],
"source": [
@@ -652,7 +670,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "37",
+ "id": "38",
"metadata": {},
"outputs": [],
"source": [
@@ -662,7 +680,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "38",
+ "id": "39",
"metadata": {},
"outputs": [],
"source": [
@@ -672,7 +690,7 @@
},
{
"cell_type": "markdown",
- "id": "39",
+ "id": "40",
"metadata": {},
"source": [
"
\n"
@@ -680,7 +698,7 @@
},
{
"cell_type": "markdown",
- "id": "40",
+ "id": "41",
"metadata": {},
"source": [
"## Step 6 — Approve the final inference job\n",
@@ -691,7 +709,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "41",
+ "id": "42",
"metadata": {},
"outputs": [],
"source": [
@@ -703,7 +721,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "42",
+ "id": "43",
"metadata": {},
"outputs": [],
"source": [
@@ -713,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 04d449d4f66..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
@@ -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))"
]
},
{
@@ -199,9 +206,20 @@
]
},
{
- "cell_type": "markdown",
+ "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",
+ "metadata": {},
"source": [
"### Step 1.2 — Attest the enclave's identity\n",
"\n",
@@ -211,7 +229,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "16",
+ "id": "17",
"metadata": {},
"outputs": [],
"source": [
@@ -221,7 +239,7 @@
},
{
"cell_type": "markdown",
- "id": "17",
+ "id": "18",
"metadata": {},
"source": [
"## Step 2 — Prepare the benchmark\n",
@@ -236,7 +254,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "18",
+ "id": "19",
"metadata": {},
"outputs": [],
"source": [
@@ -302,7 +320,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "19",
+ "id": "20",
"metadata": {},
"outputs": [],
"source": [
@@ -313,7 +331,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "20",
+ "id": "21",
"metadata": {},
"outputs": [],
"source": [
@@ -326,7 +344,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "21",
+ "id": "22",
"metadata": {},
"outputs": [],
"source": [
@@ -344,7 +362,7 @@
},
{
"cell_type": "markdown",
- "id": "22",
+ "id": "23",
"metadata": {},
"source": [
"## Step 3 — Upload the benchmark\n",
@@ -355,7 +373,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "23",
+ "id": "24",
"metadata": {},
"outputs": [],
"source": [
@@ -372,7 +390,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "24",
+ "id": "25",
"metadata": {},
"outputs": [],
"source": [
@@ -382,7 +400,7 @@
},
{
"cell_type": "markdown",
- "id": "25",
+ "id": "26",
"metadata": {},
"source": [
"
\n"
@@ -390,7 +408,7 @@
},
{
"cell_type": "markdown",
- "id": "26",
+ "id": "27",
"metadata": {},
"source": [
"## Step 4 — Review the `syft-restrict` job\n",
@@ -406,7 +424,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "27",
+ "id": "28",
"metadata": {},
"outputs": [],
"source": [
@@ -418,7 +436,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "28",
+ "id": "29",
"metadata": {},
"outputs": [],
"source": [
@@ -428,7 +446,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "29",
+ "id": "30",
"metadata": {},
"outputs": [],
"source": [
@@ -438,7 +456,7 @@
},
{
"cell_type": "markdown",
- "id": "30",
+ "id": "31",
"metadata": {},
"source": [
"### Step 4.1 — Read the certificate\n",
@@ -449,7 +467,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "31",
+ "id": "32",
"metadata": {},
"outputs": [],
"source": [
@@ -468,7 +486,7 @@
},
{
"cell_type": "markdown",
- "id": "32",
+ "id": "33",
"metadata": {},
"source": [
"## Step 5 — Submit the inference job\n",
@@ -485,7 +503,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "33",
+ "id": "34",
"metadata": {},
"outputs": [],
"source": [
@@ -496,7 +514,7 @@
},
{
"cell_type": "markdown",
- "id": "34",
+ "id": "35",
"metadata": {},
"source": [
"### Step 5.1 — Read the model owner's guardrail\n",
@@ -507,7 +525,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "35",
+ "id": "36",
"metadata": {},
"outputs": [],
"source": [
@@ -525,7 +543,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "36",
+ "id": "37",
"metadata": {},
"outputs": [],
"source": [
@@ -657,7 +675,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "37",
+ "id": "38",
"metadata": {},
"outputs": [],
"source": [
@@ -685,7 +703,7 @@
},
{
"cell_type": "markdown",
- "id": "38",
+ "id": "39",
"metadata": {},
"source": [
"## Step 6 — Approve the inference job\n",
@@ -696,7 +714,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "39",
+ "id": "40",
"metadata": {},
"outputs": [],
"source": [
@@ -707,7 +725,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "40",
+ "id": "41",
"metadata": {},
"outputs": [],
"source": [
@@ -717,7 +735,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "41",
+ "id": "42",
"metadata": {},
"outputs": [],
"source": [
@@ -727,7 +745,7 @@
},
{
"cell_type": "markdown",
- "id": "42",
+ "id": "43",
"metadata": {},
"source": [
"
\n"
@@ -735,7 +753,7 @@
},
{
"cell_type": "markdown",
- "id": "43",
+ "id": "44",
"metadata": {},
"source": [
"## Step 7 — View the results\n",
@@ -748,7 +766,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "44",
+ "id": "45",
"metadata": {},
"outputs": [],
"source": [
@@ -761,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 7172f738261..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
@@ -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))"
]
},
{
@@ -209,9 +216,20 @@
]
},
{
- "cell_type": "markdown",
+ "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",
+ "metadata": {},
"source": [
"### Step 1.2 — Attest the enclave's identity\n",
"\n",
@@ -221,7 +239,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "17",
+ "id": "18",
"metadata": {},
"outputs": [],
"source": [
@@ -231,7 +249,7 @@
},
{
"cell_type": "markdown",
- "id": "18",
+ "id": "19",
"metadata": {},
"source": [
"## Step 2 — Get the Gemma 3 model\n",
@@ -241,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",
@@ -252,7 +270,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "20",
+ "id": "21",
"metadata": {},
"outputs": [],
"source": [
@@ -271,7 +289,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "21",
+ "id": "22",
"metadata": {},
"outputs": [],
"source": [
@@ -288,7 +306,7 @@
},
{
"cell_type": "markdown",
- "id": "22",
+ "id": "23",
"metadata": {},
"source": [
"## Step 3 — Prepare the files for upload\n",
@@ -299,7 +317,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "23",
+ "id": "24",
"metadata": {},
"outputs": [],
"source": [
@@ -341,7 +359,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "24",
+ "id": "25",
"metadata": {},
"outputs": [],
"source": [
@@ -357,7 +375,7 @@
},
{
"cell_type": "markdown",
- "id": "25",
+ "id": "26",
"metadata": {},
"source": [
"## Step 4 — Upload the private model\n",
@@ -368,7 +386,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "26",
+ "id": "27",
"metadata": {},
"outputs": [],
"source": [
@@ -385,7 +403,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "27",
+ "id": "28",
"metadata": {},
"outputs": [],
"source": [
@@ -395,7 +413,7 @@
},
{
"cell_type": "markdown",
- "id": "28",
+ "id": "29",
"metadata": {},
"source": [
"## Step 5 — Run `syft-restrict` on the inference code\n",
@@ -409,7 +427,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "29",
+ "id": "30",
"metadata": {},
"outputs": [],
"source": [
@@ -469,7 +487,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "30",
+ "id": "31",
"metadata": {},
"outputs": [],
"source": [
@@ -489,7 +507,7 @@
},
{
"cell_type": "markdown",
- "id": "31",
+ "id": "32",
"metadata": {},
"source": [
"### Step 5.1 — Approve the job\n",
@@ -500,7 +518,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "32",
+ "id": "33",
"metadata": {},
"outputs": [],
"source": [
@@ -511,7 +529,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "33",
+ "id": "34",
"metadata": {},
"outputs": [],
"source": [
@@ -521,7 +539,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "34",
+ "id": "35",
"metadata": {},
"outputs": [],
"source": [
@@ -531,7 +549,7 @@
},
{
"cell_type": "markdown",
- "id": "35",
+ "id": "36",
"metadata": {},
"source": [
"
\n"
@@ -539,7 +557,7 @@
},
{
"cell_type": "markdown",
- "id": "36",
+ "id": "37",
"metadata": {},
"source": [
"## Step 6 — Approve the final inference job\n",
@@ -550,7 +568,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "37",
+ "id": "38",
"metadata": {},
"outputs": [],
"source": [
@@ -562,7 +580,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "38",
+ "id": "39",
"metadata": {},
"outputs": [],
"source": [
@@ -572,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 6ab0d7f817a..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
@@ -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))"
]
},
{
@@ -188,9 +195,20 @@
]
},
{
- "cell_type": "markdown",
+ "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",
+ "metadata": {},
"source": [
"### Step 1.2 — Attest the enclave's identity\n",
"\n",
@@ -200,7 +218,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "16",
+ "id": "17",
"metadata": {},
"outputs": [],
"source": [
@@ -210,7 +228,7 @@
},
{
"cell_type": "markdown",
- "id": "17",
+ "id": "18",
"metadata": {},
"source": [
"## Step 2 — Prepare the benchmark\n",
@@ -225,7 +243,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "18",
+ "id": "19",
"metadata": {},
"outputs": [],
"source": [
@@ -291,7 +309,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "19",
+ "id": "20",
"metadata": {},
"outputs": [],
"source": [
@@ -302,7 +320,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "20",
+ "id": "21",
"metadata": {},
"outputs": [],
"source": [
@@ -315,7 +333,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "21",
+ "id": "22",
"metadata": {},
"outputs": [],
"source": [
@@ -333,7 +351,7 @@
},
{
"cell_type": "markdown",
- "id": "22",
+ "id": "23",
"metadata": {},
"source": [
"## Step 3 — Upload the benchmark\n",
@@ -344,7 +362,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "23",
+ "id": "24",
"metadata": {},
"outputs": [],
"source": [
@@ -361,7 +379,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "24",
+ "id": "25",
"metadata": {},
"outputs": [],
"source": [
@@ -371,7 +389,7 @@
},
{
"cell_type": "markdown",
- "id": "25",
+ "id": "26",
"metadata": {},
"source": [
"
\n"
@@ -379,7 +397,7 @@
},
{
"cell_type": "markdown",
- "id": "26",
+ "id": "27",
"metadata": {},
"source": [
"## Step 4 — Review the `syft-restrict` job\n",
@@ -395,7 +413,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "27",
+ "id": "28",
"metadata": {},
"outputs": [],
"source": [
@@ -407,7 +425,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "28",
+ "id": "29",
"metadata": {},
"outputs": [],
"source": [
@@ -417,7 +435,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "29",
+ "id": "30",
"metadata": {},
"outputs": [],
"source": [
@@ -427,7 +445,7 @@
},
{
"cell_type": "markdown",
- "id": "30",
+ "id": "31",
"metadata": {},
"source": [
"### Step 4.1 — Read the certificate\n",
@@ -438,7 +456,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "31",
+ "id": "32",
"metadata": {},
"outputs": [],
"source": [
@@ -457,7 +475,7 @@
},
{
"cell_type": "markdown",
- "id": "32",
+ "id": "33",
"metadata": {},
"source": [
"## Step 5 — Submit the inference job\n",
@@ -471,7 +489,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "33",
+ "id": "34",
"metadata": {},
"outputs": [],
"source": [
@@ -482,7 +500,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "34",
+ "id": "35",
"metadata": {},
"outputs": [],
"source": [
@@ -560,7 +578,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "35",
+ "id": "36",
"metadata": {},
"outputs": [],
"source": [
@@ -585,7 +603,7 @@
},
{
"cell_type": "markdown",
- "id": "36",
+ "id": "37",
"metadata": {},
"source": [
"## Step 6 — Approve the inference job\n",
@@ -596,7 +614,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "37",
+ "id": "38",
"metadata": {},
"outputs": [],
"source": [
@@ -607,7 +625,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "38",
+ "id": "39",
"metadata": {},
"outputs": [],
"source": [
@@ -617,7 +635,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "39",
+ "id": "40",
"metadata": {},
"outputs": [],
"source": [
@@ -627,7 +645,7 @@
},
{
"cell_type": "markdown",
- "id": "40",
+ "id": "41",
"metadata": {},
"source": [
"
\n"
@@ -635,7 +653,7 @@
},
{
"cell_type": "markdown",
- "id": "41",
+ "id": "42",
"metadata": {},
"source": [
"## Step 7 — View the results\n",
@@ -646,7 +664,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "42",
+ "id": "43",
"metadata": {},
"outputs": [],
"source": [
@@ -659,7 +677,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "43",
+ "id": "44",
"metadata": {},
"outputs": [],
"source": [
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..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 — data owner clients must match: `login_do(encryption=False)`.
+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/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..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`. |
-| `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. |
-| `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` |
+| 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` |
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/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 739593760fd..ad82ddf2af4 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..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 (
@@ -68,3 +70,11 @@ 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_enclave():
+ 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..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,27 +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_no_key_bundle_is_published_without_encryption(self, monkeypatch):
- 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")
- client._rds.peer_manager.peer_store.use_encryption = False
-
- EnclaveRunner(client=client, require_tee=True).init()
-
- assert calls == []
+ # 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 = {}
@@ -205,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/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/connection_router.py b/syft/sync/connections/connection_router.py
index 35114df99fc..d797f28ff9b 100644
--- a/syft/sync/connections/connection_router.py
+++ b/syft/sync/connections/connection_router.py
@@ -1,5 +1,6 @@
import logging
-from typing import TYPE_CHECKING, Dict, List, Optional
+import warnings
+from typing import TYPE_CHECKING, Callable, Dict, List, Optional
from pydantic import BaseModel, PrivateAttr
from syft_migration import MigratableObject, ProtocolSchema
@@ -16,8 +17,14 @@
FileCollection,
SyftboxPlatformConnection,
)
+from syft.sync.encryption_policy import (
+ EncryptionPolicyConflictError,
+ PeerEncryptionPendingError,
+ UnencryptedMessageError,
+)
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,6 +53,14 @@ 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)
@@ -147,9 +162,26 @@ 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
)
@@ -161,21 +193,40 @@ 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.
+
+ 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_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.peer_store.decrypt_and_verify_if_needed(sender_email, raw_data)
- msg = ProposedFileChangesMessage.from_compressed_data(raw_data)
- msg.platform_id = file_id
- return msg
+ 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
+ )
+ 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
):
+ 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)
@@ -185,16 +236,62 @@ 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()
- raw_list = connection.watcher_download_raw_events_from_outbox(
+ messages = []
+ 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)
+ ):
+ 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_meta: dict,
+ ) -> FileChangeEventsMessage | None:
+ """Download, verify and decrypt one outbox events message.
+
+ ``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 = 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"]
+ ),
)
- for raw in raw_list
- ]
+ if raw is None:
+ 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
def watcher_get_outbox_file_metadatas(
self, peer_email: str, since_timestamp: float | None
@@ -319,6 +416,10 @@ 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)
@@ -360,6 +461,7 @@ 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()
@@ -415,9 +517,11 @@ def read_peer_version_file(self, peer_email: str) -> Optional["VersionInfo"]:
"""Read version file from a peer's SyftBox folder."""
return self.transport.peering.read_peer_version(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."""
- self.transport.peering.grant_version_read(peer_email)
+ self.transport.peering.grant_version_read(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/drive_connections.py b/syft/sync/connections/drive/drive_connections.py
index d440e3814eb..5579c42dcde 100644
--- a/syft/sync/connections/drive/drive_connections.py
+++ b/syft/sync/connections/drive/drive_connections.py
@@ -250,8 +250,10 @@ 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) -> None:
- self._conn.share_version_file_with_peer(peer)
+ def grant_version_read(
+ self, peer: str, version_info: VersionInfo | None = None
+ ) -> None:
+ self._conn.share_version_file_with_peer(peer, version_info)
def drive_transport(connection: GDriveConnection) -> Transport:
diff --git a/syft/sync/connections/drive/gdrive_transport.py b/syft/sync/connections/drive/gdrive_transport.py
index 42a54bab529..82bfe576bfa 100644
--- a/syft/sync/connections/drive/gdrive_transport.py
+++ b/syft/sync/connections/drive/gdrive_transport.py
@@ -1924,14 +1924,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..f7e6a3c90a1
--- /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
+
+
+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)
+
+
+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, 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
+
+
+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 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..ff97e1ddb5c 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,11 +28,22 @@ 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)
+ @property
+ def encryption_policy(self) -> Optional[EncryptionPolicy]:
+ """The policy the peer advertises, or None before its version file is read."""
+ if self.version is None:
+ 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 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 c94a21f6a15..582f39583dd 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.
"""
@@ -17,6 +17,16 @@
import syft_crypto_python as syc
from pydantic import BaseModel, PrivateAttr
+from syft.sync.encryption_policy import (
+ EncryptionPolicy,
+ EncryptionPolicyConflictError,
+ PeerEncryptionPolicyUnknownError,
+ PeerKeysNotReadyError,
+ PeerOutdatedError,
+ UnencryptedMessageError,
+ pair_conflicts,
+ pair_uses_encryption,
+)
from syft.sync.peers.exceptions import SyftPeerNotReadyError
from syft.sync.peers.key_bundle import (
InvalidPeerBundleError,
@@ -43,6 +53,10 @@
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"
def datasite_crypto_keys_path(syftbox_folder: Path | str, email: str) -> Path:
@@ -61,16 +75,27 @@ class PeerStore(BaseModel):
the key we encrypt to.
"""
- model_config = {"arbitrary_types_allowed": True}
+ model_config = {"arbitrary_types_allowed": True, "extra": "forbid"}
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)
+ # The last policy known of each peer in this process (see peer_policy).
+ _last_known_policies: dict[str, EncryptionPolicy] = PrivateAttr(
+ default_factory=dict
+ )
+
+ @property
+ def use_encryption(self) -> bool:
+ """True when this client requires encryption with every peer and at rest."""
+ return self.encryption_policy == EncryptionPolicy.REQUIRED
# ========== Peer list methods ==========
@@ -101,17 +126,60 @@ 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.
+ """
+ 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):
+ 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. 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.
- def _is_syc_envelope(self, data: bytes) -> bool:
+ 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
+ or the pinned peer bundle are 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.is_envelope(data):
+ if not self.peer_policy_known(email):
+ raise PeerEncryptionPolicyUnknownError(email)
+ 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)
@@ -126,19 +194,63 @@ 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 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.
+
+ 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.
+ """
+ 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 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 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.
+ """
+ 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 _cached_conflict_error(
+ self, email: str
+ ) -> EncryptionPolicyConflictError | None:
peer = self.get_cached_peer(email)
- return peer is not None and peer.use_encryption
+ 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."""
+ 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:
@@ -147,15 +259,29 @@ 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 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
def _keep_pinned_bundle(self, incoming: Peer) -> None:
"""Make ``incoming`` carry the pinned bundle for its email, if any.
@@ -164,8 +290,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
@@ -299,6 +433,84 @@ 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
+ unsaved_pins: list[tuple[str, dict]] = []
+ 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:
+ 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 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 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)
+
+ 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 "
+ f"{loaded.email!r}, not {self.email!r}"
+ )
+ self._private_keys = loaded._private_keys
+ if not self._peers:
+ self._peers = loaded._peers
+ 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:
+ 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
@@ -416,8 +628,11 @@ 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.
+
+ 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
@@ -439,8 +654,21 @@ 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,
}
+ 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)
@@ -506,7 +734,8 @@ 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._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, {}))
@@ -516,44 +745,65 @@ 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,
) -> "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`.
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)
+ 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)
+ 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.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: 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."
+ )
@contextmanager
@@ -615,7 +865,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 34c32551271..48a3a3d2a03 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[
@@ -191,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,
@@ -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[
@@ -277,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,
@@ -563,15 +564,15 @@ 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.
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
@@ -609,7 +621,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 +650,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 +776,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 +797,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 +856,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 +875,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 +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]
@@ -992,6 +1026,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,
deferred_peer_emails=[
@@ -1009,6 +1046,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
+ )
+ # 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):
@@ -1104,7 +1146,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..bdffe7e565b 100644
--- a/syft/sync/sync/caches/datasite_watcher_cache.py
+++ b/syft/sync/sync/caches/datasite_watcher_cache.py
@@ -241,9 +241,15 @@ def sync_down_parallel(
self,
peer_email: str,
executor: ThreadPoolExecutor,
- download_fn: Callable[[str], FileChangeEventsMessage],
+ download_fn: Callable[[dict], 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`` 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)
# Get file metadata (no download yet)
@@ -258,12 +264,16 @@ 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
+ # Apply in timestamp order, up to the first message that cannot be read
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/datasite_owner_syncer.py b/syft/sync/sync/datasite_owner_syncer.py
index 707a12da458..1d27bc7814b 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
@@ -20,6 +21,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
@@ -45,6 +50,7 @@
logger = logging.getLogger(__name__)
+
# Default threshold for creating incremental checkpoint from rolling state
DEFAULT_CHECKPOINT_EVENT_THRESHOLD = 50
@@ -875,14 +881,39 @@ 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, file_change_events_message = self.outbox_queue.get()
- if recipient in self._deferred_peer_emails:
+ recipient, message = self.outbox_queue.get()
+ if recipient in self._deferred_peer_emails or recipient in waiting_for:
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, file_change_events_message
+ recipient, message
)
- self._clear_delivered(recipient, file_change_events_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
def _download_outbox_message_with_new_connection(
self, file_id: str
@@ -903,10 +934,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..6346151965c 100644
--- a/syft/sync/sync/datasite_watcher_syncer.py
+++ b/syft/sync/sync/datasite_watcher_syncer.py
@@ -1,13 +1,18 @@
+import warnings
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,
@@ -74,7 +79,9 @@ 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]] = {}
+ items_by_recipient: Dict[str, list] = {}
while not self.queue.empty():
relative_path, content = self.queue.get()
@@ -97,22 +104,73 @@ 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)
-
- message = ProposedFileChangesMessage(
- sender_email=self.email, proposed_file_changes=file_changes
- )
- self.connection_router.watcher_send_proposed_file_changes_message(
- recipient, message
- )
-
- # Update local cache with new hashes
- for fc in file_changes:
+ 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)
+
+ 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(
+ 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
+
+ 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
+
+ def _send_message(
+ self, recipient: str, message: ProposedFileChangesMessage
+ ) -> None:
+ """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.
+ EncryptionPolicyConflictError: when the policies of the pair conflict.
+ """
+ self.connection_router.watcher_send_proposed_file_changes_message(
+ recipient, message
+ )
+ self._record_sent_hashes(message)
def on_file_change(
self, relative_path: Path | str, content: str | None = None, process_now=True
@@ -125,15 +183,17 @@ def on_file_change(
# --- Pulling from datasite outboxes ---
def download_events_message_with_new_connection(
- self, file_id: str, peer_email: str
- ) -> FileChangeEventsMessage:
- """Download from outbox using a new connection (thread-safe)."""
+ self, file_meta: dict, peer_email: str
+ ) -> FileChangeEventsMessage | None:
+ """Download from outbox using a new connection (thread-safe).
+
+ Returns None when the message 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_meta
)
- return FileChangeEventsMessage.from_compressed_data(raw)
def download_collection_file_with_new_connection(
self, file_id: str, owner_email: str
@@ -152,9 +212,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, pe
),
)
if event_count:
diff --git a/syft/sync/transport/protocols.py b/syft/sync/transport/protocols.py
index f8b4ec2e62e..46c60c17a7f 100644
--- a/syft/sync/transport/protocols.py
+++ b/syft/sync/transport/protocols.py
@@ -291,8 +291,13 @@ def read_peer_version(self, peer: str) -> VersionInfo | None:
"""``peer``'s published version document, or None."""
@abstractmethod
- def grant_version_read(self, peer: str) -> None:
- """Let ``peer`` read my version document."""
+ def grant_version_read(
+ self, peer: str, version_info: VersionInfo | None = None
+ ) -> None:
+ """Let ``peer`` read my version document.
+
+ When I have no document yet, publish ``version_info`` first.
+ """
class Transport(BaseModel):
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 c5c137a37b8..9bbdca34da3 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 Dict, List, Optional, Set
from googleapiclient.errors import HttpError
from pydantic import BaseModel, ConfigDict, PrivateAttr, model_validator
@@ -17,6 +17,10 @@
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,
+)
from syft.sync.peers.key_bundle import (
InvalidPeerBundleError,
PeerFingerprintMismatchError,
@@ -110,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] = []
@@ -120,7 +126,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
@@ -177,6 +183,8 @@ 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 ==========
@@ -214,10 +222,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 +298,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:
@@ -304,7 +313,9 @@ def write_own_version(self) -> None:
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."""
@@ -329,11 +340,11 @@ def _load_single_peer_version(
create_new=True
)
version_info = connection.read_peer_version_file(peer_email)
- return (peer_email, version_info)
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, version_info)
def load_peer_versions_parallel(
self, peer_emails: List[str], force: bool = False
@@ -492,6 +503,24 @@ 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.
+ 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:
+ 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:
@@ -564,9 +593,20 @@ 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
+ 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}")
# 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 +616,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 +625,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 +643,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(
@@ -627,7 +663,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)
@@ -680,10 +720,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 +811,13 @@ 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()
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(
@@ -853,17 +893,54 @@ 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 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(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/syft/sync/version/version_info.py b/syft/sync/version/version_info.py
index 1273ba2e670..1aa073b813b 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 a
+ # syft that predates it: such a peer is outdated and must upgrade.
+ 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/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
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_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_drive_connections.py b/tests/unit/test_drive_connections.py
index 8073ca15b7c..deea1c09775 100644
--- a/tests/unit/test_drive_connections.py
+++ b/tests/unit/test_drive_connections.py
@@ -31,6 +31,7 @@
WatcherStore,
)
from syft.sync.version.version_info import VersionInfo
+from tests.unit.test_sync_manager import _store_knowing
DO = "do@example.com"
DS = "ds@example.com"
@@ -255,7 +256,7 @@ def test_peering_publishes_a_version_the_peer_can_read(pair):
def _router_with_mock_p2p():
do_conn, _ = pair_with_mock_service(DO, DS)
- router = ConnectionRouter(connections=[do_conn], peer_store=PeerStore(email=DO))
+ router = ConnectionRouter(connections=[do_conn], peer_store=_store_knowing(DO, DS))
drive = do_conn.as_transport()
p2p = Mock(spec=P2PConnection)
router._transport = drive.model_copy(update={"p2p": p2p})
diff --git a/tests/unit/test_encryption.py b/tests/unit/test_encryption.py
index 81fc9dbaeb1..2f953cfc5ea 100644
--- a/tests/unit/test_encryption.py
+++ b/tests/unit/test_encryption.py
@@ -5,6 +5,7 @@
import pytest
+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
@@ -19,7 +20,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()
@@ -28,9 +31,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
@@ -50,7 +57,9 @@ 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="Keys are created at login"):
ps.encrypt("bob@example.com", b"data")
@@ -87,7 +96,9 @@ def test_encrypt_without_peer_bundle_names_the_cause(state, cause, remedy):
Naming neither the cause nor the remedy is what left data owners stuck on
this error with nothing to act on.
"""
- 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", state=state))
@@ -116,14 +127,18 @@ def test_peer_not_ready_error_survives_pickle_and_copy():
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="Keys are created at login"):
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(SyftPeerNotReadyError) as exc:
@@ -133,9 +148,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"))
@@ -150,9 +169,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"))
@@ -171,28 +194,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
# =========================================================================
@@ -270,8 +271,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")
@@ -317,7 +318,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"
@@ -330,7 +333,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"
@@ -343,7 +348,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"
@@ -353,7 +360,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
@@ -477,9 +486,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"))
@@ -505,7 +518,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())
@@ -531,7 +546,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
new file mode 100644
index 00000000000..97ddedc7d21
--- /dev/null
+++ b/tests/unit/test_encryption_policy.py
@@ -0,0 +1,1088 @@
+"""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``.
+"""
+
+import json
+import threading
+import warnings
+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,
+ 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.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
+
+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 _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, _ = (
+ connection.connection_for_receive_message().owner_download_next_raw_proposed_message_from_inbox(
+ sender_email
+ )
+ )
+ return raw
+
+
+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)
+ # A client that never saw the owner as required.
+ _forget(ds_manager.peer_manager.peer_store, do_manager.email)
+ 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)
+ _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])
+
+
+# =========================================================================
+# Policy model and advertisement
+# =========================================================================
+
+
+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)],
+)
+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,
+ 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_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 _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,
+ 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)
+ do_manager.peer_manager.connection_router.write_encryption_bundle(
+ ds_manager.email, "not json"
+ )
+ with pytest.warns(UserWarning, match="Refusing"):
+ ds_manager.load_peers()
+ assert not ds_manager.peer_manager.peer_store.has_peer_bundle(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_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)
+ 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)
+ 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 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()
+ with pytest.raises(PeerKeysNotReadyError):
+ store.encrypt_if_needed("bob@example.com", b"data")
+
+
+# =========================================================================
+# Keys and pins
+# =========================================================================
+
+
+@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=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
+
+
+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_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)
+ 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_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_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_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
+ 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"
+
+
+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)],
+)
+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 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))
+
+ ds_manager.sync()
+ 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.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 _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,
+ 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))
+ 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_to_non_peer():
+ _, 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"):
+ syncer.process_syftbox_events_queue()
+ assert syncer.outbox_queue.empty()
+
+
+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,
+ )
+ 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
+ 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
+
+
+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(
+ 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
+ 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()
+ assert any("pinned later" in c for c in _accepted_contents(do_manager))
+
+
+# =========================================================================
+# Sends while encryption is pending
+# =========================================================================
+
+
+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."""
+ 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")
+ 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
+ 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 no keys or pin 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))
+
+
+def test_send_to_non_peer_raises():
+ """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")
+
+
+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"}),
+ ]
+ )
+
+
+@pytest.mark.parametrize(
+ "error",
+ [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()
+ 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(error):
+ 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
+ )
+
+
+@pytest.mark.parametrize(
+ "error",
+ [
+ PeerEncryptionPolicyUnknownError,
+ PeerKeysNotReadyError,
+ EncryptionPolicyConflictError,
+ ],
+ ids=["policy", "keys", "conflict"],
+)
+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 wait(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)
+
+
+# =========================================================================
+# Plaintext refused
+# =========================================================================
+
+
+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, "plain content")
+
+ ds_manager.load_peers()
+ # 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 not any("plain content" in c for c in contents)
+ assert any("encrypted content" in c for c in contents)
+
+
+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, "plain content")
+
+ 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])
+ with pytest.warns(UserWarning, match="not encrypted"):
+ ds_manager.sync()
+ contents = _ds_cached_contents(ds_manager)
+ 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 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
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..f97b2b8a22d 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,22 @@ 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()
+ 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,9 +506,10 @@ 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),
+ peer_store=_store_knowing(submitter_email, do_manager.email),
)
do_manager.datasite_owner_syncer.process_local_changes(recipients)
@@ -689,9 +709,10 @@ 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),
+ peer_store=_store_knowing(peer_b_email, do_manager.email),
)
# Grant only peer A read access to project/