Compare commits

..
Author SHA1 Message Date
Jamie Pine 3bdc18f278 Remove orphaned react-qr-code entries from lockfile
bun.lock was out of date with package.json (react-qr-code was removed
without reinstalling), failing the frozen-lockfile install in CI.
2026-07-05 03:17:33 -07:00
Jamie Pine 0b8fd31c89 Address review feedback on cloud login
- time out status polling after 2 min so an abandoned browser flow
  doesn't leave the button stuck on "Waiting for browser…"
- handle non-JSON / non-object payloads from the exchange and account
  endpoints instead of 500ing after the state is consumed
- make singleton row creation race-safe (IntegrityError -> re-query)
- clear device_name on disconnect along with the rest of the metadata
- serve the dashboard URL from /cloud/status so the Manage link follows
  VOICEBOX_CLOUD_URL instead of hardcoding production
- keep a "Disconnecting…" label on the disconnect button while pending
2026-07-04 21:07:59 -07:00
21 changed files with 63 additions and 2577 deletions
+24 -250
View File
@@ -1,16 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Cloud, Copy, Loader2, RefreshCw, ShieldCheck } from 'lucide-react';
import { Cloud, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { SettingRow, SettingSection } from './SettingRow';
@@ -18,13 +9,6 @@ import { SettingRow, SettingSection } from './SettingRow';
// "Log in with browser" device pairing. The backend opens the system browser
// and completes the code exchange; here we just kick it off and poll status
// until the link goes live. The API key never touches the frontend.
//
// Once linked, the encrypted-backup rows drive the sync identity flows: this
// device registers as an encryption device, and either mints the account's
// master key (first device — the recovery phrase is force-displayed exactly
// once) or waits to be provisioned by another device / restored from the
// phrase. All crypto happens in the local backend; this UI only ever sees the
// phrase, and only at mint time.
export function CloudSection() {
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -49,6 +33,22 @@ export function CloudSection() {
}
}, [connected, polling, status?.device_name, toast]);
// Give up after two minutes so an abandoned browser flow doesn't leave the
// button stuck on "Waiting for browser…". The backend state stays valid for
// ten, so the user can simply start again.
useEffect(() => {
if (!polling) return;
const timeoutId = window.setTimeout(() => {
setPolling(false);
toast({
title: 'Sign-in timed out',
description: 'The browser sign-in was not completed. Try again.',
variant: 'destructive',
});
}, 120_000);
return () => window.clearTimeout(timeoutId);
}, [polling, toast]);
const startLogin = useMutation({
mutationFn: () => apiClient.startCloudLogin(),
onSuccess: () => {
@@ -70,10 +70,10 @@ export function CloudSection() {
mutationFn: () => apiClient.disconnectCloud(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cloud-status'] });
queryClient.invalidateQueries({ queryKey: ['cloud-sync-status'] });
toast({
title: 'Disconnected',
description: 'This device is no longer linked. The key stays valid until revoked in your account.',
description:
'This device is no longer linked. The key stays valid until revoked in your account.',
});
},
onError: (error: Error) =>
@@ -105,7 +105,10 @@ export function CloudSection() {
variant="outline"
>
{disconnect.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Disconnecting…
</>
) : (
'Disconnect'
)}
@@ -128,8 +131,6 @@ export function CloudSection() {
}
/>
{connected && <BackupRows />}
{connected && (
<SettingRow
title="Manage"
@@ -137,7 +138,7 @@ export function CloudSection() {
>
<a
className="text-sm text-accent hover:underline"
href="https://voicebox.sh/account"
href={status?.dashboard_url ?? 'https://voicebox.sh/account'}
rel="noopener noreferrer"
target="_blank"
>
@@ -148,230 +149,3 @@ export function CloudSection() {
</SettingSection>
);
}
function BackupRows() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [phrase, setPhrase] = useState<string | null>(null);
const [phraseCopied, setPhraseCopied] = useState(false);
const [restoreInput, setRestoreInput] = useState('');
const { data: sync } = useQuery({
queryKey: ['cloud-sync-status'],
queryFn: () => apiClient.getCloudSyncStatus(),
});
const refreshSync = () => queryClient.invalidateQueries({ queryKey: ['cloud-sync-status'] });
const setup = useMutation({
mutationFn: () => apiClient.setupCloudSync(),
onSuccess: (result) => {
refreshSync();
if (result.recovery_phrase) {
// First device: the phrase exists only in this response. Force-display
// it; the dialog can only be dismissed by confirming.
setPhraseCopied(false);
setPhrase(result.recovery_phrase);
}
},
onError: (error: Error) =>
toast({ title: 'Could not enable backup', description: error.message, variant: 'destructive' }),
});
const adopt = useMutation({
mutationFn: () => apiClient.adoptCloudSync(),
onSuccess: (result) => {
refreshSync();
if (result.status === 'ready') {
toast({ title: 'Backup enabled', description: 'This device received its encryption key.' });
} else {
toast({
title: 'Not approved yet',
description: 'Approve this device from another synced device, then check again.',
});
}
},
onError: (error: Error) =>
toast({ title: 'Could not check', description: error.message, variant: 'destructive' }),
});
const restore = useMutation({
mutationFn: (recoveryPhrase: string) => apiClient.restoreCloudSync(recoveryPhrase),
onSuccess: () => {
refreshSync();
setRestoreInput('');
toast({ title: 'Backup restored', description: 'Your encryption key was recovered. Syncing is ready.' });
},
onError: (error: Error) =>
toast({ title: 'Could not restore', description: error.message, variant: 'destructive' }),
});
const run = useMutation({
mutationFn: () => apiClient.runCloudSync(),
onSuccess: (report) => {
refreshSync();
const pushed = report.pushed + report.pushed_deletes;
const pulled = report.pulled + report.pulled_deletes;
toast({
title: 'Sync complete',
description:
pushed === 0 && pulled === 0
? 'Everything is already up to date.'
: `Backed up ${pushed} ${pushed === 1 ? 'item' : 'items'}, received ${pulled}.`,
});
},
onError: (error: Error) =>
toast({ title: 'Sync failed', description: error.message, variant: 'destructive' }),
});
const state = sync?.status ?? 'unregistered';
return (
<>
{state === 'unregistered' && (
<SettingRow
title="Encrypted backup"
description="Back up captures, generations, and voice profiles — encrypted on this device before anything is uploaded."
action={
<Button disabled={setup.isPending} onClick={() => setup.mutate()} size="sm">
{setup.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<ShieldCheck className="h-3.5 w-3.5 mr-1.5" />
Enable backup
</>
)}
</Button>
}
/>
)}
{state === 'awaiting_provision' && (
<>
<SettingRow
title="Waiting for encryption key"
description="This account already has a backup. Approve this device from another synced device, or restore with your recovery phrase below."
action={
<Button disabled={adopt.isPending} onClick={() => adopt.mutate()} size="sm" variant="outline">
{adopt.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Check again
</>
)}
</Button>
}
/>
<SettingRow
title="Restore with recovery phrase"
description="The 12 words you wrote down when you first enabled backup."
action={
<div className="flex items-center gap-2">
<Input
className="w-72"
onChange={(e) => setRestoreInput(e.target.value)}
placeholder="correct horse battery staple …"
value={restoreInput}
/>
<Button
disabled={restore.isPending || restoreInput.trim().split(/\s+/).length < 12}
onClick={() => restore.mutate(restoreInput)}
size="sm"
>
{restore.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Restore'}
</Button>
</div>
}
/>
</>
)}
{state === 'ready' && (
<SettingRow
title="Encrypted backup"
description="On — content is encrypted on this device before upload. The server can never read it."
action={
<Button disabled={run.isPending} onClick={() => run.mutate()} size="sm">
{run.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Syncing…
</>
) : (
<>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Sync now
</>
)}
</Button>
}
/>
)}
<RecoveryPhraseDialog
copied={phraseCopied}
onConfirm={() => setPhrase(null)}
onCopy={() => {
if (phrase) {
navigator.clipboard?.writeText(phrase);
setPhraseCopied(true);
}
}}
phrase={phrase}
/>
</>
);
}
// The one moment the recovery phrase exists outside the backend. Deliberately
// hard to dismiss: no overlay/escape close, only the explicit confirmation.
function RecoveryPhraseDialog({
phrase,
copied,
onCopy,
onConfirm,
}: {
phrase: string | null;
copied: boolean;
onCopy: () => void;
onConfirm: () => void;
}) {
return (
<Dialog onOpenChange={() => {}} open={phrase !== null}>
<DialogContent className="max-w-lg [&>button]:hidden">
<DialogHeader>
<DialogTitle>Write down your recovery phrase</DialogTitle>
<DialogDescription>
These 12 words are the only way to restore your backup if you lose all your devices.
They are never sent to the server and will not be shown again.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-3 gap-2 py-2">
{(phrase ?? '').split(' ').map((word, index) => (
<div
className="rounded-md border border-border bg-muted/40 px-2.5 py-1.5 text-sm"
// Position is the identity here — BIP39 phrases can repeat words.
// biome-ignore lint/suspicious/noArrayIndexKey: static list, never reordered
key={index}
>
<span className="mr-1.5 text-muted-foreground tabular-nums">{index + 1}.</span>
{word}
</div>
))}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button onClick={onCopy} size="sm" type="button" variant="outline">
<Copy className="h-3.5 w-3.5 mr-1.5" />
{copied ? 'Copied' : 'Copy'}
</Button>
<Button onClick={onConfirm} size="sm" type="button">
I've written it down
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
-29
View File
@@ -52,9 +52,6 @@ import type {
MCPClientBindingUpsert,
CloudLoginStartResponse,
CloudStatus,
CloudSyncRunResponse,
CloudSyncSetupResponse,
CloudSyncStatus,
} from './types';
function formatErrorDetail(detail: unknown, fallback: string): string {
@@ -940,32 +937,6 @@ class ApiClient {
async disconnectCloud(): Promise<CloudStatus> {
return this.request<CloudStatus>('/cloud/disconnect', { method: 'POST' });
}
// Encrypted backup & sync. setupCloudSync registers this install as an
// encryption device — when it returns a recovery_phrase, this device just
// minted the account key and the phrase must be force-displayed once.
async getCloudSyncStatus(): Promise<CloudSyncStatus> {
return this.request<CloudSyncStatus>('/cloud/sync/status');
}
async setupCloudSync(): Promise<CloudSyncSetupResponse> {
return this.request<CloudSyncSetupResponse>('/cloud/sync/setup', { method: 'POST' });
}
async restoreCloudSync(phrase: string): Promise<CloudSyncStatus> {
return this.request<CloudSyncStatus>('/cloud/sync/restore', {
method: 'POST',
body: JSON.stringify({ phrase }),
});
}
async adoptCloudSync(): Promise<CloudSyncStatus> {
return this.request<CloudSyncStatus>('/cloud/sync/adopt', { method: 'POST' });
}
async runCloudSync(): Promise<CloudSyncRunResponse> {
return this.request<CloudSyncRunResponse>('/cloud/sync/run', { method: 'POST' });
}
}
export const apiClient = new ApiClient();
+1 -24
View File
@@ -534,28 +534,5 @@ export interface CloudStatus {
account_user_id: string | null;
key_prefix: string | null;
connected_at: string | null;
}
export type CloudSyncState = 'unregistered' | 'awaiting_provision' | 'ready';
export interface CloudSyncStatus {
status: CloudSyncState;
device_id: string | null;
sync_cursor: number;
}
export interface CloudSyncSetupResponse {
status: CloudSyncState;
device_id: string | null;
/** Present exactly once, when this device minted the account's key
* material. Must be force-displayed and never persisted. */
recovery_phrase: string | null;
}
export interface CloudSyncRunResponse {
pushed: number;
pushed_deletes: number;
pulled: number;
pulled_deletes: number;
cursor: number;
dashboard_url: string;
}
-2
View File
@@ -12,7 +12,6 @@ from .models import (
CaptureSettings,
ChannelDeviceMapping,
CloudSettings,
CloudSyncState,
EffectPreset,
Generation,
GenerationSettings,
@@ -35,7 +34,6 @@ __all__ = [
"CaptureSettings",
"ChannelDeviceMapping",
"CloudSettings",
"CloudSyncState",
"EffectPreset",
"Generation",
"GenerationSettings",
-14
View File
@@ -43,7 +43,6 @@ 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)
@@ -284,19 +283,6 @@ def _migrate_mcp_bindings(engine, inspector, tables: set[str]) -> None:
)
def _migrate_cloud_settings(engine, inspector, tables: set[str]) -> None:
"""Add the cloud sync columns: ``sync_device_id`` (the server-assigned id
from registering this install as an encryption-capable sync device) and
``sync_cursor`` (highest applied seq from the sync feed)."""
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")
if "sync_cursor" not in columns:
_add_column(engine, "cloud_settings", "sync_cursor INTEGER NOT NULL DEFAULT 0", "sync_cursor")
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
+1 -37
View File
@@ -3,7 +3,7 @@
from datetime import datetime
import uuid
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON, UniqueConstraint
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON
from sqlalchemy.ext.declarative import declarative_base
from ..utils.capture_chords import (
@@ -253,45 +253,9 @@ 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)
# Highest server seq this install has applied from the sync feed.
sync_cursor = Column(Integer, nullable=False, default=0)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class CloudSyncState(Base):
"""Per-entity sync bookkeeping for cloud backup (one row per synced object).
Envelopes are randomized (fresh content key + nonce per encryption), so the
ciphertext hash changes on every re-encrypt even when the content didn't.
To keep the server's changed-hash dedup working, this table remembers what
was last uploaded: the plaintext fingerprints (to detect real local edits)
and the ciphertext hashes the server currently holds (to re-declare
unchanged blobs without re-encrypting or re-uploading them).
"""
__tablename__ = "cloud_sync_state"
__table_args__ = (UniqueConstraint("kind", "client_id", name="uq_cloud_sync_state_kind_client"),)
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
kind = Column(String, nullable=False) # capture | generation | profile | settings
client_id = Column(String, nullable=False) # the local entity id
server_object_id = Column(String, nullable=True)
# Client-bumped LWW version (object.version on the server).
version = Column(Integer, nullable=False, default=1)
# SHA-256 of the canonical plaintext record JSON at last push/pull.
record_fingerprint = Column(String, nullable=True)
# SHA-256 + size of the record ciphertext the server currently holds.
record_hash = Column(String, nullable=True)
record_size = Column(Integer, nullable=False, default=0)
# Per-asset bookkeeping, JSON: {clientAssetId: {role, fingerprint, hash, size}}
assets_json = Column(Text, nullable=False, default="{}")
last_synced_at = Column(DateTime, nullable=True)
class MCPClientBinding(Base):
"""Per-MCP-client settings (voice profile, engine, personality default).
+1 -38
View File
@@ -813,41 +813,4 @@ class CloudStatusResponse(BaseModel):
account_user_id: Optional[str] = None
key_prefix: Optional[str] = None
connected_at: Optional[datetime] = None
class CloudSyncSetupResponse(BaseModel):
"""Result of registering this install as a sync device.
``recovery_phrase`` is present exactly once, when this device minted the
account's key material (first device). The UI must force-display it and
never persist it. When absent, the account already has key material and
this device is awaiting provisioning (wrapped key from an existing device,
or a recovery-phrase restore)."""
status: str # unregistered | awaiting_provision | ready
device_id: Optional[str] = None
recovery_phrase: Optional[str] = None
class CloudSyncStatusResponse(BaseModel):
"""Sync identity + progress for the settings UI."""
status: str # unregistered | awaiting_provision | ready
device_id: Optional[str] = None
sync_cursor: int = 0
class CloudRestoreRequest(BaseModel):
"""Recovery-phrase restore on a fresh device."""
phrase: str
class CloudSyncRunResponse(BaseModel):
"""Outcome of one push+pull sync pass."""
pushed: int
pushed_deletes: int
pulled: int
pulled_deletes: int
cursor: int
dashboard_url: str
-6
View File
@@ -7,12 +7,6 @@ pydantic>=2.5.0
sqlalchemy>=2.0.0
alembic>=1.13.0
# 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
transformers>=4.36.0,<=4.57.6
+9 -89
View File
@@ -1,4 +1,4 @@
"""Voicebox Cloud routes: device login + encrypted backup/sync.
"""Voicebox Cloud device login routes.
The browser-based pairing flow:
1. POST /cloud/login/start — opens the browser to the cloud authorize page.
@@ -6,31 +6,17 @@ The browser-based pairing flow:
the backend exchanges it for an API key.
3. GET /cloud/status — the UI polls this to learn when it connected.
4. POST /cloud/disconnect — forget the local credential.
Sync, once logged in:
5. POST /cloud/sync/setup — register as an encryption device; on a keyless
account this mints the master key and returns
the recovery phrase (shown exactly once).
6. POST /cloud/sync/restore — recover the master key from the phrase.
7. POST /cloud/sync/adopt — pick up a wrapped key another device provisioned.
8. GET /cloud/sync/status — identity state + cursor.
9. POST /cloud/sync/run — one full push+pull pass.
"""
import socket
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from .. import models
from ..database import CloudSettings as DBCloudSettings, get_db
from ..services import cloud as cloud_service, cloud_account, cloud_sync
from ..services.cloud_account import CloudAccountError
from ..services.cloud_api import CloudApiError
from ..services.cloud_crypto import CloudCryptoError
from ..services.cloud_keys import CloudKeyStoreError
from ..services.cloud_sync import CloudSyncError
from ..database import get_db
from ..services import cloud as cloud_service
router = APIRouter(prefix="/cloud", tags=["cloud"])
@@ -58,7 +44,11 @@ async def cloud_callback(
ok, message = await cloud_service.handle_callback(db, code=code, state=state)
heading = "You're connected" if ok else "Couldn't connect"
accent = "#16a34a" if ok else "#dc2626"
sub = "Voicebox is now linked to your account. You can close this tab and return to the app." if ok else message
sub = (
"Voicebox is now linked to your account. You can close this tab and return to the app."
if ok
else message
)
html = f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -83,73 +73,3 @@ async def cloud_status(db: Session = Depends(get_db)):
async def cloud_disconnect(db: Session = Depends(get_db)):
cloud_service.disconnect(db)
return models.CloudStatusResponse(**cloud_service.get_status(db))
# ─── Encrypted backup & sync ─────────────────────────────────────────────
_SYNC_ERRORS = (CloudAccountError, CloudApiError, CloudCryptoError, CloudKeyStoreError, CloudSyncError)
def _sync_status(db: Session) -> models.CloudSyncStatusResponse:
identity = cloud_account.identity_status(db)
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == 1).first()
return models.CloudSyncStatusResponse(
status=identity.status,
device_id=identity.device_id,
sync_cursor=(row.sync_cursor or 0) if row else 0,
)
@router.post("/sync/setup", response_model=models.CloudSyncSetupResponse)
async def cloud_sync_setup(db: Session = Depends(get_db)):
try:
phrase = await cloud_account.setup_device(db)
identity = cloud_account.identity_status(db)
except _SYNC_ERRORS as err:
raise HTTPException(status_code=400, detail=str(err)) from err
return models.CloudSyncSetupResponse(
status=identity.status,
device_id=identity.device_id,
recovery_phrase=phrase,
)
@router.post("/sync/restore", response_model=models.CloudSyncStatusResponse)
async def cloud_sync_restore(body: models.CloudRestoreRequest, db: Session = Depends(get_db)):
try:
await cloud_account.restore_with_phrase(db, body.phrase)
except _SYNC_ERRORS as err:
raise HTTPException(status_code=400, detail=str(err)) from err
return _sync_status(db)
@router.post("/sync/adopt", response_model=models.CloudSyncStatusResponse)
async def cloud_sync_adopt(db: Session = Depends(get_db)):
try:
await cloud_account.adopt_wrapped_key(db)
except _SYNC_ERRORS as err:
raise HTTPException(status_code=400, detail=str(err)) from err
return _sync_status(db)
@router.get("/sync/status", response_model=models.CloudSyncStatusResponse)
async def cloud_sync_status(db: Session = Depends(get_db)):
try:
return _sync_status(db)
except CloudAccountError:
return models.CloudSyncStatusResponse(status="unregistered", device_id=None, sync_cursor=0)
@router.post("/sync/run", response_model=models.CloudSyncRunResponse)
async def cloud_sync_run(db: Session = Depends(get_db)):
try:
report = await cloud_sync.run_sync(db)
except _SYNC_ERRORS as err:
raise HTTPException(status_code=400, detail=str(err)) from err
return models.CloudSyncRunResponse(
pushed=report.pushed,
pushed_deletes=report.pushed_deletes,
pulled=report.pulled,
pulled_deletes=report.pulled_deletes,
cursor=report.cursor,
)
+27 -4
View File
@@ -19,6 +19,7 @@ import webbrowser
from urllib.parse import urlencode
import httpx
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .. import config
@@ -41,6 +42,15 @@ def _prune() -> None:
_pending.pop(state, None)
def _json_dict(response: httpx.Response) -> dict | None:
"""Parsed JSON body, or None when it isn't a JSON object."""
try:
payload = response.json()
except ValueError:
return None
return payload if isinstance(payload, dict) else None
def _consume_state(state: str) -> bool:
"""Validate and single-use-consume a pending state."""
_prune()
@@ -84,7 +94,10 @@ async def handle_callback(db: Session, code: str, state: str) -> tuple[bool, str
if exchanged.status_code != 200:
logger.warning("cloud exchange rejected code: %s", exchanged.status_code)
return False, "Could not complete sign-in — the code was rejected."
payload = exchanged.json()
payload = _json_dict(exchanged)
if payload is None:
logger.warning("cloud exchange returned a non-JSON payload")
return False, "Voicebox Cloud returned an unexpected response."
api_key = payload.get("key")
device_name = payload.get("label")
if not api_key:
@@ -98,7 +111,9 @@ async def handle_callback(db: Session, code: str, state: str) -> tuple[bool, str
if me.status_code != 200:
logger.warning("minted key failed verification: %s", me.status_code)
return False, "Sign-in succeeded but the key could not be verified."
account_user_id = (me.json().get("data") or {}).get("userId")
# The 200 above proves the key works; the user id is best-effort.
data = (_json_dict(me) or {}).get("data")
account_user_id = data.get("userId") if isinstance(data, dict) else None
except httpx.HTTPError:
logger.exception("network error during cloud exchange")
return False, "Could not reach Voicebox Cloud. Check your connection and try again."
@@ -113,8 +128,14 @@ def _get_or_create_row(db: Session) -> DBCloudSettings:
if row is None:
row = DBCloudSettings(id=SINGLETON_ID)
db.add(row)
db.commit()
db.refresh(row)
try:
db.commit()
except IntegrityError:
# Another request created the singleton concurrently.
db.rollback()
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).one()
else:
db.refresh(row)
return row
@@ -141,6 +162,7 @@ def get_status(db: Session) -> dict:
"account_user_id": row.account_user_id if connected else None,
"key_prefix": key_prefix,
"connected_at": row.connected_at if connected else None,
"dashboard_url": f"{config.get_cloud_web_url()}/account",
}
@@ -149,6 +171,7 @@ def disconnect(db: Session) -> None:
revoked from the account dashboard — surface that in the UI."""
row = _get_or_create_row(db)
row.api_key = None
row.device_name = None
row.account_user_id = None
row.connected_at = None
db.commit()
-193
View File
@@ -1,193 +0,0 @@
"""
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
@@ -1,174 +0,0 @@
"""
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
-233
View File
@@ -1,233 +0,0 @@
"""
Client-side cryptography for Voicebox Cloud backup & sync.
This is the auditable half of the cloud's privacy promise: every byte that
leaves this machine is encrypted here first, and the server only ever stores
ciphertext plus routing metadata. The key hierarchy (cloud repo,
``docs/DESIGN.md``):
Recovery phrase (BIP39) Device X25519 keypairs
| Argon2id(salt, params) | sealed box
v v
Recovery KEK --wrap--> Master Key (MK) <--wrapped to each device
|
| per-blob random Content Key (CK),
| wrapped by MK inside the envelope
v
XChaCha20-Poly1305(CK) over each record/asset blob
Everything in this module is a pure function over bytes — no I/O, no storage,
no network. Key persistence (OS keychain) and the sync engine live elsewhere.
Invariants this module enforces:
- The Master Key is random, never derived from anything the server holds
(API keys and wallet keys are auth/entitlement, never encryption roots).
- Each blob's AAD binds it to its logical slot (object, role, version), so a
server cannot substitute one blob for another without decryption failing.
- Content Keys travel only inside the envelope, wrapped by MK — the database
(local and cloud) stays free of key material.
"""
import json
import secrets
from dataclasses import dataclass
import nacl.bindings
import nacl.exceptions
import nacl.pwhash
import nacl.utils
from mnemonic import Mnemonic
from nacl.public import PrivateKey, PublicKey, SealedBox
from nacl.secret import SecretBox
KEY_BYTES = 32
# Envelope framing: magic | alg | len(wrapped_ck) | wrapped_ck | nonce | ciphertext
ENVELOPE_MAGIC = b"VBX1"
ALG_XCHACHA20_POLY1305 = 1
_WRAPPED_CK_LEN_BYTES = 2
_NONCE_BYTES = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES # 24
# Argon2id cost for the recovery-phrase KEK. MODERATE (~256 MiB, interactive
# latency) fits a desktop restore flow; the phrase itself already carries
# 128 bits of entropy, so the KDF is hardening, not the main defense.
_KDF_OPSLIMIT = nacl.pwhash.argon2id.OPSLIMIT_MODERATE
_KDF_MEMLIMIT = nacl.pwhash.argon2id.MEMLIMIT_MODERATE
_mnemonic = Mnemonic("english")
class CloudCryptoError(Exception):
"""Envelope malformed, key wrong, or ciphertext tampered with."""
# ---------------------------------------------------------------------------
# Master Key + device keys
def generate_master_key() -> bytes:
"""The 32-byte root of content secrecy. Generated once per account, client-side."""
return secrets.token_bytes(KEY_BYTES)
def generate_device_keypair() -> tuple[bytes, bytes]:
"""X25519 (private, public) for this install. The private key never leaves
the device; the public key is registered with the cloud so existing devices
can wrap MK to it."""
private = PrivateKey.generate()
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."""
return SealedBox(PublicKey(device_public_key)).encrypt(master_key)
def unwrap_master_key_for_device(wrapped: bytes, device_private_key: bytes) -> bytes:
"""Open a sealed MK with this device's private key."""
try:
return SealedBox(PrivateKey(device_private_key)).decrypt(wrapped)
except nacl.exceptions.CryptoError as err:
raise CloudCryptoError("wrapped master key does not match this device key") from err
# ---------------------------------------------------------------------------
# Recovery phrase escrow
@dataclass(frozen=True)
class RecoveryWrap:
"""What the cloud stores in ``account_key``: ciphertext + the KDF context
any client needs to re-derive the KEK from the phrase. Holds no secrets."""
wrapped_key: bytes
kdf_salt: bytes
kdf_params: str # JSON, e.g. {"m": ..., "t": ..., "p": 1}
def generate_recovery_phrase() -> str:
"""12-word BIP39 mnemonic (128-bit entropy) — shown to the user exactly once."""
return _mnemonic.generate(strength=128)
def validate_recovery_phrase(phrase: str) -> bool:
"""Word-level checksum validation, for catching typos before an unwrap attempt."""
return _mnemonic.check(_normalize_phrase(phrase))
def _normalize_phrase(phrase: str) -> str:
return " ".join(phrase.lower().split())
def _derive_kek(phrase: str, salt: bytes, opslimit: int, memlimit: int) -> bytes:
return nacl.pwhash.argon2id.kdf(
KEY_BYTES,
_normalize_phrase(phrase).encode("utf-8"),
salt,
opslimit=opslimit,
memlimit=memlimit,
)
def wrap_master_key_with_phrase(master_key: bytes, phrase: str) -> RecoveryWrap:
"""Argon2id-stretch the phrase into a KEK and wrap MK under it."""
salt = nacl.utils.random(nacl.pwhash.argon2id.SALTBYTES)
kek = _derive_kek(phrase, salt, _KDF_OPSLIMIT, _KDF_MEMLIMIT)
return RecoveryWrap(
wrapped_key=SecretBox(kek).encrypt(master_key),
kdf_salt=salt,
kdf_params=json.dumps({"m": _KDF_MEMLIMIT, "t": _KDF_OPSLIMIT, "p": 1}),
)
def unwrap_master_key_with_phrase(wrap: RecoveryWrap, phrase: str) -> bytes:
"""Recover MK on a fresh device from the phrase + the stored KDF context."""
try:
params = json.loads(wrap.kdf_params)
opslimit, memlimit = int(params["t"]), int(params["m"])
except (ValueError, KeyError, TypeError) as err:
raise CloudCryptoError("malformed KDF parameters") from err
kek = _derive_kek(phrase, wrap.kdf_salt, opslimit, memlimit)
try:
return SecretBox(kek).decrypt(wrap.wrapped_key)
except nacl.exceptions.CryptoError as err:
raise CloudCryptoError("recovery phrase does not unlock this account key") from err
# ---------------------------------------------------------------------------
# Blob envelope
def _build_aad(object_id: str, role: str, version: int) -> bytes:
# Binds a blob to its logical slot. Unambiguous because object_id is a UUID
# and role is a fixed token — neither contains ":".
return f"{object_id}:{role}:{version}".encode()
def encrypt_blob(plaintext: bytes, master_key: bytes, *, object_id: str, role: str, version: int) -> bytes:
"""Produce a self-describing VBX1 envelope: a fresh Content Key wrapped by
MK rides in the header, and the AAD pins the blob to (object, role, version)."""
content_key = secrets.token_bytes(KEY_BYTES)
wrapped_ck = SecretBox(master_key).encrypt(content_key)
nonce = nacl.utils.random(_NONCE_BYTES)
ciphertext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_encrypt(
plaintext,
_build_aad(object_id, role, version),
nonce,
content_key,
)
return b"".join(
[
ENVELOPE_MAGIC,
bytes([ALG_XCHACHA20_POLY1305]),
len(wrapped_ck).to_bytes(_WRAPPED_CK_LEN_BYTES, "big"),
wrapped_ck,
nonce,
ciphertext,
]
)
def decrypt_blob(envelope: bytes, master_key: bytes, *, object_id: str, role: str, version: int) -> bytes:
"""Open a VBX1 envelope. Raises CloudCryptoError if the envelope is
malformed, the key is wrong, the ciphertext was modified, or the blob was
served for a different (object, role, version) slot."""
offset = len(ENVELOPE_MAGIC)
if envelope[:offset] != ENVELOPE_MAGIC:
raise CloudCryptoError("not a VBX1 envelope")
if len(envelope) < offset + 1 + _WRAPPED_CK_LEN_BYTES:
raise CloudCryptoError("envelope truncated")
alg = envelope[offset]
if alg != ALG_XCHACHA20_POLY1305:
raise CloudCryptoError(f"unsupported envelope algorithm {alg}")
offset += 1
wrapped_len = int.from_bytes(envelope[offset : offset + _WRAPPED_CK_LEN_BYTES], "big")
offset += _WRAPPED_CK_LEN_BYTES
wrapped_ck = envelope[offset : offset + wrapped_len]
offset += wrapped_len
nonce = envelope[offset : offset + _NONCE_BYTES]
offset += _NONCE_BYTES
ciphertext = envelope[offset:]
if len(wrapped_ck) != wrapped_len or len(nonce) != _NONCE_BYTES or not ciphertext:
raise CloudCryptoError("envelope truncated")
try:
content_key = SecretBox(master_key).decrypt(wrapped_ck)
except nacl.exceptions.CryptoError as err:
raise CloudCryptoError("master key does not unwrap this blob's content key") from err
try:
return nacl.bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(
ciphertext,
_build_aad(object_id, role, version),
nonce,
content_key,
)
except nacl.exceptions.CryptoError as err:
raise CloudCryptoError("blob failed authentication (tampered, or served for the wrong slot)") from err
-63
View File
@@ -1,63 +0,0 @@
"""
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)
-535
View File
@@ -1,535 +0,0 @@
"""
The cloud sync engine: encrypted backup + multi-device restore.
Walks the local store (SQLite rows + audio files), maps each entity onto the
cloud's object model, and drives the push/pull loop against the blind server.
Everything crosses the wire as VBX1 ciphertext (``cloud_crypto``); the server
only ever learns kinds, ids, sizes, and hashes.
Mapping (cloud repo ``docs/DESIGN.md`` §5):
| local entity | kind | record (encrypted JSON) | assets |
| ------------------------------------- | ---------- | ------------------------- | ----------------- |
| ``captures`` row + wav | capture | the row | the capture audio |
| ``generations`` row + version wavs | generation | the row + version rows | each version wav |
| ``profiles`` row + samples + avatar | profile | the row + sample rows | sample wavs, avatar |
| ``capture_settings`` / ``generation_settings`` | settings | the row | — |
Path columns are stored storage-relative inside the (encrypted) record, so a
restore re-anchors them under the destination machine's data dir.
Change detection: envelopes are randomized, so ``CloudSyncState`` keeps the
plaintext fingerprint (did the content actually change?) alongside the
ciphertext hash the server holds (re-declare unchanged blobs without
re-encrypting). Conflicts are last-writer-wins per object, matching §6 —
push runs before pull, so local edits are declared before remote state lands.
AAD binding: records are bound to ``(clientId, "record", version)`` and
re-encrypted on every version bump. Asset blobs are bound to
``(clientId, "asset:<clientAssetId>", 1)`` — assets are content-addressed and
practically immutable (audio never changes in place), so their slot binding
doesn't chase the object version.
"""
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from sqlalchemy.orm import Session
from .. import config
from ..database import (
Capture,
CaptureSettings,
CloudSettings as DBCloudSettings,
CloudSyncState,
Generation,
GenerationSettings,
GenerationVersion,
ProfileSample,
VoiceProfile,
)
from . import cloud_account, cloud_crypto
from .cloud_api import CloudApiClient
logger = logging.getLogger(__name__)
_ASSET_AAD_VERSION = 1
class CloudSyncError(Exception):
"""Sync could not run or an object failed to round-trip."""
# ---------------------------------------------------------------------------
# Local object collection (push side)
@dataclass(frozen=True)
class LocalAsset:
client_asset_id: str
role: str # audio | version | sample | avatar
path: Path
@dataclass(frozen=True)
class LocalObject:
kind: str
client_id: str
record: dict
assets: list[LocalAsset] = field(default_factory=list)
_PATH_COLUMNS = {"audio_path", "avatar_path"}
def _row_to_record(row) -> dict:
"""All mapped columns as JSON-safe values; paths storage-relative,
datetimes ISO-8601."""
record: dict = {}
for column in row.__mapper__.columns:
value = getattr(row, column.key)
if value is None:
record[column.key] = None
elif column.key in _PATH_COLUMNS:
# Rows normally hold data-dir-relative paths already; only rebase
# absolute ones. (to_storage_path on a relative value would resolve
# it against the CWD and corrupt it.)
record[column.key] = config.to_storage_path(value) if Path(value).is_absolute() else value
elif isinstance(value, datetime):
record[column.key] = value.isoformat()
else:
record[column.key] = value
return record
def _is_datetime_column(column) -> bool:
try:
return column.type.python_type is datetime
except NotImplementedError: # e.g. JSON columns don't declare a python_type
return False
def _record_to_row(model, record: dict, existing=None):
"""Build or update a model instance from a record dict."""
row = existing if existing is not None else model()
for column in row.__mapper__.columns:
if column.key not in record:
continue
value = record[column.key]
if isinstance(value, str) and _is_datetime_column(column):
value = datetime.fromisoformat(value)
setattr(row, column.key, value)
return row
def _existing_path(value: str | None) -> Path | None:
resolved = config.resolve_storage_path(value)
return resolved if resolved is not None and resolved.exists() else None
def _collect_captures(db: Session) -> list[LocalObject]:
objects = []
for row in db.query(Capture).all():
assets = []
if (path := _existing_path(row.audio_path)) is not None:
assets.append(LocalAsset(client_asset_id=row.id, role="audio", path=path))
objects.append(LocalObject(kind="capture", client_id=row.id, record=_row_to_record(row), assets=assets))
return objects
def _collect_generations(db: Session) -> list[LocalObject]:
objects = []
for row in db.query(Generation).filter(Generation.status == "completed").all():
record = _row_to_record(row)
assets = []
if (path := _existing_path(row.audio_path)) is not None:
assets.append(LocalAsset(client_asset_id=row.id, role="audio", path=path))
versions = db.query(GenerationVersion).filter(GenerationVersion.generation_id == row.id).all()
record["versions"] = [_row_to_record(v) for v in versions]
for version in versions:
if (path := _existing_path(version.audio_path)) is not None:
assets.append(LocalAsset(client_asset_id=version.id, role="version", path=path))
objects.append(LocalObject(kind="generation", client_id=row.id, record=record, assets=assets))
return objects
def _collect_profiles(db: Session) -> list[LocalObject]:
objects = []
for row in db.query(VoiceProfile).all():
record = _row_to_record(row)
assets = []
if (path := _existing_path(row.avatar_path)) is not None:
assets.append(LocalAsset(client_asset_id=f"{row.id}-avatar", role="avatar", path=path))
samples = db.query(ProfileSample).filter(ProfileSample.profile_id == row.id).all()
record["samples"] = [_row_to_record(s) for s in samples]
for sample in samples:
if (path := _existing_path(sample.audio_path)) is not None:
assets.append(LocalAsset(client_asset_id=sample.id, role="sample", path=path))
objects.append(LocalObject(kind="profile", client_id=row.id, record=record, assets=assets))
return objects
def _collect_settings(db: Session) -> list[LocalObject]:
objects = []
for client_id, model in (("capture_settings", CaptureSettings), ("generation_settings", GenerationSettings)):
row = db.query(model).first()
if row is not None:
objects.append(LocalObject(kind="settings", client_id=client_id, record=_row_to_record(row)))
return objects
def collect_local_objects(db: Session) -> list[LocalObject]:
return _collect_captures(db) + _collect_generations(db) + _collect_profiles(db) + _collect_settings(db)
# ---------------------------------------------------------------------------
# Applying pulled records (pull side)
def _write_asset(record_path_value: str | None, data: bytes) -> None:
resolved = config.resolve_storage_path(record_path_value)
if resolved is None:
return
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.write_bytes(data)
def _apply_children(db: Session, model, parent_filter, child_records: list[dict], blobs: dict[str, bytes]) -> None:
"""Upsert child rows (versions/samples) by id; drop local children the
record no longer contains; write any pulled audio next to them."""
wanted = {child["id"] for child in child_records}
for stale in db.query(model).filter(parent_filter).all():
if stale.id not in wanted:
db.delete(stale)
for child in child_records:
existing = db.query(model).filter(model.id == child["id"]).first()
row = _record_to_row(model, child, existing)
if existing is None:
db.add(row)
if child["id"] in blobs:
_write_asset(child.get("audio_path"), blobs[child["id"]])
def _apply_capture(db: Session, client_id: str, record: dict, blobs: dict[str, bytes]) -> None:
existing = db.query(Capture).filter(Capture.id == client_id).first()
row = _record_to_row(Capture, record, existing)
if existing is None:
db.add(row)
if client_id in blobs:
_write_asset(record.get("audio_path"), blobs[client_id])
def _apply_generation(db: Session, client_id: str, record: dict, blobs: dict[str, bytes]) -> None:
record = dict(record)
versions = record.pop("versions", [])
existing = db.query(Generation).filter(Generation.id == client_id).first()
row = _record_to_row(Generation, record, existing)
if existing is None:
db.add(row)
if client_id in blobs:
_write_asset(record.get("audio_path"), blobs[client_id])
_apply_children(db, GenerationVersion, GenerationVersion.generation_id == client_id, versions, blobs)
def _apply_profile(db: Session, client_id: str, record: dict, blobs: dict[str, bytes]) -> None:
record = dict(record)
samples = record.pop("samples", [])
existing = db.query(VoiceProfile).filter(VoiceProfile.id == client_id).first()
row = _record_to_row(VoiceProfile, record, existing)
if existing is None:
db.add(row)
if f"{client_id}-avatar" in blobs:
_write_asset(record.get("avatar_path"), blobs[f"{client_id}-avatar"])
_apply_children(db, ProfileSample, ProfileSample.profile_id == client_id, samples, blobs)
def _apply_settings(db: Session, client_id: str, record: dict) -> None:
model = CaptureSettings if client_id == "capture_settings" else GenerationSettings
existing = db.query(model).first()
row = _record_to_row(model, record, existing)
if existing is None:
db.add(row)
def _apply_record(db: Session, kind: str, client_id: str, record: dict, blobs: dict[str, bytes]) -> None:
if kind == "capture":
_apply_capture(db, client_id, record, blobs)
elif kind == "generation":
_apply_generation(db, client_id, record, blobs)
elif kind == "profile":
_apply_profile(db, client_id, record, blobs)
elif kind == "settings":
_apply_settings(db, client_id, record)
else:
raise CloudSyncError(f"unknown object kind {kind!r}")
def _delete_local(db: Session, kind: str, client_id: str) -> None:
if kind == "capture":
db.query(Capture).filter(Capture.id == client_id).delete()
elif kind == "generation":
db.query(GenerationVersion).filter(GenerationVersion.generation_id == client_id).delete()
db.query(Generation).filter(Generation.id == client_id).delete()
elif kind == "profile":
db.query(ProfileSample).filter(ProfileSample.profile_id == client_id).delete()
db.query(VoiceProfile).filter(VoiceProfile.id == client_id).delete()
# settings singletons are never deleted
# ---------------------------------------------------------------------------
# The engine
@dataclass
class SyncReport:
pushed: int = 0
pushed_deletes: int = 0
pulled: int = 0
pulled_deletes: int = 0
cursor: int = 0
def _canonical(record: dict) -> bytes:
"""Canonical bytes for change detection. ``updated_at`` is excluded (at the
top level and in embedded child rows): its ``onupdate`` trigger can bump it
as a side effect of *applying* a pulled record, and letting that feed back
into the fingerprint would bounce an already-synced object back and forth.
The field still syncs — it just doesn't count as a change by itself."""
stripped = {k: v for k, v in record.items() if k != "updated_at"}
for key, value in stripped.items():
if isinstance(value, list):
stripped[key] = [
{k: v for k, v in item.items() if k != "updated_at"} if isinstance(item, dict) else item
for item in value
]
return json.dumps(stripped, sort_keys=True, separators=(",", ":")).encode()
def _fingerprint(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _get_state(db: Session, kind: str, client_id: str) -> CloudSyncState | None:
return db.query(CloudSyncState).filter(CloudSyncState.kind == kind, CloudSyncState.client_id == client_id).first()
def _settings_row(db: Session) -> DBCloudSettings:
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == 1).first()
if row is None:
raise CloudSyncError("not connected to Voicebox Cloud")
return row
async def _push_object(
client: CloudApiClient,
db: Session,
master_key: bytes,
obj: LocalObject,
state: CloudSyncState | None,
) -> bool:
"""Push one object if it changed. Returns True when a push happened."""
record_payload = json.dumps(obj.record, sort_keys=True, separators=(",", ":")).encode()
record_fp = _fingerprint(_canonical(obj.record))
known_assets: dict = json.loads(state.assets_json) if state else {}
asset_plain: dict[str, bytes] = {}
asset_fps: dict[str, str] = {}
for asset in obj.assets:
data = asset.path.read_bytes()
asset_plain[asset.client_asset_id] = data
asset_fps[asset.client_asset_id] = _fingerprint(data)
unchanged = (
state is not None
and state.server_object_id is not None
and state.record_fingerprint == record_fp
and {k: v["fingerprint"] for k, v in known_assets.items()} == asset_fps
)
if unchanged:
return False
version = (state.version + 1) if state is not None else 1
record_env = cloud_crypto.encrypt_blob(
record_payload, master_key, object_id=obj.client_id, role="record", version=version
)
descriptors = []
envelopes: dict[str, bytes] = {}
next_assets: dict[str, dict] = {}
for asset in obj.assets:
caid = asset.client_asset_id
known = known_assets.get(caid)
if known and known["fingerprint"] == asset_fps[caid]:
# Content unchanged: re-declare the ciphertext the server holds.
entry = {"role": asset.role, "fingerprint": asset_fps[caid], "hash": known["hash"], "size": known["size"]}
else:
envelope = cloud_crypto.encrypt_blob(
asset_plain[caid],
master_key,
object_id=obj.client_id,
role=f"asset:{caid}",
version=_ASSET_AAD_VERSION,
)
envelopes[caid] = envelope
entry = {
"role": asset.role,
"fingerprint": asset_fps[caid],
"hash": _fingerprint(envelope),
"size": len(envelope),
}
next_assets[caid] = entry
descriptors.append({"role": asset.role, "clientAssetId": caid, "hash": entry["hash"], "size": entry["size"]})
pushed = await client.push_object(
kind=obj.kind,
client_id=obj.client_id,
version=version,
record={"hash": _fingerprint(record_env), "size": len(record_env)},
assets=descriptors,
)
uploads = {u["for"]: u["url"] for u in pushed["uploads"]}
if "record" in uploads:
await client.upload_blob(uploads["record"], record_env)
for caid, envelope in envelopes.items():
url = uploads.get(f"asset:{caid}")
if url:
await client.upload_blob(url, envelope)
await client.commit_object(pushed["objectId"])
if state is None:
state = CloudSyncState(kind=obj.kind, client_id=obj.client_id)
db.add(state)
state.server_object_id = pushed["objectId"]
state.version = version
state.record_fingerprint = record_fp
state.record_hash = _fingerprint(record_env)
state.record_size = len(record_env)
state.assets_json = json.dumps(next_assets)
state.last_synced_at = datetime.utcnow()
return True
async def _push_all(client: CloudApiClient, db: Session, master_key: bytes, report: SyncReport) -> None:
local = collect_local_objects(db)
local_ids = {(o.kind, o.client_id) for o in local}
for obj in local:
if await _push_object(client, db, master_key, obj, _get_state(db, obj.kind, obj.client_id)):
report.pushed += 1
db.commit()
# Local deletions: state rows whose entity no longer exists → tombstone.
for state in db.query(CloudSyncState).all():
if (state.kind, state.client_id) not in local_ids and state.kind != "settings":
if state.server_object_id:
await client.delete_object(state.server_object_id)
db.delete(state)
report.pushed_deletes += 1
db.commit()
async def _pull_changes(client: CloudApiClient, db: Session, master_key: bytes, report: SyncReport) -> None:
settings = _settings_row(db)
cursor = settings.sync_cursor or 0
while True:
page = await client.get_changes(since=cursor)
for change in page["changes"]:
kind, client_id = change["kind"], change["clientId"]
state = _get_state(db, kind, client_id)
if change["deleted"]:
if state is not None:
_delete_local(db, kind, client_id)
db.delete(state)
report.pulled_deletes += 1
# Our own pushes echo back through the feed; the stored ciphertext
# hash identifies them as already applied.
elif change["record"] and (state is None or state.record_hash != change["record"]["hash"]):
await _apply_change(client, db, master_key, change, state)
report.pulled += 1
cursor = change["seq"]
settings.sync_cursor = cursor
db.commit()
report.cursor = cursor
if not page["hasMore"]:
break
async def _apply_change(
client: CloudApiClient,
db: Session,
master_key: bytes,
change: dict,
state: CloudSyncState | None,
) -> None:
kind, client_id = change["kind"], change["clientId"]
record_cipher = await client.download_blob(change["record"]["url"])
record = json.loads(
cloud_crypto.decrypt_blob(
record_cipher, master_key, object_id=client_id, role="record", version=change["version"]
)
)
known_assets: dict = json.loads(state.assets_json) if state else {}
blobs: dict[str, bytes] = {}
next_assets: dict[str, dict] = {}
for asset in change["assets"]:
caid = asset["clientAssetId"]
known = known_assets.get(caid)
if known and known["hash"] == asset["hash"]:
next_assets[caid] = known
continue # ciphertext we already hold locally
if not asset["url"]:
continue # declared but never uploaded; skip until it lands
cipher = await client.download_blob(asset["url"])
plain = cloud_crypto.decrypt_blob(
cipher, master_key, object_id=client_id, role=f"asset:{caid}", version=_ASSET_AAD_VERSION
)
blobs[caid] = plain
next_assets[caid] = {
"role": asset["role"],
"fingerprint": _fingerprint(plain),
"hash": asset["hash"],
"size": asset["size"],
}
_apply_record(db, kind, client_id, record, blobs)
if state is None:
state = CloudSyncState(kind=kind, client_id=client_id)
db.add(state)
state.server_object_id = change["id"]
state.version = change["version"]
state.record_fingerprint = _fingerprint(_canonical(record))
state.record_hash = change["record"]["hash"]
state.record_size = change["record"]["size"]
state.assets_json = json.dumps(next_assets)
state.last_synced_at = datetime.utcnow()
async def run_sync(db: Session) -> SyncReport:
"""One full sync: push local changes, then pull and apply remote ones."""
settings = _settings_row(db)
master_key = cloud_account.load_master_key(db)
report = SyncReport()
async with CloudApiClient(config.get_cloud_api_url(), settings.api_key) as client:
await _push_all(client, db, master_key, report)
await _pull_changes(client, db, master_key, report)
logger.info(
"cloud sync: pushed %d (+%d deletes), pulled %d (+%d deletes), cursor %d",
report.pushed,
report.pushed_deletes,
report.pulled,
report.pulled_deletes,
report.cursor,
)
return report
-188
View File
@@ -1,188 +0,0 @@
"""An in-process fake of the voicebox-cloud API for tests.
Implements the surface the desktop sync client uses — devices, the account-key
escrow, the encrypted object store, the sync feed, and blob storage — behind an
httpx.MockTransport, mirroring apps/api in the voicebox-cloud repo. Every
request body is kept in ``seen_bodies`` so tests can assert what the server was
shown (never key material, never plaintext).
"""
import json
import httpx
STORAGE_HOST = "http://cloud.test/__storage/"
class FakeCloud:
def __init__(self):
self.devices: dict[str, dict] = {}
self.account_key: dict | None = None
self.objects: dict[str, dict] = {} # objectId -> row (incl. assets dict)
self.storage: dict[str, bytes] = {} # key -> ciphertext
self.seen_bodies: list[bytes] = []
self._next_device = 0
self._next_object = 0
self._seq = 0
def transport(self) -> httpx.MockTransport:
return httpx.MockTransport(self.handle)
# -- helpers --------------------------------------------------------------
@staticmethod
def _ok(data, status=200):
return httpx.Response(status, json={"ok": True, "data": data})
def _seq_next(self) -> int:
self._seq += 1
return self._seq
def _find_object(self, kind: str, client_id: str) -> dict | None:
return next((o for o in self.objects.values() if o["kind"] == kind and o["clientId"] == client_id), None)
# -- request routing --------------------------------------------------------
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.startswith("/__storage/"):
key = path[len("/__storage/") :]
if method == "PUT":
self.storage[key] = request.content
return httpx.Response(200)
data = self.storage.get(key)
return httpx.Response(200, content=data) if data is not None else httpx.Response(404)
if path == "/v1/devices" and method == "POST":
return self._register_device(json.loads(request.content))
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.startswith("/v1/devices/") and path.endswith("/wrapped-key"):
device_id = path.split("/")[3]
if method == "POST":
self.devices[device_id]["wrappedMasterKey"] = json.loads(request.content)["wrappedMasterKey"]
return self._ok(None)
return self._ok({"wrappedMasterKey": self.devices[device_id]["wrappedMasterKey"]})
if path == "/v1/objects" and method == "POST":
return self._upsert_object(json.loads(request.content))
if path.startswith("/v1/objects/") and path.endswith("/commit"):
return self._commit(path.split("/")[3])
if path.startswith("/v1/objects/") and method == "DELETE":
obj = self.objects.get(path.split("/")[3])
if obj:
obj["deleted"] = True
obj["seq"] = self._seq_next()
return self._ok(None)
if path == "/v1/sync/changes" and method == "GET":
return self._changes(request.url.params)
return httpx.Response(404, json={"ok": False, "error": {"message": f"unhandled {method} {path}"}})
# -- endpoint implementations ----------------------------------------------
def _register_device(self, body: dict) -> httpx.Response:
self._next_device += 1
device_id = f"dev-{self._next_device}"
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)
def _upsert_object(self, body: dict) -> httpx.Response:
obj = self._find_object(body["kind"], body["clientId"])
if obj is None:
self._next_object += 1
obj = {
"id": f"obj-{self._next_object}",
"kind": body["kind"],
"clientId": body["clientId"],
"version": 0,
"deleted": False,
"record": None,
"assets": {},
}
self.objects[obj["id"]] = obj
obj["version"] = max(obj["version"], body["version"])
obj["seq"] = self._seq_next()
obj["deleted"] = False
uploads = []
record = body.get("record")
if record and (obj["record"] is None or obj["record"]["hash"] != record["hash"]):
key = f"o/{obj['id']}/record"
obj["record"] = {**record, "key": key}
uploads.append({"for": "record", "key": key, "url": STORAGE_HOST + key})
for asset in body.get("assets", []):
caid = asset["clientAssetId"]
existing = obj["assets"].get(caid)
if existing is None or existing["hash"] != asset["hash"]:
key = f"o/{obj['id']}/a/{caid}"
obj["assets"][caid] = {**asset, "key": key}
uploads.append({"for": f"asset:{caid}", "key": key, "url": STORAGE_HOST + key})
return self._ok({"objectId": obj["id"], "seq": obj["seq"], "uploads": uploads}, 201)
def _commit(self, object_id: str) -> httpx.Response:
obj = self.objects.get(object_id)
if obj is None:
return httpx.Response(404, json={"ok": False, "error": {"message": "object not found"}})
missing = []
if obj["record"] and obj["record"]["key"] not in self.storage:
missing.append("record")
for caid, asset in obj["assets"].items():
if asset["key"] not in self.storage:
missing.append(f"asset:{caid}")
if missing:
return httpx.Response(409, json={"ok": False, "error": {"message": f"uploads missing: {missing}"}})
return self._ok({"objectId": object_id, "committed": True})
def _changes(self, params) -> httpx.Response:
since = int(params.get("since", 0))
limit = int(params.get("limit", 200))
rows = sorted((o for o in self.objects.values() if o["seq"] > since), key=lambda o: o["seq"])[:limit]
changes = [
{
"id": o["id"],
"kind": o["kind"],
"clientId": o["clientId"],
"version": o["version"],
"seq": o["seq"],
"deleted": o["deleted"],
"record": (
{
"hash": o["record"]["hash"],
"size": o["record"]["size"],
"url": STORAGE_HOST + o["record"]["key"],
}
if o["record"]
else None
),
"assets": [
{
"clientAssetId": caid,
"role": a["role"],
"hash": a["hash"],
"size": a["size"],
"url": STORAGE_HOST + a["key"] if a["key"] in self.storage else None,
}
for caid, a in o["assets"].items()
],
}
for o in rows
]
cursor = rows[-1]["seq"] if rows else since
return self._ok({"changes": changes, "cursor": cursor, "hasMore": len(rows) == limit})
-174
View File
@@ -1,174 +0,0 @@
"""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
from datetime import datetime
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
from backend.tests.fake_cloud import FakeCloud
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)
@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)
-137
View File
@@ -1,137 +0,0 @@
"""Tests for the cloud E2E crypto primitives (services/cloud_crypto.py)."""
import pytest
from backend.services.cloud_crypto import (
ALG_XCHACHA20_POLY1305,
ENVELOPE_MAGIC,
CloudCryptoError,
RecoveryWrap,
decrypt_blob,
encrypt_blob,
generate_device_keypair,
generate_master_key,
generate_recovery_phrase,
unwrap_master_key_for_device,
unwrap_master_key_with_phrase,
validate_recovery_phrase,
wrap_master_key_for_device,
wrap_master_key_with_phrase,
)
SLOT = {"object_id": "0c8f6f4e-9f5a-4a2f-8f6a-1d2e3f4a5b6c", "role": "audio", "version": 3}
class TestMasterKey:
def test_master_keys_are_random_32_bytes(self):
a, b = generate_master_key(), generate_master_key()
assert len(a) == 32
assert a != b
class TestDeviceWrap:
def test_round_trip(self):
mk = generate_master_key()
private, public = generate_device_keypair()
assert unwrap_master_key_for_device(wrap_master_key_for_device(mk, public), private) == mk
def test_wrong_device_key_fails(self):
mk = generate_master_key()
_, public = generate_device_keypair()
other_private, _ = generate_device_keypair()
with pytest.raises(CloudCryptoError):
unwrap_master_key_for_device(wrap_master_key_for_device(mk, public), other_private)
class TestRecoveryPhrase:
def test_phrase_is_valid_bip39(self):
phrase = generate_recovery_phrase()
assert len(phrase.split()) == 12
assert validate_recovery_phrase(phrase)
def test_typo_fails_checksum(self):
words = generate_recovery_phrase().split()
words[0] = "abandon" if words[0] != "abandon" else "ability"
assert not validate_recovery_phrase(" ".join(words))
def test_round_trip(self):
mk = generate_master_key()
phrase = generate_recovery_phrase()
assert unwrap_master_key_with_phrase(wrap_master_key_with_phrase(mk, phrase), phrase) == mk
def test_normalization_tolerates_case_and_whitespace(self):
mk = generate_master_key()
phrase = generate_recovery_phrase()
wrap = wrap_master_key_with_phrase(mk, phrase)
sloppy = f" {phrase.upper().replace(' ', ' ')} \n"
assert unwrap_master_key_with_phrase(wrap, sloppy) == mk
def test_wrong_phrase_fails(self):
wrap = wrap_master_key_with_phrase(generate_master_key(), generate_recovery_phrase())
with pytest.raises(CloudCryptoError):
unwrap_master_key_with_phrase(wrap, generate_recovery_phrase())
def test_malformed_kdf_params_fail(self):
wrap = wrap_master_key_with_phrase(generate_master_key(), generate_recovery_phrase())
broken = RecoveryWrap(wrapped_key=wrap.wrapped_key, kdf_salt=wrap.kdf_salt, kdf_params="not json")
with pytest.raises(CloudCryptoError):
unwrap_master_key_with_phrase(broken, generate_recovery_phrase())
class TestEnvelope:
def test_round_trip(self):
mk = generate_master_key()
plaintext = b"capture transcript \xf0\x9f\x8e\x99 and some audio bytes" * 100
envelope = encrypt_blob(plaintext, mk, **SLOT)
assert envelope[:4] == ENVELOPE_MAGIC
assert envelope[4] == ALG_XCHACHA20_POLY1305
assert decrypt_blob(envelope, mk, **SLOT) == plaintext
def test_fresh_content_key_per_blob(self):
mk = generate_master_key()
assert encrypt_blob(b"same", mk, **SLOT) != encrypt_blob(b"same", mk, **SLOT)
def test_wrong_master_key_fails(self):
envelope = encrypt_blob(b"secret", generate_master_key(), **SLOT)
with pytest.raises(CloudCryptoError):
decrypt_blob(envelope, generate_master_key(), **SLOT)
@pytest.mark.parametrize(
"slot",
[
{**SLOT, "object_id": "11111111-2222-3333-4444-555555555555"},
{**SLOT, "role": "avatar"},
{**SLOT, "version": 4},
],
)
def test_wrong_slot_fails_aad(self, slot):
mk = generate_master_key()
envelope = encrypt_blob(b"secret", mk, **SLOT)
with pytest.raises(CloudCryptoError):
decrypt_blob(envelope, mk, **slot)
def test_tampered_ciphertext_fails(self):
mk = generate_master_key()
envelope = bytearray(encrypt_blob(b"secret", mk, **SLOT))
envelope[-1] ^= 0x01
with pytest.raises(CloudCryptoError):
decrypt_blob(bytes(envelope), mk, **SLOT)
def test_bad_magic_and_truncation_fail(self):
mk = generate_master_key()
envelope = encrypt_blob(b"secret", mk, **SLOT)
with pytest.raises(CloudCryptoError):
decrypt_blob(b"NOPE" + envelope[4:], mk, **SLOT)
with pytest.raises(CloudCryptoError):
decrypt_blob(envelope[:20], mk, **SLOT)
def test_unknown_algorithm_fails(self):
mk = generate_master_key()
envelope = bytearray(encrypt_blob(b"secret", mk, **SLOT))
envelope[4] = 99
with pytest.raises(CloudCryptoError):
decrypt_blob(bytes(envelope), mk, **SLOT)
def test_empty_plaintext_round_trips(self):
mk = generate_master_key()
assert decrypt_blob(encrypt_blob(b"", mk, **SLOT), mk, **SLOT) == b""
@@ -1,104 +0,0 @@
"""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
-278
View File
@@ -1,278 +0,0 @@
"""Tests for the cloud sync engine (services/cloud_sync.py).
Simulates two installs ("machines") of the desktop app — each with its own
SQLite database, data directory, and keychain — syncing through the in-process
FakeCloud. Covers the full backup → restore path, incremental pushes, deletes,
and the blindness invariant (nothing plaintext ever lands in server storage).
"""
from datetime import datetime
import keyring
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backend import config
from backend.database.models import (
Base,
Capture,
CaptureSettings,
CloudSettings,
Generation,
GenerationVersion,
ProfileSample,
VoiceProfile,
)
from backend.services import cloud_account, cloud_sync
from backend.services.cloud_api import CloudApiClient
from backend.tests.fake_cloud import FakeCloud
AUDIO_A = b"RIFFfake-capture-audio" + b"\x11" * 4000
AUDIO_B = b"RIFFfake-generation-audio" + b"\x22" * 4000
AUDIO_C = b"RIFFfake-version-audio" + b"\x33" * 4000
AUDIO_D = b"RIFFfake-sample-audio" + b"\x44" * 4000
AVATAR = b"\x89PNGfake-avatar" + b"\x55" * 500
class Install:
"""One simulated machine: its own DB, data dir, and keychain store."""
def __init__(self, root, name: str):
self.data_dir = root / name
self.data_dir.mkdir()
self.keychain: dict[tuple[str, str], str] = {}
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
self.db = sessionmaker(bind=engine)()
self.db.add(
CloudSettings(
id=1,
api_key=f"voicebox_{name}",
device_name=name,
account_user_id="user-1",
connected_at=datetime(2026, 7, 1),
)
)
self.db.commit()
def activate(self, monkeypatch):
"""Point global config + keychain at this machine."""
config.set_data_dir(self.data_dir)
store = self.keychain
monkeypatch.setattr(keyring, "get_password", lambda s, u: store.get((s, u)))
monkeypatch.setattr(keyring, "set_password", lambda s, u, p: store.__setitem__((s, u), p))
monkeypatch.setattr(keyring, "delete_password", lambda s, u: store.pop((s, u), None))
def write_file(self, relative: str, data: bytes) -> str:
path = self.data_dir / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return str(path)
@pytest.fixture
def cloud(monkeypatch):
fake = FakeCloud()
monkeypatch.setattr(
cloud_sync,
"CloudApiClient",
lambda url, key: CloudApiClient(url, key, transport=fake.transport()),
)
monkeypatch.setattr(
cloud_account,
"_client",
lambda row: CloudApiClient("http://cloud.test", row.api_key, transport=fake.transport()),
)
return fake
def seed_content(install: Install) -> None:
db = install.db
profile = VoiceProfile(
id="prof-1",
name="Morgan",
description="test voice",
language="en",
avatar_path=install.write_file("profiles/prof-1/avatar.png", AVATAR),
personality="dry wit",
)
db.add(profile)
db.add(
ProfileSample(
id="samp-1",
profile_id="prof-1",
audio_path=install.write_file("profiles/prof-1/samples/samp-1.wav", AUDIO_D),
reference_text="hello there",
)
)
db.add(
Capture(
id="cap-1",
audio_path=install.write_file("captures/cap-1.wav", AUDIO_A),
source="dictation",
language="en",
transcript_raw="the raw transcript",
transcript_refined="the refined transcript",
)
)
db.add(
Generation(
id="gen-1",
profile_id="prof-1",
text="hello world",
audio_path=install.write_file("generations/gen-1.wav", AUDIO_B),
status="completed",
source="manual",
)
)
db.add(
GenerationVersion(
id="ver-1",
generation_id="gen-1",
label="Take 2",
audio_path=install.write_file("generations/ver-1.wav", AUDIO_C),
)
)
db.add(CaptureSettings(id=1, stt_model="turbo", language="auto"))
db.commit()
async def connect(install: Install, monkeypatch, phrase: str | None = None) -> str | None:
install.activate(monkeypatch)
result = await cloud_account.setup_device(install.db)
if phrase is not None:
await cloud_account.restore_with_phrase(install.db, phrase)
return result
class TestSyncEngine:
async def test_backup_then_restore_on_second_machine(self, cloud, tmp_path, monkeypatch):
a = Install(tmp_path, "machine-a")
phrase = await connect(a, monkeypatch)
seed_content(a)
report = await cloud_sync.run_sync(a.db)
assert report.pushed == 4 # capture, generation, profile, capture_settings
assert report.pulled == 0 # own echoes are recognized by ciphertext hash
# Server blindness: every stored blob is a VBX1 envelope, no plaintext.
assert cloud.storage
for blob in cloud.storage.values():
assert blob[:4] == b"VBX1"
assert b"transcript" not in blob
assert AUDIO_A not in blob
for body in cloud.seen_bodies:
assert b"the raw transcript" not in body
assert b"Morgan" not in body
# Fresh machine: restore identity from phrase, then pull everything.
b = Install(tmp_path, "machine-b")
await connect(b, monkeypatch, phrase=phrase)
report_b = await cloud_sync.run_sync(b.db)
assert report_b.pulled == report.pushed
cap = b.db.query(Capture).one()
assert cap.id == "cap-1"
assert cap.transcript_raw == "the raw transcript"
assert (b.data_dir / "captures/cap-1.wav").read_bytes() == AUDIO_A
prof = b.db.query(VoiceProfile).one()
assert prof.name == "Morgan"
assert prof.personality == "dry wit"
assert (b.data_dir / "profiles/prof-1/avatar.png").read_bytes() == AVATAR
samp = b.db.query(ProfileSample).one()
assert samp.reference_text == "hello there"
assert (b.data_dir / "profiles/prof-1/samples/samp-1.wav").read_bytes() == AUDIO_D
gen = b.db.query(Generation).one()
assert gen.text == "hello world"
assert (b.data_dir / "generations/gen-1.wav").read_bytes() == AUDIO_B
ver = b.db.query(GenerationVersion).one()
assert ver.label == "Take 2"
assert (b.data_dir / "generations/ver-1.wav").read_bytes() == AUDIO_C
settings = b.db.query(CaptureSettings).one()
assert settings.stt_model == "turbo"
# Second sync on B is a no-op in both directions.
report_b2 = await cloud_sync.run_sync(b.db)
assert (report_b2.pushed, report_b2.pulled) == (0, 0)
async def test_incremental_edit_propagates(self, cloud, tmp_path, monkeypatch):
a = Install(tmp_path, "machine-a")
phrase = await connect(a, monkeypatch)
seed_content(a)
await cloud_sync.run_sync(a.db)
b = Install(tmp_path, "machine-b")
await connect(b, monkeypatch, phrase=phrase)
await cloud_sync.run_sync(b.db)
# Edit on A: only the capture should push, and only its record blob
# should re-upload (the audio is unchanged).
a.activate(monkeypatch)
blobs_before = dict(cloud.storage)
cap = a.db.query(Capture).one()
cap.transcript_refined = "edited on machine A"
a.db.commit()
report_a = await cloud_sync.run_sync(a.db)
assert report_a.pushed == 1
changed_keys = [k for k, v in cloud.storage.items() if blobs_before.get(k) != v]
assert changed_keys == [k for k in changed_keys if k.endswith("/record")]
b.activate(monkeypatch)
report_b = await cloud_sync.run_sync(b.db)
assert report_b.pulled == 1
assert b.db.query(Capture).one().transcript_refined == "edited on machine A"
async def test_delete_propagates(self, cloud, tmp_path, monkeypatch):
a = Install(tmp_path, "machine-a")
phrase = await connect(a, monkeypatch)
seed_content(a)
await cloud_sync.run_sync(a.db)
b = Install(tmp_path, "machine-b")
await connect(b, monkeypatch, phrase=phrase)
await cloud_sync.run_sync(b.db)
a.activate(monkeypatch)
cap = a.db.query(Capture).one()
a.db.delete(cap)
a.db.commit()
report_a = await cloud_sync.run_sync(a.db)
assert report_a.pushed_deletes == 1
b.activate(monkeypatch)
report_b = await cloud_sync.run_sync(b.db)
assert report_b.pulled_deletes == 1
assert b.db.query(Capture).count() == 0
async def test_last_writer_wins_on_conflict(self, cloud, tmp_path, monkeypatch):
a = Install(tmp_path, "machine-a")
phrase = await connect(a, monkeypatch)
seed_content(a)
await cloud_sync.run_sync(a.db)
b = Install(tmp_path, "machine-b")
await connect(b, monkeypatch, phrase=phrase)
await cloud_sync.run_sync(b.db)
# Concurrent edits to the same capture on both machines.
a.activate(monkeypatch)
a.db.query(Capture).one().transcript_refined = "A's edit"
a.db.commit()
await cloud_sync.run_sync(a.db)
b.activate(monkeypatch)
b.db.query(Capture).one().transcript_refined = "B's edit"
b.db.commit()
await cloud_sync.run_sync(b.db) # B pushes after A: B is the last writer
a.activate(monkeypatch)
await cloud_sync.run_sync(a.db)
assert a.db.query(Capture).one().transcript_refined == "B's edit"
b.activate(monkeypatch)
await cloud_sync.run_sync(b.db)
assert b.db.query(Capture).one().transcript_refined == "B's edit"
-5
View File
@@ -57,7 +57,6 @@
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"react-i18next": "^17.0.4",
"react-qr-code": "^2.0.18",
"react-sound-visualizer": "^1.4.0",
"tailwind-merge": "^2.5.4",
"wavesurfer.js": "^7.0.0",
@@ -1006,8 +1005,6 @@
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qr.js": ["[email protected]", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="],
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
@@ -1022,8 +1019,6 @@
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
"react-qr-code": ["[email protected]", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="],
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],