fix(auth): store bearer tokens in native credentials

This commit is contained in:
2026-07-10 07:57:44 -04:00
parent 34ac842078
commit 94afa23a69
26 changed files with 2316 additions and 286 deletions
Generated
+118 -13
View File
@@ -162,6 +162,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "ambient-authority"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -1146,6 +1152,48 @@ dependencies = [
"wayland-client", "wayland-client",
] ]
[[package]]
name = "cap-fs-ext"
version = "4.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78e5a3368ae89b7cb68186411452b4b9fac8b41be9c19bf3f47c2d2c8e36e6b"
dependencies = [
"cap-primitives",
"cap-std",
"io-lifetimes 3.0.1",
"windows-sys 0.61.2",
]
[[package]]
name = "cap-primitives"
version = "4.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0"
dependencies = [
"ambient-authority",
"fs-set-times",
"io-extras",
"io-lifetimes 3.0.1",
"ipnet",
"maybe-owned",
"rustix 1.1.4",
"rustix-linux-procfs",
"windows-sys 0.61.2",
"winx",
]
[[package]]
name = "cap-std"
version = "4.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608"
dependencies = [
"cap-primitives",
"io-extras",
"io-lifetimes 3.0.1",
"rustix 1.1.4",
]
[[package]] [[package]]
name = "cbc" name = "cbc"
version = "0.1.2" version = "0.1.2"
@@ -2445,6 +2493,8 @@ name = "ely_app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ahash", "ahash",
"cap-fs-ext",
"cap-std",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-video", "core-video",
"directories", "directories",
@@ -2454,6 +2504,7 @@ dependencies = [
"ely_domain", "ely_domain",
"ely_servo_host", "ely_servo_host",
"ely_sync_client", "ely_sync_client",
"fs2",
"gpui", "gpui",
"gpui-component", "gpui-component",
"gpui-component-assets", "gpui-component-assets",
@@ -2532,7 +2583,10 @@ dependencies = [
name = "ely_sync_client" name = "ely_sync_client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apple-native-keyring-store",
"base64", "base64",
"cap-fs-ext",
"cap-std",
"chacha20poly1305", "chacha20poly1305",
"ed25519-dalek 2.2.0", "ed25519-dalek 2.2.0",
"ely_domain", "ely_domain",
@@ -2541,13 +2595,17 @@ dependencies = [
"hkdf 0.12.4", "hkdf 0.12.4",
"hmac 0.12.1", "hmac 0.12.1",
"hpke", "hpke",
"keyring", "keyring-core",
"rustix 1.1.4",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"ureq", "ureq",
"uuid", "uuid",
"windows-native-keyring-store",
"zbus-secret-service-keyring-store",
"zeroize", "zeroize",
] ]
@@ -3108,6 +3166,17 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "fs-set-times"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
dependencies = [
"io-lifetimes 2.0.4",
"rustix 1.1.4",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "fs2" name = "fs2"
version = "0.4.3" version = "0.4.3"
@@ -4999,6 +5068,28 @@ dependencies = [
"rustversion", "rustversion",
] ]
[[package]]
name = "io-extras"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
dependencies = [
"io-lifetimes 3.0.1",
"windows-sys 0.59.0",
]
[[package]]
name = "io-lifetimes"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
[[package]]
name = "io-lifetimes"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96"
[[package]] [[package]]
name = "io-surface" name = "io-surface"
version = "0.16.1" version = "0.16.1"
@@ -5241,18 +5332,6 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "keyring"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ee8d4dae108d4177d0a0ce241f98acc1ef28e20837bdef43cff4d160cf70fe"
dependencies = [
"apple-native-keyring-store",
"keyring-core",
"windows-native-keyring-store",
"zbus-secret-service-keyring-store",
]
[[package]] [[package]]
name = "keyring-core" name = "keyring-core"
version = "1.0.0" version = "1.0.0"
@@ -5662,6 +5741,12 @@ dependencies = [
"regex-automata 0.4.14", "regex-automata 0.4.14",
] ]
[[package]]
name = "maybe-owned"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
[[package]] [[package]]
name = "maybe-rayon" name = "maybe-rayon"
version = "0.1.1" version = "0.1.1"
@@ -7872,6 +7957,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustix-linux-procfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
dependencies = [
"once_cell",
"rustix 1.1.4",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.40"
@@ -12608,6 +12703,16 @@ version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "winx"
version = "0.36.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
dependencies = [
"bitflags 2.11.1",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "wio" name = "wio"
version = "0.2.2" version = "0.2.2"
+4 -1
View File
@@ -19,6 +19,8 @@ rust-version = "1.95"
[workspace.dependencies] [workspace.dependencies]
base64 = "0.22.1" base64 = "0.22.1"
cap-fs-ext = "4.0.2"
cap-std = "4.0.2"
chacha20poly1305 = "0.11.0" chacha20poly1305 = "0.11.0"
directories = "6.0.0" directories = "6.0.0"
dpi = "0.1" dpi = "0.1"
@@ -32,7 +34,8 @@ hkdf = "0.12.4"
hmac = "0.12.1" hmac = "0.12.1"
hpke = { version = "0.14.0", default-features = false, features = ["alloc", "chacha", "getrandom", "x25519"] } hpke = { version = "0.14.0", default-features = false, features = ["alloc", "chacha", "getrandom", "x25519"] }
image = "0.25.10" image = "0.25.10"
keyring = "4.1.4" keyring-core = "1.0.0"
rustix = "1.1.4"
servo = { version = "0.4.0", git = "https://github.com/servo/servo.git", rev = "c983c232385edf1170118b24637d2eb2564674d7" } servo = { version = "0.4.0", git = "https://github.com/servo/servo.git", rev = "c983c232385edf1170118b24637d2eb2564674d7" }
sha2 = "0.10.9" sha2 = "0.10.9"
semver = "1.0.28" semver = "1.0.28"
+2 -1
View File
@@ -960,6 +960,7 @@ Better Auth 在 Cloudflare Workers 中初始化,D1 binding 作为 database 传
- Google/GitHub OAuth 按环境启用。 - Google/GitHub OAuth 按环境启用。
- D1 session validation 与自定义 device/session binding。 - D1 session validation 与自定义 device/session binding。
- Bearer logout 精确删除当前 D1 session,并级联清理 session device context 与 rebind challenge。 - Bearer logout 精确删除当前 D1 session,并级联清理 session device context 与 rebind challenge。
- Desktop bearer 以 stable `ProfileId` 作为系统凭据 accountWindows 使用 Local persistence;旧明文文件在 credential read-back 与 durable marker 提交后清理,系统凭据不可用时阻断设备加载与 Sync upload。
- 设备注册、rebind、批准、撤销与 Vault rotation。 - 设备注册、rebind、批准、撤销与 Vault rotation。
- Signed Sync reset 和 signed account deletion。 - Signed Sync reset 和 signed account deletion。
- 管理所有 `/api/auth/*` 路由。 - 管理所有 `/api/auth/*` 路由。
@@ -1571,7 +1572,7 @@ Servo Host 需要实现:
| ID | 模块 | 验收标准 | | ID | 模块 | 验收标准 |
|---|---|---| |---|---|---|
| S-001 | Sync encryption | 服务端无法解密 Sync payload | | S-001 | Sync encryption | 服务端无法解密 Sync payload |
| S-002 | Keychain | session token 和本地数据库密钥存入系统钥匙串 | | S-002 | Keychain | session token 按 stable ProfileId 隔离存入原生系统凭据;明文 legacy token 完成可恢复的一次迁移后删除;凭据不可用时进入独立错误状态并暂停云端动作 |
| S-003 | Device revoke | 撤销设备后目标 sessions 失效、后续 Sync API 被拒绝、approved revoke 原子轮换 Vault generation | | S-003 | Device revoke | 撤销设备后目标 sessions 失效、后续 Sync API 被拒绝、approved revoke 原子轮换 Vault generation |
| S-004 | Plugin sandbox | 插件无声明权限时无法访问 tabs/bookmarks/history/page | | S-004 | Plugin sandbox | 插件无声明权限时无法访问 tabs/bookmarks/history/page |
| S-005 | Plugin signature | 市场插件必须签名验证通过 | | S-005 | Plugin signature | 市场插件必须签名验证通过 |
+1 -1
View File
@@ -3,7 +3,7 @@ import { primaryD1Session } from "./bindings.js";
import { prefixedKvKey } from "./kv_keys.js"; import { prefixedKvKey } from "./kv_keys.js";
const AUTH_SESSION_CACHE_NAMESPACE = "auth_session_cache"; const AUTH_SESSION_CACHE_NAMESPACE = "auth_session_cache";
const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]{32,4096}$/; const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]{32,2560}$/;
const SUBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; const SUBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
const BETTER_AUTH_SESSION_QUERY = ` const BETTER_AUTH_SESSION_QUERY = `
+17
View File
@@ -149,6 +149,23 @@ describe("api controls", () => {
]); ]);
}); });
it("rejects bearer tokens larger than the native credential contract", async () => {
const d1Queries: string[] = [];
const response = await withAuthenticatedApiControls(
new Request("https://elydora.test/api/devices", {
headers: { authorization: `Bearer ${"A".repeat(2561)}` },
}),
testEnv({ d1: testD1Database({ queries: d1Queries }) }),
"devices.list",
["GET"],
() => Promise.resolve(jsonResponse({ ok: true }, 200)),
);
assert.equal(response.status, 401);
assert.deepEqual(await response.json(), { error: "authorization_invalid" });
assert.deepEqual(d1Queries, []);
});
it("passes authenticated session context and records subject audit fields", async () => { it("passes authenticated session context and records subject audit fields", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN); const tokenHash = await authTokenHash(ACCESS_TOKEN);
const auditEvents: ElyAnalyticsDataPoint[] = []; const auditEvents: ElyAnalyticsDataPoint[] = [];
+4 -1
View File
@@ -10,12 +10,15 @@ live-site-smoke = []
[dependencies] [dependencies]
ahash = "0.8" ahash = "0.8"
cap-fs-ext.workspace = true
cap-std.workspace = true
directories.workspace = true directories.workspace = true
ed25519-dalek.workspace = true ed25519-dalek.workspace = true
ely_browser_core = { path = "../ely_browser_core" } ely_browser_core = { path = "../ely_browser_core" }
ely_design_system = { path = "../ely_design_system" } ely_design_system = { path = "../ely_design_system" }
ely_domain = { path = "../ely_domain" } ely_domain = { path = "../ely_domain" }
ely_sync_client = { path = "../ely_sync_client" } ely_sync_client = { path = "../ely_sync_client" }
fs2.workspace = true
gpui.workspace = true gpui.workspace = true
gpui-component.workspace = true gpui-component.workspace = true
gpui-component-assets.workspace = true gpui-component-assets.workspace = true
@@ -29,6 +32,7 @@ tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
ureq.workspace = true ureq.workspace = true
url.workspace = true url.workspace = true
uuid.workspace = true
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10" core-foundation = "0.10"
@@ -37,7 +41,6 @@ io-surface = "0.16"
mach2 = "0.6" mach2 = "0.6"
objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] } objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] }
objc2-io-surface = "0.3.2" objc2-io-surface = "0.3.2"
uuid.workspace = true
[dev-dependencies] [dev-dependencies]
ely_servo_host = { path = "../ely_servo_host" } ely_servo_host = { path = "../ely_servo_host" }
+116 -11
View File
@@ -139,29 +139,54 @@ impl ElyShell {
} }
/// Drop the persisted bearer token and reset the local form. /// Drop the persisted bearer token and reset the local form.
/// The bearer file is removed synchronously — there is no network
/// call to make, the token is the only artefact we own.
pub(crate) fn submit_sign_out(&mut self, _cx: &mut Context<Self>) { pub(crate) fn submit_sign_out(&mut self, _cx: &mut Context<Self>) {
self.auth_flow_phase = AuthFlowPhase::Idle;
self.sync_devices.reset();
self.sync_retry_at = None;
self.clear_pending_cloud_sync_upload();
let active_profile_id = match active_profile_id_for(&self.state) { let active_profile_id = match active_profile_id_for(&self.state) {
Some(profile_id) => profile_id, Some(profile_id) => profile_id,
None => return, None => return,
}; };
self.auth_flow_phase = AuthFlowPhase::Idle;
self.sync_devices.reset();
self.sync_upload_scheduled = false;
self.sync_retry_at = None;
self.clear_pending_cloud_sync_upload();
let Some(profile_root) = default_profile_data_root() else { let Some(profile_root) = default_profile_data_root() else {
self.set_sign_out_error(
active_profile_id,
"Profile data root is unavailable. Retry sign out.",
);
return; return;
}; };
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile_id); let profile_dir = sync_profile_data_dir(&profile_root, &active_profile_id);
if let Err(error) = clear_persisted_bearer(&profile_dir) { if let Err(error) = clear_persisted_bearer(
&active_profile_id,
&profile_dir,
&profile_root,
self.default_profile_id.as_ref(),
) {
tracing::warn!(target: "ely::sync", error = %error, "sign-out failed to clear bearer"); tracing::warn!(target: "ely::sync", error = %error, "sign-out failed to clear bearer");
self.set_sign_out_error(
active_profile_id,
"System credential access failed. Retry sign out.",
);
return;
} }
if let ShellState::Ready(core) = &mut self.state { if let ShellState::Ready(core) = &mut self.state {
core.set_sync_connection_state(ely_domain::SyncConnectionState::SignedOut); core.set_sync_connection_state(ely_domain::SyncConnectionState::SignedOut);
} }
} }
fn set_sign_out_error(&mut self, profile_id: ProfileId, message: &str) {
self.auth_flow_phase =
AuthFlowPhase::Error { profile_id, email: String::new(), message: message.to_string() };
if let ShellState::Ready(core) = &mut self.state {
core.set_sync_connection_state(
ely_domain::SyncConnectionState::CredentialUnavailable {
message: message.to_string(),
},
);
}
}
fn read_auth_email_input(&self, cx: &Context<Self>) -> String { fn read_auth_email_input(&self, cx: &Context<Self>) -> String {
self.auth_email_input.read(cx).value().to_string() self.auth_email_input.read(cx).value().to_string()
} }
@@ -201,8 +226,28 @@ fn active_profile_id_for(state: &ShellState) -> Option<ProfileId> {
core.snapshot().ok().map(|snapshot| snapshot.active_profile_id) core.snapshot().ok().map(|snapshot| snapshot.active_profile_id)
} }
pub(super) fn clear_persisted_bearer(profile_dir: &Path) -> Result<(), SyncClientError> { pub(super) fn clear_persisted_bearer(
BearerTokenStore::new(profile_dir.join("sync").join("bearer.token")).clear() profile_id: &ProfileId,
profile_dir: &Path,
profile_root: &Path,
default_profile_id: Option<&ProfileId>,
) -> Result<(), SyncClientError> {
bearer_store_for_profile(profile_id, profile_dir, profile_root, default_profile_id).clear()
}
pub(super) fn bearer_store_for_profile(
profile_id: &ProfileId,
profile_dir: &Path,
profile_root: &Path,
default_profile_id: Option<&ProfileId>,
) -> BearerTokenStore {
let store = BearerTokenStore::new(profile_id, profile_dir);
if default_profile_id != Some(profile_id) {
return store;
}
store.with_legacy_path(
profile_root.join("default").join("servo").join("sync").join("bearer.token"),
)
} }
fn spawn_send_otp(profile_id: ProfileId, email: String, tx: Sender<SyncStateUpdate>) { fn spawn_send_otp(profile_id: ProfileId, email: String, tx: Sender<SyncStateUpdate>) {
@@ -252,7 +297,12 @@ fn spawn_verify_otp(
} }
}; };
let mut engine = let mut engine =
match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) { match SyncEngine::for_profile_dir(
&profile_id,
&profile_dir,
"ELY",
sync_platform_label(),
) {
Ok(engine) => engine, Ok(engine) => engine,
Err(error) => { Err(error) => {
let _ = tx.send(SyncStateUpdate::AuthError { let _ = tx.send(SyncStateUpdate::AuthError {
@@ -284,7 +334,9 @@ mod tests {
use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::ProfileId; use ely_domain::ProfileId;
use super::{AuthFlowPhase, active_profile_sync_context_for, normalize_email}; use super::{
AuthFlowPhase, active_profile_sync_context_for, bearer_store_for_profile, normalize_email,
};
use crate::shell::ShellState; use crate::shell::ShellState;
#[test] #[test]
@@ -334,4 +386,57 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn default_profile_store_cleans_stable_and_old_legacy_paths()
-> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let profile_dir = directory.path().join(profile_id.as_str()).join("servo");
let stable = profile_dir.join("sync/bearer.token");
let old = directory.path().join("default/servo/sync/bearer.token");
std::fs::create_dir_all(stable.parent().ok_or("missing stable parent")?)?;
std::fs::create_dir_all(old.parent().ok_or("missing old parent")?)?;
std::fs::write(&stable, "a".repeat(64))?;
std::fs::write(&old, "b".repeat(64))?;
let store = bearer_store_for_profile(
&profile_id,
&profile_dir,
directory.path(),
Some(&profile_id),
);
store.clear_legacy_files()?;
assert!(!stable.exists());
assert!(!old.exists());
Ok(())
}
#[test]
fn custom_profile_store_leaves_default_legacy_credentials_untouched()
-> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let default_profile_id = ProfileId::new();
let profile_dir = directory.path().join(profile_id.as_str()).join("servo");
let stable = profile_dir.join("sync/bearer.token");
let old_default = directory.path().join("default/servo/sync/bearer.token");
std::fs::create_dir_all(stable.parent().ok_or("missing stable parent")?)?;
std::fs::create_dir_all(old_default.parent().ok_or("missing default parent")?)?;
std::fs::write(&stable, "a".repeat(64))?;
std::fs::write(&old_default, "b".repeat(64))?;
let store = bearer_store_for_profile(
&profile_id,
&profile_dir,
directory.path(),
Some(&default_profile_id),
);
store.clear_legacy_files()?;
assert!(!stable.exists());
assert!(old_default.exists());
Ok(())
}
} }
@@ -15,7 +15,6 @@ use super::sync_controls::{
render_reset_button, render_secondary_button, render_sign_out_button, render_reset_button, render_secondary_button, render_sign_out_button,
}; };
use super::{ElyShell, render_canvas_surface}; use super::{ElyShell, render_canvas_surface};
impl ElyShell { impl ElyShell {
pub(super) fn render_sync_page( pub(super) fn render_sync_page(
&mut self, &mut self,
@@ -23,7 +22,10 @@ impl ElyShell {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
if profile_allows_sync_controls(&snapshot.active_profile_kind) if profile_allows_sync_controls(&snapshot.active_profile_kind)
&& !matches!(snapshot.sync_status.connection(), SyncConnectionState::SignedOut) && !matches!(
snapshot.sync_status.connection(),
SyncConnectionState::SignedOut | SyncConnectionState::CredentialUnavailable { .. }
)
{ {
self.ensure_sync_devices_loaded(cx); self.ensure_sync_devices_loaded(cx);
} }
@@ -37,7 +39,6 @@ impl ElyShell {
) )
} }
} }
fn render_sync_body( fn render_sync_body(
shell: &mut ElyShell, shell: &mut ElyShell,
snapshot: &BrowserSnapshot, snapshot: &BrowserSnapshot,
@@ -63,11 +64,9 @@ fn render_sync_body(
) )
.into_any_element() .into_any_element()
} }
fn profile_allows_sync_controls(profile_kind: &ProfileKind) -> bool { fn profile_allows_sync_controls(profile_kind: &ProfileKind) -> bool {
profile_kind == &ProfileKind::Standard profile_kind == &ProfileKind::Standard
} }
fn render_private_profile_card() -> AnyElement { fn render_private_profile_card() -> AnyElement {
div() div()
.p(px(16.0)) .p(px(16.0))
@@ -88,7 +87,6 @@ fn render_private_profile_card() -> AnyElement {
) )
.into_any_element() .into_any_element()
} }
fn render_account_card( fn render_account_card(
shell: &mut ElyShell, shell: &mut ElyShell,
snapshot: &BrowserSnapshot, snapshot: &BrowserSnapshot,
@@ -102,6 +100,11 @@ fn render_account_card(
.child(render_card_heading("Account")) .child(render_card_heading("Account"))
.children(account_form(shell, &snapshot.active_profile_id, cx)) .children(account_form(shell, &snapshot.active_profile_id, cx))
.into_any_element(), .into_any_element(),
SyncConnectionState::CredentialUnavailable { message } => card
.child(render_card_heading("Account"))
.child(render_inline_error(message))
.children(account_form(shell, &snapshot.active_profile_id, cx))
.into_any_element(),
SyncConnectionState::SignedIn SyncConnectionState::SignedIn
| SyncConnectionState::AwaitingDeviceApproval | SyncConnectionState::AwaitingDeviceApproval
| SyncConnectionState::SyncReady { .. } | SyncConnectionState::SyncReady { .. }
@@ -120,11 +123,13 @@ fn render_account_card(
.text_color(rgb(colors::ink_3())) .text_color(rgb(colors::ink_3()))
.child("End-to-end encrypted"), .child("End-to-end encrypted"),
) )
.when_some(shell.auth_flow_phase.error_message(), |card, message| {
card.child(render_inline_error(message))
})
.child(render_devices(shell, cx)) .child(render_devices(shell, cx))
.into_any_element(), .into_any_element(),
} }
} }
fn render_devices(shell: &mut ElyShell, cx: &mut Context<ElyShell>) -> AnyElement { fn render_devices(shell: &mut ElyShell, cx: &mut Context<ElyShell>) -> AnyElement {
let loading = shell.sync_devices.is_loading(); let loading = shell.sync_devices.is_loading();
let header = let header =
+10 -5
View File
@@ -115,6 +115,7 @@ pub struct ElyShell {
sync_upload_pending: bool, sync_upload_pending: bool,
sync_upload_pending_merge: Option<PendingMergeUpload>, sync_upload_pending_merge: Option<PendingMergeUpload>,
sync_retry_at: Option<std::time::Instant>, sync_retry_at: Option<std::time::Instant>,
default_profile_id: Option<ProfileId>,
pub(crate) sync_devices: SyncDeviceUiState, pub(crate) sync_devices: SyncDeviceUiState,
pub(crate) sync_verification_input: Entity<InputState>, pub(crate) sync_verification_input: Entity<InputState>,
pub(crate) auth_email_input: Entity<InputState>, pub(crate) auth_email_input: Entity<InputState>,
@@ -126,21 +127,23 @@ pub struct ElyShell {
impl ElyShell { impl ElyShell {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut default_profile_id = None;
let config = InitialBrowserConfig::ely_defaults() let config = InitialBrowserConfig::ely_defaults()
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
.and_then(|mut config| { .and_then(|mut config| {
config.profile_id = Some( let profile_id = crate::services::profile_identity::default_standard_profile_id()
crate::services::profile_identity::default_standard_profile_id() .map_err(|error| error.to_string())?;
.map_err(|error| error.to_string())?, config.profile_id = Some(profile_id.clone());
); default_profile_id = Some(profile_id);
Ok(config) Ok(config)
}); });
Self::new_with_config(config, window, cx) Self::new_with_config(config, default_profile_id, window, cx)
} }
pub fn new_private(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new_private(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new_with_config( Self::new_with_config(
InitialBrowserConfig::private_window().map_err(|error| error.to_string()), InitialBrowserConfig::private_window().map_err(|error| error.to_string()),
None,
window, window,
cx, cx,
) )
@@ -148,6 +151,7 @@ impl ElyShell {
fn new_with_config( fn new_with_config(
config: Result<InitialBrowserConfig, String>, config: Result<InitialBrowserConfig, String>,
default_profile_id: Option<ProfileId>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
@@ -261,6 +265,7 @@ impl ElyShell {
sync_upload_pending: false, sync_upload_pending: false,
sync_upload_pending_merge: None, sync_upload_pending_merge: None,
sync_retry_at: None, sync_retry_at: None,
default_profile_id,
sync_devices: SyncDeviceUiState::default(), sync_devices: SyncDeviceUiState::default(),
sync_verification_input, sync_verification_input,
auth_email_input, auth_email_input,
+13 -12
View File
@@ -9,7 +9,9 @@ use gpui_component::slider::SliderValue;
use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir}; use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir};
use super::sync_state::{PendingMergeUpload, SyncStateUpdate, sync_platform_label}; use super::sync_state::{
PendingMergeUpload, SyncStateUpdate, sync_failure_update, sync_platform_label,
};
use super::{ElyShell, ShellState}; use super::{ElyShell, ShellState};
impl ElyShell { impl ElyShell {
@@ -255,11 +257,11 @@ impl ElyShell {
} }
fn trigger_cloud_sync_upload_with_merge(&mut self, mut merge: Option<PendingMergeUpload>) { fn trigger_cloud_sync_upload_with_merge(&mut self, mut merge: Option<PendingMergeUpload>) {
let active_profile_allows_sync = match &self.state { let cloud_sync_enabled = match &self.state {
ShellState::Ready(core) => core.active_profile_allows_sync(), ShellState::Ready(core) => core.cloud_sync_upload_enabled(),
ShellState::StartupError(_) => false, ShellState::StartupError(_) => false,
}; };
if !active_profile_allows_sync { if !cloud_sync_enabled {
self.sync_upload_scheduled = false; self.sync_upload_scheduled = false;
self.clear_pending_cloud_sync_upload(); self.clear_pending_cloud_sync_upload();
return; return;
@@ -352,6 +354,7 @@ fn run_sync_upload(
inbox: std::sync::mpsc::Sender<SyncStateUpdate>, inbox: std::sync::mpsc::Sender<SyncStateUpdate>,
) { ) {
let mut engine = match SyncEngine::for_profile_dir( let mut engine = match SyncEngine::for_profile_dir(
&profile_id,
&profile_dir, &profile_dir,
device_name, device_name,
sync_platform_label(), sync_platform_label(),
@@ -360,7 +363,7 @@ fn run_sync_upload(
Err(error) => { Err(error) => {
let message = error.to_string(); let message = error.to_string();
tracing::warn!(target: "ely::sync", error = %message, "could not initialise sync engine"); tracing::warn!(target: "ely::sync", error = %message, "could not initialise sync engine");
let _ = inbox.send(SyncStateUpdate::SyncError { profile_id, message }); let _ = inbox.send(sync_failure_update(profile_id, error));
return; return;
} }
}; };
@@ -380,7 +383,10 @@ fn run_sync_upload(
device_id = %device_id, device_id = %device_id,
"sync device is awaiting approval", "sync device is awaiting approval",
); );
let _ = inbox.send(SyncStateUpdate::AwaitingDeviceApproval { profile_id }); let _ = inbox.send(SyncStateUpdate::AwaitingDeviceApproval {
profile_id,
finishes_upload: true,
});
} }
Ok(ely_browser_core::SyncOutcome::RemoteSnapshot { Ok(ely_browser_core::SyncOutcome::RemoteSnapshot {
snapshot_id, snapshot_id,
@@ -454,12 +460,7 @@ fn run_sync_upload(
Err(error) => { Err(error) => {
let message = error.to_string(); let message = error.to_string();
tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed"); tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed");
let update = if message.contains("device_not_approved") { let _ = inbox.send(sync_failure_update(profile_id, error));
SyncStateUpdate::AwaitingDeviceApproval { profile_id }
} else {
SyncStateUpdate::SyncError { profile_id, message }
};
let _ = inbox.send(update);
} }
} }
} }
+84 -12
View File
@@ -7,7 +7,7 @@ use gpui::Context;
use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir}; use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir};
use super::sync_state::sync_platform_label; use super::sync_state::{device_failure_update, sync_platform_label};
use super::{ElyShell, ShellState, sync_state::SyncStateUpdate}; use super::{ElyShell, ShellState, sync_state::SyncStateUpdate};
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -141,8 +141,12 @@ impl ElyShell {
} }
let tx = self.sync_inbox_tx.clone(); let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-approve", profile_id.clone(), tx, move || { spawn_device_task("ely-sync-device-approve", profile_id.clone(), tx, move || {
let engine = let engine = SyncEngine::for_profile_dir(
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?; &profile_id,
&profile_dir,
device_name,
sync_platform_label(),
)?;
engine.approve_cloud_device(&device_id, &verification_code)?; engine.approve_cloud_device(&device_id, &verification_code)?;
load_devices(profile_id, engine) load_devices(profile_id, engine)
}); });
@@ -163,8 +167,12 @@ impl ElyShell {
} }
let tx = self.sync_inbox_tx.clone(); let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-revoke", profile_id.clone(), tx, move || { spawn_device_task("ely-sync-device-revoke", profile_id.clone(), tx, move || {
let engine = let engine = SyncEngine::for_profile_dir(
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?; &profile_id,
&profile_dir,
device_name,
sync_platform_label(),
)?;
engine.revoke_cloud_device(&device_id)?; engine.revoke_cloud_device(&device_id)?;
load_devices(profile_id, engine) load_devices(profile_id, engine)
}); });
@@ -181,8 +189,12 @@ impl ElyShell {
} }
let tx = self.sync_inbox_tx.clone(); let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-list", profile_id.clone(), tx, move || { spawn_device_task("ely-sync-device-list", profile_id.clone(), tx, move || {
let engine = let engine = SyncEngine::for_profile_dir(
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?; &profile_id,
&profile_dir,
device_name,
sync_platform_label(),
)?;
load_devices(profile_id, engine) load_devices(profile_id, engine)
}); });
} }
@@ -191,7 +203,7 @@ impl ElyShell {
let ShellState::Ready(core) = &self.state else { let ShellState::Ready(core) = &self.state else {
return None; return None;
}; };
if !core.active_profile_allows_sync() { if !core.cloud_sync_upload_enabled() {
return None; return None;
} }
let snapshot = core.snapshot().ok()?; let snapshot = core.snapshot().ok()?;
@@ -225,10 +237,7 @@ fn spawn_device_task<F>(
let worker_tx = tx.clone(); let worker_tx = tx.clone();
let worker_profile_id = profile_id.clone(); let worker_profile_id = profile_id.clone();
let spawn_result = std::thread::Builder::new().name(thread_name).spawn(move || { let spawn_result = std::thread::Builder::new().name(thread_name).spawn(move || {
let update = task().unwrap_or_else(|error| SyncStateUpdate::DevicesError { let update = task().unwrap_or_else(|error| device_failure_update(worker_profile_id, error));
profile_id: worker_profile_id,
message: error.to_string(),
});
let _ = worker_tx.send(update); let _ = worker_tx.send(update);
}); });
if let Err(error) = spawn_result { if let Err(error) = spawn_result {
@@ -257,4 +266,67 @@ mod tests {
assert!(state.current_code().is_none()); assert!(state.current_code().is_none());
assert!(state.error().is_none()); assert!(state.error().is_none());
} }
#[test]
fn credential_failures_do_not_finish_an_upload() {
let profile_id = ProfileId::new();
let update = device_failure_update(
profile_id.clone(),
ely_sync_client::SyncClientError::BearerCredentialStorage("locked".to_string()),
);
assert!(matches!(
update,
SyncStateUpdate::CredentialUnavailable {
profile_id: owner,
finishes_upload: false,
..
} if owner == profile_id
));
}
#[test]
fn approval_failures_do_not_finish_an_upload() {
let profile_id = ProfileId::new();
let errors = [
ely_sync_client::SyncClientError::DeviceApprovalStatus {
device_id: "device-id".to_string(),
status: "pending".to_string(),
},
ely_sync_client::SyncClientError::HttpStatus {
endpoint: "/api/sync/devices".to_string(),
status: 403,
body: r#"{"error":"device_not_approved"}"#.to_string(),
},
];
for error in errors {
let update = device_failure_update(profile_id.clone(), error);
assert!(matches!(
update,
SyncStateUpdate::AwaitingDeviceApproval {
profile_id: owner,
finishes_upload: false,
} if owner == profile_id
));
}
}
#[test]
fn operational_failures_stay_in_device_ui_state() {
let profile_id = ProfileId::new();
let update = device_failure_update(
profile_id.clone(),
ely_sync_client::SyncClientError::HttpStatus {
endpoint: "/api/sync/devices".to_string(),
status: 503,
body: "unavailable".to_string(),
},
);
assert!(matches!(
update,
SyncStateUpdate::DevicesError { profile_id: owner, .. } if owner == profile_id
));
}
} }
+139 -105
View File
@@ -1,11 +1,13 @@
use std::{path::Path, time::Duration}; use std::{path::Path, time::Duration};
use ely_domain::{ProfileId, ProfileKind, SyncConnectionState}; use ely_domain::{ProfileId, ProfileKind, SyncConnectionState};
use ely_sync_client::{AuthenticatedSnapshotHead, DeviceRecord}; use ely_sync_client::{AuthenticatedSnapshotHead, BearerTokenStore, DeviceRecord};
use gpui::{Context, Timer}; use gpui::{Context, Timer};
use super::{ElyShell, ShellState, auth}; use super::{ElyShell, ShellState, auth};
mod legacy_sync_migration;
const CLOUD_SYNC_UPLOAD_DEBOUNCE: Duration = Duration::from_millis(750); const CLOUD_SYNC_UPLOAD_DEBOUNCE: Duration = Duration::from_millis(750);
const CAS_RETRY_LIMIT: u8 = 3; const CAS_RETRY_LIMIT: u8 = 3;
const CAS_RETRY_DELAY: Duration = Duration::from_secs(2); const CAS_RETRY_DELAY: Duration = Duration::from_secs(2);
@@ -25,11 +27,12 @@ pub(crate) struct PendingMergeUpload {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) enum SyncStateUpdate { pub(crate) enum SyncStateUpdate {
SignedOut { profile_id: ProfileId }, SignedOut { profile_id: ProfileId },
AwaitingDeviceApproval { profile_id: ProfileId }, AwaitingDeviceApproval { profile_id: ProfileId, finishes_upload: bool },
RemoteSnapshot { profile_id: ProfileId, bytes: Vec<u8>, merge: PendingMergeUpload }, RemoteSnapshot { profile_id: ProfileId, bytes: Vec<u8>, merge: PendingMergeUpload },
SyncReady { profile_id: ProfileId, last_synced_at_secs: u64 }, SyncReady { profile_id: ProfileId, last_synced_at_secs: u64 },
SyncBusy { profile_id: ProfileId }, SyncBusy { profile_id: ProfileId },
SyncError { profile_id: ProfileId, message: String }, SyncError { profile_id: ProfileId, message: String },
CredentialUnavailable { profile_id: ProfileId, message: String, finishes_upload: bool },
DevicesLoaded { profile_id: ProfileId, devices: Vec<DeviceRecord>, current_code: String }, DevicesLoaded { profile_id: ProfileId, devices: Vec<DeviceRecord>, current_code: String },
DevicesError { profile_id: ProfileId, message: String }, DevicesError { profile_id: ProfileId, message: String },
AuthOtpSent { profile_id: ProfileId, email: String }, AuthOtpSent { profile_id: ProfileId, email: String },
@@ -37,6 +40,45 @@ pub(crate) enum SyncStateUpdate {
AuthError { profile_id: ProfileId, email: String, message: String }, AuthError { profile_id: ProfileId, email: String, message: String },
} }
pub(super) fn sync_failure_update(
profile_id: ProfileId,
error: ely_sync_client::SyncClientError,
) -> SyncStateUpdate {
let message = error.to_string();
match error {
ely_sync_client::SyncClientError::BearerCredentialStorage(_)
| ely_sync_client::SyncClientError::AccountKeyStorage(_)
| ely_sync_client::SyncClientError::DeviceKeyStorage(_) => {
SyncStateUpdate::CredentialUnavailable { profile_id, message, finishes_upload: true }
}
ely_sync_client::SyncClientError::DeviceApprovalStatus { .. } => {
SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload: true }
}
_ if message.contains("device_not_approved") => {
SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload: true }
}
_ => SyncStateUpdate::SyncError { profile_id, message },
}
}
pub(super) fn device_failure_update(
profile_id: ProfileId,
error: ely_sync_client::SyncClientError,
) -> SyncStateUpdate {
match sync_failure_update(profile_id, error) {
SyncStateUpdate::CredentialUnavailable { profile_id, message, .. } => {
SyncStateUpdate::CredentialUnavailable { profile_id, message, finishes_upload: false }
}
SyncStateUpdate::AwaitingDeviceApproval { profile_id, .. } => {
SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload: false }
}
SyncStateUpdate::SyncError { profile_id, message } => {
SyncStateUpdate::DevicesError { profile_id, message }
}
update => update,
}
}
/// Stable label for the current OS used by the device registration /// Stable label for the current OS used by the device registration
/// payload. Defined once here so every off-thread call site agrees. /// payload. Defined once here so every off-thread call site agrees.
pub(crate) const fn sync_platform_label() -> &'static str { pub(crate) const fn sync_platform_label() -> &'static str {
@@ -128,7 +170,7 @@ impl ElyShell {
let ShellState::Ready(core) = &mut self.state else { let ShellState::Ready(core) = &mut self.state else {
return false; return false;
}; };
probe_initial_sync_state_at(core, &profile_root) probe_initial_sync_state_at(core, &profile_root, self.default_profile_id.as_ref())
} }
/// Drain any sync upload outcomes the off-thread worker pushed /// Drain any sync upload outcomes the off-thread worker pushed
@@ -155,8 +197,8 @@ impl ElyShell {
latest_connection = Some(SyncConnectionState::SignedOut); latest_connection = Some(SyncConnectionState::SignedOut);
} }
} }
SyncStateUpdate::AwaitingDeviceApproval { profile_id } => { SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload } => {
upload_finished = true; upload_finished |= finishes_upload;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) { if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval); latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval);
} }
@@ -216,6 +258,20 @@ impl ElyShell {
latest_connection = Some(SyncConnectionState::SyncError { message }); latest_connection = Some(SyncConnectionState::SyncError { message });
} }
} }
SyncStateUpdate::CredentialUnavailable { profile_id, message, finishes_upload } => {
upload_finished |= finishes_upload;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection =
Some(SyncConnectionState::CredentialUnavailable { message });
self.sync_devices.reset();
self.sync_upload_scheduled = false;
self.sync_retry_at = None;
self.clear_pending_cloud_sync_upload();
trigger_initial_sync = false;
trigger_merged_upload = None;
devices_changed = true;
}
}
SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code } => { SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) { if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.sync_devices.set_ready(profile_id, devices, current_code); self.sync_devices.set_ready(profile_id, devices, current_code);
@@ -296,6 +352,7 @@ fn active_profile_id(state: &ShellState) -> Option<ProfileId> {
fn probe_initial_sync_state_at( fn probe_initial_sync_state_at(
core: &mut ely_browser_core::BrowserCore, core: &mut ely_browser_core::BrowserCore,
profile_root: &Path, profile_root: &Path,
default_profile_id: Option<&ProfileId>,
) -> bool { ) -> bool {
let Some(snapshot) = core.snapshot().ok() else { let Some(snapshot) = core.snapshot().ok() else {
return false; return false;
@@ -305,65 +362,46 @@ fn probe_initial_sync_state_at(
&snapshot.active_profile_id, &snapshot.active_profile_id,
); );
if !core.active_profile_allows_sync() { if !core.active_profile_allows_sync() {
if let Err(error) = auth::clear_persisted_bearer(&profile_dir) { let store = BearerTokenStore::new(&snapshot.active_profile_id, &profile_dir);
if let Err(error) = store.clear_legacy_files() {
tracing::warn!(target: "ely::sync", error = %error, "private bearer cleanup failed"); tracing::warn!(target: "ely::sync", error = %error, "private bearer cleanup failed");
} }
core.set_sync_connection_state(SyncConnectionState::SignedOut); core.set_sync_connection_state(SyncConnectionState::SignedOut);
return false; return false;
} }
if snapshot.active_profile_name == "Default" let is_default_profile = matches!(snapshot.active_profile_kind, ProfileKind::Standard)
&& matches!(snapshot.active_profile_kind, ProfileKind::Standard) && default_profile_id == Some(&snapshot.active_profile_id);
if is_default_profile
&& let Err(error) =
legacy_sync_migration::migrate_default_sync_device(profile_root, &profile_dir)
{ {
migrate_legacy_default_sync_dir(profile_root, &profile_dir);
}
let bearer_path = profile_dir.join("sync").join("bearer.token");
let bearer_present = bearer_token_file_present(&bearer_path);
let state = if bearer_present {
ely_domain::SyncConnectionState::SignedIn
} else {
ely_domain::SyncConnectionState::SignedOut
};
core.set_sync_connection_state(state);
bearer_present
}
fn bearer_token_file_present(path: &Path) -> bool {
std::fs::metadata(path).map(|metadata| metadata.len() > 0).unwrap_or(false)
}
fn migrate_legacy_default_sync_dir(profile_root: &Path, stable_profile_dir: &Path) {
let stable_sync_dir = stable_profile_dir.join("sync");
if stable_sync_dir.exists() {
return;
}
let candidate = profile_root.join("default").join("servo").join("sync");
if !bearer_token_file_present(&candidate.join("bearer.token")) {
return;
}
if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) {
tracing::warn!( tracing::warn!(
target: "ely::sync", target: "ely::sync",
error = %error, error = %error,
source = %candidate.display(),
"legacy sync profile migration failed", "legacy sync profile migration failed",
); );
} }
let store = auth::bearer_store_for_profile(
&snapshot.active_profile_id,
&profile_dir,
profile_root,
default_profile_id,
);
let (bearer_present, state) = match store.load() {
Ok(Some(_)) => (true, SyncConnectionState::SignedIn),
Ok(None) => (false, SyncConnectionState::SignedOut),
Err(error) => {
tracing::warn!(target: "ely::sync", error = %error, "bearer credential probe failed");
(
false,
SyncConnectionState::CredentialUnavailable {
message: "System credential access failed.".to_string(),
},
)
} }
};
fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()> { core.set_sync_connection_state(state);
std::fs::create_dir_all(destination)?; bearer_present
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let file_type = entry.file_type()?;
let destination_path = destination.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&entry.path(), &destination_path)?;
} else if file_type.is_file() {
std::fs::copy(entry.path(), destination_path)?;
}
}
Ok(())
} }
#[cfg(test)] #[cfg(test)]
@@ -371,59 +409,9 @@ mod tests {
use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::SyncConnectionState; use ely_domain::SyncConnectionState;
use super::{ use super::{SyncStateUpdate, probe_initial_sync_state_at, sync_failure_update};
bearer_token_file_present, migrate_legacy_default_sync_dir, probe_initial_sync_state_at,
};
use crate::services::servo_profile_data::sync_profile_data_dir; use crate::services::servo_profile_data::sync_profile_data_dir;
#[test]
fn bearer_token_file_presence_requires_bytes() -> Result<(), Box<dyn std::error::Error>> {
let suffix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos();
let dir = std::env::temp_dir().join(format!("ely-sync-token-probe-{}", suffix));
std::fs::create_dir_all(&dir)?;
let path = dir.join("bearer.token");
assert!(!bearer_token_file_present(&path));
std::fs::write(&path, "")?;
assert!(!bearer_token_file_present(&path));
std::fs::write(&path, "session-token")?;
assert!(bearer_token_file_present(&path));
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[test]
fn known_default_sync_directory_is_migrated() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let legacy = directory.path().join("default/servo/sync");
let stable = directory.path().join("profile_stable/servo");
std::fs::create_dir_all(&legacy)?;
std::fs::write(legacy.join("bearer.token"), "default-token")?;
migrate_legacy_default_sync_dir(directory.path(), &stable);
assert_eq!(std::fs::read_to_string(stable.join("sync/bearer.token"))?, "default-token");
Ok(())
}
#[test]
fn custom_profile_bearer_is_ignored_during_default_migration()
-> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let custom = directory.path().join("profile_custom/servo/sync");
let stable = directory.path().join("profile_stable/servo");
std::fs::create_dir_all(&custom)?;
std::fs::write(custom.join("bearer.token"), "custom-token")?;
migrate_legacy_default_sync_dir(directory.path(), &stable);
assert!(!stable.join("sync/bearer.token").exists());
Ok(())
}
#[test] #[test]
fn private_startup_clears_a_persisted_bearer() -> Result<(), Box<dyn std::error::Error>> { fn private_startup_clears_a_persisted_bearer() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?; let directory = tempfile::tempdir()?;
@@ -436,7 +424,7 @@ mod tests {
std::fs::write(bearer_path.with_extension("tmp"), "b".repeat(64))?; std::fs::write(bearer_path.with_extension("tmp"), "b".repeat(64))?;
core.set_sync_connection_state(SyncConnectionState::SignedIn); core.set_sync_connection_state(SyncConnectionState::SignedIn);
assert!(!probe_initial_sync_state_at(&mut core, directory.path())); assert!(!probe_initial_sync_state_at(&mut core, directory.path(), None));
assert!(!bearer_path.exists()); assert!(!bearer_path.exists());
assert!(!bearer_path.with_extension("tmp").exists()); assert!(!bearer_path.with_extension("tmp").exists());
assert_eq!(core.snapshot()?.sync_status.connection(), &SyncConnectionState::SignedOut); assert_eq!(core.snapshot()?.sync_status.connection(), &SyncConnectionState::SignedOut);
@@ -454,10 +442,56 @@ mod tests {
std::fs::create_dir_all(bearer_path.parent().ok_or("missing bearer parent")?)?; std::fs::create_dir_all(bearer_path.parent().ok_or("missing bearer parent")?)?;
std::fs::write(&bearer_path, "a".repeat(64))?; std::fs::write(&bearer_path, "a".repeat(64))?;
assert!(probe_initial_sync_state_at(&mut core, directory.path())); assert!(probe_initial_sync_state_at(&mut core, directory.path(), Some(&profile_id),));
assert!(bearer_path.exists()); assert!(!bearer_path.exists());
assert_eq!(core.snapshot()?.sync_status.connection(), &SyncConnectionState::SignedIn); assert_eq!(core.snapshot()?.sync_status.connection(), &SyncConnectionState::SignedIn);
let store = ely_sync_client::BearerTokenStore::new(&profile_id, &profile_dir);
assert!(store.load()?.is_some());
store.clear()?;
Ok(()) Ok(())
} }
#[cfg(unix)]
#[test]
fn credential_probe_failure_enters_unavailable_state() -> Result<(), Box<dyn std::error::Error>>
{
use std::os::unix::fs::symlink;
let directory = tempfile::tempdir()?;
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let profile_id = core.snapshot()?.active_profile_id;
let profile_dir = sync_profile_data_dir(directory.path(), &profile_id);
let lock = profile_dir.join("sync/bearer.lock");
let target = directory.path().join("untrusted.lock");
std::fs::create_dir_all(lock.parent().ok_or("missing lock parent")?)?;
std::fs::write(&target, "target")?;
symlink(&target, &lock)?;
assert!(!probe_initial_sync_state_at(&mut core, directory.path(), Some(&profile_id),));
assert!(matches!(
core.snapshot()?.sync_status.connection(),
SyncConnectionState::CredentialUnavailable { .. }
));
Ok(())
}
#[test]
fn credential_storage_failures_use_the_unavailable_update() {
let profile_id = ely_domain::ProfileId::new();
let update = sync_failure_update(
profile_id.clone(),
ely_sync_client::SyncClientError::BearerCredentialStorage("locked".to_string()),
);
assert!(matches!(
update,
SyncStateUpdate::CredentialUnavailable {
profile_id: owner,
finishes_upload: true,
..
} if owner == profile_id
));
}
} }
@@ -0,0 +1,351 @@
use std::{
io::{self, ErrorKind, Read, Write},
path::{Component, Path},
};
use cap_fs_ext::{
DirExt, FollowSymlinks, MetadataExt as CrossPlatformMetadataExt, OpenOptionsFollowExt,
};
use cap_std::{
ambient_authority,
fs::{Dir, File, Metadata, OpenOptions, Permissions},
};
use ely_sync_client::DeviceIdentity;
use uuid::Uuid;
const SOURCE_COMPONENTS: [&str; 3] = ["default", "servo", "sync"];
const DEVICE_FILE: &str = "device.json";
const COMPLETION_MARKER: &str = ".default-sync-device-migrated-v1";
const LOCK_FILE: &str = ".default-sync-device-migration.lock";
const TEMP_PREFIX: &str = ".ely-sync-migration-";
const COMPLETION_BYTES: &[u8] = b"ely-default-sync-device-migration-v1\n";
const MAX_DEVICE_BYTES: usize = 16 * 1024;
#[cfg(unix)]
type ExpectedOwner = u32;
#[cfg(not(unix))]
type ExpectedOwner = ();
pub(super) fn migrate_default_sync_device(
profile_root: &Path,
stable_profile_dir: &Path,
) -> io::Result<()> {
let expected_owner = effective_owner()?;
let root = Dir::open_ambient_dir(profile_root, ambient_authority())?;
validate_directory(&root, expected_owner)?;
let relative_destination = stable_profile_dir
.strip_prefix(profile_root)
.map_err(|_| invalid_source("stable profile directory escapes the profile root"))?;
let stable = open_or_create_path(&root, relative_destination, expected_owner)?;
let destination = open_or_create_child(&stable, "sync", expected_owner)?;
let _lock = acquire_lock(&destination, expected_owner)?;
cleanup_temporary_files(&destination)?;
if completion_marker_exists(&destination, expected_owner)? {
validate_optional_device(&destination, expected_owner)?;
return Ok(());
}
if validate_optional_device(&destination, expected_owner)? {
return write_completion_marker(&destination, expected_owner);
}
let Some(source) = open_source_directory(&root, expected_owner)? else {
return Ok(());
};
let Some(bytes) = read_secure_file(&source, DEVICE_FILE, expected_owner, MAX_DEVICE_BYTES)?
else {
return Ok(());
};
validate_device_bytes(&bytes)?;
persist_bytes_if_absent(
&destination,
DEVICE_FILE,
&bytes,
expected_owner,
validate_device_bytes,
)?;
write_completion_marker(&destination, expected_owner)
}
fn open_source_directory(root: &Dir, owner: ExpectedOwner) -> io::Result<Option<Dir>> {
let mut directory = root.try_clone()?;
for component in SOURCE_COMPONENTS {
directory = match directory.open_dir_nofollow(component) {
Ok(child) => child,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
validate_directory(&directory, owner)?;
}
Ok(Some(directory))
}
fn open_or_create_path(root: &Dir, path: &Path, owner: ExpectedOwner) -> io::Result<Dir> {
let mut directory = root.try_clone()?;
for component in path.components() {
let Component::Normal(name) = component else {
return Err(invalid_source("stable profile directory contains an invalid component"));
};
directory = open_or_create_child(&directory, name, owner)?;
}
Ok(directory)
}
fn open_or_create_child(
parent: &Dir,
name: impl AsRef<Path>,
owner: ExpectedOwner,
) -> io::Result<Dir> {
let name = name.as_ref();
let directory = match parent.open_dir_nofollow(name) {
Ok(directory) => directory,
Err(error) if error.kind() == ErrorKind::NotFound => {
match parent.create_dir(name) {
Ok(()) => {}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
let directory = parent.open_dir_nofollow(name)?;
set_private_directory_permissions(&directory)?;
directory
}
Err(error) => return Err(error),
};
validate_directory(&directory, owner)?;
Ok(directory)
}
fn acquire_lock(directory: &Dir, owner: ExpectedOwner) -> io::Result<std::fs::File> {
let mut options = private_open_options();
options.read(true).write(true).create(true);
let file = directory.open_with(LOCK_FILE, &options)?;
validate_file(&file, owner)?;
set_private_file_permissions(&file)?;
let file = file.into_std();
fs2::FileExt::lock_exclusive(&file)?;
Ok(file)
}
fn validate_optional_device(directory: &Dir, owner: ExpectedOwner) -> io::Result<bool> {
let Some(bytes) = read_secure_file(directory, DEVICE_FILE, owner, MAX_DEVICE_BYTES)? else {
return Ok(false);
};
validate_device_bytes(&bytes)?;
Ok(true)
}
fn completion_marker_exists(directory: &Dir, owner: ExpectedOwner) -> io::Result<bool> {
let Some(bytes) =
read_secure_file(directory, COMPLETION_MARKER, owner, COMPLETION_BYTES.len())?
else {
return Ok(false);
};
validate_marker_bytes(&bytes)?;
Ok(true)
}
fn write_completion_marker(directory: &Dir, owner: ExpectedOwner) -> io::Result<()> {
persist_bytes_if_absent(
directory,
COMPLETION_MARKER,
COMPLETION_BYTES,
owner,
validate_marker_bytes,
)
}
fn persist_bytes_if_absent(
directory: &Dir,
destination: &str,
bytes: &[u8],
owner: ExpectedOwner,
validate: fn(&[u8]) -> io::Result<()>,
) -> io::Result<()> {
if let Some(existing) = read_secure_file(directory, destination, owner, MAX_DEVICE_BYTES)? {
return validate(&existing);
}
let temporary = format!("{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)?;
validate_file(&file, owner)?;
set_private_file_permissions(&file)?;
if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
let _ = directory.remove_file(&temporary);
return Err(error);
}
let linked = match directory.hard_link(&temporary, directory, destination) {
Ok(()) => true,
Err(error) if error.kind() == ErrorKind::AlreadyExists => false,
Err(error) => {
let _ = directory.remove_file(&temporary);
return Err(error);
}
};
remove_file_if_present(directory, &temporary)?;
if linked {
sync_directory(directory)?;
}
let existing = read_secure_file(directory, destination, owner, MAX_DEVICE_BYTES)?
.ok_or_else(|| invalid_source("migration destination disappeared"))?;
validate(&existing)
}
fn read_secure_file(
directory: &Dir,
name: &str,
owner: ExpectedOwner,
maximum_bytes: usize,
) -> io::Result<Option<Vec<u8>>> {
let mut options = private_open_options();
options.read(true);
let file = match directory.open_with(name, &options) {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
validate_file(&file, owner)?;
if file.metadata()?.len() > maximum_bytes as u64 {
return Err(invalid_source("migration file exceeds its size limit"));
}
let mut bytes = Vec::new();
file.into_std().take((maximum_bytes + 1) as u64).read_to_end(&mut bytes)?;
if bytes.len() > maximum_bytes {
return Err(invalid_source("migration file exceeds its size limit"));
}
Ok(Some(bytes))
}
fn cleanup_temporary_files(directory: &Dir) -> io::Result<()> {
for entry in directory.entries()? {
let entry = entry?;
let name = entry.file_name();
if !name.to_string_lossy().starts_with(TEMP_PREFIX) {
continue;
}
if entry.file_type()?.is_dir() {
return Err(invalid_source("migration temporary path is a directory"));
}
directory.remove_file_or_symlink(name)?;
}
Ok(())
}
fn remove_file_if_present(directory: &Dir, name: &str) -> io::Result<()> {
match directory.remove_file(name) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
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_directory(directory: &Dir, owner: ExpectedOwner) -> io::Result<()> {
let metadata = directory.dir_metadata()?;
if !metadata.is_dir() {
return Err(invalid_source("migration path is not a directory"));
}
validate_owner_and_mode(&metadata, owner)
}
fn validate_file(file: &File, owner: ExpectedOwner) -> io::Result<()> {
let metadata = file.metadata()?;
if !metadata.is_file() || CrossPlatformMetadataExt::nlink(&metadata) != 1 {
return Err(invalid_source("migration file link state is invalid"));
}
validate_owner_and_mode(&metadata, owner)
}
#[cfg(unix)]
fn validate_owner_and_mode(metadata: &Metadata, owner: ExpectedOwner) -> io::Result<()> {
use cap_std::fs::{MetadataExt, PermissionsExt};
if metadata.uid() != owner || metadata.permissions().mode() & 0o022 != 0 {
return Err(io::Error::new(
ErrorKind::PermissionDenied,
"migration path ownership or permissions are invalid",
));
}
Ok(())
}
#[cfg(not(unix))]
const fn validate_owner_and_mode(_metadata: &Metadata, _owner: ExpectedOwner) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn effective_owner() -> io::Result<ExpectedOwner> {
use std::os::unix::fs::MetadataExt;
let probe = tempfile::NamedTempFile::new()?;
Ok(probe.as_file().metadata()?.uid())
}
#[cfg(not(unix))]
const fn effective_owner() -> io::Result<ExpectedOwner> {
Ok(())
}
#[cfg(unix)]
fn set_private_directory_permissions(directory: &Dir) -> io::Result<()> {
use cap_std::fs::PermissionsExt;
directory.set_permissions(".", Permissions::from_mode(0o700))
}
#[cfg(not(unix))]
const fn set_private_directory_permissions(_directory: &Dir) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn set_private_file_permissions(file: &File) -> io::Result<()> {
use cap_std::fs::PermissionsExt;
file.set_permissions(Permissions::from_mode(0o600))
}
#[cfg(not(unix))]
const fn set_private_file_permissions(_file: &File) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn sync_directory(directory: &Dir) -> io::Result<()> {
directory.try_clone()?.into_std_file().sync_all()
}
#[cfg(not(unix))]
const fn sync_directory(_directory: &Dir) -> io::Result<()> {
Ok(())
}
fn validate_device_bytes(bytes: &[u8]) -> io::Result<()> {
DeviceIdentity::validate_stored_bytes(bytes, Path::new(DEVICE_FILE))
.map_err(|error| invalid_source(error.to_string()))
}
fn validate_marker_bytes(bytes: &[u8]) -> io::Result<()> {
if bytes == COMPLETION_BYTES {
Ok(())
} else {
Err(invalid_source("legacy sync migration marker is invalid"))
}
}
fn invalid_source(message: impl Into<String>) -> io::Error {
io::Error::new(ErrorKind::InvalidData, message.into())
}
#[cfg(test)]
#[path = "legacy_sync_migration_tests.rs"]
mod tests;
@@ -0,0 +1,159 @@
use std::{fs, path::Path};
use super::*;
const PUBLIC_KEY: &str = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";
#[test]
fn copies_only_valid_device_once() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let source = source_dir(directory.path());
let stable = stable_dir(directory.path());
fs::create_dir_all(source.join("nested"))?;
fs::write(source.join(DEVICE_FILE), device_bytes("Legacy"))?;
fs::write(source.join("bearer.token"), "secret")?;
fs::write(source.join("other.json"), "other")?;
migrate_default_sync_device(directory.path(), &stable)?;
let destination = stable.join("sync");
assert_eq!(fs::read(destination.join(DEVICE_FILE))?, device_bytes("Legacy"));
assert!(!destination.join("bearer.token").exists());
assert!(!destination.join("other.json").exists());
assert!(!destination.join("nested").exists());
fs::remove_file(destination.join(DEVICE_FILE))?;
fs::write(source.join(DEVICE_FILE), device_bytes("Changed"))?;
migrate_default_sync_device(directory.path(), &stable)?;
assert!(!destination.join(DEVICE_FILE).exists());
Ok(())
}
#[test]
fn preserves_a_valid_existing_destination() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let source = source_dir(directory.path());
let stable = stable_dir(directory.path());
fs::create_dir_all(&source)?;
fs::create_dir_all(stable.join("sync"))?;
fs::write(source.join(DEVICE_FILE), device_bytes("Legacy"))?;
fs::write(stable.join("sync/device.json"), device_bytes("Current"))?;
migrate_default_sync_device(directory.path(), &stable)?;
assert_eq!(fs::read(stable.join("sync/device.json"))?, device_bytes("Current"));
assert!(stable.join("sync").join(COMPLETION_MARKER).exists());
Ok(())
}
#[test]
fn missing_source_retries_later() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let stable = stable_dir(directory.path());
migrate_default_sync_device(directory.path(), &stable)?;
assert!(!stable.join("sync").join(COMPLETION_MARKER).exists());
let source = source_dir(directory.path());
fs::create_dir_all(&source)?;
fs::write(source.join(DEVICE_FILE), device_bytes("Late"))?;
migrate_default_sync_device(directory.path(), &stable)?;
assert_eq!(fs::read(stable.join("sync/device.json"))?, device_bytes("Late"));
Ok(())
}
#[test]
fn rejects_invalid_and_oversized_sources() -> Result<(), Box<dyn std::error::Error>> {
for bytes in [b"{}".to_vec(), vec![b'a'; MAX_DEVICE_BYTES + 1]] {
let directory = tempfile::tempdir()?;
let source = source_dir(directory.path());
let stable = stable_dir(directory.path());
fs::create_dir_all(&source)?;
fs::write(source.join(DEVICE_FILE), bytes)?;
assert!(migrate_default_sync_device(directory.path(), &stable).is_err());
assert!(!stable.join("sync").join(COMPLETION_MARKER).exists());
}
Ok(())
}
#[cfg(unix)]
#[test]
fn rejects_source_ancestor_and_destination_links() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::symlink;
for linked_component in 0..SOURCE_COMPONENTS.len() {
let directory = tempfile::tempdir()?;
let target = directory.path().join("source-target");
let mut real = target.clone();
for component in &SOURCE_COMPONENTS[linked_component + 1..] {
real.push(component);
}
fs::create_dir_all(&real)?;
fs::write(real.join(DEVICE_FILE), device_bytes("Linked"))?;
let mut link = directory.path().to_path_buf();
for component in &SOURCE_COMPONENTS[..linked_component] {
link.push(component);
}
fs::create_dir_all(&link)?;
link.push(SOURCE_COMPONENTS[linked_component]);
symlink(&target, &link)?;
assert!(
migrate_default_sync_device(directory.path(), &stable_dir(directory.path())).is_err()
);
}
let directory = tempfile::tempdir()?;
let stable = stable_dir(directory.path());
fs::create_dir_all(&stable)?;
symlink(directory.path().join("elsewhere"), stable.join("sync"))?;
assert!(migrate_default_sync_device(directory.path(), &stable).is_err());
Ok(())
}
#[cfg(unix)]
#[test]
fn rejects_linked_destination_files() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::symlink;
for name in [DEVICE_FILE, COMPLETION_MARKER] {
let directory = tempfile::tempdir()?;
let stable = stable_dir(directory.path());
let destination = stable.join("sync");
let target = directory.path().join("target");
fs::create_dir_all(&destination)?;
fs::write(
&target,
if name == DEVICE_FILE { device_bytes("Target") } else { COMPLETION_BYTES.to_vec() },
)?;
symlink(&target, destination.join(name))?;
assert!(migrate_default_sync_device(directory.path(), &stable).is_err());
}
Ok(())
}
#[test]
fn rejects_hardlinked_source_and_destination() -> Result<(), Box<dyn std::error::Error>> {
for destination_link in [false, true] {
let directory = tempfile::tempdir()?;
let source = source_dir(directory.path());
let stable = stable_dir(directory.path());
let target = directory.path().join("target.json");
fs::create_dir_all(&source)?;
fs::write(&target, device_bytes("Target"))?;
if destination_link {
fs::create_dir_all(stable.join("sync"))?;
fs::hard_link(&target, stable.join("sync/device.json"))?;
} else {
fs::hard_link(&target, source.join(DEVICE_FILE))?;
}
assert!(migrate_default_sync_device(directory.path(), &stable).is_err());
}
Ok(())
}
fn source_dir(root: &Path) -> std::path::PathBuf {
root.join("default/servo/sync")
}
fn stable_dir(root: &Path) -> std::path::PathBuf {
root.join("profile_stable/servo")
}
fn device_bytes(name: &str) -> Vec<u8> {
format!(
r#"{{"device_id":"ely-legacy-device","public_key":"{PUBLIC_KEY}","device_name":"{name}","platform":"macos"}}"#,
)
.into_bytes()
}
+10 -6
View File
@@ -3,6 +3,7 @@ use std::{
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
use ely_domain::ProfileId;
use ely_sync_client::{ use ely_sync_client::{
AccountKey, ApiClientConfig, AuthenticatedSnapshotHead, BearerToken, BearerTokenStore, AccountKey, ApiClientConfig, AuthenticatedSnapshotHead, BearerToken, BearerTokenStore,
DeviceIdentity, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, SnapshotDownloadResult, DeviceIdentity, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, SnapshotDownloadResult,
@@ -34,6 +35,7 @@ impl SyncEngine {
/// fresh device identity on first use, then keeps it stable across /// fresh device identity on first use, then keeps it stable across
/// runs so the server's `user_devices` row stays bound. /// runs so the server's `user_devices` row stays bound.
pub fn for_profile_dir( pub fn for_profile_dir(
profile_id: &ProfileId,
profile_data_dir: &Path, profile_data_dir: &Path,
device_name: impl Into<String>, device_name: impl Into<String>,
platform: impl Into<String>, platform: impl Into<String>,
@@ -46,7 +48,7 @@ impl SyncEngine {
.join(".sync-key-locks"); .join(".sync-key-locks");
let identity = let identity =
DeviceIdentity::load_or_create(&sync_dir.join("device.json"), device_name, platform)?; DeviceIdentity::load_or_create(&sync_dir.join("device.json"), device_name, platform)?;
let bearer_store = BearerTokenStore::new(sync_dir.join("bearer.token")); let bearer_store = BearerTokenStore::new(profile_id, profile_data_dir);
Ok(Self { Ok(Self {
api_config: ApiClientConfig::production(), api_config: ApiClientConfig::production(),
bearer_store, bearer_store,
@@ -60,10 +62,6 @@ impl SyncEngine {
&self.identity &self.identity
} }
pub fn bearer_path(&self) -> &Path {
self.bearer_store.path()
}
pub fn last_outcome(&self) -> Option<&SyncOutcome> { pub fn last_outcome(&self) -> Option<&SyncOutcome> {
self.last_outcome.as_ref() self.last_outcome.as_ref()
} }
@@ -436,6 +434,7 @@ fn device_registration_idempotency_key(identity: &DeviceIdentity) -> String {
#[derive(Debug)] #[derive(Debug)]
pub struct SyncEngineBuilder { pub struct SyncEngineBuilder {
pub profile_id: ProfileId,
pub profile_data_dir: PathBuf, pub profile_data_dir: PathBuf,
pub device_name: String, pub device_name: String,
pub platform: String, pub platform: String,
@@ -443,7 +442,12 @@ pub struct SyncEngineBuilder {
impl SyncEngineBuilder { impl SyncEngineBuilder {
pub fn build(self) -> Result<SyncEngine, SyncClientError> { pub fn build(self) -> Result<SyncEngine, SyncClientError> {
SyncEngine::for_profile_dir(&self.profile_data_dir, self.device_name, self.platform) SyncEngine::for_profile_dir(
&self.profile_id,
&self.profile_data_dir,
self.device_name,
self.platform,
)
} }
} }
+11
View File
@@ -38,6 +38,17 @@ fn default_sync_status_reflects_local_browser_state() -> Result<(), Box<dyn Erro
Ok(()) Ok(())
} }
#[test]
fn unavailable_credentials_disable_cloud_uploads() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
core.set_sync_connection_state(SyncConnectionState::CredentialUnavailable {
message: "credential unavailable".to_string(),
});
assert!(!core.cloud_sync_upload_enabled());
Ok(())
}
#[test] #[test]
fn sync_object_policy_pauses_object_kind() -> Result<(), Box<dyn Error>> { fn sync_object_policy_pauses_object_kind() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
+3 -1
View File
@@ -13,6 +13,7 @@
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum SyncConnectionState { pub enum SyncConnectionState {
SignedOut, SignedOut,
CredentialUnavailable { message: String },
SignedIn, SignedIn,
AwaitingDeviceApproval, AwaitingDeviceApproval,
SyncReady { last_synced_at_secs: u64 }, SyncReady { last_synced_at_secs: u64 },
@@ -104,7 +105,8 @@ impl SyncStatus {
#[must_use] #[must_use]
pub fn new(connection: SyncConnectionState, objects: Vec<SyncObjectStatus>) -> Self { pub fn new(connection: SyncConnectionState, objects: Vec<SyncObjectStatus>) -> Self {
let failed_objects = match &connection { let failed_objects = match &connection {
SyncConnectionState::SyncError { .. } => 1, SyncConnectionState::CredentialUnavailable { .. }
| SyncConnectionState::SyncError { .. } => 1,
_ => 0, _ => 0,
}; };
Self { connection, pending_objects: 0, failed_objects, objects } Self { connection, pending_objects: 0, failed_objects, objects }
+18 -1
View File
@@ -7,6 +7,8 @@ rust-version.workspace = true
[dependencies] [dependencies]
base64.workspace = true base64.workspace = true
cap-fs-ext.workspace = true
cap-std.workspace = true
chacha20poly1305.workspace = true chacha20poly1305.workspace = true
ed25519-dalek.workspace = true ed25519-dalek.workspace = true
ely_domain = { path = "../ely_domain" } ely_domain = { path = "../ely_domain" }
@@ -15,7 +17,7 @@ getrandom.workspace = true
hkdf.workspace = true hkdf.workspace = true
hmac.workspace = true hmac.workspace = true
hpke.workspace = true hpke.workspace = true
keyring.workspace = true keyring-core.workspace = true
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
@@ -24,5 +26,20 @@ ureq = { workspace = true, features = ["json"] }
uuid = { workspace = true } uuid = { workspace = true }
zeroize.workspace = true zeroize.workspace = true
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(unix)'.dependencies]
rustix = { workspace = true, features = ["process"] }
[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows-native-keyring-store = "1.1.0"
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))'.dependencies]
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
[lints] [lints]
workspace = true workspace = true
+253 -90
View File
@@ -1,31 +1,56 @@
use std::{ use std::{
fs, fmt,
io::{self, ErrorKind},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use crate::error::SyncClientError; use ely_domain::ProfileId;
use fs2::FileExt;
use zeroize::Zeroizing;
/// Better Auth bearer token issued by `https://<base>/api/auth/*`. The use crate::{
/// token grants access to the per-user `withAuthenticatedApiControls` auth_files::{
/// routes and, once the device is bound to the session, to the ensure_migration_marker, migration_marker_exists, open_lock_file, read_legacy_bytes,
/// `withApprovedDeviceApiControls` routes used by sync. remove_file_if_present,
#[derive(Clone, Debug, Eq, PartialEq)] },
pub struct BearerToken(String); credential_store::{clear_secret, load_secret, save_secret},
error::SyncClientError,
};
const KEYCHAIN_SERVICE: &str = "com.elydora.ely-browser.auth.bearer.v1";
const MIN_BEARER_TOKEN_BYTES: usize = 32;
const MAX_BEARER_TOKEN_BYTES: usize = 2560;
/// Better Auth bearer token issued by `https://<base>/api/auth/*`.
pub struct BearerToken(Zeroizing<String>);
impl Clone for BearerToken {
fn clone(&self) -> Self {
Self(Zeroizing::new(self.as_str().to_string()))
}
}
impl fmt::Debug for BearerToken {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("BearerToken([REDACTED])")
}
}
impl PartialEq for BearerToken {
fn eq(&self, other: &Self) -> bool {
self.as_str() == other.as_str()
}
}
impl Eq for BearerToken {}
impl BearerToken { impl BearerToken {
/// Construct a token from an existing string. Trims whitespace and
/// enforces the same character envelope the worker validates so
/// obviously-malformed tokens fail before we hit the network.
pub fn new(value: impl Into<String>) -> Result<Self, SyncClientError> { pub fn new(value: impl Into<String>) -> Result<Self, SyncClientError> {
let value = value.into(); let value = Zeroizing::new(value.into());
let trimmed = value.trim(); let trimmed = value.trim();
if trimmed.is_empty() || !is_better_auth_bearer(trimmed) { if trimmed.is_empty() || !is_better_auth_bearer(trimmed) {
return Err(SyncClientError::TokenStorage( return Err(storage_error("bearer token is not a Better Auth session token"));
"bearer token is not a Better Auth session token".to_string(),
));
} }
Ok(Self(trimmed.to_string())) Ok(Self(Zeroizing::new(trimmed.to_string())))
} }
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
@@ -34,7 +59,7 @@ impl BearerToken {
} }
fn is_better_auth_bearer(token: &str) -> bool { fn is_better_auth_bearer(token: &str) -> bool {
let length_ok = (32..=4096).contains(&token.len()); let length_ok = (MIN_BEARER_TOKEN_BYTES..=MAX_BEARER_TOKEN_BYTES).contains(&token.len());
let charset_ok = token let charset_ok = token
.as_bytes() .as_bytes()
.iter() .iter()
@@ -42,100 +67,238 @@ fn is_better_auth_bearer(token: &str) -> bool {
length_ok && charset_ok length_ok && charset_ok
} }
/// File-backed bearer token store. Lives in the per-profile data trait CredentialBackend {
/// directory so a private window never inherits the standard fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String>;
/// profile's session — same isolation the rest of the runtime fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String>;
/// enforces. Writes go through a temp-rename so a partial write fn clear(&self, service: &str, account: &str) -> Result<(), String>;
/// can't corrupt the persisted token. }
struct NativeCredentialBackend;
impl CredentialBackend for NativeCredentialBackend {
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
load_secret(service, account)
}
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
save_secret(service, account, secret)
}
fn clear(&self, service: &str, account: &str) -> Result<(), String> {
clear_secret(service, account)
}
}
/// Profile-scoped native credential store with one-time plaintext-file migration.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct BearerTokenStore { pub struct BearerTokenStore {
path: PathBuf, account: String,
lock_path: PathBuf,
migration_marker_path: PathBuf,
legacy_paths: Vec<PathBuf>,
} }
impl BearerTokenStore { impl BearerTokenStore {
pub fn new(path: PathBuf) -> Self { pub fn new(profile_id: &ProfileId, profile_data_dir: &Path) -> Self {
Self { path } let sync_dir = profile_data_dir.join("sync");
Self {
account: profile_id.as_str().to_string(),
lock_path: sync_dir.join("bearer.lock"),
migration_marker_path: sync_dir.join("bearer.migrated"),
legacy_paths: vec![sync_dir.join("bearer.token")],
}
} }
pub fn path(&self) -> &Path { pub fn with_legacy_path(mut self, path: PathBuf) -> Self {
&self.path if !self.legacy_paths.contains(&path) {
self.legacy_paths.push(path);
}
self
} }
pub fn load(&self) -> Result<Option<BearerToken>, SyncClientError> { pub fn load(&self) -> Result<Option<BearerToken>, SyncClientError> {
match fs::read_to_string(&self.path) { self.load_with(&NativeCredentialBackend)
Ok(contents) => {
if contents.trim().is_empty() {
return Ok(None);
}
BearerToken::new(contents).map(Some)
}
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
}
} }
pub fn save(&self, token: &BearerToken) -> Result<(), SyncClientError> { pub fn save(&self, token: &BearerToken) -> Result<(), SyncClientError> {
if let Some(parent) = self.path.parent() { self.save_with(&NativeCredentialBackend, token)
fs::create_dir_all(parent).map_err(io_err)?;
}
let tmp = self.path.with_extension("tmp");
fs::write(&tmp, token.as_str()).map_err(io_err)?;
fs::rename(&tmp, &self.path).map_err(io_err)?;
Ok(())
} }
pub fn clear(&self) -> Result<(), SyncClientError> { pub fn clear(&self) -> Result<(), SyncClientError> {
remove_file_if_present(&self.path)?; self.clear_with(&NativeCredentialBackend)
remove_file_if_present(&self.path.with_extension("tmp")) }
pub fn clear_if_matches(&self, token: &BearerToken) -> Result<bool, SyncClientError> {
self.clear_if_matches_with(&NativeCredentialBackend, token)
}
fn clear_if_matches_with<B: CredentialBackend>(
&self,
backend: &B,
token: &BearerToken,
) -> Result<bool, SyncClientError> {
self.with_lock(|| {
let current = load_backend_token(backend, &self.account)?;
ensure_migration_marker(&self.migration_marker_path)?;
self.cleanup_legacy_paths()?;
if current.as_ref().is_some_and(|current| current != token) {
return Ok(false);
}
backend.clear(KEYCHAIN_SERVICE, &self.account).map_err(storage_error)?;
Ok(true)
})
}
pub fn clear_legacy_files(&self) -> Result<(), SyncClientError> {
self.with_lock(|| self.cleanup_legacy_paths())
}
fn load_with<B: CredentialBackend>(
&self,
backend: &B,
) -> Result<Option<BearerToken>, SyncClientError> {
self.with_lock(|| self.load_locked(backend))
}
fn save_with<B: CredentialBackend>(
&self,
backend: &B,
token: &BearerToken,
) -> Result<(), SyncClientError> {
self.with_lock(|| {
ensure_migration_marker(&self.migration_marker_path)?;
self.cleanup_legacy_paths()?;
backend
.save(KEYCHAIN_SERVICE, &self.account, token.as_str().as_bytes())
.map_err(storage_error)
})
}
fn clear_with<B: CredentialBackend>(&self, backend: &B) -> Result<(), SyncClientError> {
self.with_lock(|| {
ensure_migration_marker(&self.migration_marker_path)?;
self.cleanup_legacy_paths()?;
backend.clear(KEYCHAIN_SERVICE, &self.account).map_err(storage_error)
})
}
fn load_locked<B: CredentialBackend>(
&self,
backend: &B,
) -> Result<Option<BearerToken>, SyncClientError> {
if let Some(token) = load_backend_token(backend, &self.account)? {
ensure_migration_marker(&self.migration_marker_path)?;
self.cleanup_legacy_paths()?;
return Ok(Some(token));
}
if migration_marker_exists(&self.migration_marker_path)? {
self.cleanup_legacy_paths()?;
return Ok(None);
}
let mut candidate = None;
for path in &self.legacy_paths {
if let Some(token) = read_legacy_token(path)? {
candidate = Some(token);
break;
}
}
let Some(token) = candidate else {
self.cleanup_legacy_tmp_files()?;
return Ok(None);
};
backend
.save(KEYCHAIN_SERVICE, &self.account, token.as_str().as_bytes())
.map_err(storage_error)?;
if let Err(error) = verify_backend_token(backend, &self.account, &token) {
return Err(rollback_migration(backend, &self.account, error));
}
if let Err(error) = ensure_migration_marker(&self.migration_marker_path) {
if matches!(migration_marker_exists(&self.migration_marker_path), Ok(false)) {
return Err(rollback_migration(backend, &self.account, error));
}
return Err(error);
}
self.cleanup_legacy_paths()?;
Ok(Some(token))
}
fn cleanup_legacy_paths(&self) -> Result<(), SyncClientError> {
for path in &self.legacy_paths {
remove_file_if_present(path)?;
remove_file_if_present(&path.with_extension("tmp"))?;
}
Ok(())
}
fn cleanup_legacy_tmp_files(&self) -> Result<(), SyncClientError> {
for path in &self.legacy_paths {
remove_file_if_present(&path.with_extension("tmp"))?;
}
Ok(())
}
fn with_lock<T>(
&self,
operation: impl FnOnce() -> Result<T, SyncClientError>,
) -> Result<T, SyncClientError> {
let lock = open_lock_file(&self.lock_path)?;
lock.lock_exclusive().map_err(|error| storage_error(error.to_string()))?;
operation()
} }
} }
fn remove_file_if_present(path: &Path) -> Result<(), SyncClientError> { fn load_backend_token<B: CredentialBackend>(
match fs::remove_file(path) { backend: &B,
Ok(()) => Ok(()), account: &str,
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), ) -> Result<Option<BearerToken>, SyncClientError> {
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())), let Some(secret) = backend.load(KEYCHAIN_SERVICE, account).map_err(storage_error)? else {
return Ok(None);
};
let value =
std::str::from_utf8(&secret).map_err(|error| storage_error(error.to_string()))?.to_string();
BearerToken::new(value).map(Some)
}
fn verify_backend_token<B: CredentialBackend>(
backend: &B,
account: &str,
token: &BearerToken,
) -> Result<(), SyncClientError> {
let stored = load_backend_token(backend, account)?;
if stored.as_ref() != Some(token) {
return Err(storage_error("native credential read-back did not match"));
}
Ok(())
}
fn rollback_migration<B: CredentialBackend>(
backend: &B,
account: &str,
cause: SyncClientError,
) -> SyncClientError {
match backend.clear(KEYCHAIN_SERVICE, account) {
Ok(()) => cause,
Err(rollback_error) => {
storage_error(format!("{cause}; native credential rollback failed: {rollback_error}"))
}
} }
} }
fn io_err(error: io::Error) -> SyncClientError { fn read_legacy_token(path: &Path) -> Result<Option<BearerToken>, SyncClientError> {
SyncClientError::TokenStorage(error.to_string()) let Some(bytes) = read_legacy_bytes(path, MAX_BEARER_TOKEN_BYTES + 1)? else {
return Ok(None);
};
let value =
std::str::from_utf8(&bytes).map_err(|error| storage_error(error.to_string()))?.to_string();
BearerToken::new(value).map(Some)
}
fn storage_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::BearerCredentialStorage(message.into())
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "auth_tests.rs"]
use super::*; mod tests;
use std::env::temp_dir;
#[test]
fn rejects_obviously_broken_tokens() {
assert!(BearerToken::new("").is_err());
assert!(BearerToken::new(" ").is_err());
assert!(BearerToken::new("short").is_err());
// Spaces are not in the Better Auth bearer charset.
assert!(BearerToken::new(format!("{}aaa bb", "a".repeat(40))).is_err());
}
#[test]
fn accepts_better_auth_shape() -> Result<(), SyncClientError> {
let token = format!("{}-{}_{}", "a".repeat(20), "b".repeat(20), "c".repeat(20));
BearerToken::new(token).map(|_| ())
}
#[test]
fn token_store_round_trips() -> Result<(), SyncClientError> {
let dir = temp_dir().join(format!("ely-token-{}", uuid::Uuid::now_v7().simple()));
let store = BearerTokenStore::new(dir.join("token"));
let token = BearerToken::new("a".repeat(64))?;
assert_eq!(store.load()?, None);
store.save(&token)?;
assert_eq!(store.load()?, Some(token.clone()));
fs::write(store.path().with_extension("tmp"), token.as_str()).map_err(io_err)?;
store.clear()?;
assert_eq!(store.load()?, None);
assert!(!store.path().with_extension("tmp").exists());
Ok(())
}
}
+296
View File
@@ -0,0 +1,296 @@
use std::{
fs,
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 zeroize::Zeroizing;
use crate::error::SyncClientError;
const MARKER_CONTENT: &[u8] = b"ely-bearer-migration-v1\n";
pub(super) fn read_legacy_bytes(
path: &Path,
maximum_bytes: usize,
) -> Result<Option<Zeroizing<Vec<u8>>>, SyncClientError> {
let Some((directory, name)) = open_parent(path, false)? else {
return Ok(None);
};
let Some(file) = open_existing(&directory, name)? else {
return Ok(None);
};
validate_private_file(&file)?;
let mut bytes = Zeroizing::new(Vec::new());
file.into_std().take((maximum_bytes + 1) as u64).read_to_end(&mut bytes).map_err(storage_io)?;
if bytes.len() > maximum_bytes {
return Err(storage_error("legacy bearer token is too large"));
}
Ok(Some(bytes))
}
pub(super) fn ensure_migration_marker(path: &Path) -> Result<(), SyncClientError> {
let (directory, name) = open_parent(path, true)?
.ok_or_else(|| storage_error("bearer migration marker parent is missing"))?;
if let Some(file) = open_existing(&directory, name)? {
return validate_migration_marker(file);
}
let temporary_path = marker_temporary_path(path)?;
remove_file_if_present(&temporary_path)?;
let temporary_name = temporary_path
.file_name()
.ok_or_else(|| storage_error("bearer migration marker temp name is missing"))?;
let mut options = private_open_options();
options.write(true).create_new(true);
let mut file = directory.open_with(temporary_name, &options).map_err(storage_io)?;
validate_private_file(&file)?;
set_private_file_permissions(&file)?;
file.write_all(MARKER_CONTENT).map_err(storage_io)?;
file.sync_all().map_err(storage_io)?;
drop(file);
directory.rename(temporary_name, &directory, name).map_err(storage_io)?;
sync_directory(&directory)
}
pub(super) fn migration_marker_exists(path: &Path) -> Result<bool, SyncClientError> {
let Some((directory, name)) = open_parent(path, false)? else {
return Ok(false);
};
let Some(file) = open_existing(&directory, name)? else {
return Ok(false);
};
validate_migration_marker(file)?;
Ok(true)
}
fn validate_migration_marker(file: File) -> Result<(), SyncClientError> {
validate_private_file(&file)?;
let mut bytes = Vec::new();
file.into_std()
.take((MARKER_CONTENT.len() + 1) as u64)
.read_to_end(&mut bytes)
.map_err(storage_io)?;
if bytes != MARKER_CONTENT {
return Err(storage_error("bearer migration marker content is invalid"));
}
Ok(())
}
fn marker_temporary_path(path: &Path) -> Result<PathBuf, SyncClientError> {
let mut name = path
.file_name()
.ok_or_else(|| storage_error("bearer migration marker name is missing"))?
.to_os_string();
name.push(".tmp");
Ok(path.with_file_name(name))
}
pub(super) fn remove_file_if_present(path: &Path) -> Result<(), SyncClientError> {
let Some((directory, name)) = open_parent(path, false)? else {
return Ok(());
};
let metadata = match directory.symlink_metadata(name) {
Ok(metadata) => metadata,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(storage_io(error)),
};
if metadata.is_symlink() {
return directory.remove_file_or_symlink(name).map_err(storage_io);
}
let file = open_existing(&directory, name)?.ok_or_else(|| {
storage_error("legacy bearer token disappeared during cleanup validation")
})?;
validate_private_file(&file)?;
drop(file);
directory.remove_file_or_symlink(name).map_err(storage_io)
}
pub(super) fn open_lock_file(path: &Path) -> Result<fs::File, SyncClientError> {
let (directory, name) =
open_parent(path, true)?.ok_or_else(|| storage_error("bearer lock parent is missing"))?;
let mut options = private_open_options();
options.read(true).write(true).create(true);
let file = directory.open_with(name, &options).map_err(storage_io)?;
validate_private_file(&file)?;
set_private_file_permissions(&file)?;
Ok(file.into_std())
}
fn open_parent(
path: &Path,
create: bool,
) -> Result<Option<(Dir, &std::ffi::OsStr)>, SyncClientError> {
let parent = path.parent().ok_or_else(|| storage_error("credential file parent is missing"))?;
let name = path.file_name().ok_or_else(|| storage_error("credential file name is missing"))?;
let Some(directory) = open_directory_nofollow(parent, create)? else {
return Ok(None);
};
validate_private_directory(&directory)?;
set_private_directory_permissions(&directory)?;
Ok(Some((directory, name)))
}
fn open_directory_nofollow(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_error("credential path traversal is invalid"));
}
}
}
if root.as_os_str().is_empty() {
return Err(storage_error("credential paths 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;
}
Ok(Some(directory))
}
#[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 open_existing(directory: &Dir, name: &std::ffi::OsStr) -> Result<Option<File>, SyncClientError> {
let mut options = private_open_options();
options.read(true);
match directory.open_with(name, &options) {
Ok(file) => Ok(Some(file)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(storage_io(error)),
}
}
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_error("credential file parent is not a directory"));
}
#[cfg(unix)]
{
use cap_std::fs::MetadataExt;
if metadata.uid() != rustix::process::geteuid().as_raw() {
return Err(storage_error("credential file parent 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_error("credential file link state is invalid"));
}
#[cfg(unix)]
{
use cap_std::fs::MetadataExt;
if metadata.uid() != rustix::process::geteuid().as_raw() {
return Err(storage_error("credential file ownership is invalid"));
}
}
Ok(())
}
fn set_private_directory_permissions(directory: &Dir) -> Result<(), SyncClientError> {
#[cfg(unix)]
directory
.set_permissions(".", Permissions::from_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(fs::Permissions::from_mode(0o600)))
.map_err(storage_io)?;
Ok(())
}
#[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(())
}
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn storage_io(error: io::Error) -> SyncClientError {
storage_error(error.to_string())
}
fn storage_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::BearerCredentialStorage(message.into())
}
+483
View File
@@ -0,0 +1,483 @@
use std::{
collections::BTreeMap,
fs,
sync::{Mutex, MutexGuard},
};
use ely_domain::ProfileId;
use zeroize::Zeroizing;
use super::*;
#[derive(Default)]
struct MemoryCredentialBackend {
values: Mutex<BTreeMap<(String, String), Vec<u8>>>,
}
impl CredentialBackend for MemoryCredentialBackend {
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
Ok(self
.values()
.get(&(service.to_string(), account.to_string()))
.cloned()
.map(Zeroizing::new))
}
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
self.values().insert((service.to_string(), account.to_string()), secret.to_vec());
Ok(())
}
fn clear(&self, service: &str, account: &str) -> Result<(), String> {
self.values().remove(&(service.to_string(), account.to_string()));
Ok(())
}
}
impl MemoryCredentialBackend {
fn values(&self) -> MutexGuard<'_, BTreeMap<(String, String), Vec<u8>>> {
self.values.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
struct DroppingCredentialBackend;
impl CredentialBackend for DroppingCredentialBackend {
fn load(&self, _service: &str, _account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
Ok(None)
}
fn save(&self, _service: &str, _account: &str, _secret: &[u8]) -> Result<(), String> {
Ok(())
}
fn clear(&self, _service: &str, _account: &str) -> Result<(), String> {
Ok(())
}
}
struct SaveFailureBackend;
impl CredentialBackend for SaveFailureBackend {
fn load(&self, _service: &str, _account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
Ok(None)
}
fn save(&self, _service: &str, _account: &str, _secret: &[u8]) -> Result<(), String> {
Err("credential backend is locked".to_string())
}
fn clear(&self, _service: &str, _account: &str) -> Result<(), String> {
Ok(())
}
}
#[derive(Default)]
struct ClearFailureBackend {
values: Mutex<BTreeMap<(String, String), Vec<u8>>>,
}
impl CredentialBackend for ClearFailureBackend {
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
Ok(self
.values
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&(service.to_string(), account.to_string()))
.cloned()
.map(Zeroizing::new))
}
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
self.values
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert((service.to_string(), account.to_string()), secret.to_vec());
Ok(())
}
fn clear(&self, _service: &str, _account: &str) -> Result<(), String> {
Err("credential backend is locked".to_string())
}
}
#[test]
fn validates_token_shape_boundaries() -> Result<(), SyncClientError> {
assert!(BearerToken::new("").is_err());
assert!(BearerToken::new("short").is_err());
assert!(BearerToken::new(format!("{}aaa bb", "a".repeat(40))).is_err());
BearerToken::new("a".repeat(32))?;
BearerToken::new("a".repeat(MAX_BEARER_TOKEN_BYTES))?;
assert!(BearerToken::new("a".repeat(MAX_BEARER_TOKEN_BYTES + 1)).is_err());
Ok(())
}
#[test]
fn debug_output_redacts_the_token() -> Result<(), SyncClientError> {
let token = BearerToken::new("secret-session-token".repeat(3))?;
let output = format!("{token:?}");
assert_eq!(output, "BearerToken([REDACTED])");
assert!(!output.contains(token.as_str()));
Ok(())
}
#[test]
fn keychain_entry_wins_and_cleans_every_legacy_path() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
let old = directory.path().join("old/bearer.token");
write_token(&stable, 'a')?;
write_token(&old, 'b')?;
write_token(&stable.with_extension("tmp"), 'c')?;
write_token(&old.with_extension("tmp"), 'd')?;
let backend = MemoryCredentialBackend::default();
let expected = BearerToken::new("e".repeat(64))?;
backend.save(KEYCHAIN_SERVICE, profile_id.as_str(), expected.as_str().as_bytes())?;
let store = BearerTokenStore::new(&profile_id, directory.path()).with_legacy_path(old.clone());
assert_eq!(store.load_with(&backend)?, Some(expected));
for path in [stable, old] {
assert!(!path.exists());
assert!(!path.with_extension("tmp").exists());
}
assert!(migration_marker_path(directory.path()).exists());
Ok(())
}
#[test]
fn stable_legacy_token_precedes_old_default_source() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
let old = directory.path().join("old/bearer.token");
write_token(&stable, 'a')?;
write_token(&old, 'b')?;
let backend = MemoryCredentialBackend::default();
let store = BearerTokenStore::new(&profile_id, directory.path()).with_legacy_path(old.clone());
assert_eq!(
store.load_with(&backend)?.as_ref().map(BearerToken::as_str),
Some("a".repeat(64).as_str())
);
assert!(!stable.exists());
assert!(!old.exists());
Ok(())
}
#[test]
fn failed_keychain_readback_preserves_the_legacy_source() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
write_token(&stable, 'a')?;
let store = BearerTokenStore::new(&profile_id, directory.path());
assert!(store.load_with(&DroppingCredentialBackend).is_err());
assert!(stable.exists());
Ok(())
}
#[test]
fn existing_keychain_entry_finishes_a_crashed_migration() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
write_token(&stable, 'a')?;
let backend = MemoryCredentialBackend::default();
backend.save(KEYCHAIN_SERVICE, profile_id.as_str(), "a".repeat(64).as_bytes())?;
let store = BearerTokenStore::new(&profile_id, directory.path());
assert!(store.load_with(&backend)?.is_some());
assert!(!stable.exists());
Ok(())
}
#[test]
fn profile_accounts_are_isolated_and_clear_is_idempotent() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let first_id = ProfileId::new();
let second_id = ProfileId::new();
let first = BearerTokenStore::new(&first_id, &directory.path().join("first"));
let second = BearerTokenStore::new(&second_id, &directory.path().join("second"));
let first_token = BearerToken::new("a".repeat(64))?;
let second_token = BearerToken::new("b".repeat(64))?;
let backend = MemoryCredentialBackend::default();
first.save_with(&backend, &first_token)?;
second.save_with(&backend, &second_token)?;
assert_eq!(first.load_with(&backend)?, Some(first_token));
assert_eq!(second.load_with(&backend)?, Some(second_token.clone()));
first.clear_with(&backend)?;
first.clear_with(&backend)?;
assert_eq!(first.load_with(&backend)?, None);
assert_eq!(second.load_with(&backend)?, Some(second_token));
Ok(())
}
#[test]
fn conditional_clear_preserves_a_newer_profile_token() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let store = BearerTokenStore::new(&profile_id, directory.path());
let old = BearerToken::new("a".repeat(64))?;
let current = BearerToken::new("b".repeat(64))?;
let backend = MemoryCredentialBackend::default();
store.save_with(&backend, &old)?;
store.save_with(&backend, &current)?;
assert!(!store.clear_if_matches_with(&backend, &old)?);
assert_eq!(store.load_with(&backend)?, Some(current.clone()));
assert!(store.clear_if_matches_with(&backend, &current)?);
assert_eq!(store.load_with(&backend)?, None);
Ok(())
}
#[test]
fn clear_failure_preserves_the_credential_for_retry() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let store = BearerTokenStore::new(&profile_id, directory.path());
let token = BearerToken::new("a".repeat(64))?;
let backend = ClearFailureBackend::default();
store.save_with(&backend, &token)?;
assert!(store.clear_with(&backend).is_err());
assert_eq!(store.load_with(&backend)?, Some(token));
Ok(())
}
#[test]
fn migration_marker_blocks_recreated_legacy_tokens() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let old = directory.path().join("old/bearer.token");
let store = BearerTokenStore::new(&profile_id, directory.path()).with_legacy_path(old.clone());
let backend = MemoryCredentialBackend::default();
let token = BearerToken::new("a".repeat(64))?;
store.save_with(&backend, &token)?;
store.clear_with(&backend)?;
write_token(&old, 'b')?;
assert_eq!(store.load_with(&backend)?, None);
assert!(!old.exists());
Ok(())
}
#[cfg(unix)]
#[test]
fn cleanup_failure_prevents_a_direct_keychain_commit() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
let target = directory.path().join("target.tmp");
write_token(&target, 'b')?;
fs::create_dir_all(stable.parent().ok_or("missing stable parent")?)?;
fs::hard_link(&target, stable.with_extension("tmp"))?;
let store = BearerTokenStore::new(&profile_id, directory.path());
let backend = MemoryCredentialBackend::default();
let token = BearerToken::new("a".repeat(64))?;
assert!(store.save_with(&backend, &token).is_err());
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_none());
Ok(())
}
#[test]
fn direct_save_failure_retires_stale_legacy_credentials() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
write_token(&stable, 'a')?;
let store = BearerTokenStore::new(&profile_id, directory.path());
let token = BearerToken::new("b".repeat(64))?;
assert!(store.save_with(&SaveFailureBackend, &token).is_err());
assert!(!stable.exists());
assert!(migration_marker_path(directory.path()).exists());
assert_eq!(store.load_with(&MemoryCredentialBackend::default())?, None);
Ok(())
}
#[cfg(unix)]
#[test]
fn migration_cleanup_failure_keeps_the_committed_keychain() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
let target = directory.path().join("target.tmp");
write_token(&stable, 'a')?;
write_token(&target, 'b')?;
fs::hard_link(&target, stable.with_extension("tmp"))?;
let store = BearerTokenStore::new(&profile_id, directory.path());
let backend = MemoryCredentialBackend::default();
assert!(store.load_with(&backend).is_err());
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_some());
assert!(migration_marker_path(directory.path()).exists());
fs::remove_file(stable.with_extension("tmp"))?;
assert_eq!(
store.load_with(&backend)?.as_ref().map(BearerToken::as_str),
Some("a".repeat(64).as_str())
);
Ok(())
}
#[test]
fn invalid_migration_marker_never_retires_a_legacy_token() -> Result<(), Box<dyn std::error::Error>>
{
for marker in [b"".as_slice(), b"partial".as_slice()] {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
let marker_path = migration_marker_path(directory.path());
write_token(&stable, 'a')?;
fs::write(&marker_path, marker)?;
let store = BearerTokenStore::new(&profile_id, directory.path());
let backend = MemoryCredentialBackend::default();
assert!(store.load_with(&backend).is_err());
assert!(stable.exists());
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_none());
}
Ok(())
}
#[test]
fn crashed_marker_temp_is_replaced_atomically() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let marker = migration_marker_path(directory.path());
let marker_tmp = marker.with_file_name("bearer.migrated.tmp");
fs::create_dir_all(marker.parent().ok_or("missing marker parent")?)?;
fs::write(&marker_tmp, b"partial")?;
let store = BearerTokenStore::new(&profile_id, directory.path());
store.clear_with(&MemoryCredentialBackend::default())?;
assert_eq!(fs::read(marker)?, b"ely-bearer-migration-v1\n");
assert!(!marker_tmp.exists());
Ok(())
}
#[test]
fn stale_tmp_is_removed_without_becoming_a_token() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
write_token(&stable.with_extension("tmp"), 'a')?;
let store = BearerTokenStore::new(&profile_id, directory.path());
assert_eq!(store.load_with(&MemoryCredentialBackend::default())?, None);
assert!(!stable.with_extension("tmp").exists());
Ok(())
}
#[cfg(unix)]
#[test]
fn symlink_and_hardlink_legacy_sources_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::symlink;
for hard_link in [false, true] {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let target = directory.path().join("target.token");
let stable = stable_legacy_path(directory.path());
write_token(&target, 'a')?;
fs::create_dir_all(stable.parent().ok_or("missing stable parent")?)?;
if hard_link {
fs::hard_link(&target, &stable)?;
} else {
symlink(&target, &stable)?;
}
let backend = MemoryCredentialBackend::default();
let store = BearerTokenStore::new(&profile_id, directory.path());
assert!(store.load_with(&backend).is_err());
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_none());
}
Ok(())
}
#[cfg(unix)]
#[test]
fn symlink_and_hardlink_lock_files_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::{PermissionsExt, symlink};
for hard_link in [false, true] {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let target = directory.path().join("target.lock");
let lock = directory.path().join("sync/bearer.lock");
fs::write(&target, b"lock-target")?;
fs::set_permissions(&target, fs::Permissions::from_mode(0o644))?;
fs::create_dir_all(lock.parent().ok_or("missing lock parent")?)?;
if hard_link {
fs::hard_link(&target, &lock)?;
} else {
symlink(&target, &lock)?;
}
let store = BearerTokenStore::new(&profile_id, directory.path());
assert!(store.load_with(&MemoryCredentialBackend::default()).is_err());
assert_eq!(fs::metadata(&target)?.permissions().mode() & 0o777, 0o644);
}
Ok(())
}
#[cfg(unix)]
#[test]
fn symlinked_profile_directories_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::symlink;
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let target = directory.path().join("target-profile");
let linked_profile = directory.path().join("linked-profile");
fs::create_dir_all(&target)?;
symlink(&target, &linked_profile)?;
let store = BearerTokenStore::new(&profile_id, &linked_profile);
assert!(store.load_with(&MemoryCredentialBackend::default()).is_err());
assert!(!target.join("sync").exists());
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn native_keychain_round_trip() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let store = BearerTokenStore::new(&profile_id, directory.path());
let token = BearerToken::new("n".repeat(64))?;
let result = (|| {
store.save(&token)?;
assert_eq!(store.load()?, Some(token));
Ok::<(), SyncClientError>(())
})();
store.clear()?;
result?;
Ok(())
}
fn stable_legacy_path(profile_dir: &std::path::Path) -> std::path::PathBuf {
profile_dir.join("sync/bearer.token")
}
fn migration_marker_path(profile_dir: &std::path::Path) -> std::path::PathBuf {
profile_dir.join("sync/bearer.migrated")
}
fn write_token(path: &std::path::Path, character: char) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, character.to_string().repeat(64))
}
+155 -9
View File
@@ -1,28 +1,174 @@
use keyring::{Entry, Error as KeyringError}; use keyring_core::{CredentialStore, Entry, Error as KeyringError};
use std::sync::{Arc, Mutex};
use zeroize::Zeroizing; use zeroize::Zeroizing;
static NATIVE_STORE: Mutex<Option<Arc<CredentialStore>>> = Mutex::new(None);
pub(crate) fn load_secret( pub(crate) fn load_secret(
service: &str, service: &str,
account: &str, account: &str,
) -> Result<Option<Zeroizing<Vec<u8>>>, String> { ) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
match entry(service, account)?.get_secret() { native_operation(service, account, |entry| match entry.get_secret() {
Ok(secret) => Ok(Some(Zeroizing::new(secret))), Ok(secret) => Ok(Some(Zeroizing::new(secret))),
Err(KeyringError::NoEntry) => Ok(None), Err(KeyringError::NoEntry) => Ok(None),
Err(error) => Err(error.to_string()), Err(error) => Err(error),
} })
} }
pub(crate) fn save_secret(service: &str, account: &str, secret: &[u8]) -> Result<(), String> { pub(crate) fn save_secret(service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
entry(service, account)?.set_secret(secret).map_err(|error| error.to_string()) native_operation(service, account, |entry| entry.set_secret(secret))
} }
pub(crate) fn clear_secret(service: &str, account: &str) -> Result<(), String> { pub(crate) fn clear_secret(service: &str, account: &str) -> Result<(), String> {
match entry(service, account)?.delete_credential() { native_operation(service, account, |entry| match entry.delete_credential() {
Ok(()) | Err(KeyringError::NoEntry) => Ok(()), Ok(()) | Err(KeyringError::NoEntry) => Ok(()),
Err(error) => Err(error.to_string()), Err(error) => Err(error),
})
}
fn native_operation<T>(
service: &str,
account: &str,
operation: impl Fn(&Entry) -> keyring_core::Result<T>,
) -> Result<T, String> {
#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))]
{
return retry_once(
|| entry(service, account).and_then(|entry| operation(&entry)),
retryable_store_error,
clear_cached_store,
)
.map_err(|error| error.to_string());
}
#[cfg(not(all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
)))]
{
entry(service, account)
.and_then(|entry| operation(&entry))
.map_err(|error| error.to_string())
} }
} }
fn entry(service: &str, account: &str) -> Result<Entry, String> { #[cfg(any(
Entry::new(service, account).map_err(|error| error.to_string()) test,
all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android")))
))]
fn retry_once<T, E>(
mut attempt: impl FnMut() -> Result<T, E>,
should_retry: impl Fn(&E) -> bool,
before_retry: impl FnOnce(),
) -> Result<T, E> {
let first = attempt();
if first.as_ref().is_err_and(should_retry) {
before_retry();
attempt()
} else {
first
}
}
fn entry(service: &str, account: &str) -> keyring_core::Result<Entry> {
let store = {
let mut guard = NATIVE_STORE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if guard.is_none() {
*guard = Some(platform_store()?);
}
guard.as_ref().cloned().ok_or(KeyringError::NoDefaultStore)?
};
build_entry(store.as_ref(), service, account)
}
#[cfg(any(
test,
all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android")))
))]
fn retryable_store_error(error: &KeyringError) -> bool {
matches!(
error,
KeyringError::PlatformFailure(_)
| KeyringError::NoStorageAccess(_)
| KeyringError::NoDefaultStore
)
}
#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))]
fn clear_cached_store() {
let mut guard = NATIVE_STORE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = None;
}
#[cfg(target_os = "windows")]
fn build_entry(
store: &CredentialStore,
service: &str,
account: &str,
) -> keyring_core::Result<Entry> {
let modifiers = std::collections::HashMap::from([("persistence", "Local")]);
store.build(service, account, Some(&modifiers))
}
#[cfg(not(target_os = "windows"))]
fn build_entry(
store: &CredentialStore,
service: &str,
account: &str,
) -> keyring_core::Result<Entry> {
store.build(service, account, None)
}
#[cfg(target_os = "macos")]
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
apple_native_keyring_store::keychain::Store::new().map(|store| store as Arc<CredentialStore>)
}
#[cfg(target_os = "windows")]
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
windows_native_keyring_store::Store::new().map(|store| store as Arc<CredentialStore>)
}
#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))]
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
zbus_secret_service_keyring_store::Store::new().map(|store| store as Arc<CredentialStore>)
}
#[cfg(not(any(
target_os = "macos",
target_os = "windows",
all(unix, not(any(target_os = "ios", target_os = "android")))
)))]
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
Err(KeyringError::NoDefaultStore)
}
#[cfg(test)]
mod tests {
use super::{KeyringError, retry_once, retryable_store_error};
use std::cell::Cell;
#[test]
fn retry_policy_targets_store_connectivity_errors() {
assert!(retryable_store_error(&KeyringError::NoDefaultStore));
assert!(!retryable_store_error(&KeyringError::NoEntry));
}
#[test]
fn retry_evicts_once_and_recreates_the_store_once() {
let attempts = Cell::new(0);
let evictions = Cell::new(0);
let result = retry_once(
|| {
let attempt = attempts.get() + 1;
attempts.set(attempt);
Err::<(), _>(attempt)
},
|_| true,
|| evictions.set(evictions.get() + 1),
);
assert_eq!(result, Err(2));
assert_eq!(attempts.get(), 2);
assert_eq!(evictions.get(), 1);
}
} }
+38
View File
@@ -17,6 +17,7 @@ use crate::{
const PUBLIC_KEY_BYTES: usize = 32; const PUBLIC_KEY_BYTES: usize = 32;
const MAX_DEVICE_TEXT_CHARS: usize = 128; const MAX_DEVICE_TEXT_CHARS: usize = 128;
const MAX_STORED_IDENTITY_BYTES: usize = 16 * 1024;
/// Public device identity persisted in the profile directory. Both private /// Public device identity persisted in the profile directory. Both private
/// keys live in the macOS data-protection Keychain under `device_id`. /// keys live in the macOS data-protection Keychain under `device_id`.
@@ -33,6 +34,18 @@ pub struct DeviceIdentity {
} }
impl DeviceIdentity { impl DeviceIdentity {
pub fn validate_stored_bytes(bytes: &[u8], path: &Path) -> Result<(), SyncClientError> {
if bytes.len() > MAX_STORED_IDENTITY_BYTES {
return Err(key_error("stored device identity is too large"));
}
let contents = std::str::from_utf8(bytes)
.map_err(|error| SyncClientError::TokenStorage(error.to_string()))?;
match decode_stored_identity(contents, path)? {
StoredIdentity::V2(identity) => identity.validate(),
StoredIdentity::Legacy(identity) => identity.validate(),
}
}
/// Loads a v2 identity and its Keychain secrets. A legacy public-only /// Loads a v2 identity and its Keychain secrets. A legacy public-only
/// identity is rotated to a fresh device ID because its private key was /// identity is rotated to a fresh device ID because its private key was
/// never persisted and cannot prove device continuity. /// never persisted and cannot prove device continuity.
@@ -413,6 +426,31 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn stored_identity_validation_is_bounded_and_schema_strict() -> Result<(), SyncClientError> {
let (identity, _) = generate_key_material("Test".to_string(), "macos".to_string())?;
let bytes = serde_json::to_vec(&identity).map_err(|error| {
SyncClientError::TokenStorage(format!("device identity serialize: {error}"))
})?;
DeviceIdentity::validate_stored_bytes(&bytes, Path::new("device.json"))?;
assert!(
DeviceIdentity::validate_stored_bytes(
&vec![b'a'; MAX_STORED_IDENTITY_BYTES + 1],
Path::new("device.json"),
)
.is_err()
);
assert!(
DeviceIdentity::validate_stored_bytes(
br#"{"device_id":"ely-test","unknown":true}"#,
Path::new("device.json"),
)
.is_err()
);
Ok(())
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[test] #[test]
fn legacy_identity_rotates_to_new_device_id() -> Result<(), SyncClientError> { fn legacy_identity_rotates_to_new_device_id() -> Result<(), SyncClientError> {
+12 -7
View File
@@ -16,6 +16,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Duration; use std::time::Duration;
use ureq::{Agent, AgentBuilder}; use ureq::{Agent, AgentBuilder};
use zeroize::Zeroizing;
use crate::{auth::BearerToken, client::ApiClientConfig, error::SyncClientError}; use crate::{auth::BearerToken, client::ApiClientConfig, error::SyncClientError};
@@ -86,20 +87,21 @@ pub fn verify_email_otp(
match response { match response {
Ok(ok) => { Ok(ok) => {
let cookie_token = better_auth_cookie_token(ok.header("set-cookie")); let cookie_token = better_auth_cookie_token(ok.header("set-cookie"));
let body = ok.into_string().map_err(|error| SyncClientError::HttpStatus { let body =
Zeroizing::new(ok.into_string().map_err(|error| SyncClientError::HttpStatus {
endpoint: endpoint.clone(), endpoint: endpoint.clone(),
status: 200, status: 200,
body: error.to_string(), body: error.to_string(),
})?; })?);
let json = serde_json::from_str::<VerifyOtpResponse>(&body).map_err(|error| { let json = serde_json::from_str::<VerifyOtpResponse>(&body).map_err(|error| {
SyncClientError::Json { endpoint: endpoint.clone(), source: error } SyncClientError::Json { endpoint: endpoint.clone(), source: error }
})?; })?;
let token = json.token.or(cookie_token).ok_or_else(|| { let token = json.token.map(Zeroizing::new).or(cookie_token).ok_or_else(|| {
SyncClientError::TokenStorage( SyncClientError::TokenStorage(
"sign-in response did not include a session token".to_string(), "sign-in response did not include a session token".to_string(),
) )
})?; })?;
BearerToken::new(token) BearerToken::new(token.as_str())
} }
Err(ureq::Error::Status(status, raw)) => { Err(ureq::Error::Status(status, raw)) => {
let body = raw.into_string().unwrap_or_default(); let body = raw.into_string().unwrap_or_default();
@@ -117,14 +119,14 @@ fn build_agent() -> Agent {
/// better-auth.session_token=<token>; …` header. Strip the cookie's /// better-auth.session_token=<token>; …` header. Strip the cookie's
/// attributes and return just the value. Multi-cookie responses are /// attributes and return just the value. Multi-cookie responses are
/// concatenated by `ureq` into a single header line per spec. /// concatenated by `ureq` into a single header line per spec.
fn better_auth_cookie_token(set_cookie: Option<&str>) -> Option<String> { fn better_auth_cookie_token(set_cookie: Option<&str>) -> Option<Zeroizing<String>> {
let header = set_cookie?; let header = set_cookie?;
for cookie in header.split(',') { for cookie in header.split(',') {
let trimmed = cookie.trim(); let trimmed = cookie.trim();
if let Some(rest) = trimmed.strip_prefix("better-auth.session_token=") { if let Some(rest) = trimmed.strip_prefix("better-auth.session_token=") {
let token = rest.split(';').next()?.trim(); let token = rest.split(';').next()?.trim();
if !token.is_empty() { if !token.is_empty() {
return Some(token.to_string()); return Some(Zeroizing::new(token.to_string()));
} }
} }
} }
@@ -139,7 +141,10 @@ mod tests {
fn picks_session_token_out_of_set_cookie_header() { fn picks_session_token_out_of_set_cookie_header() {
let header = let header =
"better-auth.session_token=abc.def.ghi; Path=/; HttpOnly; Secure; SameSite=Lax"; "better-auth.session_token=abc.def.ghi; Path=/; HttpOnly; Secure; SameSite=Lax";
assert_eq!(better_auth_cookie_token(Some(header)), Some("abc.def.ghi".to_string()),); assert_eq!(
better_auth_cookie_token(Some(header)).as_deref().map(String::as_str),
Some("abc.def.ghi"),
);
} }
#[test] #[test]
+3
View File
@@ -8,6 +8,9 @@ pub enum SyncClientError {
#[error("Bearer token storage is unavailable: {0}")] #[error("Bearer token storage is unavailable: {0}")]
TokenStorage(String), TokenStorage(String),
#[error("Bearer credential storage is unavailable: {0}")]
BearerCredentialStorage(String),
#[error("HTTP request failed for {endpoint}: {source}")] #[error("HTTP request failed for {endpoint}: {source}")]
Http { Http {
endpoint: String, endpoint: String,
+1
View File
@@ -17,6 +17,7 @@
//! the bearer token out-of-band and hand it to the client. //! the bearer token out-of-band and hand it to the client.
pub mod auth; pub mod auth;
mod auth_files;
pub mod client; pub mod client;
mod credential_store; mod credential_store;
pub mod device; pub mod device;