mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 21:30:39 -07:00
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.
194 lines
8.5 KiB
Python
194 lines
8.5 KiB
Python
"""
|
|
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
|