484 lines
18 KiB
Rust
484 lines
18 KiB
Rust
use std::{
|
|
io::{self, Read},
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use serde::de::DeserializeOwned;
|
|
use ureq::{Agent, AgentBuilder};
|
|
|
|
use crate::{
|
|
SnapshotHeadRef,
|
|
auth::BearerToken,
|
|
device::{DeviceIdentity, DeviceListResponse, DeviceRegistration},
|
|
device_api::{
|
|
DeviceApprovalDocument, DeviceApprovalRequest, DeviceRebindChallengeDocument,
|
|
DeviceRebindChallengeRequest, DeviceRebindDocument,
|
|
},
|
|
device_revocation::{DeviceRevocationDocument, DeviceRevocationRequest},
|
|
error::SyncClientError,
|
|
snapshot::{MAX_SNAPSHOT_BYTES, SnapshotDownload, SnapshotUploadRequest},
|
|
vault::SyncVaultDocument,
|
|
vault_bootstrap::SyncVaultBootstrapRequest,
|
|
};
|
|
|
|
mod session;
|
|
|
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
const USER_AGENT: &str = concat!("ELY Browser/", env!("CARGO_PKG_VERSION"));
|
|
|
|
/// Resolved configuration for talking to a worker deployment.
|
|
#[derive(Clone, Debug)]
|
|
pub struct ApiClientConfig {
|
|
base_url: String,
|
|
region: String,
|
|
}
|
|
|
|
impl ApiClientConfig {
|
|
/// Build the config used by the production deployment. The base URL
|
|
/// matches `wrangler.toml`'s `ELY_AUTH_BASE_URL`.
|
|
pub fn production() -> Self {
|
|
Self {
|
|
base_url: "https://ely-browser-cloud.zhangyanghaha0407.workers.dev".to_string(),
|
|
region: "auto".to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn custom(base_url: impl Into<String>, region: impl Into<String>) -> Self {
|
|
Self { base_url: base_url.into(), region: region.into() }
|
|
}
|
|
|
|
pub fn base_url(&self) -> &str {
|
|
&self.base_url
|
|
}
|
|
|
|
pub fn region(&self) -> &str {
|
|
&self.region
|
|
}
|
|
}
|
|
|
|
/// Bearer-token authenticated HTTP client targeting `ely-browser-cloud`.
|
|
pub struct SyncApiClient {
|
|
agent: Agent,
|
|
config: ApiClientConfig,
|
|
bearer: BearerToken,
|
|
}
|
|
|
|
impl SyncApiClient {
|
|
pub fn new(config: ApiClientConfig, bearer: BearerToken) -> Result<Self, SyncClientError> {
|
|
if !config.base_url.starts_with("https://") && !config.base_url.starts_with("http://") {
|
|
return Err(SyncClientError::InvalidBaseUrl { url: config.base_url.clone() });
|
|
}
|
|
let agent = AgentBuilder::new().timeout(REQUEST_TIMEOUT).user_agent(USER_AGENT).build();
|
|
Ok(Self { agent, config, bearer })
|
|
}
|
|
|
|
pub fn config(&self) -> &ApiClientConfig {
|
|
&self.config
|
|
}
|
|
|
|
/// `POST /api/devices/register` — bind a freshly-generated device
|
|
/// identity to the current session. The response carries the
|
|
/// canonical device record from the worker's `user_devices` table,
|
|
/// including the `approval_status` that gates `/api/sync/*`.
|
|
pub fn register_device(
|
|
&self,
|
|
identity: &DeviceIdentity,
|
|
idempotency_key: &str,
|
|
) -> Result<DeviceRecordDocument, SyncClientError> {
|
|
let registration_proof = identity.registration_proof(idempotency_key)?;
|
|
let registration = DeviceRegistration {
|
|
version: 2,
|
|
device_id: &identity.device_id,
|
|
public_key: &identity.public_key,
|
|
wrapping_public_key: &identity.wrapping_public_key,
|
|
device_name: &identity.device_name,
|
|
platform: &identity.platform,
|
|
idempotency_key,
|
|
registration_proof: ®istration_proof,
|
|
};
|
|
let endpoint = self.endpoint("/api/devices/register");
|
|
let response = self
|
|
.agent
|
|
.post(&endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.set("Content-Type", "application/json")
|
|
.send_json(serde_json::to_value(®istration).map_err(|error| {
|
|
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
|
|
})?);
|
|
let body = read_json_response::<DeviceRecordDocument>(&endpoint, response)?;
|
|
Ok(body)
|
|
}
|
|
|
|
/// `GET /api/devices` — return every device bound to the user,
|
|
/// approved or otherwise. Used by the UI to render the pending
|
|
/// device-approval list.
|
|
pub fn list_devices(&self) -> Result<DeviceListResponse, SyncClientError> {
|
|
let endpoint = self.endpoint("/api/devices");
|
|
let response = self
|
|
.agent
|
|
.get(&endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.call();
|
|
read_json_response::<DeviceListResponse>(&endpoint, response)
|
|
}
|
|
|
|
/// Rebind an existing v2 device to a fresh authenticated session.
|
|
pub fn rebind_device(
|
|
&self,
|
|
identity: &DeviceIdentity,
|
|
) -> Result<DeviceRebindDocument, SyncClientError> {
|
|
let challenge_endpoint = self.endpoint("/api/devices/rebind/challenge");
|
|
let challenge_request =
|
|
DeviceRebindChallengeRequest { version: 1, device_id: &identity.device_id };
|
|
let challenge_response = self
|
|
.agent
|
|
.post(&challenge_endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.set("Content-Type", "application/json")
|
|
.send_json(serde_json::to_value(&challenge_request).map_err(|source| {
|
|
SyncClientError::Json { endpoint: challenge_endpoint.clone(), source }
|
|
})?);
|
|
let challenge = read_json_response::<DeviceRebindChallengeDocument>(
|
|
&challenge_endpoint,
|
|
challenge_response,
|
|
)?;
|
|
let now_seconds = current_time_seconds()?;
|
|
let rebind_request = challenge.signed_request(identity, now_seconds)?;
|
|
let rebind_endpoint = self.endpoint("/api/devices/rebind");
|
|
let rebind_response = self
|
|
.agent
|
|
.post(&rebind_endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.set("Content-Type", "application/json")
|
|
.send_json(serde_json::to_value(&rebind_request).map_err(|source| {
|
|
SyncClientError::Json { endpoint: rebind_endpoint.clone(), source }
|
|
})?);
|
|
let document =
|
|
read_json_response::<DeviceRebindDocument>(&rebind_endpoint, rebind_response)?;
|
|
document.validate(identity, &challenge, current_time_seconds()?)?;
|
|
Ok(document)
|
|
}
|
|
|
|
pub fn approve_device(
|
|
&self,
|
|
request: &DeviceApprovalRequest<'_>,
|
|
) -> Result<DeviceApprovalDocument, SyncClientError> {
|
|
let endpoint = self.endpoint("/api/devices/approve");
|
|
let response =
|
|
self.agent
|
|
.post(&endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.set("Content-Type", "application/json")
|
|
.send_json(serde_json::to_value(request).map_err(|source| {
|
|
SyncClientError::Json { endpoint: endpoint.clone(), source }
|
|
})?);
|
|
read_json_response::<DeviceApprovalDocument>(&endpoint, response)
|
|
}
|
|
|
|
pub fn revoke_device(
|
|
&self,
|
|
request: &DeviceRevocationRequest,
|
|
) -> Result<DeviceRevocationDocument, SyncClientError> {
|
|
let endpoint = self.endpoint("/api/devices/revoke");
|
|
let response =
|
|
self.agent
|
|
.post(&endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.set("Content-Type", "application/json")
|
|
.send_json(serde_json::to_value(request).map_err(|source| {
|
|
SyncClientError::Json { endpoint: endpoint.clone(), source }
|
|
})?);
|
|
read_json_response::<DeviceRevocationDocument>(&endpoint, response)
|
|
}
|
|
|
|
/// `GET /api/sync/status` — return the worker-side cursor,
|
|
/// object, snapshot, and device summary for the authenticated
|
|
/// approved device.
|
|
pub fn sync_status(&self) -> Result<SyncStatusDocument, SyncClientError> {
|
|
let endpoint = self.endpoint("/api/sync/status");
|
|
let response = self
|
|
.agent
|
|
.get(&endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.call();
|
|
read_json_response::<SyncStatusDocument>(&endpoint, response)
|
|
}
|
|
|
|
pub fn current_sync_vault(&self) -> Result<SyncVaultDocument, SyncClientError> {
|
|
let endpoint = self.endpoint("/api/sync/vault");
|
|
let response = self
|
|
.agent
|
|
.get(&endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.call();
|
|
read_json_response::<SyncVaultDocument>(&endpoint, response)
|
|
}
|
|
|
|
pub fn sync_vault_generation(
|
|
&self,
|
|
generation: u64,
|
|
key_id: &str,
|
|
) -> Result<SyncVaultDocument, SyncClientError> {
|
|
let endpoint =
|
|
self.endpoint(&format!("/api/sync/vault?generation={generation}&key_id={key_id}"));
|
|
let response = self
|
|
.agent
|
|
.get(&endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.call();
|
|
read_json_response::<SyncVaultDocument>(&endpoint, response)
|
|
}
|
|
|
|
pub fn bootstrap_sync_vault(
|
|
&self,
|
|
request: &SyncVaultBootstrapRequest<'_>,
|
|
) -> Result<SyncVaultDocument, SyncClientError> {
|
|
let endpoint = self.endpoint("/api/sync/vault/bootstrap");
|
|
let response =
|
|
self.agent
|
|
.post(&endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.set("Content-Type", "application/json")
|
|
.send_json(serde_json::to_value(request).map_err(|error| {
|
|
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
|
|
})?);
|
|
read_json_response::<SyncVaultDocument>(&endpoint, response)
|
|
}
|
|
|
|
/// `POST /api/sync/snapshot` — push the full per-user state. The
|
|
/// worker enforces logical-clock monotonicity, so callers must
|
|
/// pass a value strictly greater than the last accepted snapshot.
|
|
pub fn upload_snapshot(
|
|
&self,
|
|
request: &SnapshotUploadRequest<'_>,
|
|
) -> Result<SnapshotUploadResult, SyncClientError> {
|
|
let endpoint = self.endpoint("/api/sync/snapshot");
|
|
let response =
|
|
self.agent
|
|
.post(&endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.set("Content-Type", "application/json")
|
|
.send_json(serde_json::to_value(request).map_err(|error| {
|
|
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
|
|
})?);
|
|
match response {
|
|
Ok(response) => read_json_from_response::<SnapshotUploadDocument>(&endpoint, response)
|
|
.map(SnapshotUploadResult::Committed),
|
|
Err(ureq::Error::Status(409, response)) => {
|
|
let conflict = read_json_from_response::<SyncSnapshotHeadConflictDocument>(
|
|
&endpoint, response,
|
|
)?;
|
|
if conflict.version != 1 || conflict.error != "sync_snapshot_head_conflict" {
|
|
return Err(SyncClientError::DeviceTrust {
|
|
reason: "snapshot conflict response is invalid",
|
|
});
|
|
}
|
|
Ok(SnapshotUploadResult::Conflict(conflict))
|
|
}
|
|
Err(error) => read_json_response::<SnapshotUploadDocument>(&endpoint, Err(error))
|
|
.map(SnapshotUploadResult::Committed),
|
|
}
|
|
}
|
|
|
|
/// `GET /api/sync/snapshot?snapshot_id=…` — fetch the snapshot for
|
|
/// the named id. Returns the encoded payload plus the snapshot
|
|
/// metadata; callers should verify the payload hash before trusting
|
|
/// the bytes.
|
|
pub fn download_snapshot(
|
|
&self,
|
|
head: &SnapshotHeadRef,
|
|
) -> Result<SnapshotDownloadResult, SyncClientError> {
|
|
let endpoint = self.endpoint(&format!(
|
|
"/api/sync/snapshot?snapshot_id={}&head_revision={}&payload_hash={}",
|
|
head.snapshot_id(),
|
|
head.revision(),
|
|
head.payload_hash(),
|
|
));
|
|
let response = self
|
|
.agent
|
|
.get(&endpoint)
|
|
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
|
|
.call();
|
|
match response {
|
|
Ok(response) => read_json_from_response::<SnapshotDownload>(&endpoint, response)
|
|
.map(SnapshotDownloadResult::Downloaded),
|
|
Err(ureq::Error::Status(409, response)) => {
|
|
let conflict = read_json_from_response::<SyncSnapshotHeadConflictDocument>(
|
|
&endpoint, response,
|
|
)?;
|
|
if conflict.version != 1 || conflict.error != "sync_snapshot_head_conflict" {
|
|
return Err(SyncClientError::DeviceTrust {
|
|
reason: "snapshot conflict response is invalid",
|
|
});
|
|
}
|
|
Ok(SnapshotDownloadResult::Conflict(conflict))
|
|
}
|
|
Err(error) => read_json_response::<SnapshotDownload>(&endpoint, Err(error))
|
|
.map(SnapshotDownloadResult::Downloaded),
|
|
}
|
|
}
|
|
|
|
fn endpoint(&self, path: &str) -> String {
|
|
format!("{}{}", self.config.base_url.trim_end_matches('/'), path)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
pub struct DeviceRecordDocument {
|
|
pub version: u32,
|
|
pub user_id: String,
|
|
pub device: crate::device::DeviceRecord,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
pub struct SnapshotUploadDocument {
|
|
pub version: u32,
|
|
pub user_id: String,
|
|
pub device_id: String,
|
|
pub snapshot: crate::snapshot::SnapshotDocument,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
pub struct SyncStatusDocument {
|
|
pub version: u32,
|
|
pub user_id: String,
|
|
pub device_id: String,
|
|
pub cursor: SyncCursorStatusDocument,
|
|
pub objects: Vec<SyncObjectStatusDocument>,
|
|
pub snapshots: SyncSnapshotStatusDocument,
|
|
pub devices: SyncDeviceStatusDocument,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
pub struct SyncCursorStatusDocument {
|
|
pub latest_change_id: u64,
|
|
pub total_changes: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
pub struct SyncObjectStatusDocument {
|
|
pub object_type: String,
|
|
pub active_count: u64,
|
|
pub deleted_count: u64,
|
|
pub latest_logical_clock: u64,
|
|
pub latest_updated_at: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
pub struct SyncSnapshotStatusDocument {
|
|
pub total_snapshots: u64,
|
|
pub head: Option<SyncLatestSnapshotDocument>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
pub struct SyncLatestSnapshotDocument {
|
|
pub head_revision: u64,
|
|
pub base_head: Option<SnapshotHeadRef>,
|
|
pub snapshot_id: String,
|
|
pub payload_hash: String,
|
|
pub encryption_version: u32,
|
|
pub vault_generation: u64,
|
|
pub key_id: String,
|
|
pub content_hash: String,
|
|
pub logical_clock: u64,
|
|
pub device_id: String,
|
|
pub size_bytes: u64,
|
|
pub created_at: u64,
|
|
}
|
|
|
|
impl SyncLatestSnapshotDocument {
|
|
pub fn head_ref(&self) -> Result<SnapshotHeadRef, SyncClientError> {
|
|
SnapshotHeadRef::new(
|
|
self.head_revision,
|
|
self.snapshot_id.clone(),
|
|
self.payload_hash.clone(),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum SnapshotUploadResult {
|
|
Committed(SnapshotUploadDocument),
|
|
Conflict(SyncSnapshotHeadConflictDocument),
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum SnapshotDownloadResult {
|
|
Downloaded(SnapshotDownload),
|
|
Conflict(SyncSnapshotHeadConflictDocument),
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SyncSnapshotHeadConflictDocument {
|
|
pub version: u32,
|
|
pub error: String,
|
|
pub current_head: Option<SyncLatestSnapshotDocument>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
pub struct SyncDeviceStatusDocument {
|
|
pub approved_count: u64,
|
|
pub current_device_id: String,
|
|
pub current_device_approved: bool,
|
|
}
|
|
|
|
/// A snapshot download legally carries `MAX_SNAPSHOT_BYTES` of payload as
|
|
/// base64 (4/3 expansion) plus its JSON envelope, which exceeds ureq's
|
|
/// 10 MiB `into_string` cap. Anything above this bound fails closed.
|
|
const MAX_RESPONSE_BODY_BYTES: usize = MAX_SNAPSHOT_BYTES.div_ceil(3) * 4 + 64 * 1024;
|
|
|
|
fn read_response_body(response: ureq::Response) -> io::Result<String> {
|
|
let mut body = String::new();
|
|
response.into_reader().take(MAX_RESPONSE_BODY_BYTES as u64 + 1).read_to_string(&mut body)?;
|
|
if body.len() > MAX_RESPONSE_BODY_BYTES {
|
|
return Err(io::Error::other("response body exceeds the sync wire limit"));
|
|
}
|
|
Ok(body)
|
|
}
|
|
|
|
fn read_json_response<T: DeserializeOwned>(
|
|
endpoint: &str,
|
|
response: Result<ureq::Response, ureq::Error>,
|
|
) -> Result<T, SyncClientError> {
|
|
match response {
|
|
Ok(ok) => read_json_from_response(endpoint, ok),
|
|
Err(ureq::Error::Status(status, raw)) => {
|
|
let body = read_response_body(raw).unwrap_or_default();
|
|
if session::response_ends_session(status, &body) {
|
|
return Err(SyncClientError::SessionEnded);
|
|
}
|
|
Err(SyncClientError::HttpStatus { endpoint: endpoint.to_string(), status, body })
|
|
}
|
|
Err(other) => {
|
|
Err(SyncClientError::Http { endpoint: endpoint.to_string(), source: Box::new(other) })
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_json_from_response<T: DeserializeOwned>(
|
|
endpoint: &str,
|
|
response: ureq::Response,
|
|
) -> Result<T, SyncClientError> {
|
|
let status = response.status();
|
|
let body = read_response_body(response).map_err(|error| SyncClientError::HttpStatus {
|
|
endpoint: endpoint.to_string(),
|
|
status,
|
|
body: error.to_string(),
|
|
})?;
|
|
serde_json::from_str::<T>(&body)
|
|
.map_err(|source| SyncClientError::Json { endpoint: endpoint.to_string(), source })
|
|
}
|
|
|
|
fn current_time_seconds() -> Result<u64, SyncClientError> {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_secs())
|
|
.map_err(|_| SyncClientError::DeviceTrust { reason: "system clock is invalid" })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "client_tests.rs"]
|
|
mod tests;
|