diff --git a/Cargo.lock b/Cargo.lock index 256270d..e5a6c65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2534,6 +2534,7 @@ dependencies = [ "ely_sync_client", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "url", "uuid", diff --git a/PRD.md b/PRD.md index ad0992c..59ea753 100644 --- a/PRD.md +++ b/PRD.md @@ -963,6 +963,7 @@ Better Auth 在 Cloudflare Workers 中初始化,D1 binding 作为 database 传 - Desktop bearer 以 stable `ProfileId` 作为系统凭据 account,Windows 使用 Local persistence;旧明文文件在 credential read-back 与 durable marker 提交后清理,系统凭据不可用时阻断设备加载与 Sync upload。 - Desktop sign-out closes the authenticated-operation gate, drains active leases, revokes the exact server session, and conditionally clears the captured native credential; generation-stamped async results cannot restore stale auth or Sync state. - Runtime `session_not_found` and `session_expired` responses conditionally clear the exact captured bearer inside `SyncEngine`; replacement credentials survive, stale Profile work converges through credential reprobe, and active Cloud Sync/device work resets before session-state reconciliation. +- One immutable Ely `user_id` owns the complete browser data root and its multi-Profile encrypted snapshot. Interactive OTP sign-in claims an unowned root after generation validation; every background device or Sync operation verifies the owner through `GET /api/devices` before device registration or cloud data access. - 设备注册、rebind、批准、撤销与 Vault rotation。 - Signed Sync reset 和 signed account deletion。 - 管理所有 `/api/auth/*` 路由。 diff --git a/cloudflare/tests/devices_routes.test.ts b/cloudflare/tests/devices_routes.test.ts index 23980b8..d52ad41 100644 --- a/cloudflare/tests/devices_routes.test.ts +++ b/cloudflare/tests/devices_routes.test.ts @@ -102,6 +102,30 @@ describe("device routes", () => { ]); }); + it("returns identity for an unbound session before device registration", async () => { + const d1 = testD1Database({ + sessionRow: { + id: "session-01", + userId: "user-01", + expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + deviceId: null, + }, + allRows: [], + }); + const response = await handleRequest( + new Request("https://elydora.test/api/devices", { + headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, + }), + testEnv({ d1 }), + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { version: 1, user_id: "user-01", devices: [] }); + assert.deepEqual(d1.binds, [["user-01"]]); + assert.deepEqual(d1.batches, []); + }); + it("rejects unauthenticated device list requests before D1 reads", async () => { const d1 = testD1Database([]); const response = await handleRequest( diff --git a/crates/ely_app/src/shell/auth.rs b/crates/ely_app/src/shell/auth.rs index 9315239..87cc748 100644 --- a/crates/ely_app/src/shell/auth.rs +++ b/crates/ely_app/src/shell/auth.rs @@ -11,8 +11,8 @@ use std::path::Path; use ely_domain::ProfileId; use ely_sync_client::{ - ApiClientConfig, BearerToken, BearerTokenStore, SyncApiClient, SyncClientError, send_email_otp, - verify_email_otp, + ApiClientConfig, BearerToken, BearerTokenStore, SyncApiClient, SyncClientError, SyncOwnerStore, + send_email_otp, verify_email_otp, }; use gpui::Context; @@ -386,7 +386,21 @@ fn spawn_verify_otp(profile_id: ProfileId, email: String, otp: String, tx: SyncS return; } }; - let update = SyncStateUpdate::AuthVerified { profile_id, email, token }; + let user_id = match SyncApiClient::new(config, token.clone()) + .and_then(|client| client.authenticated_user_id()) + { + Ok(user_id) => user_id, + Err(error) => { + retire_bearer(token); + let _ = tx.send(SyncStateUpdate::AuthError { + profile_id, + email, + message: error.to_string(), + }); + return; + } + }; + let update = SyncStateUpdate::AuthVerified { profile_id, email, user_id, token }; if let Err(error) = tx.send(update) { retire_stale_auth_update(error.0.update); } @@ -407,18 +421,20 @@ pub(super) fn retire_stale_auth_update(update: SyncStateUpdate) { }; std::thread::Builder::new() .name("ely-sync-auth-retire".to_string()) - .spawn(move || { - let client = SyncApiClient::new(ApiClientConfig::production(), token); - if let Ok(client) = client { - let _ = client.sign_out(); - } - }) + .spawn(move || retire_bearer(token)) .map(|_| ()) .unwrap_or_else(|error| { tracing::warn!(target: "ely::sync", error = %error, "spawn auth retire failed"); }); } +fn retire_bearer(token: BearerToken) { + let client = SyncApiClient::new(ApiClientConfig::production(), token); + if let Ok(client) = client { + let _ = client.sign_out(); + } +} + fn verified_bearer_from(update: SyncStateUpdate) -> Option { match update { SyncStateUpdate::AuthVerified { token, .. } => Some(token), @@ -428,17 +444,28 @@ fn verified_bearer_from(update: SyncStateUpdate) -> Option { pub(super) fn save_verified_bearer( profile_id: &ProfileId, + user_id: &str, token: &BearerToken, default_profile_id: Option<&ProfileId>, ) -> Result<(), SyncClientError> { let profile_root = default_profile_data_root().ok_or_else(|| { SyncClientError::BearerCredentialStorage("profile data root is unavailable".to_string()) })?; + SyncOwnerStore::new(&profile_root).claim(user_id)?; let profile_dir = sync_profile_data_dir(&profile_root, profile_id); bearer_store_for_profile(profile_id, &profile_dir, &profile_root, default_profile_id) .save(token) } +pub(super) fn verified_session_persistence_message(error: &SyncClientError) -> String { + match error { + SyncClientError::SyncOwnerMismatch + | SyncClientError::SyncOwnerUnclaimed + | SyncClientError::SyncOwnerStorage(_) => error.to_string(), + _ => "System credential access failed.".to_string(), + } +} + #[cfg(test)] #[path = "auth_tests.rs"] mod tests; diff --git a/crates/ely_app/src/shell/auth_tests.rs b/crates/ely_app/src/shell/auth_tests.rs index de6e2bf..95afc1f 100644 --- a/crates/ely_app/src/shell/auth_tests.rs +++ b/crates/ely_app/src/shell/auth_tests.rs @@ -4,7 +4,7 @@ use ely_domain::ProfileId; use super::super::{ShellState, sync_state::SyncStateUpdate}; use super::{ AuthFlowPhase, active_profile_sync_context_for, bearer_store_for_profile, normalize_email, - verified_bearer_from, + verified_bearer_from, verified_session_persistence_message, }; #[test] @@ -47,6 +47,7 @@ fn stale_verified_updates_retain_the_token_for_server_retirement() let update = SyncStateUpdate::AuthVerified { profile_id, email: "user@example.com".to_string(), + user_id: "user-01".to_string(), token: token.clone(), }; @@ -54,6 +55,14 @@ fn stale_verified_updates_retain_the_token_for_server_retirement() Ok(()) } +#[test] +fn owner_mismatch_is_actionable_in_the_account_form() { + assert_eq!( + verified_session_persistence_message(&ely_sync_client::SyncClientError::SyncOwnerMismatch), + "This browser data belongs to a different Ely account" + ); +} + #[test] fn private_profile_has_no_sync_auth_context() -> Result<(), Box> { let state = diff --git a/crates/ely_app/src/shell/sync_state.rs b/crates/ely_app/src/shell/sync_state.rs index dd9ac44..e96227e 100644 --- a/crates/ely_app/src/shell/sync_state.rs +++ b/crates/ely_app/src/shell/sync_state.rs @@ -45,7 +45,7 @@ pub(crate) enum SyncStateUpdate { SignOutSucceeded { profile_id: ProfileId }, SignOutFailed { profile_id: ProfileId, message: String }, AuthOtpSent { profile_id: ProfileId, email: String }, - AuthVerified { profile_id: ProfileId, email: String, token: BearerToken }, + AuthVerified { profile_id: ProfileId, email: String, user_id: String, token: BearerToken }, AuthError { profile_id: ProfileId, email: String, message: String }, } @@ -321,7 +321,7 @@ impl ElyShell { self.release_auth_flow_barrier(); } } - SyncStateUpdate::AuthVerified { profile_id, email, token } => { + SyncStateUpdate::AuthVerified { profile_id, email, user_id, token } => { let attempt_matches = active_profile_id(&self.state).as_ref() == Some(&profile_id) && matches!( @@ -336,12 +336,14 @@ impl ElyShell { auth::retire_stale_auth_update(SyncStateUpdate::AuthVerified { profile_id, email, + user_id, token, }); continue; } match auth::save_verified_bearer( &profile_id, + &user_id, &token, self.default_profile_id.as_ref(), ) { @@ -357,9 +359,10 @@ impl ElyShell { auth::retire_stale_auth_update(SyncStateUpdate::AuthVerified { profile_id: profile_id.clone(), email: email.clone(), + user_id, token, }); - let message = "System credential access failed.".to_string(); + let message = auth::verified_session_persistence_message(&error); self.auth_flow_phase = auth::AuthFlowPhase::Error { profile_id, email, diff --git a/crates/ely_browser_core/Cargo.toml b/crates/ely_browser_core/Cargo.toml index 3e661be..2a16cdf 100644 --- a/crates/ely_browser_core/Cargo.toml +++ b/crates/ely_browser_core/Cargo.toml @@ -14,5 +14,8 @@ thiserror.workspace = true url.workspace = true uuid.workspace = true +[dev-dependencies] +tempfile.workspace = true + [lints] workspace = true diff --git a/crates/ely_browser_core/src/sync_engine.rs b/crates/ely_browser_core/src/sync_engine.rs index 3fb656f..4ce1308 100644 --- a/crates/ely_browser_core/src/sync_engine.rs +++ b/crates/ely_browser_core/src/sync_engine.rs @@ -8,7 +8,7 @@ use ely_sync_client::{ AccountKey, ApiClientConfig, AuthenticatedSnapshotHead, BearerToken, BearerTokenStore, DeviceIdentity, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, SnapshotDownloadResult, SnapshotPayload, SnapshotUploadRequest, SnapshotUploadResult, SyncApiClient, SyncClientError, - SyncLatestSnapshotDocument, + SyncLatestSnapshotDocument, SyncOwnerStore, }; use crate::state::BrowserCore; @@ -27,6 +27,7 @@ pub struct SyncEngine { api_config: ApiClientConfig, bearer_store: BearerTokenStore, account_key_lock_dir: PathBuf, + owner_store: SyncOwnerStore, identity: DeviceIdentity, last_outcome: Option, } @@ -41,12 +42,10 @@ impl SyncEngine { device_name: impl Into, platform: impl Into, ) -> Result { + let browser_data_root = browser_data_root(profile_id, profile_data_dir)?; let sync_dir = profile_data_dir.join("sync"); - let account_key_lock_dir = profile_data_dir - .parent() - .and_then(Path::parent) - .unwrap_or(profile_data_dir) - .join(".sync-key-locks"); + let account_key_lock_dir = browser_data_root.join(".sync-key-locks"); + let owner_store = SyncOwnerStore::new(browser_data_root); let identity = DeviceIdentity::load_or_create(&sync_dir.join("device.json"), device_name, platform)?; let bearer_store = BearerTokenStore::new(profile_id, profile_data_dir); @@ -54,6 +53,7 @@ impl SyncEngine { api_config: ApiClientConfig::production(), bearer_store, account_key_lock_dir, + owner_store, identity, last_outcome: None, }) @@ -107,6 +107,8 @@ impl SyncEngine { client: SyncApiClient, ) -> Result<(SyncApiClient, ely_sync_client::client::DeviceRecordDocument), SyncClientError> { + let authenticated_user_id = client.authenticated_user_id()?; + self.owner_store.verify(&authenticated_user_id)?; let idempotency_key = device_registration_idempotency_key(&self.identity); let registration = match client.register_device(&self.identity, &idempotency_key) { Ok(registration) => registration, @@ -122,6 +124,11 @@ impl SyncEngine { } Err(error) => return Err(error), }; + if registration.version != 2 || registration.user_id != authenticated_user_id { + return Err(SyncClientError::DeviceTrust { + reason: "device registration account does not match authenticated session", + }); + } Ok((client, registration)) } @@ -366,6 +373,22 @@ fn device_registration_idempotency_key(identity: &DeviceIdentity) -> String { format!("device-register:{}", identity.device_id) } +fn browser_data_root<'a>( + profile_id: &ProfileId, + profile_data_dir: &'a Path, +) -> Result<&'a Path, SyncClientError> { + let profile_directory = profile_data_dir + .parent() + .filter(|_| profile_data_dir.file_name().is_some_and(|name| name == "servo")) + .filter(|directory| directory.file_name().is_some_and(|name| name == profile_id.as_str())) + .ok_or_else(|| { + SyncClientError::SyncOwnerStorage("sync profile data path is invalid".to_string()) + })?; + profile_directory.parent().ok_or_else(|| { + SyncClientError::SyncOwnerStorage("browser data root is unavailable".to_string()) + }) +} + #[derive(Debug)] pub struct SyncEngineBuilder { pub profile_id: ProfileId, diff --git a/crates/ely_browser_core/src/sync_engine/session.rs b/crates/ely_browser_core/src/sync_engine/session.rs index d9eed9b..f4fbc0b 100644 --- a/crates/ely_browser_core/src/sync_engine/session.rs +++ b/crates/ely_browser_core/src/sync_engine/session.rs @@ -107,10 +107,86 @@ fn reconcile_authenticated_result( #[cfg(test)] mod tests { - use std::cell::Cell; + use std::{ + cell::Cell, + io::{Read, Write}, + net::TcpListener, + thread::{self, JoinHandle}, + time::Duration, + }; - use super::reconcile_authenticated_result; - use ely_sync_client::SyncClientError; + use super::{SyncEngine, reconcile_authenticated_result}; + use crate::sync_engine::browser_data_root; + use ely_domain::ProfileId; + use ely_sync_client::{ + ApiClientConfig, BearerToken, BearerTokenStore, DeviceIdentity, SyncApiClient, + SyncClientError, SyncOwnerStore, + }; + + type IdentityServer = JoinHandle>; + + #[test] + fn owner_mismatch_stops_before_device_registration() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let profile_id = ProfileId::new(); + let profile_data_dir = directory.path().join(profile_id.as_str()).join("servo"); + std::fs::create_dir_all(&profile_data_dir)?; + let owner_store = SyncOwnerStore::new(directory.path()); + owner_store.claim("user-A")?; + let (base_url, server) = spawn_identity_server("user-B")?; + let engine = SyncEngine { + api_config: ApiClientConfig::custom(base_url.clone(), "auto"), + bearer_store: BearerTokenStore::new(&profile_id, &profile_data_dir), + account_key_lock_dir: directory.path().join(".sync-key-locks"), + owner_store, + identity: DeviceIdentity { + device_id: "device-01".to_string(), + public_key: "a".repeat(64), + wrapping_public_key: "b".repeat(64), + device_name: "Test".to_string(), + platform: "test".to_string(), + }, + last_outcome: None, + }; + let client = SyncApiClient::new( + ApiClientConfig::custom(base_url, "auto"), + BearerToken::new("a".repeat(64))?, + )?; + + let result = engine.registered_client(client); + assert!(matches!(result, Err(SyncClientError::SyncOwnerMismatch))); + let request = join_identity_server(server)?; + assert!(request.starts_with("GET /api/devices HTTP/1.1\r\n")); + Ok(()) + } + + #[test] + fn profile_data_path_must_resolve_to_the_global_root() { + let profile_id = ProfileId::new(); + let valid = std::path::PathBuf::from("/profiles").join(profile_id.as_str()).join("servo"); + + assert!(matches!( + browser_data_root(&profile_id, &valid), + Ok(root) if root == std::path::Path::new("/profiles") + )); + assert!(browser_data_root(&profile_id, std::path::Path::new("/servo")).is_err()); + assert!( + browser_data_root( + &profile_id, + &std::path::PathBuf::from("/profiles") + .join(ProfileId::new().as_str()) + .join("servo") + ) + .is_err() + ); + assert!( + browser_data_root( + &profile_id, + &std::path::PathBuf::from("/profiles").join(profile_id.as_str()).join("other") + ) + .is_err() + ); + } #[test] fn terminal_session_clears_the_captured_credential() -> Result<(), SyncClientError> { @@ -156,4 +232,48 @@ mod tests { assert!(matches!(result, Err(SyncClientError::SnapshotBusy))); assert!(!clear_called.get()); } + + fn spawn_identity_server(user_id: &str) -> Result<(String, IdentityServer), std::io::Error> { + let listener = TcpListener::bind("127.0.0.1:0")?; + listener.set_nonblocking(true)?; + let address = listener.local_addr()?; + let body = format!(r#"{{"version":1,"user_id":"{user_id}","devices":[]}}"#); + let server = thread::spawn(move || { + let (mut stream, _) = (0..100) + .find_map(|_| match listener.accept() { + Ok(connection) => Some(Ok(connection)), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + None + } + Err(error) => Some(Err(error)), + }) + .ok_or_else(|| std::io::Error::other("identity request was not received"))??; + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut chunk)?; + if read == 0 || request.len() + read > 8192 { + return Err(std::io::Error::other("identity request headers are incomplete")); + } + request.extend_from_slice(&chunk[..read]); + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes())?; + stream.flush()?; + String::from_utf8(request) + .map_err(|_| std::io::Error::other("identity request encoding is invalid")) + }); + Ok((format!("http://{address}"), server)) + } + + fn join_identity_server(server: IdentityServer) -> Result> { + match server.join() { + Ok(result) => result.map_err(Into::into), + Err(_) => Err("identity server thread panicked".into()), + } + } } diff --git a/crates/ely_sync_client/src/client/session.rs b/crates/ely_sync_client/src/client/session.rs index 4093eda..355c5ba 100644 --- a/crates/ely_sync_client/src/client/session.rs +++ b/crates/ely_sync_client/src/client/session.rs @@ -1,7 +1,7 @@ use serde::Deserialize; use super::{SyncApiClient, read_json_from_response}; -use crate::error::SyncClientError; +use crate::{device_api::is_subject_id, error::SyncClientError}; #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -17,6 +17,16 @@ struct AuthErrorDocument { } impl SyncApiClient { + pub fn authenticated_user_id(&self) -> Result { + let document = self.list_devices()?; + if document.version != 1 || !is_subject_id(&document.user_id) { + return Err(SyncClientError::DeviceTrust { + reason: "authenticated user identity is invalid", + }); + } + Ok(document.user_id) + } + pub fn sign_out(&self) -> Result<(), SyncClientError> { let endpoint = self.endpoint("/api/session/logout"); let response = self diff --git a/crates/ely_sync_client/src/client_tests.rs b/crates/ely_sync_client/src/client_tests.rs index 6fae0da..93be0ea 100644 --- a/crates/ely_sync_client/src/client_tests.rs +++ b/crates/ely_sync_client/src/client_tests.rs @@ -12,6 +12,40 @@ use crate::{ type TestServer = JoinHandle>; +#[test] +fn authenticated_user_id_uses_the_read_only_device_list() -> Result<(), Box> { + let (base_url, server) = spawn_authenticated_server( + "GET /api/devices HTTP/1.1\r\n", + "200 OK", + r#"{"version":1,"user_id":"user-01","devices":[]}"#, + )?; + let client = SyncApiClient::new( + ApiClientConfig::custom(base_url, "auto"), + BearerToken::new("a".repeat(64))?, + )?; + + assert_eq!(client.authenticated_user_id()?, "user-01"); + join_server(server)?; + + for body in [ + r#"{"version":2,"user_id":"user-01","devices":[]}"#, + r#"{"version":1,"user_id":"x","devices":[]}"#, + ] { + let (base_url, server) = + spawn_authenticated_server("GET /api/devices HTTP/1.1\r\n", "200 OK", body)?; + let client = SyncApiClient::new( + ApiClientConfig::custom(base_url, "auto"), + BearerToken::new("a".repeat(64))?, + )?; + assert!(matches!( + client.authenticated_user_id(), + Err(crate::SyncClientError::DeviceTrust { .. }) + )); + join_server(server)?; + } + Ok(()) +} + #[test] fn sign_out_posts_the_bearer_and_validates_success() -> Result<(), Box> { let (base_url, server) = spawn_logout_server("200 OK", r#"{"version":1,"signed_out":true}"#)?; diff --git a/crates/ely_sync_client/src/device_api.rs b/crates/ely_sync_client/src/device_api.rs index 2df00e0..1df7c61 100644 --- a/crates/ely_sync_client/src/device_api.rs +++ b/crates/ely_sync_client/src/device_api.rs @@ -241,7 +241,7 @@ fn challenge_value<'a>(line: &'a str, name: &'static str) -> Result<&'a str, Syn Ok(value) } -fn is_subject_id(value: &str) -> bool { +pub(crate) fn is_subject_id(value: &str) -> bool { (3..=128).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte)) } diff --git a/crates/ely_sync_client/src/error.rs b/crates/ely_sync_client/src/error.rs index c550dcb..1aa3cbc 100644 --- a/crates/ely_sync_client/src/error.rs +++ b/crates/ely_sync_client/src/error.rs @@ -20,6 +20,15 @@ pub enum SyncClientError { #[error("Authenticated session changed during reconciliation")] SessionChanged, + #[error("Cloud Sync owner is unclaimed; sign in again to bind this browser data")] + SyncOwnerUnclaimed, + + #[error("This browser data belongs to a different Ely account")] + SyncOwnerMismatch, + + #[error("Cloud Sync owner storage is unavailable: {0}")] + SyncOwnerStorage(String), + #[error("HTTP request failed for {endpoint}: {source}")] Http { endpoint: String, diff --git a/crates/ely_sync_client/src/lib.rs b/crates/ely_sync_client/src/lib.rs index 4c7f103..17199b0 100644 --- a/crates/ely_sync_client/src/lib.rs +++ b/crates/ely_sync_client/src/lib.rs @@ -30,6 +30,7 @@ pub mod encryption; pub mod error; pub mod key_store; pub mod snapshot; +mod sync_owner; pub mod vault; mod vault_bootstrap; @@ -53,6 +54,7 @@ pub use snapshot::{ AuthenticatedSnapshot, AuthenticatedSnapshotHead, SnapshotDownload, SnapshotPayload, SnapshotUploadRequest, }; +pub use sync_owner::SyncOwnerStore; pub use vault::{ ACCOUNT_KEY_WRAP_SUITE, ACCOUNT_KEY_WRAP_VERSION, SyncVaultDocument, VaultContext, WrappedAccountKey, diff --git a/crates/ely_sync_client/src/sync_owner.rs b/crates/ely_sync_client/src/sync_owner.rs new file mode 100644 index 0000000..dd910d0 --- /dev/null +++ b/crates/ely_sync_client/src/sync_owner.rs @@ -0,0 +1,308 @@ +use std::{ + io::{self, ErrorKind, Read, Write}, + path::{Component, Path, PathBuf}, +}; + +use cap_fs_ext::{ + DirExt, FollowSymlinks, MetadataExt as CrossPlatformMetadataExt, OpenOptionsFollowExt, +}; +use cap_std::{ + ambient_authority, + fs::{Dir, File, OpenOptions, Permissions}, +}; +use uuid::Uuid; + +use crate::{SyncClientError, device_api::is_subject_id}; + +const OWNER_FILE: &str = ".sync-owner-user-id"; +const OWNER_TEMP_PREFIX: &str = ".sync-owner-tmp-"; +const MAX_SUBJECT_ID_BYTES: usize = 128; + +#[derive(Clone, Debug)] +pub struct SyncOwnerStore { + root: PathBuf, +} + +impl SyncOwnerStore { + pub fn new(browser_data_root: &Path) -> Self { + Self { root: browser_data_root.to_path_buf() } + } + + pub fn claim(&self, user_id: &str) -> Result<(), SyncClientError> { + validate_subject_id(user_id)?; + let directory = open_root(&self.root, true)? + .ok_or_else(|| storage("browser data root is unavailable"))?; + if let Some(owner) = read_owner(&directory)? { + return verify_owner(&owner, user_id); + } + persist_owner(&directory, user_id) + } + + pub fn verify(&self, user_id: &str) -> Result<(), SyncClientError> { + validate_subject_id(user_id)?; + let Some(directory) = open_root(&self.root, false)? else { + return Err(SyncClientError::SyncOwnerUnclaimed); + }; + let owner = read_owner(&directory)?.ok_or(SyncClientError::SyncOwnerUnclaimed)?; + verify_owner(&owner, user_id) + } +} + +fn persist_owner(directory: &Dir, user_id: &str) -> Result<(), SyncClientError> { + let temporary = format!("{OWNER_TEMP_PREFIX}{}", Uuid::now_v7().simple()); + let mut options = private_open_options(); + options.write(true).create_new(true); + let mut file = directory.open_with(&temporary, &options).map_err(storage_io)?; + validate_private_file(&file)?; + set_private_file_permissions(&file)?; + let write_result = file + .write_all(user_id.as_bytes()) + .and_then(|()| file.write_all(b"\n")) + .and_then(|()| file.sync_all()); + drop(file); + if let Err(error) = write_result { + let _ = remove_temporary(directory, &temporary); + return Err(storage_io(error)); + } + + match publish_owner(directory, &temporary) { + Ok(()) => sync_directory(directory)?, + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + remove_temporary(directory, &temporary)?; + } + Err(error) => { + let _ = remove_temporary(directory, &temporary); + return Err(storage_io(error)); + } + } + let owner = read_owner(directory)?.ok_or_else(|| storage("sync owner disappeared"))?; + verify_owner(&owner, user_id) +} + +fn read_owner(directory: &Dir) -> Result, SyncClientError> { + let mut options = private_open_options(); + options.read(true); + let file = match directory.open_with(OWNER_FILE, &options) { + Ok(file) => file, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(storage_io(error)), + }; + validate_private_file(&file)?; + let mut bytes = Vec::new(); + file.into_std() + .take((MAX_SUBJECT_ID_BYTES + 2) as u64) + .read_to_end(&mut bytes) + .map_err(storage_io)?; + if bytes.len() > MAX_SUBJECT_ID_BYTES + 1 || bytes.last() != Some(&b'\n') { + return Err(storage("sync owner record is invalid")); + } + bytes.pop(); + let owner = String::from_utf8(bytes).map_err(|_| storage("sync owner record is invalid"))?; + validate_subject_id(&owner)?; + Ok(Some(owner)) +} + +fn open_root(path: &Path, create: bool) -> Result, SyncClientError> { + let mut root = PathBuf::new(); + let mut names = Vec::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => root.push(prefix.as_os_str()), + Component::RootDir => root.push(std::path::MAIN_SEPARATOR_STR), + Component::Normal(name) => names.push(name.to_os_string()), + Component::CurDir => {} + Component::ParentDir => return Err(storage("browser data root traversal is invalid")), + } + } + if root.as_os_str().is_empty() { + return Err(storage("browser data root must be absolute")); + } + let mut directory = Dir::open_ambient_dir(root, ambient_authority()).map_err(storage_io)?; + #[cfg(windows)] + let strict_component = names.len().saturating_sub(4); + #[cfg(windows)] + let mut require_nofollow = false; + #[cfg(windows)] + let mut component_index = 0; + #[cfg(not(windows))] + let mut require_nofollow = directory_requires_nofollow(&directory)?; + for name in names { + #[cfg(windows)] + { + require_nofollow |= component_index >= strict_component; + component_index += 1; + } + if create { + match directory.create_dir(&name) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(error) => return Err(storage_io(error)), + } + } + let opened = match directory.open_dir_nofollow(&name) { + Ok(directory) => directory, + Err(_) if !require_nofollow => match directory.open_dir(&name) { + Ok(directory) => directory, + Err(error) if !create && error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(storage_io(error)), + }, + Err(error) if !create && error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(storage_io(error)), + }; + #[cfg(not(windows))] + { + require_nofollow |= directory_requires_nofollow(&opened)?; + } + directory = opened; + } + validate_private_directory(&directory)?; + if create { + set_private_directory_permissions(&directory)?; + } + Ok(Some(directory)) +} + +fn private_open_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.follow(FollowSymlinks::No); + #[cfg(unix)] + { + use cap_std::fs::OpenOptionsExt; + options.mode(0o600); + } + options +} + +fn validate_private_directory(directory: &Dir) -> Result<(), SyncClientError> { + let metadata = directory.dir_metadata().map_err(storage_io)?; + if !metadata.is_dir() { + return Err(storage("browser data root is invalid")); + } + #[cfg(unix)] + { + use cap_std::fs::MetadataExt; + if metadata.uid() != rustix::process::geteuid().as_raw() { + return Err(storage("browser data root ownership is invalid")); + } + } + Ok(()) +} + +fn validate_private_file(file: &File) -> Result<(), SyncClientError> { + let metadata = file.metadata().map_err(storage_io)?; + if !metadata.is_file() || CrossPlatformMetadataExt::nlink(&metadata) != 1 { + return Err(storage("sync owner record is invalid")); + } + #[cfg(unix)] + { + use cap_std::fs::MetadataExt; + if metadata.uid() != rustix::process::geteuid().as_raw() || metadata.mode() & 0o077 != 0 { + return Err(storage("sync owner record permissions are invalid")); + } + } + Ok(()) +} + +#[cfg(unix)] +fn directory_requires_nofollow(directory: &Dir) -> Result { + use cap_std::fs::MetadataExt; + + let metadata = directory.dir_metadata().map_err(storage_io)?; + Ok(metadata.uid() == rustix::process::geteuid().as_raw() || metadata.mode() & 0o022 != 0) +} + +#[cfg(not(any(unix, windows)))] +fn directory_requires_nofollow(_directory: &Dir) -> Result { + Ok(true) +} + +fn set_private_directory_permissions(directory: &Dir) -> Result<(), SyncClientError> { + #[cfg(unix)] + directory + .set_permissions(".", Permissions::from_std(std::fs::Permissions::from_mode(0o700))) + .map_err(storage_io)?; + Ok(()) +} + +fn set_private_file_permissions(file: &File) -> Result<(), SyncClientError> { + #[cfg(unix)] + file.set_permissions(Permissions::from_std(std::fs::Permissions::from_mode(0o600))) + .map_err(storage_io)?; + Ok(()) +} + +#[cfg(any( + target_vendor = "apple", + target_os = "linux", + target_os = "android", + target_os = "redox" +))] +fn publish_owner(directory: &Dir, temporary: &str) -> io::Result<()> { + use std::os::fd::AsFd; + + rustix::fs::renameat_with( + directory.as_fd(), + temporary, + directory.as_fd(), + OWNER_FILE, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(Into::into) +} + +#[cfg(windows)] +fn publish_owner(directory: &Dir, temporary: &str) -> io::Result<()> { + directory.rename(temporary, directory, OWNER_FILE) +} + +#[cfg(not(any( + target_vendor = "apple", + target_os = "linux", + target_os = "android", + target_os = "redox", + windows +)))] +fn publish_owner(directory: &Dir, temporary: &str) -> io::Result<()> { + directory.hard_link(temporary, directory, OWNER_FILE)?; + directory.remove_file(temporary) +} + +fn remove_temporary(directory: &Dir, temporary: &str) -> Result<(), SyncClientError> { + match directory.remove_file(temporary) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(storage_io(error)), + } +} + +#[cfg(unix)] +fn sync_directory(directory: &Dir) -> Result<(), SyncClientError> { + directory.try_clone().map_err(storage_io)?.into_std_file().sync_all().map_err(storage_io) +} + +#[cfg(not(unix))] +fn sync_directory(_directory: &Dir) -> Result<(), SyncClientError> { + Ok(()) +} + +fn validate_subject_id(value: &str) -> Result<(), SyncClientError> { + if is_subject_id(value) { + return Ok(()); + } + Err(storage("sync owner user identifier is invalid")) +} + +fn verify_owner(owner: &str, user_id: &str) -> Result<(), SyncClientError> { + if owner == user_id { Ok(()) } else { Err(SyncClientError::SyncOwnerMismatch) } +} + +fn storage_io(error: io::Error) -> SyncClientError { + storage(error.to_string()) +} + +fn storage(message: impl Into) -> SyncClientError { + SyncClientError::SyncOwnerStorage(message.into()) +} + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; diff --git a/crates/ely_sync_client/tests/sync_owner.rs b/crates/ely_sync_client/tests/sync_owner.rs new file mode 100644 index 0000000..c11d6c2 --- /dev/null +++ b/crates/ely_sync_client/tests/sync_owner.rs @@ -0,0 +1,80 @@ +use ely_sync_client::{SyncClientError, SyncOwnerStore}; + +#[test] +fn browser_data_root_keeps_one_sync_owner() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let store = SyncOwnerStore::new(directory.path()); + + assert!(matches!(store.verify("user-01"), Err(SyncClientError::SyncOwnerUnclaimed))); + store.claim("user-01")?; + store.claim("user-01")?; + SyncOwnerStore::new(directory.path()).verify("user-01")?; + + assert!(matches!(store.verify("user-02"), Err(SyncClientError::SyncOwnerMismatch))); + Ok(()) +} + +#[test] +fn malformed_sync_owner_fails_closed() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + write_owner(directory.path(), "invalid owner id!")?; + let store = SyncOwnerStore::new(directory.path()); + + assert!(matches!(store.verify("user-01"), Err(SyncClientError::SyncOwnerStorage(_)))); + Ok(()) +} + +#[test] +fn concurrent_claims_publish_exactly_one_owner() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let root = std::sync::Arc::new(directory.path().to_path_buf()); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let threads = ["user-A", "user-B"].map(|user_id| { + let root = root.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + SyncOwnerStore::new(&root).claim(user_id).is_ok() + }) + }); + let outcomes = threads + .into_iter() + .map(|thread| thread.join().map_err(|_| "owner claim thread panicked")) + .collect::, _>>()?; + + assert_eq!(outcomes.iter().filter(|outcome| **outcome).count(), 1); + let owner = std::fs::read_to_string(directory.path().join(".sync-owner-user-id"))?; + assert!(matches!(owner.as_str(), "user-A\n" | "user-B\n")); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn linked_owner_records_fail_closed() -> Result<(), Box> { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir()?; + let external = directory.path().join("external-owner"); + write_private_file(&external, "user-01\n")?; + symlink(&external, directory.path().join(".sync-owner-user-id"))?; + + assert!(matches!( + SyncOwnerStore::new(directory.path()).verify("user-01"), + Err(SyncClientError::SyncOwnerStorage(_)) + )); + Ok(()) +} + +fn write_owner(root: &std::path::Path, value: &str) -> Result<(), std::io::Error> { + write_private_file(&root.join(".sync-owner-user-id"), value) +} + +fn write_private_file(path: &std::path::Path, value: &str) -> Result<(), std::io::Error> { + std::fs::write(path, value)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +}