From 2529c875a8434222ee5f233f5c762be1ae6517fb Mon Sep 17 00:00:00 2001 From: rasswanth-s <43314053+rasswanth-s@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:31:22 +0530 Subject: [PATCH] Validate and pin peer encryption bundles --- syft/sync/peers/key_bundle.py | 107 ++++++++ syft/sync/peers/peer.py | 15 ++ syft/sync/peers/peer_store.py | 221 ++++++++++++++-- syft/sync/syftbox_manager.py | 32 +++ syft/sync/utils/print_utils.py | 14 +- syft/sync/version/peer_manager.py | 215 ++++++++++++++-- tests/unit/test_peer_key_pinning.py | 373 ++++++++++++++++++++++++++++ 7 files changed, 937 insertions(+), 40 deletions(-) create mode 100644 syft/sync/peers/key_bundle.py create mode 100644 tests/unit/test_peer_key_pinning.py diff --git a/syft/sync/peers/key_bundle.py b/syft/sync/peers/key_bundle.py new file mode 100644 index 00000000000..4afc0ca6e06 --- /dev/null +++ b/syft/sync/peers/key_bundle.py @@ -0,0 +1,107 @@ +"""Validation and fingerprints for peer public key bundles. + +A peer's public encryption bundle is a DID document that arrives over Google +Drive, and the threat model treats that transport as adversarial. Before a +bundle is pinned it must: + +- parse as a DID document whose inner signatures verify: the identity key + signed the key-agreement keys it is bundled with, and +- assert the identity it is filed under: ``id`` is ``did:syft:`` and the + ``identity`` field, when present, is that email. + +Neither check proves the identity key belongs to the person. Only comparing +fingerprints out of band does, which is what :func:`format_fingerprint` is for. +""" + +from typing import Optional + +import syft_crypto_python as syc + +DID_PREFIX = "did:syft:" + + +class InvalidPeerBundleError(ValueError): + """The bundle does not parse, its signatures fail, or it names another identity.""" + + +class PeerKeyChangedError(ValueError): + """A bundle for an already-pinned peer carries a different identity key.""" + + def __init__( + self, peer_email: str, old_fingerprint: str, new_fingerprint: str + ) -> None: + self.peer_email = peer_email + self.old_fingerprint = old_fingerprint + self.new_fingerprint = new_fingerprint + super().__init__( + f"The encryption key of {peer_email} changed.\n" + f" pinned: {format_fingerprint(old_fingerprint)}\n" + f" new: {format_fingerprint(new_fingerprint)}\n" + "A key changes when the peer reinstalls or regenerates their keys, " + "and also when someone tampers with the key exchange. Confirm the " + "new fingerprint with the peer out of band before trusting it." + ) + + +def did_for_email(email: str) -> str: + return f"{DID_PREFIX}{email}" + + +def _same_email(a: str, b: str) -> bool: + return a.strip().casefold() == b.strip().casefold() + + +def format_fingerprint(fingerprint: Optional[str]) -> str: + """Group a hex fingerprint into blocks of four for reading aloud.""" + if not fingerprint: + return "" + return " ".join(fingerprint[i : i + 4] for i in range(0, len(fingerprint), 4)) + + +def bundle_fingerprint(bundle: dict) -> str: + """Fingerprint of the identity key in ``bundle`` (no identity check).""" + return _parse(bundle).identity_fingerprint() + + +def _parse(bundle: dict) -> syc.SyftPublicKeyBundle: + if not isinstance(bundle, dict): + raise InvalidPeerBundleError( + f"Expected a DID document (dict), got {type(bundle).__name__}" + ) + try: + parsed = syc.SyftPublicKeyBundle.from_did_document(bundle) + except Exception as e: + raise InvalidPeerBundleError(f"Could not parse key bundle: {e}") from e + if not parsed.verify_signatures(): + raise InvalidPeerBundleError("Key bundle signatures do not verify") + return parsed + + +def parse_and_validate_bundle(peer_email: str, bundle: dict) -> syc.SyftPublicKeyBundle: + """Parse ``bundle``, verify its signatures, and check it belongs to ``peer_email``. + + Raises: + InvalidPeerBundleError: on any failure. + """ + parsed = _parse(bundle) + + did = bundle.get("id") + if not isinstance(did, str) or not did.startswith(DID_PREFIX): + raise InvalidPeerBundleError( + f"Key bundle for {peer_email} has no {DID_PREFIX!r} id (got {did!r})" + ) + did_email = did[len(DID_PREFIX) :] + if not _same_email(did_email, peer_email): + raise InvalidPeerBundleError( + f"Key bundle filed under {peer_email} asserts the identity {did_email!r}" + ) + + identity = bundle.get("identity") + if identity is not None and ( + not isinstance(identity, str) or not _same_email(identity, peer_email) + ): + raise InvalidPeerBundleError( + f"Key bundle filed under {peer_email} carries identity {identity!r}" + ) + + return parsed diff --git a/syft/sync/peers/peer.py b/syft/sync/peers/peer.py index e6fbc9d8372..3b53e1a6163 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.peers.key_bundle import bundle_fingerprint from syft.sync.platforms.base_platform import BasePlatform from syft.sync.version.version_info import VersionInfo @@ -23,6 +24,20 @@ class Peer(BaseModel): # Set by SyftboxManager.peers when this Peer is handed out. _manager: Any = PrivateAttr(default=None) + @property + def fingerprint(self) -> Optional[str]: + """Fingerprint of the peer's pinned identity key, or None without a bundle. + + Compare it with the peer out of band: the bundle arrives over Drive, and + only that comparison shows the key is theirs. + """ + if self.public_encryption_bundle is None: + return None + try: + return bundle_fingerprint(self.public_encryption_bundle) + except ValueError: + return None + @property def is_approved(self) -> bool: """Returns True if peer is accepted""" diff --git a/syft/sync/peers/peer_store.py b/syft/sync/peers/peer_store.py index eb8ef855497..527ed68ed67 100644 --- a/syft/sync/peers/peer_store.py +++ b/syft/sync/peers/peer_store.py @@ -5,14 +5,26 @@ """ import json +import logging +import warnings from pathlib import Path from typing import List, Optional import syft_crypto_python as syc from pydantic import BaseModel, PrivateAttr +from syft.sync.peers.key_bundle import ( + InvalidPeerBundleError, + PeerKeyChangedError, + bundle_fingerprint, + did_for_email, + format_fingerprint, + parse_and_validate_bundle, +) from syft.sync.peers.peer import Peer +logger = logging.getLogger(__name__) + # Encryption key bundles persist inside the participant's own SyftBox datasite # folder, under private/ (which is never synced to Drive). This scopes keys per # identity by location, so several identities can run on one machine without @@ -24,6 +36,8 @@ # and add a read path for every earlier version. A file with no version was # written before the field, and is version 0. CRYPTO_KEYS_VERSION = 1 +# Key in the crypto key file under which pinned peer bundles are stored. +PEER_BUNDLES_KEY = "peer_bundles" def datasite_crypto_keys_path(syftbox_folder: Path | str, email: str) -> Path: @@ -32,7 +46,15 @@ def datasite_crypto_keys_path(syftbox_folder: Path | str, email: str) -> Path: class PeerStore(BaseModel): - """Manages peers and encryption keys for E2E encryption.""" + """Manages peers and encryption keys for E2E encryption. + + Peer public key bundles are *pinned*: the first validated bundle seen for a + peer is kept, persisted next to our own keys in the private key file, and a + later bundle with a different identity key is refused unless the caller + passes ``allow_key_change=True``. The pin lives in the local key file, not + in ``SYFT_peers.json`` on Drive, so an edit to the Drive copy cannot swap + the key we encrypt to. + """ model_config = {"arbitrary_types_allowed": True} @@ -41,6 +63,9 @@ class PeerStore(BaseModel): _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) # ========== Peer list methods ========== @@ -109,6 +134,7 @@ def peer_uses_encryption(self, email: str) -> bool: 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: self._peers[i] = peer @@ -117,13 +143,76 @@ def set_peer(self, peer: Peer) -> None: 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 + def _keep_pinned_bundle(self, incoming: Peer) -> None: + """Make ``incoming`` carry the pinned bundle for its email, if any. + + Peers loaded from ``SYFT_peers.json`` carry whatever bundle the Drive + 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. + """ + if not self.use_encryption: + return + cached = self.get_cached_peer(incoming.email) + pinned = cached.public_encryption_bundle if cached else None + candidate = incoming.public_encryption_bundle + if pinned is not None: + if candidate is not None and candidate != pinned: + if self._is_trusted_replacement(incoming.email, pinned, candidate): + self._persist_peer_bundle(incoming.email, candidate) + return + self._warn_ignoring_cached_key(incoming.email, pinned, candidate) + incoming.public_encryption_bundle = pinned + elif candidate is not None: + try: + parse_and_validate_bundle(incoming.email, candidate) + except InvalidPeerBundleError as e: + warnings.warn(f"Dropping cached encryption key: {e}") + incoming.public_encryption_bundle = None + else: + self._persist_peer_bundle(incoming.email, candidate) + + @staticmethod + def _warn_ignoring_cached_key( + peer_email: str, pinned: dict, candidate: dict + ) -> None: + try: + over = f" over {format_fingerprint(bundle_fingerprint(candidate))}" + except ValueError: + over = "" + warnings.warn( + f"Ignoring a cached encryption key for {peer_email} that differs from " + f"the pinned key. Keeping the pinned key " + f"{format_fingerprint(bundle_fingerprint(pinned))}{over}." + ) + + def _is_trusted_replacement( + self, peer_email: str, pinned: dict, candidate: dict + ) -> bool: + """Whether ``candidate`` may replace ``pinned`` without a user decision. + + True when it validates and either carries the same identity key (the + peer re-signed their prekeys; the identity key vouches for them) or + matches the pin another process on this datasite already recorded in + the key file (syft-bg trusted it next to a notebook). + """ + try: + parsed = parse_and_validate_bundle(peer_email, candidate) + except InvalidPeerBundleError: + return False + if parsed.identity_fingerprint() == bundle_fingerprint(pinned): + return True + return candidate == self._stored_pin(peer_email) + # ========== Ensure helpers ========== def _ensure_private_keys(self) -> syc.SyftPrivateKeys: @@ -159,14 +248,54 @@ def public_key(self) -> syc.SyftPublicKeyBundle: def get_public_bundle(self) -> dict: keys = self._ensure_private_keys() bundle = keys.to_public_bundle() - did = f"did:syft:{self.email}" - did_doc = bundle.to_did_document(did) + did_doc = bundle.to_did_document(did_for_email(self.email)) did_doc["identity"] = self.email return did_doc - def set_peer_bundle(self, peer_email: str, bundle: dict) -> None: + @property + def my_fingerprint(self) -> str: + """Fingerprint of our own identity key, to hand to peers out of band.""" + return self.public_key.identity_fingerprint() + + def peer_fingerprint(self, peer_email: str) -> Optional[str]: + """Fingerprint of the pinned identity key of ``peer_email``, or None.""" + peer = self.get_cached_peer(peer_email) + if peer is None or peer.public_encryption_bundle is None: + return None + return bundle_fingerprint(peer.public_encryption_bundle) + + def validate_peer_bundle( + self, peer_email: str, bundle: dict + ) -> syc.SyftPublicKeyBundle: + """Parse ``bundle``, verify its signatures and its asserted identity. + + Raises: + InvalidPeerBundleError: when the bundle fails any check. + """ + return parse_and_validate_bundle(peer_email, bundle) + + def set_peer_bundle( + self, peer_email: str, bundle: dict, allow_key_change: bool = False + ) -> None: + """Pin ``bundle`` as the public key of ``peer_email``. + + The bundle is validated first. When a different key is already pinned + the call raises :class:`PeerKeyChangedError`, unless + ``allow_key_change`` is set by a caller acting on an explicit user + decision (approving a peer, or ``trust_peer_key``). + """ peer = self._ensure_peer(peer_email) + parsed = self.validate_peer_bundle(peer_email, bundle) + pinned = peer.public_encryption_bundle + if pinned is not None: + old_fp = bundle_fingerprint(pinned) + new_fp = parsed.identity_fingerprint() + if old_fp != new_fp and not allow_key_change: + raise PeerKeyChangedError(peer_email, old_fp, new_fp) + if pinned == bundle: + return peer.public_encryption_bundle = bundle + self._persist_peer_bundle(peer_email, bundle) def has_peer_bundle(self, peer_email: str) -> bool: peer = self.get_cached_peer(peer_email) @@ -174,7 +303,7 @@ def has_peer_bundle(self, peer_email: str) -> bool: def _get_parsed_peer_bundle(self, peer_email: str) -> syc.SyftPublicKeyBundle: bundle = self._ensure_peer_bundle(peer_email) - return syc.SyftPublicKeyBundle.from_did_document(bundle) + return self.validate_peer_bundle(peer_email, bundle) def verify_message(self, sender_email: str, envelope: bytes) -> None: """Verify the envelope signature against the sender's public key. Raises on failure.""" @@ -232,21 +361,64 @@ def decrypt_and_verify_for_self_if_needed(self, data: bytes) -> bytes: # ========== Persistence ========== + def _pinned_bundles(self) -> dict[str, dict]: + return { + peer.email: peer.public_encryption_bundle + for peer in self._peers + if peer.public_encryption_bundle is not None + } + def save_keys(self, path: Path) -> None: keys = self._ensure_private_keys() data = { "version": CRYPTO_KEYS_VERSION, "email": self.email, "keys_jwk": keys.to_jwks(), - "peer_bundles": { - peer.email: peer.public_encryption_bundle - for peer in self._peers - if peer.public_encryption_bundle is not None - }, + PEER_BUNDLES_KEY: self._pinned_bundles(), } path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, indent=2)) + self._keys_path = path + + def _read_key_file(self) -> dict | None: + """The key file as a dict, or None when missing, unreadable or another identity's.""" + if self._keys_path is None: + return None + try: + data = json.loads(self._keys_path.read_text()) + except (OSError, ValueError): + return None + if not isinstance(data, dict) or data.get("email") != self.email: + return None + return data + + def _stored_pin(self, peer_email: str) -> dict | None: + """The pin recorded for ``peer_email`` in the key file, if any.""" + data = self._read_key_file() + bundles = data.get(PEER_BUNDLES_KEY) if data else None + pin = bundles.get(peer_email) if isinstance(bundles, dict) else None + return pin if isinstance(pin, dict) else None + + def _persist_peer_bundle(self, peer_email: str, bundle: dict) -> None: + """Record one pin in the key file, keeping pins other processes wrote. + + The file is re-read and only this peer's entry is replaced, so two + processes on one datasite (a notebook and syft-bg) do not erase each + other's pins. + """ + if self._keys_path is None or self._private_keys is None: + return + data = self._read_key_file() + if data is None: + self.save_keys(self._keys_path) + return + bundles = data.get(PEER_BUNDLES_KEY) + if not isinstance(bundles, dict): + bundles = {} + bundles[peer_email] = bundle + data[PEER_BUNDLES_KEY] = bundles + self._keys_path.write_text(json.dumps(data, indent=2)) @classmethod def load_keys(cls, path: Path) -> "PeerStore": @@ -263,13 +435,8 @@ def load_keys(cls, path: Path) -> "PeerStore": ) store = cls(email=data["email"], use_encryption=True) store._private_keys = syc.SyftPrivateKeys.from_jwks(data["keys_jwk"]) - for email, bundle_dict in data.get("peer_bundles", {}).items(): - peer = Peer( - email=email, - public_encryption_bundle=bundle_dict, - use_encryption=True, - ) - store._peers.append(peer) + store._keys_path = Path(path) + store._peers = _peers_from_stored_pins(data.get(PEER_BUNDLES_KEY, {})) return store @classmethod @@ -314,3 +481,23 @@ def create( store.generate_keys() store.save_keys(path) return store + + +def _peers_from_stored_pins(bundles: dict[str, dict]) -> List[Peer]: + """Peers carrying the pins in the key file, minus any pin that no longer validates. + + The key file is private and never synced, so a bad pin there is corruption, + not an attack. It is dropped, and the peer's bundle is read from Drive and + pinned again on the next load. + """ + peers = [] + for email, bundle in bundles.items(): + try: + parse_and_validate_bundle(email, bundle) + 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) + ) + return peers diff --git a/syft/sync/syftbox_manager.py b/syft/sync/syftbox_manager.py index 3585538682d..9c093eeba19 100644 --- a/syft/sync/syftbox_manager.py +++ b/syft/sync/syftbox_manager.py @@ -1009,6 +1009,38 @@ def reject_peer_request(self, email_or_peer: str | Peer): """Reject a pending peer request. Delegates to PeerManager.""" self.peer_manager.reject_peer_request(email_or_peer) + # ========== Encryption key fingerprints ========== + + @property + def encryption_fingerprint(self) -> str | None: + """Fingerprint of our own encryption key, to give peers out of band. + + None when encryption is off. + """ + return self.peer_manager.my_fingerprint() + + def peer_fingerprint(self, peer_email: str) -> str | None: + """Fingerprint of the encryption key pinned for ``peer_email``. + + Compare it with the fingerprint the peer reads out from their own + ``client.encryption_fingerprint``. The key arrived over Drive, and only + that comparison shows it is theirs. None when no key is pinned. + """ + return self.peer_manager.peer_fingerprint(peer_email) + + def trust_peer_key(self, peer_email: str) -> str | None: + """Adopt the key ``peer_email`` currently publishes, replacing the pin. + + Run this after a peer reinstalled or regenerated their keys, once you + have confirmed the new fingerprint with them. Returns the fingerprint + now pinned. + """ + fingerprint = self.peer_manager.refresh_peer_bundle( + peer_email, trust_new_key=True + ) + self._emit_peers_loaded() + return fingerprint + def _add_connection(self, connection: SyftboxPlatformConnection): if not ( isinstance(connection, GDriveConnection) diff --git a/syft/sync/utils/print_utils.py b/syft/sync/utils/print_utils.py index d463456ca44..3795065ad2e 100644 --- a/syft/sync/utils/print_utils.py +++ b/syft/sync/utils/print_utils.py @@ -1,3 +1,4 @@ +from syft.sync.peers.key_bundle import format_fingerprint from syft.sync.peers.peer import Peer from syft.sync.utils.syftbox_utils import check_env from syft.sync.environments.environment import Environment @@ -20,6 +21,11 @@ def print_client_connected(client: "SyftboxManager"): print(f" SyftBox folder : {client.syftbox_folder}") print(f" Version : {SYFT_VERSION}") + fingerprint = client.peer_manager.my_fingerprint() + if fingerprint: + print(f" Key fingerprint: {format_fingerprint(fingerprint)}") + print(" Share it with your peers so they can confirm your key.") + peers = client.peer_manager.approved_peers if peers: print(f"\nšŸ‘„ {len(peers)} peer(s) restored from previous session.") @@ -63,8 +69,14 @@ def print_peer_request_sent(peer_email: str) -> None: print(" Once approved, run client.sync() to confirm the connection.") -def print_peer_connection_established(peer_email: str) -> None: +def print_peer_connection_established( + peer_email: str, fingerprint: str | None = None +) -> None: print(f"\nāœ… Connection with {peer_email} established!") + if fingerprint: + print(f" šŸ” Their key fingerprint: {format_fingerprint(fingerprint)}") + print(" Confirm it with them out of band (a call or a message on") + print(" another channel) before sharing anything sensitive.") print(" Run client.sync() to start syncing.") diff --git a/syft/sync/version/peer_manager.py b/syft/sync/version/peer_manager.py index 14d3acf951c..f5c68236deb 100644 --- a/syft/sync/version/peer_manager.py +++ b/syft/sync/version/peer_manager.py @@ -15,6 +15,12 @@ from syft.sync.connections.base_connection import ConnectionConfig from syft.sync.connections.connection_router import ConnectionRouter +from syft.sync.peers.key_bundle import ( + InvalidPeerBundleError, + PeerKeyChangedError, + bundle_fingerprint, + format_fingerprint, +) from syft.sync.peers.peer import Peer, PeerState from syft.sync.peers.peer_store import PeerStore, datasite_crypto_keys_path from syft.sync.utils.print_utils import ( @@ -33,6 +39,9 @@ logger = logging.getLogger(__name__) +# Key wrapping the DID document inside a published encryption bundle file. +BUNDLE_FILE_KEY = "public_encryption_bundle" + class CompatAction(str, Enum): """What the caller is doing — used to phrase warning messages.""" @@ -551,9 +560,18 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): if self.peer_store.use_encryption: self._write_encryption_bundle_for_peer(peer_email) + # Accepting a request or re-adding an accepted peer with force is an + # explicit user decision, so a changed key is adopted with a warning. + re_adding_accepted = bool( + force + and existing_peer_obj + and existing_peer_obj.state == PeerState.ACCEPTED + ) peer_bundle = None - if self.peer_store.use_encryption and is_accepting: - peer_bundle = self._read_peer_encryption_bundle(peer_email) + if self.peer_store.use_encryption and (is_accepting or re_adding_accepted): + peer_bundle = self._pin_published_bundle( + peer_email, new_peer_obj, explicit=True + ) self.connection_router.update_peer_state( peer_email, new_state.value, public_encryption_bundle=peer_bundle @@ -563,33 +581,174 @@ def add_peer(self, peer_email: str, force: bool = False, verbose: bool = True): self._update_peer_schemas(peer_email, version_info) new_peer_obj.version = version_info - new_peer_obj.public_encryption_bundle = peer_bundle + if peer_bundle is not None: + new_peer_obj.public_encryption_bundle = peer_bundle new_peer_obj.state = new_state self.peer_store.set_peer(new_peer_obj) if verbose: if is_accepting: - print_peer_connection_established(peer_email) + print_peer_connection_established( + peer_email, fingerprint=self.peer_fingerprint(peer_email) + ) else: print_peer_request_sent(peer_email) + # ========== 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(): + 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( + self, peer_email: str, peer: Peer, explicit: bool + ) -> dict | None: + """Read the bundle ``peer_email`` published for us and pin it. + + Returns the pinned bundle, or None when nothing was published or the + bundle was refused (see ``_adopt_peer_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) + if raw_bundle is None: + return None + return self._adopt_peer_bundle(peer_email, raw_bundle, explicit=explicit) + + def _adopt_peer_bundle( + self, peer_email: str, bundle: dict, explicit: bool + ) -> dict | None: + """Validate ``bundle`` and pin it; return it, or None when refused. + + An invalid bundle is always refused. A bundle whose key differs from + the pin is adopted only when ``explicit`` is set (the user approved + the peer or called ``trust_peer_key``), else refused with a warning. + """ + try: + self.peer_store.set_peer_bundle(peer_email, bundle) + return bundle + except InvalidPeerBundleError as e: + warnings.warn(f"Refusing the encryption key of {peer_email}: {e}") + return None + except PeerKeyChangedError as e: + if not explicit: + warnings.warn( + f"{e}\nKeeping the pinned key. To trust the new key after " + f"confirming it, run client.trust_peer_key({peer_email!r})." + ) + return None + warnings.warn( + f"{e}\nTrusting the new key because you approved this peer. " + "If you did not expect a key change, stop syncing and confirm " + "the fingerprint with the peer." + ) + self.peer_store.set_peer_bundle(peer_email, bundle, allow_key_change=True) + return bundle + + def refresh_peer_bundle( + self, peer_email: str, trust_new_key: bool = False + ) -> Optional[str]: + """Re-read the peer's published key bundle and compare it to the pin. + + Returns the fingerprint of the pinned key afterwards. A changed key is + adopted only when ``trust_new_key`` is set. + """ + 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()") + 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") + return self.peer_fingerprint(peer_email) + adopted = self._adopt_peer_bundle( + peer_email, raw_bundle, explicit=trust_new_key + ) + if adopted is not None: + peer = self.peer_store.get_cached_peer(peer_email) + self.connection_router.update_peer_state( + peer_email, peer.state.value, public_encryption_bundle=adopted + ) + return self.peer_fingerprint(peer_email) + + def _read_single_peer_bundle(self, peer_email: str) -> tuple[str, dict | None]: + """Read one peer's published bundle on a private connection (thread-safe).""" + try: + connection = self.connection_router.connection_for_version_read( + create_new=True + ) + return ( + peer_email, + self._read_peer_encryption_bundle(peer_email, connection), + ) + except Exception as e: + logger.warning(f"Could not read the encryption key of {peer_email}: {e}") + return (peer_email, None) + + def _check_peer_key_rotations(self, peer_emails: List[str]) -> None: + """Warn when a pinned peer now publishes a different key. + + Never adopts the new key: that takes ``trust_peer_key``. Without this + check a rotated peer key shows up only as signature failures on every + message, with nothing pointing at the cause. + """ + pinned = [e for e in peer_emails if self.peer_store.has_peer_bundle(e)] + if not pinned: + return + for peer_email, bundle in self._executor.map( + self._read_single_peer_bundle, pinned + ): + if bundle is not None: + self._warn_if_key_rotated(peer_email, bundle) + + def _warn_if_key_rotated(self, peer_email: str, published_bundle: dict) -> None: + """Warn when ``published_bundle`` carries another identity key than the pin.""" + current = self.peer_store.peer_fingerprint(peer_email) + try: + published = bundle_fingerprint(published_bundle) + except ValueError: + return + if published == current: + return + warnings.warn( + f"The published encryption key of {peer_email} differs from the " + f"pinned key.\n pinned: {format_fingerprint(current)}\n" + f" published: {format_fingerprint(published)}\n" + f"Keeping the pinned key. Messages from this peer will fail to verify " + f"until you confirm the new fingerprint with them and run " + f"client.trust_peer_key({peer_email!r})." + ) + 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") bundle = self.peer_store.get_public_bundle() - bundle_json = json.dumps({"public_encryption_bundle": 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) return bundle - def _read_peer_encryption_bundle(self, peer_email: str) -> dict | None: - """Read a peer's encryption bundle if available.""" - bundle_json = self.connection_router.read_peer_encryption_bundle(peer_email) + def _read_peer_encryption_bundle( + self, peer_email: str, connection=None + ) -> dict | None: + """Read the bundle a peer published for us, on ``connection`` when given.""" + if connection is None: + bundle_json = self.connection_router.read_peer_encryption_bundle(peer_email) + else: + bundle_json = connection.read_peer_encryption_bundle(peer_email) if not bundle_json: return None - data = json.loads(bundle_json) - return data.get("public_encryption_bundle") + return json.loads(bundle_json).get(BUNDLE_FILE_KEY) def load_peers(self, force_download: bool = False): """Load peers: from JSON (accepted + requested_by_me) + new requests from folder scan. @@ -638,24 +797,36 @@ def load_peers(self, force_download: bool = False): email, PeerState.ACCEPTED.value ) + # 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) - # Try to read encryption bundles from GDrive for peers missing bundles if self.peer_store.use_encryption: - for peer in peers: - if peer.state in (PeerState.ACCEPTED, PeerState.REQUESTED_BY_ME): - if not self.peer_store.has_peer_bundle(peer.email): - bundle = self._read_peer_encryption_bundle(peer.email) - if bundle: - self.peer_store.set_peer_bundle(peer.email, bundle) - self.connection_router.update_peer_state( - peer.email, - peer.state.value, - public_encryption_bundle=bundle, - ) + 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]) self.load_peer_versions_parallel([peer.email for peer in peers]) + 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). + + A peer that already has a pin is never re-pinned here; a changed key + is only reported by ``_check_peer_key_rotations``. + """ + for peer in peers: + if self.peer_store.has_peer_bundle(peer.email): + continue + adopted = self._pin_published_bundle(peer.email, peer, explicit=False) + if adopted is not None: + self.connection_router.update_peer_state( + peer.email, peer.state.value, public_encryption_bundle=adopted + ) + def check_peer_request_exists(self, email: str) -> bool: """Check if a peer request exists for the given email.""" return any(p.email == email for p in self.requested_by_peer_peers) diff --git a/tests/unit/test_peer_key_pinning.py b/tests/unit/test_peer_key_pinning.py new file mode 100644 index 00000000000..0c298fcf2ab --- /dev/null +++ b/tests/unit/test_peer_key_pinning.py @@ -0,0 +1,373 @@ +"""Peer public key bundles are validated, pinned locally, and never swapped silently. + +The bundle of a peer arrives over Google Drive, which the threat model treats +as an adversarial transport. These tests cover the checks at ingestion (DID +identity + signatures), the local pin that outlives the Drive copy of +SYFT_peers.json, and how a rotated key is reported and then trusted. +""" + +import json +import warnings + +import pytest + +from syft.sync.connections.drive.gdrive_transport import SYFT_PEERS_FILE +from syft.sync.peers.key_bundle import ( + InvalidPeerBundleError, + PeerKeyChangedError, + bundle_fingerprint, + format_fingerprint, +) +from syft.sync.peers.peer import Peer +from syft.sync.peers.peer_store import PeerStore, datasite_crypto_keys_path +from syft.sync.syftbox_manager import SyftboxManager +from tests.unit.test_sync_manager import path_for_job +from tests.unit.utils import grant_job_inbox_access + +ALICE = "alice@example.com" +BOB = "bob@example.com" +MALLORY = "mallory@example.com" + + +def _store(email: str) -> PeerStore: + store = PeerStore(email=email, use_encryption=True) + store.generate_keys() + return store + + +def _alice_with_bob_pinned() -> tuple[PeerStore, PeerStore]: + alice, bob = _store(ALICE), _store(BOB) + alice.add_peer(Peer(email=BOB)) + alice.set_peer_bundle(BOB, bob.get_public_bundle()) + return alice, bob + + +# ========================================================================= +# Validation at ingestion +# ========================================================================= + + +def test_valid_bundle_is_accepted_and_fingerprinted(): + alice, bob = _alice_with_bob_pinned() + assert alice.peer_fingerprint(BOB) == bob.my_fingerprint + assert alice.get_cached_peer(BOB).fingerprint == bob.my_fingerprint + # 64 hex chars in groups of four, readable over the phone. + assert len(format_fingerprint(bob.my_fingerprint).split(" ")) == 16 + + +def test_bundle_filed_under_another_email_is_refused(): + alice, mallory = _store(ALICE), _store(MALLORY) + alice.add_peer(Peer(email=BOB)) + # Mallory's real bundle, filed as if it were Bob's. + with pytest.raises(InvalidPeerBundleError, match="asserts the identity"): + alice.set_peer_bundle(BOB, mallory.get_public_bundle()) + assert not alice.has_peer_bundle(BOB) + + +def test_bundle_with_forged_did_but_foreign_identity_field_is_refused(): + alice, mallory = _store(ALICE), _store(MALLORY) + alice.add_peer(Peer(email=BOB)) + forged = mallory.get_public_bundle() + forged["id"] = f"did:syft:{BOB}" + with pytest.raises(InvalidPeerBundleError, match="carries identity"): + alice.set_peer_bundle(BOB, forged) + + +def test_did_email_comparison_ignores_case(): + alice, bob = _store(ALICE), _store(BOB) + alice.add_peer(Peer(email=BOB.upper())) + alice.set_peer_bundle(BOB.upper(), bob.get_public_bundle()) + assert alice.has_peer_bundle(BOB.upper()) + + +def test_bundle_with_swapped_key_material_is_refused(): + alice, bob, mallory = _store(ALICE), _store(BOB), _store(MALLORY) + alice.add_peer(Peer(email=BOB)) + tampered = bob.get_public_bundle() + # Keep Bob's DID and identity key, swap in Mallory's key-agreement keys. + tampered["keyAgreement"] = mallory.get_public_bundle()["keyAgreement"] + with pytest.raises(InvalidPeerBundleError, match="parse|signature"): + alice.set_peer_bundle(BOB, tampered) + + +def test_pinned_bundle_that_no_longer_validates_is_refused_at_use(): + alice, bob = _alice_with_bob_pinned() + # Corrupt the pin behind the store's back (as a bad key file would). + alice.get_cached_peer(BOB).public_encryption_bundle["id"] = f"did:syft:{MALLORY}" + with pytest.raises(InvalidPeerBundleError): + alice.encrypt(BOB, b"hello") + + +# ========================================================================= +# Pinning +# ========================================================================= + + +def test_changed_key_is_refused_unless_change_is_allowed(): + alice, bob = _alice_with_bob_pinned() + old_fp = alice.peer_fingerprint(BOB) + + new_bob = _store(BOB) # Bob reinstalled: same email, new keys + with pytest.raises(PeerKeyChangedError) as info: + alice.set_peer_bundle(BOB, new_bob.get_public_bundle()) + assert info.value.old_fingerprint == old_fp + assert info.value.new_fingerprint == new_bob.my_fingerprint + assert alice.peer_fingerprint(BOB) == old_fp + + alice.set_peer_bundle(BOB, new_bob.get_public_bundle(), allow_key_change=True) + assert alice.peer_fingerprint(BOB) == new_bob.my_fingerprint + + +def test_re_pinning_same_key_is_no_op(): + alice, bob = _alice_with_bob_pinned() + alice.set_peer_bundle(BOB, bob.get_public_bundle()) + assert alice.peer_fingerprint(BOB) == bob.my_fingerprint + + +def test_set_peers_keeps_pin_over_differing_cached_bundle(): + """What load_peers does: the peer list is rebuilt from SYFT_peers.json.""" + alice, bob = _alice_with_bob_pinned() + mallory = _store(MALLORY) + swapped = mallory.get_public_bundle() + swapped["id"] = f"did:syft:{BOB}" + swapped["identity"] = BOB + + with pytest.warns(UserWarning, match="differs from the pinned key"): + alice.set_peers([Peer(email=BOB, public_encryption_bundle=swapped)]) + + assert alice.peer_fingerprint(BOB) == bob.my_fingerprint + + +def test_set_peers_adopts_re_signed_prekeys_under_same_identity_key(): + """The identity key is what is pinned; prekeys it signed may change freely.""" + alice, bob = _alice_with_bob_pinned() + refreshed = bob.get_public_bundle() + refreshed["@context"] = list(refreshed["@context"]) + ["https://example.com/x"] + assert refreshed != alice.get_cached_peer(BOB).public_encryption_bundle + + with warnings.catch_warnings(): + warnings.simplefilter("error") + alice.set_peers([Peer(email=BOB, public_encryption_bundle=refreshed)]) + assert alice.get_cached_peer(BOB).public_encryption_bundle == refreshed + + +def test_set_peers_pins_valid_cached_bundle_on_first_use(): + alice, bob = _store(ALICE), _store(BOB) + alice.set_peers([Peer(email=BOB, public_encryption_bundle=bob.get_public_bundle())]) + assert alice.peer_fingerprint(BOB) == bob.my_fingerprint + + +def test_set_peers_drops_invalid_cached_bundle(): + alice, mallory = _store(ALICE), _store(MALLORY) + with pytest.warns(UserWarning, match="Dropping cached encryption key"): + alice.set_peers( + [Peer(email=BOB, public_encryption_bundle=mallory.get_public_bundle())] + ) + assert not alice.has_peer_bundle(BOB) + + +# ========================================================================= +# Local persistence of pins +# ========================================================================= + + +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) + bob = _store(BOB) + alice.add_peer(Peer(email=BOB)) + alice.set_peer_bundle(BOB, bob.get_public_bundle()) + + 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) + 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) + bob, carol = _store(BOB), _store("carol@example.com") + + # Another process (syft-bg) pinned Carol meanwhile. + data = json.loads(path.read_text()) + data["peer_bundles"] = {carol.email: carol.get_public_bundle()} + path.write_text(json.dumps(data)) + + alice.add_peer(Peer(email=BOB)) + alice.set_peer_bundle(BOB, bob.get_public_bundle()) + + on_disk = json.loads(path.read_text())["peer_bundles"] + assert set(on_disk) == {BOB, carol.email} + + +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) + 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.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. + with warnings.catch_warnings(): + warnings.simplefilter("error") + notebook.set_peers( + [Peer(email=BOB, public_encryption_bundle=new_bob.get_public_bundle())] + ) + assert notebook.peer_fingerprint(BOB) == new_bob.my_fingerprint + + +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) + bob = _store(BOB) + alice.add_peer(Peer(email=BOB)) + alice.set_peer_bundle(BOB, bob.get_public_bundle()) + + data = json.loads(path.read_text()) + data["peer_bundles"][BOB]["id"] = f"did:syft:{MALLORY}" + path.write_text(json.dumps(data)) + + reloaded = PeerStore.load_keys(path) + assert not reloaded.has_peer_bundle(BOB) + + +# ========================================================================= +# Through the manager, over the mock Drive +# ========================================================================= + + +def _backing_store(manager: SyftboxManager): + connection = manager.peer_manager.connection_router.connections[0] + return connection.drive_service._backing_store + + +def _peers_json_file(manager: SyftboxManager): + files = [ + f + for f in _backing_store(manager).files.values() + if f.name == SYFT_PEERS_FILE + and f.owners + and f.owners[0]["emailAddress"] == manager.email + ] + assert len(files) == 1 + return files[0] + + +def _bundle_file_of(manager: SyftboxManager, for_email: str): + name = f"encryption_bundle_{manager.email}_for_{for_email}.json" + files = [f for f in _backing_store(manager).files.values() if f.name == name] + assert len(files) == 1 + return files[0] + + +def test_swapped_bundle_in_drive_peers_file_is_not_adopted(): + ds, do = SyftboxManager.pair_with_mock_drive_service_connection(encryption=True) + grant_job_inbox_access(do, ds.email) + ds.load_peers() + original = do.peer_fingerprint(ds.email) + assert original == ds.encryption_fingerprint + + # Someone with write access to Drive bytes swaps the DS entry in the DO's + # SYFT_peers.json for a key they control, consistently signed and filed + # under the DS's DID. + mallory = _store(MALLORY) + swapped = mallory.get_public_bundle() + swapped["id"] = f"did:syft:{ds.email}" + swapped["identity"] = ds.email + peers_file = _peers_json_file(do) + data = json.loads(peers_file.content) + data[ds.email]["public_encryption_bundle"] = swapped + peers_file.content = json.dumps(data).encode() + + with pytest.warns(UserWarning, match="differs from the pinned key"): + do.load_peers(force_download=True) + + assert do.peer_fingerprint(ds.email) == original + + # Encrypted traffic still verifies against the real DS key. + ds._send_file_change(path_for_job(do.email, ds.email, "my.job"), "secret") + do.sync() + events = [e for m in do._get_all_accepted_events_do() for e in m.events] + assert any("secret" in str(e.content) for e in events) + + +def test_rotated_peer_key_is_reported_on_load_and_adopted_on_trust(): + ds, do = SyftboxManager.pair_with_mock_drive_service_connection(encryption=True) + grant_job_inbox_access(do, ds.email) + ds.load_peers() + old_fp = do.peer_fingerprint(ds.email) + + # The DS reinstalls: new keys, and re-publishes the bundle for the DO. + ds._init_encrypted_peer_store() + ds.peer_manager._write_encryption_bundle_for_peer(do.email) + new_fp = ds.encryption_fingerprint + assert new_fp != old_fp + + with pytest.warns(UserWarning, match="differs from\\s+the pinned key"): + do.load_peers(force_download=True) + assert do.peer_fingerprint(ds.email) == old_fp + + with pytest.warns(UserWarning, match="Trusting the new key"): + assert do.trust_peer_key(ds.email) == new_fp + assert do.peer_fingerprint(ds.email) == new_fp + + # The Drive cache now carries the trusted key too. + data = json.loads(_peers_json_file(do).content) + assert bundle_fingerprint(data[ds.email]["public_encryption_bundle"]) == new_fp + + +def test_approving_peer_again_after_key_change_warns_and_adopts(): + ds, do = SyftboxManager.pair_with_mock_drive_service_connection(encryption=True) + old_fp = do.peer_fingerprint(ds.email) + + ds._init_encrypted_peer_store() + ds.peer_manager._write_encryption_bundle_for_peer(do.email) + + with pytest.warns(UserWarning, match="Trusting the new key"): + do.add_peer(ds.email, force=True, sync=False) + assert do.peer_fingerprint(ds.email) == ds.encryption_fingerprint != old_fp + + +def test_bundle_file_naming_another_identity_is_refused_at_approval(): + ds, do = SyftboxManager.pair_with_mock_drive_service_connection( + encryption=True, add_peers=False + ) + ds.add_peer(do.email, sync=False) + # Replace what the DS published with Mallory's bundle, DID and all. + mallory = _store(MALLORY) + bundle_file = _bundle_file_of(ds, do.email) + bundle_file.content = json.dumps( + {"public_encryption_bundle": mallory.get_public_bundle()} + ).encode() + + do.load_peers() + with pytest.warns(UserWarning, match="Refusing the encryption key"): + do.approve_peer_request(ds.email) + assert do.peer_fingerprint(ds.email) is None + + +def test_pins_survive_new_manager_on_same_datasite(): + ds, do = SyftboxManager.pair_with_mock_drive_service_connection(encryption=True) + ds.load_peers() + # Point the DO's store at a key file and pin through it, as a real login does. + keys_path = datasite_crypto_keys_path(do.syftbox_folder, do.email) + do._peer_store.save_keys(keys_path) + do.trust_peer_key(ds.email) + + reloaded = PeerStore.create( + email=do.email, use_encryption=True, keys_path=keys_path + ) + assert reloaded.peer_fingerprint(ds.email) == ds.encryption_fingerprint + + +def test_fingerprints_are_none_without_encryption(): + ds, do = SyftboxManager.pair_with_mock_drive_service_connection(encryption=False) + assert do.encryption_fingerprint is None + assert do.peer_fingerprint(ds.email) is None