mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 06:40:38 -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:
@@ -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
|
||||
Reference in New Issue
Block a user