fix(sync): bind browser data to one account owner
This commit is contained in:
Generated
+1
@@ -2534,6 +2534,7 @@ dependencies = [
|
||||
"ely_sync_client",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"uuid",
|
||||
|
||||
@@ -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/*` 路由。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<BearerToken> {
|
||||
match update {
|
||||
SyncStateUpdate::AuthVerified { token, .. } => Some(token),
|
||||
@@ -428,17 +444,28 @@ fn verified_bearer_from(update: SyncStateUpdate) -> Option<BearerToken> {
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
let state =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,5 +14,8 @@ thiserror.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -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<SyncOutcome>,
|
||||
}
|
||||
@@ -41,12 +42,10 @@ impl SyncEngine {
|
||||
device_name: impl Into<String>,
|
||||
platform: impl Into<String>,
|
||||
) -> Result<Self, SyncClientError> {
|
||||
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,
|
||||
|
||||
@@ -107,10 +107,86 @@ fn reconcile_authenticated_result<T>(
|
||||
|
||||
#[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<std::io::Result<String>>;
|
||||
|
||||
#[test]
|
||||
fn owner_mismatch_stops_before_device_registration() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<String, Box<dyn std::error::Error>> {
|
||||
match server.join() {
|
||||
Ok(result) => result.map_err(Into::into),
|
||||
Err(_) => Err("identity server thread panicked".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, SyncClientError> {
|
||||
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
|
||||
|
||||
@@ -12,6 +12,40 @@ use crate::{
|
||||
|
||||
type TestServer = JoinHandle<std::io::Result<()>>;
|
||||
|
||||
#[test]
|
||||
fn authenticated_user_id_uses_the_read_only_device_list() -> Result<(), Box<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
let (base_url, server) = spawn_logout_server("200 OK", r#"{"version":1,"signed_out":true}"#)?;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Option<String>, 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<Option<Dir>, 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<bool, SyncClientError> {
|
||||
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<bool, SyncClientError> {
|
||||
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<String>) -> SyncClientError {
|
||||
SyncClientError::SyncOwnerStorage(message.into())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -0,0 +1,80 @@
|
||||
use ely_sync_client::{SyncClientError, SyncOwnerStore};
|
||||
|
||||
#[test]
|
||||
fn browser_data_root_keeps_one_sync_owner() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
|
||||
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<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user