fix(sync): bind browser data to one account owner
This commit is contained in:
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user