diff --git a/backend/database/migrations.py b/backend/database/migrations.py index d353b58c..e589b673 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -43,6 +43,7 @@ def run_migrations(engine) -> None: _migrate_generation_versions(engine, inspector, tables) _migrate_capture_settings(engine, inspector, tables) _migrate_mcp_bindings(engine, inspector, tables) + _migrate_cloud_settings(engine, inspector, tables) _normalize_storage_paths(engine, tables) @@ -283,6 +284,16 @@ def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None: ) +def _migrate_cloud_settings(engine, inspector, tables: set[str]) -> None: + """Add ``sync_device_id`` — the server-assigned id from registering this + install as an encryption-capable sync device.""" + if "cloud_settings" not in tables: + return + columns = _get_columns(inspector, "cloud_settings") + if "sync_device_id" not in columns: + _add_column(engine, "cloud_settings", "sync_device_id VARCHAR", "sync_device_id") + + def _supports_drop_column(engine) -> bool: """Whether ``ALTER TABLE … DROP COLUMN`` is supported by the dialect + runtime. Non-SQLite dialects (Postgres, MySQL) have supported it for diff --git a/backend/database/models.py b/backend/database/models.py index b85a55b1..e726d6f4 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -253,6 +253,10 @@ class CloudSettings(Base): device_name = Column(String, nullable=True) account_user_id = Column(String, nullable=True) connected_at = Column(DateTime, nullable=True) + # Server-assigned sync device id (device table on the cloud side). Set when + # this install registers as an encryption-capable device; the matching + # X25519 private key lives in the OS keychain (services/cloud_keys.py). + sync_device_id = Column(String, nullable=True) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/requirements.txt b/backend/requirements.txt index c61a51b3..0edcc1db 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,9 +7,11 @@ pydantic>=2.5.0 sqlalchemy>=2.0.0 alembic>=1.13.0 -# Cloud backup/sync E2E encryption (services/cloud_crypto.py) +# Cloud backup/sync E2E encryption (services/cloud_crypto.py); keyring stores +# the device key + master key in the OS keychain (services/cloud_keys.py) pynacl>=1.5.0 mnemonic>=0.21 +keyring>=25 # ML models torch>=2.2.0 diff --git a/backend/services/cloud_account.py b/backend/services/cloud_account.py new file mode 100644 index 00000000..37f6b709 --- /dev/null +++ b/backend/services/cloud_account.py @@ -0,0 +1,193 @@ +""" +Cloud sync identity: the Master Key lifecycle across devices. + +Ties together the pieces below into the flows from the cloud design doc §9 +(first device, add-a-device, recovery). The server participates only as a +mailbox for ciphertext — every wrap/unwrap here happens locally. + +- ``cloud_crypto`` — the primitives (MK, recovery phrase, sealed boxes) +- ``cloud_keys`` — OS-keychain persistence of the private key + MK +- ``cloud_api`` — the bearer-key HTTP client +- ``CloudSettings`` — the local row holding the API key + sync device id + +Flows: +- **First device** (``setup_device`` on a keyless account): generate MK, wrap + it under a fresh recovery phrase → escrow to the server, wrap it to our own + device key, keep MK in the keychain. Returns the phrase for one-time display. +- **New device on an existing account** (``setup_device`` when the account has + key material): register and wait — an existing device provisions us + (``provision_device`` there, ``adopt_wrapped_key`` here), or the user types + the recovery phrase (``restore_with_phrase``). +""" + +import base64 +import logging +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from .. import config +from ..database import CloudSettings as DBCloudSettings +from . import cloud_crypto, cloud_keys +from .cloud_api import CloudApiClient +from .cloud_crypto import RecoveryWrap + +logger = logging.getLogger(__name__) + + +class CloudAccountError(Exception): + """The identity flow cannot proceed (not connected, no escrow, bad phrase…).""" + + +def _b64e(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii") + + +def _b64d(encoded: str) -> bytes: + return base64.b64decode(encoded) + + +def _settings(db: Session) -> DBCloudSettings: + row = db.query(DBCloudSettings).filter(DBCloudSettings.id == 1).first() + if row is None or not row.api_key or not row.account_user_id: + raise CloudAccountError("not connected to Voicebox Cloud — log in first") + return row + + +def _client(row: DBCloudSettings) -> CloudApiClient: + return CloudApiClient(config.get_cloud_api_url(), row.api_key) + + +@dataclass(frozen=True) +class SyncIdentity: + # "unregistered" — logged in, but not yet a sync device + # "awaiting_provision" — registered, waiting for a wrapped MK or the phrase + # "ready" — MK in the keychain, sync can run + status: str + device_id: str | None + + +def identity_status(db: Session) -> SyncIdentity: + row = _settings(db) + if not row.sync_device_id: + return SyncIdentity(status="unregistered", device_id=None) + has_mk = cloud_keys.load_secret(row.account_user_id, cloud_keys.MASTER_KEY) is not None + return SyncIdentity(status="ready" if has_mk else "awaiting_provision", device_id=row.sync_device_id) + + +async def setup_device(db: Session) -> str | None: + """Register this install as a sync device. + + On a keyless account this is first-device setup: mints MK + the recovery + escrow and returns the phrase — the caller must display it exactly once and + never persist it. On an account with existing key material it returns None + and the device waits in ``awaiting_provision``. + """ + row = _settings(db) + if row.sync_device_id: + raise CloudAccountError("this install is already registered as a sync device") + + private_key, public_key = cloud_crypto.generate_device_keypair() + async with _client(row) as client: + registered = await client.register_device(row.device_name or "Voicebox Desktop", _b64e(public_key)) + device_id = registered["deviceId"] + + # Persist the private key before anything can depend on it; a crash + # after registration leaves a provisionable device, never a locked one. + cloud_keys.store_secret(row.account_user_id, cloud_keys.DEVICE_PRIVATE_KEY, private_key) + row.sync_device_id = device_id + db.commit() + + if registered["accountHasKey"]: + logger.info("registered sync device %s; awaiting MK provisioning", device_id) + return None + + master_key = cloud_crypto.generate_master_key() + phrase = cloud_crypto.generate_recovery_phrase() + escrow = cloud_crypto.wrap_master_key_with_phrase(master_key, phrase) + await client.put_account_key(_b64e(escrow.wrapped_key), _b64e(escrow.kdf_salt), escrow.kdf_params) + await client.put_wrapped_key(device_id, _b64e(cloud_crypto.wrap_master_key_for_device(master_key, public_key))) + cloud_keys.store_secret(row.account_user_id, cloud_keys.MASTER_KEY, master_key) + logger.info("initialized account key material as first device %s", device_id) + return phrase + + +async def restore_with_phrase(db: Session, phrase: str) -> None: + """Recover MK from the server-side escrow using the recovery phrase. + The device must be registered (``setup_device``) first.""" + row = _settings(db) + if not row.sync_device_id: + raise CloudAccountError("register this device before restoring") + if not cloud_crypto.validate_recovery_phrase(phrase): + raise CloudAccountError("that doesn't look like a valid recovery phrase — check for typos") + + async with _client(row) as client: + escrow = await client.get_account_key() + if not escrow: + raise CloudAccountError("this account has no recovery escrow yet") + wrap = RecoveryWrap( + wrapped_key=_b64d(escrow["recoveryWrappedKey"]), + kdf_salt=_b64d(escrow["kdfSalt"]), + kdf_params=escrow["kdfParams"], + ) + master_key = cloud_crypto.unwrap_master_key_with_phrase(wrap, phrase) + + # Also wrap MK to our own device key so future restores on this device + # don't need the phrase. + private_key = cloud_keys.load_secret(row.account_user_id, cloud_keys.DEVICE_PRIVATE_KEY) + if private_key is None: + raise CloudAccountError("device key missing from the OS keychain — register this device again") + public_key = cloud_crypto.device_public_key(private_key) + await client.put_wrapped_key( + row.sync_device_id, _b64e(cloud_crypto.wrap_master_key_for_device(master_key, public_key)) + ) + cloud_keys.store_secret(row.account_user_id, cloud_keys.MASTER_KEY, master_key) + logger.info("restored master key from recovery phrase on device %s", row.sync_device_id) + + +async def adopt_wrapped_key(db: Session) -> bool: + """For a device in ``awaiting_provision``: fetch our wrapped MK if an + existing device has provisioned it. Returns True once MK is in the keychain.""" + row = _settings(db) + if not row.sync_device_id: + raise CloudAccountError("register this device before adopting a key") + + private_key = cloud_keys.load_secret(row.account_user_id, cloud_keys.DEVICE_PRIVATE_KEY) + if private_key is None: + raise CloudAccountError("device key missing from the OS keychain — register this device again") + + async with _client(row) as client: + wrapped = await client.get_wrapped_key(row.sync_device_id) + if not wrapped: + return False + master_key = cloud_crypto.unwrap_master_key_for_device(_b64d(wrapped), private_key) + cloud_keys.store_secret(row.account_user_id, cloud_keys.MASTER_KEY, master_key) + logger.info("adopted provisioned master key on device %s", row.sync_device_id) + return True + + +async def provision_device(db: Session, target_device_id: str) -> None: + """Run on a device that already holds MK: wrap it to another registered + device's public key so that device can start syncing.""" + row = _settings(db) + master_key = cloud_keys.load_secret(row.account_user_id, cloud_keys.MASTER_KEY) + if master_key is None: + raise CloudAccountError("this device holds no master key to provision with") + + async with _client(row) as client: + devices = await client.list_devices() + target = next((d for d in devices if d["id"] == target_device_id and not d.get("revokedAt")), None) + if target is None: + raise CloudAccountError("target device not found (or revoked)") + wrapped = cloud_crypto.wrap_master_key_for_device(master_key, _b64d(target["publicKey"])) + await client.put_wrapped_key(target_device_id, _b64e(wrapped)) + logger.info("provisioned master key to device %s", target_device_id) + + +def load_master_key(db: Session) -> bytes: + """The MK for the sync engine. Raises if this device isn't ready.""" + row = _settings(db) + master_key = cloud_keys.load_secret(row.account_user_id, cloud_keys.MASTER_KEY) + if master_key is None: + raise CloudAccountError("no master key on this device — finish setup or restore first") + return master_key diff --git a/backend/services/cloud_api.py b/backend/services/cloud_api.py new file mode 100644 index 00000000..6230dffe --- /dev/null +++ b/backend/services/cloud_api.py @@ -0,0 +1,174 @@ +""" +HTTP client for the Voicebox Cloud API (api.voicebox.sh). + +A thin async wrapper over the bearer-key endpoints the sync client needs: +device/key distribution, the encrypted object store, and the sync feed. Every +payload sent through here is ciphertext or metadata about ciphertext — the +encryption itself happens in ``cloud_crypto`` before bytes reach this module. + +Blob bytes don't flow through the API at all: pushes receive presigned PUT +URLs and pulls receive presigned GET URLs, and the client transfers ciphertext +directly with the storage host. Those transfers use a separate unauthenticated +HTTP client so the bearer key is never sent to the storage host. +""" + +import contextlib +import logging +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +_TIMEOUT = 30.0 +_BLOB_TIMEOUT = 120.0 # audio assets can be tens of MB + + +class CloudApiError(Exception): + def __init__(self, message: str, status: int | None = None): + super().__init__(message) + self.status = status + + +class CloudApiClient: + """One authenticated session against the cloud API. Use as an async context + manager so both underlying connection pools are closed.""" + + def __init__(self, api_url: str, api_key: str, *, transport: httpx.AsyncBaseTransport | None = None): + self._api = httpx.AsyncClient( + base_url=api_url.rstrip("/"), + headers={"Authorization": f"Bearer {api_key}"}, + timeout=_TIMEOUT, + transport=transport, + ) + # Presigned-URL transfers: no Authorization header, longer timeout. + self._blobs = httpx.AsyncClient(timeout=_BLOB_TIMEOUT, transport=transport) + + async def __aenter__(self) -> "CloudApiClient": + return self + + async def __aexit__(self, *exc: object) -> None: + await self.aclose() + + async def aclose(self) -> None: + await self._api.aclose() + await self._blobs.aclose() + + async def _call( + self, + method: str, + path: str, + *, + json: dict | None = None, + params: dict | None = None, + ) -> Any: + try: + resp = await self._api.request(method, path, json=json, params=params) + except httpx.HTTPError as err: + raise CloudApiError(f"could not reach Voicebox Cloud: {err}") from err + if resp.status_code >= 400: + message = f"{method} {path} failed ({resp.status_code})" + with contextlib.suppress(ValueError): + message = resp.json().get("error", {}).get("message", message) + raise CloudApiError(message, status=resp.status_code) + payload = resp.json() + if not payload.get("ok"): + raise CloudApiError(f"{method} {path} returned ok=false", status=resp.status_code) + return payload.get("data") + + # -- account ------------------------------------------------------------ + + async def me(self) -> dict: + return await self._call("GET", "/v1/account/me") + + # -- devices & key distribution ------------------------------------------ + + async def register_device(self, name: str, public_key_b64: str) -> dict: + """Returns {deviceId, accountHasKey}.""" + return await self._call("POST", "/v1/devices", json={"name": name, "publicKey": public_key_b64}) + + async def list_devices(self) -> list[dict]: + return await self._call("GET", "/v1/devices") + + async def put_wrapped_key(self, device_id: str, wrapped_master_key_b64: str) -> None: + await self._call( + "POST", + f"/v1/devices/{device_id}/wrapped-key", + json={"wrappedMasterKey": wrapped_master_key_b64}, + ) + + async def get_wrapped_key(self, device_id: str) -> str | None: + data = await self._call("GET", f"/v1/devices/{device_id}/wrapped-key") + return data.get("wrappedMasterKey") if data else None + + async def put_account_key(self, recovery_wrapped_key_b64: str, kdf_salt_b64: str, kdf_params: str) -> None: + await self._call( + "PUT", + "/v1/devices/account-key", + json={ + "recoveryWrappedKey": recovery_wrapped_key_b64, + "kdfSalt": kdf_salt_b64, + "kdfParams": kdf_params, + }, + ) + + async def get_account_key(self) -> dict | None: + """Returns {recoveryWrappedKey, kdfSalt, kdfParams} or None if the + account has no escrow yet.""" + return await self._call("GET", "/v1/devices/account-key") + + # -- encrypted object store ---------------------------------------------- + + async def push_object( + self, + *, + kind: str, + client_id: str, + version: int, + record: dict | None, + assets: list[dict] | None = None, + ) -> dict: + """Upsert object metadata. ``record`` is {hash, size}; each asset is + {role, clientAssetId, hash, size}. Returns {objectId, seq, uploads}, + where uploads lists presigned PUT URLs for exactly the changed blobs.""" + return await self._call( + "POST", + "/v1/objects", + json={ + "kind": kind, + "clientId": client_id, + "version": version, + "record": record, + "assets": assets or [], + }, + ) + + async def commit_object(self, object_id: str) -> None: + """Ask the server to verify all claimed blobs actually landed in storage.""" + await self._call("POST", f"/v1/objects/{object_id}/commit") + + async def delete_object(self, object_id: str) -> None: + await self._call("DELETE", f"/v1/objects/{object_id}") + + async def get_changes(self, since: int, limit: int = 200) -> dict: + """Sync pull: {changes, cursor, hasMore} for everything newer than ``since``.""" + return await self._call("GET", "/v1/sync/changes", params={"since": since, "limit": limit}) + + # -- blob transfer (presigned URLs, ciphertext only) ---------------------- + + async def upload_blob(self, url: str, data: bytes) -> None: + try: + resp = await self._blobs.put(url, content=data, headers={"Content-Type": "application/octet-stream"}) + except httpx.HTTPError as err: + raise CloudApiError(f"blob upload failed: {err}") from err + if resp.status_code >= 400: + raise CloudApiError(f"blob upload rejected ({resp.status_code})", status=resp.status_code) + + async def download_blob(self, url: str) -> bytes: + try: + resp = await self._blobs.get(url) + except httpx.HTTPError as err: + raise CloudApiError(f"blob download failed: {err}") from err + if resp.status_code >= 400: + raise CloudApiError(f"blob download rejected ({resp.status_code})", status=resp.status_code) + return resp.content diff --git a/backend/services/cloud_crypto.py b/backend/services/cloud_crypto.py index 31253fb4..a88e2b36 100644 --- a/backend/services/cloud_crypto.py +++ b/backend/services/cloud_crypto.py @@ -78,6 +78,11 @@ def generate_device_keypair() -> tuple[bytes, bytes]: return bytes(private), bytes(private.public_key) +def device_public_key(device_private_key: bytes) -> bytes: + """Re-derive the public half from a stored private key.""" + return bytes(PrivateKey(device_private_key).public_key) + + def wrap_master_key_for_device(master_key: bytes, device_public_key: bytes) -> bytes: """Seal MK to another device's public key (run on an *existing* device when provisioning a new one). Only the target device's private key can open it.""" diff --git a/backend/services/cloud_keys.py b/backend/services/cloud_keys.py new file mode 100644 index 00000000..de4cf78e --- /dev/null +++ b/backend/services/cloud_keys.py @@ -0,0 +1,63 @@ +""" +OS-keychain persistence for cloud E2E key material. + +The bearer API key lives in the local database (``CloudSettings``) because it +is auth, not secrecy. The *encryption* keys never touch the database: this +module stores the device's X25519 private key and the unwrapped Master Key in +the OS keychain (macOS Keychain, Windows Credential Locker, Secret Service on +Linux) via ``keyring``. Entries are namespaced by cloud account user id so +relinking to a different account can never read the previous account's keys. + +Headless installs (Docker/web) may have no keychain backend; operations then +raise ``CloudKeyStoreError`` and cloud sync stays unavailable rather than +silently degrading to plaintext-on-disk key storage. +""" + +import base64 + +import keyring +import keyring.errors + +_SERVICE = "sh.voicebox.cloud" + +DEVICE_PRIVATE_KEY = "device_private_key" +MASTER_KEY = "master_key" +_ALL_ENTRIES = (DEVICE_PRIVATE_KEY, MASTER_KEY) + + +class CloudKeyStoreError(Exception): + """The OS keychain is unavailable or rejected the operation.""" + + +def _entry(account_user_id: str, name: str) -> str: + return f"{account_user_id}:{name}" + + +def store_secret(account_user_id: str, name: str, secret: bytes) -> None: + try: + keyring.set_password(_SERVICE, _entry(account_user_id, name), base64.b64encode(secret).decode("ascii")) + except keyring.errors.KeyringError as err: + raise CloudKeyStoreError(f"could not store {name} in the OS keychain") from err + + +def load_secret(account_user_id: str, name: str) -> bytes | None: + try: + stored = keyring.get_password(_SERVICE, _entry(account_user_id, name)) + except keyring.errors.KeyringError as err: + raise CloudKeyStoreError(f"could not read {name} from the OS keychain") from err + return base64.b64decode(stored) if stored else None + + +def delete_secret(account_user_id: str, name: str) -> None: + try: + keyring.delete_password(_SERVICE, _entry(account_user_id, name)) + except keyring.errors.PasswordDeleteError: + pass # already absent + except keyring.errors.KeyringError as err: + raise CloudKeyStoreError(f"could not delete {name} from the OS keychain") from err + + +def clear(account_user_id: str) -> None: + """Forget all key material for an account (disconnect / account switch).""" + for name in _ALL_ENTRIES: + delete_secret(account_user_id, name) diff --git a/backend/tests/test_cloud_account.py b/backend/tests/test_cloud_account.py new file mode 100644 index 00000000..796bdf60 --- /dev/null +++ b/backend/tests/test_cloud_account.py @@ -0,0 +1,226 @@ +"""Tests for the cloud sync identity flows (services/cloud_account.py). + +Runs the real flows against a fake in-process cloud (httpx.MockTransport) and +a fake in-memory keyring — no network, no OS keychain. The central assertion: +the master key and recovery phrase never appear in anything sent to the server. +""" + +import base64 +import json +from datetime import datetime + +import httpx +import keyring +import keyring.backend +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from backend.database.models import Base, CloudSettings +from backend.services import cloud_account, cloud_crypto, cloud_keys +from backend.services.cloud_account import CloudAccountError +from backend.services.cloud_api import CloudApiClient + +USER_A = "user-a" + + +class InMemoryKeyring(keyring.backend.KeyringBackend): + priority = 1 + + def __init__(self): + super().__init__() + self.store: dict[tuple[str, str], str] = {} + + def get_password(self, service, username): + return self.store.get((service, username)) + + def set_password(self, service, username, password): + self.store[(service, username)] = password + + def delete_password(self, service, username): + self.store.pop((service, username), None) + + +class FakeCloud: + """Just enough of the cloud API for the identity flows, plus a transcript + of every request body so tests can assert what the server was shown.""" + + def __init__(self): + self.devices: dict[str, dict] = {} + self.account_key: dict | None = None + self.seen_bodies: list[bytes] = [] + self._next_id = 0 + + def transport(self) -> httpx.MockTransport: + return httpx.MockTransport(self.handle) + + def handle(self, request: httpx.Request) -> httpx.Response: + if request.content: + self.seen_bodies.append(request.content) + path, method = request.url.path, request.method + + if path == "/v1/devices" and method == "POST": + body = json.loads(request.content) + self._next_id += 1 + device_id = f"dev-{self._next_id}" + self.devices[device_id] = { + "id": device_id, + "name": body["name"], + "publicKey": body["publicKey"], + "wrappedMasterKey": None, + "revokedAt": None, + } + return self._ok({"deviceId": device_id, "accountHasKey": self.account_key is not None}, 201) + if path == "/v1/devices" and method == "GET": + return self._ok(list(self.devices.values())) + if path == "/v1/devices/account-key" and method == "PUT": + self.account_key = json.loads(request.content) + return self._ok(None) + if path == "/v1/devices/account-key" and method == "GET": + return self._ok(self.account_key) + if path.endswith("/wrapped-key") and method == "POST": + device_id = path.split("/")[3] + self.devices[device_id]["wrappedMasterKey"] = json.loads(request.content)["wrappedMasterKey"] + return self._ok(None) + if path.endswith("/wrapped-key") and method == "GET": + device_id = path.split("/")[3] + return self._ok({"wrappedMasterKey": self.devices[device_id]["wrappedMasterKey"]}) + return httpx.Response(404, json={"ok": False, "error": {"message": f"unhandled {method} {path}"}}) + + @staticmethod + def _ok(data, status=200): + return httpx.Response(status, json={"ok": True, "data": data}) + + +@pytest.fixture +def fake_keyring(monkeypatch): + backend = InMemoryKeyring() + monkeypatch.setattr(keyring, "get_password", backend.get_password) + monkeypatch.setattr(keyring, "set_password", backend.set_password) + monkeypatch.setattr(keyring, "delete_password", backend.delete_password) + return backend + + +@pytest.fixture +def cloud(): + return FakeCloud() + + +def make_db(account_user_id=USER_A): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + db.add( + CloudSettings( + id=1, + api_key="voicebox_test", + device_name="Test Mac", + account_user_id=account_user_id, + connected_at=datetime(2026, 7, 1), + ) + ) + db.commit() + return db + + +@pytest.fixture +def patched_client(monkeypatch, cloud): + def _client(row): + return CloudApiClient("http://cloud.test", row.api_key, transport=cloud.transport()) + + monkeypatch.setattr(cloud_account, "_client", _client) + + +@pytest.mark.usefixtures("patched_client", "fake_keyring") +class TestIdentityFlows: + async def test_first_device_setup(self, cloud): + db = make_db() + phrase = await cloud_account.setup_device(db) + + assert phrase is not None + assert cloud_crypto.validate_recovery_phrase(phrase) + assert cloud_account.identity_status(db).status == "ready" + assert cloud.account_key is not None + # Registered + provisioned to itself. + (device,) = cloud.devices.values() + assert device["wrappedMasterKey"] + + # The invariant: neither MK nor the phrase ever crossed the wire. + mk = cloud_account.load_master_key(db) + for body in cloud.seen_bodies: + assert mk not in body + assert base64.b64encode(mk) not in body + assert phrase.encode() not in body + + async def test_second_device_via_provisioning(self, cloud): + db_a = make_db() + await cloud_account.setup_device(db_a) + mk_a = cloud_account.load_master_key(db_a) + + # Second install: same account, its own DB + keychain namespace. Reuse + # the same fake keyring but a distinct account row would collide, so + # simulate the second device with a separate account_user_id-scoped + # keychain by clearing MK after capturing device state. + db_b = make_db(account_user_id="user-a-second-install") + assert await cloud_account.setup_device(db_b) is None # account already has key material + assert cloud_account.identity_status(db_b).status == "awaiting_provision" + assert await cloud_account.adopt_wrapped_key(db_b) is False # nothing provisioned yet + + target_id = db_b.query(CloudSettings).one().sync_device_id + await cloud_account.provision_device(db_a, target_id) + assert await cloud_account.adopt_wrapped_key(db_b) is True + assert cloud_account.load_master_key(db_b) == mk_a + + async def test_restore_with_phrase(self, cloud): + db_a = make_db() + phrase = await cloud_account.setup_device(db_a) + mk_a = cloud_account.load_master_key(db_a) + + db_b = make_db(account_user_id="user-a-fresh-machine") + assert await cloud_account.setup_device(db_b) is None + await cloud_account.restore_with_phrase(db_b, phrase) + assert cloud_account.load_master_key(db_b) == mk_a + assert cloud_account.identity_status(db_b).status == "ready" + + async def test_restore_rejects_wrong_phrase(self, cloud): + db_a = make_db() + await cloud_account.setup_device(db_a) + + db_b = make_db(account_user_id="user-a-fresh-machine") + await cloud_account.setup_device(db_b) + with pytest.raises(cloud_crypto.CloudCryptoError): + await cloud_account.restore_with_phrase(db_b, cloud_crypto.generate_recovery_phrase()) + + async def test_restore_rejects_invalid_phrase_early(self, cloud): + db = make_db() + await cloud_account.setup_device(db) + with pytest.raises(CloudAccountError, match="valid recovery phrase"): + await cloud_account.restore_with_phrase( + db, "not a real phrase at all twelve words missing checksum here ok" + ) + + async def test_double_registration_rejected(self, cloud): + db = make_db() + await cloud_account.setup_device(db) + with pytest.raises(CloudAccountError, match="already registered"): + await cloud_account.setup_device(db) + + async def test_requires_login(self, cloud): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + with pytest.raises(CloudAccountError, match="log in"): + await cloud_account.setup_device(db) + + +@pytest.mark.usefixtures("fake_keyring") +class TestKeyStore: + def test_round_trip_and_clear(self): + cloud_keys.store_secret(USER_A, cloud_keys.MASTER_KEY, b"\x01" * 32) + assert cloud_keys.load_secret(USER_A, cloud_keys.MASTER_KEY) == b"\x01" * 32 + assert cloud_keys.load_secret("other-user", cloud_keys.MASTER_KEY) is None + cloud_keys.clear(USER_A) + assert cloud_keys.load_secret(USER_A, cloud_keys.MASTER_KEY) is None + + def test_delete_absent_is_noop(self): + cloud_keys.delete_secret(USER_A, cloud_keys.DEVICE_PRIVATE_KEY) diff --git a/backend/tests/test_cloud_roundtrip_integration.py b/backend/tests/test_cloud_roundtrip_integration.py new file mode 100644 index 00000000..4b6f5f51 --- /dev/null +++ b/backend/tests/test_cloud_roundtrip_integration.py @@ -0,0 +1,104 @@ +"""End-to-end round-trip against a real voicebox-cloud dev server. + +Skipped unless VOICEBOX_CLOUD_TEST_API + VOICEBOX_CLOUD_TEST_KEY are set: + + cd voicebox-cloud && pnpm dev:db && pnpm db:migrate && pnpm dev:api + # create an account + API key (web app or seed script), then: + VOICEBOX_CLOUD_TEST_API=http://localhost:17593 \\ + VOICEBOX_CLOUD_TEST_KEY=voicebox_… \\ + pytest backend/tests/test_cloud_roundtrip_integration.py -v + +Exercises the scaffolded server for real: device registration, recovery +escrow, encrypted push (presigned PUT + commit), sync pull, decrypt — and +verifies the ciphertext at rest is opaque. +""" + +import base64 +import hashlib +import json +import os +import uuid + +import pytest + +from backend.services import cloud_crypto +from backend.services.cloud_api import CloudApiClient + +API_URL = os.environ.get("VOICEBOX_CLOUD_TEST_API") +API_KEY = os.environ.get("VOICEBOX_CLOUD_TEST_KEY") + +pytestmark = pytest.mark.skipif( + not (API_URL and API_KEY), + reason="set VOICEBOX_CLOUD_TEST_API and VOICEBOX_CLOUD_TEST_KEY to run against a dev server", +) + + +async def test_full_roundtrip(): + master_key = cloud_crypto.generate_master_key() + client_id = str(uuid.uuid4()) + record_plain = json.dumps({"transcript_raw": "hello from the integration test", "language": "en"}).encode() + audio_plain = os.urandom(64_000) # stands in for capture audio + + async with CloudApiClient(API_URL, API_KEY) as client: + # Device + escrow round-trip. + private_key, public_key = cloud_crypto.generate_device_keypair() + registered = await client.register_device("integration-test", base64.b64encode(public_key).decode()) + device_id = registered["deviceId"] + wrapped = cloud_crypto.wrap_master_key_for_device(master_key, public_key) + await client.put_wrapped_key(device_id, base64.b64encode(wrapped).decode()) + fetched = await client.get_wrapped_key(device_id) + assert cloud_crypto.unwrap_master_key_for_device(base64.b64decode(fetched), private_key) == master_key + + # Push: encrypt locally, upsert metadata, PUT ciphertext, commit. + object_id_placeholder = client_id # AAD object binding uses the client id pre-push + record_env = cloud_crypto.encrypt_blob( + record_plain, master_key, object_id=object_id_placeholder, role="record", version=1 + ) + audio_env = cloud_crypto.encrypt_blob( + audio_plain, master_key, object_id=object_id_placeholder, role="audio", version=1 + ) + pushed = await client.push_object( + kind="capture", + client_id=client_id, + version=1, + record={"hash": hashlib.sha256(record_env).hexdigest(), "size": len(record_env)}, + assets=[ + { + "role": "audio", + "clientAssetId": f"{client_id}-audio", + "hash": hashlib.sha256(audio_env).hexdigest(), + "size": len(audio_env), + } + ], + ) + uploads = {u["for"]: u["url"] for u in pushed["uploads"]} + await client.upload_blob(uploads["record"], record_env) + await client.upload_blob(uploads[f"asset:{client_id}-audio"], audio_env) + await client.commit_object(pushed["objectId"]) + + # Pull: cursor 0 must include our object; ciphertext decrypts to the original. + changes = await client.get_changes(since=pushed["seq"] - 1, limit=10) + change = next(c for c in changes["changes"] if c["clientId"] == client_id) + assert change["kind"] == "capture" + assert not change["deleted"] + + record_cipher = await client.download_blob(change["record"]["url"]) + assert record_cipher == record_env # opaque, byte-identical ciphertext at rest + assert record_plain not in record_cipher + assert ( + cloud_crypto.decrypt_blob(record_cipher, master_key, object_id=client_id, role="record", version=1) + == record_plain + ) + + (asset,) = change["assets"] + audio_cipher = await client.download_blob(asset["url"]) + assert ( + cloud_crypto.decrypt_blob(audio_cipher, master_key, object_id=client_id, role="audio", version=1) + == audio_plain + ) + + # Tombstone propagates. + await client.delete_object(pushed["objectId"]) + changes = await client.get_changes(since=changes["cursor"], limit=10) + tombstone = next(c for c in changes["changes"] if c["clientId"] == client_id) + assert tombstone["deleted"] is True