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.
64 lines
2.3 KiB
Python
64 lines
2.3 KiB
Python
"""
|
|
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)
|