mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 21:30:39 -07:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user