Add cloud key store, API client, and sync identity flows

cloud_keys: device private key + master key live in the OS keychain
(keyring), namespaced per cloud account — never in the local DB. Headless
installs without a keychain get a hard error, not a plaintext fallback.

cloud_api: async httpx client for the bearer-key surface (devices,
account-key escrow, object push/commit, sync pull). Presigned blob
transfers use a separate unauthenticated client so the bearer key never
reaches the storage host.

cloud_account: the design-doc §9 flows — first-device setup mints MK +
recovery phrase (returned for one-time display), later devices register
and either adopt a wrapped MK provisioned by an existing device or
restore from the phrase. sync_device_id lands on cloud_settings with an
idempotent migration.

Tests run the real flows against an in-process fake cloud and assert the
master key and phrase never appear in any request body. A full round-trip
integration test (encrypt -> push -> pull -> decrypt -> tombstone) runs
against a live dev server when VOICEBOX_CLOUD_TEST_API/KEY are set.
This commit is contained in:
Jamie Pine
2026-07-01 15:02:11 -07:00
parent 9a8425f401
commit 9b0e024d3b
9 changed files with 783 additions and 1 deletions
+11
View File
@@ -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
+4
View File
@@ -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)
+3 -1
View File
@@ -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
+193
View File
@@ -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
+174
View File
@@ -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
+5
View File
@@ -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."""
+63
View File
@@ -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)
+226
View File
@@ -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)
@@ -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