fix(sync): secure encrypted snapshot lifecycle

This commit is contained in:
2026-07-10 06:24:53 -04:00
parent 556c5ff624
commit 540b901fd6
106 changed files with 18026 additions and 3309 deletions
+67 -24
View File
@@ -34,17 +34,17 @@ pub(crate) enum AuthFlowPhase {
Idle,
/// `send_email_otp` is in flight. UI disables the form so the
/// user can't resend before the worker confirms acceptance.
SendingCode { email: String },
SendingCode { profile_id: ProfileId, email: String },
/// The worker accepted the request and Cloudflare's `SEND_EMAIL`
/// binding handed the message to the recipient's MTA. UI now
/// reveals the OTP field.
AwaitingOtp { email: String },
AwaitingOtp { profile_id: ProfileId, email: String },
/// `verify_email_otp` is in flight. UI shows a transient
/// "Verifying…" state.
Verifying { email: String },
Verifying { profile_id: ProfileId, email: String },
/// Last attempt failed. UI surfaces the message inline so the
/// user knows what to retry.
Error { email: String, message: String },
Error { profile_id: ProfileId, email: String, message: String },
}
impl AuthFlowPhase {
@@ -58,6 +58,16 @@ impl AuthFlowPhase {
pub(crate) fn is_busy(&self) -> bool {
matches!(self, Self::SendingCode { .. } | Self::Verifying { .. })
}
pub(crate) fn belongs_to(&self, profile_id: &ProfileId) -> bool {
match self {
Self::Idle => true,
Self::SendingCode { profile_id: owner, .. }
| Self::AwaitingOtp { profile_id: owner, .. }
| Self::Verifying { profile_id: owner, .. }
| Self::Error { profile_id: owner, .. } => owner == profile_id,
}
}
}
impl ElyShell {
@@ -66,54 +76,66 @@ impl ElyShell {
/// through the shared `SyncStateUpdate` channel, which the next
/// shell tick reconciles into the `auth_flow_phase`.
pub(crate) fn submit_email_otp_request(&mut self, cx: &mut Context<Self>) {
if active_profile_sync_context_for(&self.state).is_none() {
let Some(active_profile) = active_profile_sync_context_for(&self.state) else {
return;
}
};
let email = self.read_auth_email_input(cx);
let Some(email) = normalize_email(&email) else {
self.auth_flow_phase = AuthFlowPhase::Error {
profile_id: active_profile.id,
email: String::new(),
message: "Enter a valid email to receive a code.".to_string(),
};
return;
};
self.auth_flow_phase = AuthFlowPhase::SendingCode { email: email.clone() };
let profile_id = active_profile.id;
self.auth_flow_phase =
AuthFlowPhase::SendingCode { profile_id: profile_id.clone(), email: email.clone() };
let tx = self.sync_inbox_tx.clone();
spawn_send_otp(email, tx);
spawn_send_otp(profile_id, email, tx);
}
/// Hand the typed OTP to the worker thread that calls
/// `verify_email_otp`, persists the bearer token, and triggers
/// the first snapshot upload on success.
pub(crate) fn submit_email_otp_verify(&mut self, cx: &mut Context<Self>) {
let email = match self.auth_flow_phase.clone() {
AuthFlowPhase::AwaitingOtp { email }
| AuthFlowPhase::Error { email, .. }
| AuthFlowPhase::Verifying { email } => email,
let (profile_id, email) = match self.auth_flow_phase.clone() {
AuthFlowPhase::AwaitingOtp { profile_id, email }
| AuthFlowPhase::Error { profile_id, email, .. }
| AuthFlowPhase::Verifying { profile_id, email } => (profile_id, email),
_ => return,
};
let otp = self.read_auth_otp_input(cx);
let normalized_otp = otp.trim().replace(['-', ' '], "");
if normalized_otp.is_empty() {
self.auth_flow_phase =
AuthFlowPhase::Error { email, message: "Enter the code you received.".to_string() };
self.auth_flow_phase = AuthFlowPhase::Error {
profile_id,
email,
message: "Enter the code you received.".to_string(),
};
return;
}
let active_profile = match active_profile_sync_context_for(&self.state) {
Some(profile) => profile,
None => return,
};
if active_profile.id != profile_id {
self.auth_flow_phase = AuthFlowPhase::Idle;
return;
}
let Some(profile_root) = default_profile_data_root() else {
self.auth_flow_phase = AuthFlowPhase::Error {
profile_id,
email,
message: "Profile data root is unavailable on this machine.".to_string(),
};
return;
};
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id);
self.auth_flow_phase = AuthFlowPhase::Verifying { email: email.clone() };
let profile_dir = sync_profile_data_dir(&profile_root, &profile_id);
self.auth_flow_phase =
AuthFlowPhase::Verifying { profile_id: profile_id.clone(), email: email.clone() };
let tx = self.sync_inbox_tx.clone();
spawn_verify_otp(email, normalized_otp, profile_dir, tx);
spawn_verify_otp(profile_id, email, normalized_otp, profile_dir, tx);
}
/// Drop the persisted bearer token and reset the local form.
@@ -121,6 +143,9 @@ impl ElyShell {
/// call to make, the token is the only artefact we own.
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) {
Some(profile_id) => profile_id,
None => return,
@@ -180,18 +205,21 @@ pub(super) fn clear_persisted_bearer(profile_dir: &Path) -> Result<(), SyncClien
BearerTokenStore::new(profile_dir.join("sync").join("bearer.token")).clear()
}
fn spawn_send_otp(email: String, tx: Sender<SyncStateUpdate>) {
fn spawn_send_otp(profile_id: ProfileId, email: String, tx: Sender<SyncStateUpdate>) {
std::thread::Builder::new()
.name("ely-sync-auth-send".to_string())
.spawn(move || {
let config = ApiClientConfig::production();
match send_email_otp(&config, &email) {
Ok(()) => {
let _ = tx.send(SyncStateUpdate::AuthOtpSent { email });
let _ = tx.send(SyncStateUpdate::AuthOtpSent { profile_id, email });
}
Err(error) => {
let _ =
tx.send(SyncStateUpdate::AuthError { email, message: error.to_string() });
let _ = tx.send(SyncStateUpdate::AuthError {
profile_id,
email,
message: error.to_string(),
});
}
}
})
@@ -202,6 +230,7 @@ fn spawn_send_otp(email: String, tx: Sender<SyncStateUpdate>) {
}
fn spawn_verify_otp(
profile_id: ProfileId,
email: String,
otp: String,
profile_dir: std::path::PathBuf,
@@ -215,6 +244,7 @@ fn spawn_verify_otp(
Ok(token) => token,
Err(error) => {
let _ = tx.send(SyncStateUpdate::AuthError {
profile_id,
email,
message: error.to_string(),
});
@@ -226,6 +256,7 @@ fn spawn_verify_otp(
Ok(engine) => engine,
Err(error) => {
let _ = tx.send(SyncStateUpdate::AuthError {
profile_id,
email,
message: error.to_string(),
});
@@ -233,10 +264,14 @@ fn spawn_verify_otp(
}
};
if let Err(error) = engine.install_bearer(token.as_str()) {
let _ = tx.send(SyncStateUpdate::AuthError { email, message: error.to_string() });
let _ = tx.send(SyncStateUpdate::AuthError {
profile_id,
email,
message: error.to_string(),
});
return;
}
let _ = tx.send(SyncStateUpdate::AuthSucceeded { email });
let _ = tx.send(SyncStateUpdate::AuthSucceeded { profile_id, email });
})
.map(|_| ())
.unwrap_or_else(|error| {
@@ -247,6 +282,7 @@ fn spawn_verify_otp(
#[cfg(test)]
mod tests {
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::ProfileId;
use super::{AuthFlowPhase, active_profile_sync_context_for, normalize_email};
use crate::shell::ShellState;
@@ -266,11 +302,18 @@ mod tests {
#[test]
fn auth_phase_helpers() {
let phase = AuthFlowPhase::Verifying { email: "you@there".to_string() };
let profile_id = ProfileId::new();
let phase = AuthFlowPhase::Verifying {
profile_id: profile_id.clone(),
email: "you@there".to_string(),
};
assert!(phase.is_busy());
assert!(phase.belongs_to(&profile_id));
assert!(!phase.belongs_to(&ProfileId::new()));
assert_eq!(phase.error_message(), None);
let phase = AuthFlowPhase::Error {
profile_id,
email: "you@there".to_string(),
message: "rate limited".to_string(),
};
+224 -9
View File
@@ -1,8 +1,10 @@
use ely_browser_core::BrowserSnapshot;
use ely_design_system::colors;
use ely_domain::{ProfileKind, SyncConnectionState, SyncObjectKind, SyncObjectStatus};
use ely_domain::{ProfileId, ProfileKind, SyncConnectionState, SyncObjectKind, SyncObjectStatus};
use ely_sync_client::DeviceRecord;
use gpui::{
AnyElement, Context, FontWeight, IntoElement, ParentElement, Styled, div, px, rgb, rgba,
AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString,
StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, px, rgb, rgba,
};
use gpui_component::{input::Input, scroll::ScrollableElement};
@@ -10,7 +12,7 @@ use crate::shell::auth::AuthFlowPhase;
use super::sync_controls::{
button_bg, render_dual_button_row, render_policy_toggle, render_primary_button,
render_reset_button, render_sign_out_button,
render_reset_button, render_secondary_button, render_sign_out_button,
};
use super::{ElyShell, render_canvas_surface};
@@ -20,6 +22,11 @@ impl ElyShell {
snapshot: &BrowserSnapshot,
cx: &mut Context<Self>,
) -> AnyElement {
if profile_allows_sync_controls(&snapshot.active_profile_kind)
&& !matches!(snapshot.sync_status.connection(), SyncConnectionState::SignedOut)
{
self.ensure_sync_devices_loaded(cx);
}
render_canvas_surface(
div()
.size_full()
@@ -83,7 +90,7 @@ fn render_private_profile_card() -> AnyElement {
}
fn render_account_card(
shell: &ElyShell,
shell: &mut ElyShell,
snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>,
) -> AnyElement {
@@ -93,21 +100,229 @@ fn render_account_card(
match snapshot.sync_status.connection() {
SyncConnectionState::SignedOut => card
.child(render_card_heading("Account"))
.children(account_form(shell, cx))
.children(account_form(shell, &snapshot.active_profile_id, cx))
.into_any_element(),
SyncConnectionState::SignedIn
| SyncConnectionState::AwaitingDeviceApproval
| SyncConnectionState::SyncReady { .. }
| SyncConnectionState::SyncError { .. } => card
.child(render_card_heading("Account"))
.child(render_sign_out_button(shell, cx))
.child(
div()
.flex()
.items_center()
.justify_between()
.child(render_card_heading("Account"))
.child(render_sign_out_button(shell, cx)),
)
.child(
div()
.text_size(px(12.0))
.text_color(rgb(colors::ink_3()))
.child("End-to-end encrypted"),
)
.child(render_devices(shell, cx))
.into_any_element(),
}
}
fn account_form(shell: &ElyShell, cx: &mut Context<ElyShell>) -> Vec<AnyElement> {
fn render_devices(shell: &mut ElyShell, cx: &mut Context<ElyShell>) -> AnyElement {
let loading = shell.sync_devices.is_loading();
let header =
div().flex().items_center().justify_between().child(render_card_heading("Devices")).child(
render_secondary_button(
shell,
"sync-devices-refresh",
"Refresh",
loading,
cx,
|shell, cx| shell.refresh_sync_devices(cx),
),
);
let mut section = div()
.pt(px(12.0))
.border_t_1()
.border_color(rgba(colors::divider()))
.flex()
.flex_col()
.gap(px(10.0))
.child(header);
if shell.sync_devices.is_loading() {
return section.child(render_device_note("Loading devices")).into_any_element();
}
if let Some(message) = shell.sync_devices.error() {
section = section.child(render_inline_error(message));
}
let devices = shell.sync_devices.devices().to_vec();
if devices.is_empty() {
return section.child(render_device_note("No devices")).into_any_element();
}
let current_approved = devices.iter().any(|device| device.current && device.is_approved());
if current_approved
&& devices.iter().any(|device| !device.current && device.approval_status == "pending")
{
section = section.child(
div().px(px(10.0)).py(px(7.0)).rounded(px(8.0)).bg(rgba(button_bg())).child(
Input::new(&shell.sync_verification_input).appearance(false).cleanable(false),
),
);
}
for device in devices {
section = section.child(render_device_row(shell, &device, current_approved, cx));
}
section.into_any_element()
}
fn render_device_row(
shell: &ElyShell,
device: &DeviceRecord,
current_approved: bool,
cx: &mut Context<ElyShell>,
) -> AnyElement {
let status = if device.current {
"This device"
} else if device.is_approved() {
"Approved"
} else if device.approval_status == "pending" {
"Pending"
} else {
"Revoked"
};
let mut row = div()
.py(px(8.0))
.border_b_1()
.border_color(rgba(colors::divider()))
.flex()
.flex_col()
.gap(px(7.0))
.child(
div()
.flex()
.items_center()
.justify_between()
.gap(px(10.0))
.child(
div()
.min_w_0()
.text_size(px(12.5))
.font_weight(FontWeight(500.0))
.text_color(rgb(colors::ink()))
.child(device.device_name.clone()),
)
.child(div().text_size(px(11.0)).text_color(rgb(colors::ink_4())).child(status)),
);
let code = if device.current {
shell.sync_devices.current_code().map(str::to_string)
} else {
device.verification_code().ok()
};
if let Some(code) = code {
row = row.child(div().text_size(px(11.5)).text_color(rgb(colors::ink_3())).child(code));
}
let can_revoke = current_approved
&& !device.current
&& device.revoked_at.is_none()
&& matches!(device.approval_status.as_str(), "pending" | "approved");
if can_revoke {
let device_id = device.device_id.clone();
let busy = shell.sync_devices.is_acting_on(&device_id);
let confirmed = shell.sync_devices.is_revoke_confirmation(&device_id);
row = row.child(
div()
.flex()
.items_center()
.justify_end()
.gap(px(8.0))
.when(device.approval_status == "pending", |buttons| {
buttons.child(render_device_approve_button(device_id.clone(), busy, cx))
})
.when(can_revoke, |buttons| {
buttons.child(render_device_revoke_button(device_id, confirmed, busy, cx))
}),
);
}
row.into_any_element()
}
fn render_device_approve_button(
device_id: String,
disabled: bool,
cx: &mut Context<ElyShell>,
) -> AnyElement {
let id = SharedString::from(format!("sync-device-approve-{device_id}"));
div()
.id(id)
.px(px(12.0))
.py(px(8.0))
.rounded(px(8.0))
.bg(rgba(colors::accent()))
.text_size(px(12.0))
.font_weight(FontWeight(500.0))
.text_color(rgb(0xfff5e6))
.when(!disabled, |element| {
element
.cursor_pointer()
.hover(|style| style.opacity(0.92))
.active(|style| style.opacity(0.78))
.on_click(cx.listener(move |shell, _, _, cx| {
shell.approve_sync_device(device_id.clone(), cx);
}))
})
.when(disabled, |element| element.opacity(0.6))
.child(if disabled { "Approving" } else { "Approve" })
.into_any_element()
}
fn render_device_revoke_button(
device_id: String,
confirmed: bool,
disabled: bool,
cx: &mut Context<ElyShell>,
) -> AnyElement {
let id = SharedString::from(format!("sync-device-revoke-{device_id}"));
div()
.id(id)
.px(px(12.0))
.py(px(8.0))
.rounded(px(8.0))
.bg(rgba(if confirmed { colors::error() } else { button_bg() }))
.text_size(px(12.0))
.font_weight(FontWeight(500.0))
.text_color(rgb(if confirmed { 0xffffff } else { colors::error() }))
.when(!disabled, |element| {
element
.cursor_pointer()
.hover(|style| style.opacity(0.9))
.active(|style| style.opacity(0.78))
.on_click(cx.listener(move |shell, _, _, cx| {
shell.revoke_sync_device(device_id.clone(), cx);
}))
})
.when(disabled, |element| element.opacity(0.6))
.child(if disabled {
"Revoking"
} else if confirmed {
"Confirm revoke"
} else {
"Revoke"
})
.into_any_element()
}
fn render_device_note(message: &'static str) -> AnyElement {
div().text_size(px(11.5)).text_color(rgb(colors::ink_4())).child(message).into_any_element()
}
fn account_form(
shell: &ElyShell,
profile_id: &ProfileId,
cx: &mut Context<ElyShell>,
) -> Vec<AnyElement> {
let mut elements: Vec<AnyElement> = Vec::new();
let phase = shell.auth_flow_phase.clone();
let phase = if shell.auth_flow_phase.belongs_to(profile_id) {
shell.auth_flow_phase.clone()
} else {
AuthFlowPhase::Idle
};
elements.push(render_field_label("Email"));
elements.push(render_input(&shell.auth_email_input));
@@ -133,7 +133,7 @@ pub(super) fn render_policy_toggle(
chrome_motion_feedback(press_id, selection_id, enabled, element)
}
fn render_secondary_button<F>(
pub(super) fn render_secondary_button<F>(
shell: &ElyShell,
id: &'static str,
label: &'static str,
+17 -3
View File
@@ -24,6 +24,7 @@ mod site_permissions;
mod space_files;
mod spaces;
mod splits;
mod sync_devices;
mod sync_state;
mod tab_groups;
mod tab_lifecycle;
@@ -57,7 +58,8 @@ use bookmarks::PendingBookmarkEdit;
use downloads::PendingDownloadFileAction;
use history::{PendingHistoryDomainClear, PendingHistoryTimeClear};
use plugins::{PendingPluginInstall, PendingPluginUninstall};
use sync_state::SyncStateUpdate;
use sync_devices::SyncDeviceUiState;
use sync_state::{PendingMergeUpload, SyncStateUpdate};
use web_surface::WebSurfaceStore;
enum ShellState {
@@ -111,7 +113,10 @@ pub struct ElyShell {
sync_upload_scheduled: bool,
sync_upload_in_flight: bool,
sync_upload_pending: bool,
sync_upload_pending_logical_clock_floor: Option<u64>,
sync_upload_pending_merge: Option<PendingMergeUpload>,
sync_retry_at: Option<std::time::Instant>,
pub(crate) sync_devices: SyncDeviceUiState,
pub(crate) sync_verification_input: Entity<InputState>,
pub(crate) auth_email_input: Entity<InputState>,
pub(crate) auth_otp_input: Entity<InputState>,
pub(crate) auth_flow_phase: auth::AuthFlowPhase,
@@ -153,6 +158,8 @@ impl ElyShell {
let auth_email_input =
cx.new(|cx| InputState::new(window, cx).placeholder("you@elydora.com"));
let auth_otp_input = cx.new(|cx| InputState::new(window, cx).placeholder("123456"));
let sync_verification_input =
cx.new(|cx| InputState::new(window, cx).placeholder("ABCD-EF01-2345-6789"));
let translucency_slider = cx.new(|_cx| {
SliderState::new()
.min(0.0)
@@ -252,7 +259,10 @@ impl ElyShell {
sync_upload_scheduled: false,
sync_upload_in_flight: false,
sync_upload_pending: false,
sync_upload_pending_logical_clock_floor: None,
sync_upload_pending_merge: None,
sync_retry_at: None,
sync_devices: SyncDeviceUiState::default(),
sync_verification_input,
auth_email_input,
auth_otp_input,
auth_flow_phase: auth::AuthFlowPhase::Idle,
@@ -308,6 +318,10 @@ impl ElyShell {
if let ShellState::Ready(core) = &mut self.state
&& core.select_profile(profile_id).is_ok()
{
self.auth_flow_phase = auth::AuthFlowPhase::Idle;
self.sync_devices.reset();
self.sync_retry_at = None;
self.clear_pending_cloud_sync_upload();
self.sync_address_input(window, cx);
self.schedule_cloud_sync_upload(cx);
cx.notify();
+37 -20
View File
@@ -9,7 +9,7 @@ use gpui_component::slider::SliderValue;
use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir};
use super::sync_state::{SyncStateUpdate, sync_platform_label};
use super::sync_state::{PendingMergeUpload, SyncStateUpdate, sync_platform_label};
use super::{ElyShell, ShellState};
impl ElyShell {
@@ -247,14 +247,14 @@ impl ElyShell {
}
pub(crate) fn trigger_cloud_sync_upload(&mut self) {
self.trigger_cloud_sync_upload_with_clock_floor(None);
self.trigger_cloud_sync_upload_with_merge(None);
}
pub(crate) fn trigger_cloud_sync_upload_after_remote(&mut self, logical_clock_floor: u64) {
self.trigger_cloud_sync_upload_with_clock_floor(Some(logical_clock_floor));
pub(super) fn trigger_cloud_sync_upload_after_remote(&mut self, merge: PendingMergeUpload) {
self.trigger_cloud_sync_upload_with_merge(Some(merge));
}
fn trigger_cloud_sync_upload_with_clock_floor(&mut self, logical_clock_floor: Option<u64>) {
fn trigger_cloud_sync_upload_with_merge(&mut self, mut merge: Option<PendingMergeUpload>) {
let active_profile_allows_sync = match &self.state {
ShellState::Ready(core) => core.active_profile_allows_sync(),
ShellState::StartupError(_) => false,
@@ -266,7 +266,7 @@ impl ElyShell {
}
if self.sync_upload_in_flight {
self.sync_upload_scheduled = false;
self.queue_cloud_sync_upload(logical_clock_floor);
self.queue_cloud_sync_upload(merge);
return;
}
self.sync_upload_scheduled = false;
@@ -278,6 +278,9 @@ impl ElyShell {
return;
};
let active_profile_id = snapshot.active_profile_id.clone();
if merge.as_ref().is_some_and(|merge| merge.profile_id != active_profile_id) {
merge = None;
}
let device_name = format!("ELY · {}", snapshot.active_profile_name);
let Some(profile_root) = default_profile_data_root() else {
tracing::warn!(target: "ely::sync", "profile data root is unavailable");
@@ -296,12 +299,12 @@ impl ElyShell {
}
};
let tx = self.sync_inbox_tx.clone();
let thread_name =
if logical_clock_floor.is_some() { "ely-sync-merge-upload" } else { "ely-sync-upload" };
let worker_profile_id = active_profile_id.clone();
let thread_name = if merge.is_some() { "ely-sync-merge-upload" } else { "ely-sync-upload" };
self.sync_upload_in_flight = true;
if let Err(error) =
std::thread::Builder::new().name(thread_name.to_string()).spawn(move || {
run_sync_upload(profile_dir, device_name, bytes, logical_clock_floor, tx)
run_sync_upload(worker_profile_id, profile_dir, device_name, bytes, merge, tx)
})
{
self.sync_upload_in_flight = false;
@@ -341,10 +344,11 @@ impl ElyShell {
}
fn run_sync_upload(
profile_id: ProfileId,
profile_dir: std::path::PathBuf,
device_name: String,
bytes: Vec<u8>,
logical_clock_floor: Option<u64>,
merge: Option<PendingMergeUpload>,
inbox: std::sync::mpsc::Sender<SyncStateUpdate>,
) {
let mut engine = match SyncEngine::for_profile_dir(
@@ -356,18 +360,19 @@ fn run_sync_upload(
Err(error) => {
let message = error.to_string();
tracing::warn!(target: "ely::sync", error = %message, "could not initialise sync engine");
let _ = inbox.send(SyncStateUpdate::SyncError { message });
let _ = inbox.send(SyncStateUpdate::SyncError { profile_id, message });
return;
}
};
let outcome = match logical_clock_floor {
Some(floor) => engine.upload_merged_bytes(bytes, floor),
let prior_conflict_count = merge.as_ref().map_or(0, |merge| merge.conflict_count);
let outcome = match merge {
Some(merge) => engine.upload_merged_bytes(bytes, merge.base),
None => engine.sync_bytes(bytes),
};
match outcome {
Ok(ely_browser_core::SyncOutcome::SignedOut) => {
tracing::info!(target: "ely::sync", "no bearer token on disk; sync skipped");
let _ = inbox.send(SyncStateUpdate::SignedOut);
let _ = inbox.send(SyncStateUpdate::SignedOut { profile_id });
}
Ok(ely_browser_core::SyncOutcome::AwaitingDeviceApproval { device_id }) => {
tracing::info!(
@@ -375,7 +380,7 @@ fn run_sync_upload(
device_id = %device_id,
"sync device is awaiting approval",
);
let _ = inbox.send(SyncStateUpdate::AwaitingDeviceApproval);
let _ = inbox.send(SyncStateUpdate::AwaitingDeviceApproval { profile_id });
}
Ok(ely_browser_core::SyncOutcome::RemoteSnapshot {
snapshot_id,
@@ -383,6 +388,8 @@ fn run_sync_upload(
payload_bytes,
device_id,
bytes,
merge_base,
cas_conflict,
}) => {
tracing::info!(
target: "ely::sync",
@@ -392,7 +399,13 @@ fn run_sync_upload(
device_id = %device_id,
"remote snapshot downloaded",
);
let _ = inbox.send(SyncStateUpdate::RemoteSnapshot { bytes, logical_clock });
let conflict_count =
if cas_conflict { prior_conflict_count.saturating_add(1) } else { 0 };
let _ = inbox.send(SyncStateUpdate::RemoteSnapshot {
profile_id: profile_id.clone(),
bytes,
merge: PendingMergeUpload { profile_id, base: merge_base, conflict_count },
});
}
Ok(ely_browser_core::SyncOutcome::AlreadyCurrent {
snapshot_id,
@@ -412,7 +425,7 @@ fn run_sync_upload(
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = inbox.send(SyncStateUpdate::SyncReady { last_synced_at_secs });
let _ = inbox.send(SyncStateUpdate::SyncReady { profile_id, last_synced_at_secs });
}
Ok(ely_browser_core::SyncOutcome::Uploaded {
snapshot_id,
@@ -432,15 +445,19 @@ fn run_sync_upload(
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = inbox.send(SyncStateUpdate::SyncReady { last_synced_at_secs });
let _ = inbox.send(SyncStateUpdate::SyncReady { profile_id, last_synced_at_secs });
}
Err(ely_sync_client::SyncClientError::SnapshotBusy) => {
tracing::info!(target: "ely::sync", "snapshot head is busy; retry scheduled");
let _ = inbox.send(SyncStateUpdate::SyncBusy { profile_id });
}
Err(error) => {
let message = error.to_string();
tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed");
let update = if message.contains("device_not_approved") {
SyncStateUpdate::AwaitingDeviceApproval
SyncStateUpdate::AwaitingDeviceApproval { profile_id }
} else {
SyncStateUpdate::SyncError { message }
SyncStateUpdate::SyncError { profile_id, message }
};
let _ = inbox.send(update);
}
+260
View File
@@ -0,0 +1,260 @@
use std::path::PathBuf;
use ely_browser_core::SyncEngine;
use ely_domain::ProfileId;
use ely_sync_client::DeviceRecord;
use gpui::Context;
use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir};
use super::sync_state::sync_platform_label;
use super::{ElyShell, ShellState, sync_state::SyncStateUpdate};
#[derive(Clone, Debug, Default)]
enum DeviceUiPhase {
#[default]
Idle,
Loading,
Ready,
Acting {
device_id: String,
},
Error,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct SyncDeviceUiState {
profile_id: Option<ProfileId>,
phase: DeviceUiPhase,
devices: Vec<DeviceRecord>,
current_code: Option<String>,
revoke_confirmation: Option<String>,
error: Option<String>,
}
impl SyncDeviceUiState {
pub(crate) fn devices(&self) -> &[DeviceRecord] {
&self.devices
}
pub(crate) fn current_code(&self) -> Option<&str> {
self.current_code.as_deref()
}
pub(crate) fn error(&self) -> Option<&str> {
self.error.as_deref()
}
pub(crate) fn is_loading(&self) -> bool {
matches!(self.phase, DeviceUiPhase::Loading)
}
pub(crate) fn is_acting_on(&self, device_id: &str) -> bool {
matches!(&self.phase, DeviceUiPhase::Acting { device_id: active } if active == device_id)
}
pub(crate) fn is_revoke_confirmation(&self, device_id: &str) -> bool {
self.revoke_confirmation.as_deref() == Some(device_id)
}
pub(crate) fn reset(&mut self) {
*self = Self::default();
}
pub(crate) fn set_ready(
&mut self,
profile_id: ProfileId,
devices: Vec<DeviceRecord>,
current_code: String,
) {
self.profile_id = Some(profile_id);
self.phase = DeviceUiPhase::Ready;
self.devices = devices;
self.current_code = Some(current_code);
self.revoke_confirmation = None;
self.error = None;
}
pub(crate) fn set_error(&mut self, profile_id: ProfileId, message: String) {
self.profile_id = Some(profile_id);
self.phase = DeviceUiPhase::Error;
self.revoke_confirmation = None;
self.error = Some(message);
}
fn prepare_profile(&mut self, profile_id: &ProfileId) {
if self.profile_id.as_ref() != Some(profile_id) {
self.reset();
self.profile_id = Some(profile_id.clone());
}
}
fn begin_load(&mut self, force: bool) -> bool {
if !force && !matches!(self.phase, DeviceUiPhase::Idle) {
return false;
}
if matches!(self.phase, DeviceUiPhase::Loading | DeviceUiPhase::Acting { .. }) {
return false;
}
self.phase = DeviceUiPhase::Loading;
self.error = None;
true
}
fn begin_action(&mut self, device_id: String) -> bool {
if !matches!(self.phase, DeviceUiPhase::Ready | DeviceUiPhase::Error) {
return false;
}
self.phase = DeviceUiPhase::Acting { device_id };
self.revoke_confirmation = None;
self.error = None;
true
}
fn request_revoke_confirmation(&mut self, device_id: &str) -> bool {
if self.revoke_confirmation.as_deref() == Some(device_id) {
return true;
}
self.revoke_confirmation = Some(device_id.to_string());
false
}
}
impl ElyShell {
pub(crate) fn ensure_sync_devices_loaded(&mut self, cx: &mut Context<Self>) {
self.load_sync_devices(false, cx);
}
pub(crate) fn refresh_sync_devices(&mut self, cx: &mut Context<Self>) {
self.load_sync_devices(true, cx);
}
pub(crate) fn approve_sync_device(&mut self, device_id: String, cx: &mut Context<Self>) {
let verification_code = self.sync_verification_input.read(cx).value().to_string();
let Some((profile_id, profile_dir, device_name)) = self.sync_device_context() else {
self.sync_devices.reset();
return;
};
self.sync_devices.prepare_profile(&profile_id);
if !self.sync_devices.begin_action(device_id.clone()) {
return;
}
let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-approve", profile_id.clone(), tx, move || {
let engine =
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
engine.approve_cloud_device(&device_id, &verification_code)?;
load_devices(profile_id, engine)
});
}
pub(crate) fn revoke_sync_device(&mut self, device_id: String, cx: &mut Context<Self>) {
let Some((profile_id, profile_dir, device_name)) = self.sync_device_context() else {
self.sync_devices.reset();
return;
};
self.sync_devices.prepare_profile(&profile_id);
if !self.sync_devices.request_revoke_confirmation(&device_id) {
cx.notify();
return;
}
if !self.sync_devices.begin_action(device_id.clone()) {
return;
}
let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-revoke", profile_id.clone(), tx, move || {
let engine =
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
engine.revoke_cloud_device(&device_id)?;
load_devices(profile_id, engine)
});
}
fn load_sync_devices(&mut self, force: bool, _cx: &mut Context<Self>) {
let Some((profile_id, profile_dir, device_name)) = self.sync_device_context() else {
self.sync_devices.reset();
return;
};
self.sync_devices.prepare_profile(&profile_id);
if !self.sync_devices.begin_load(force) {
return;
}
let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-list", profile_id.clone(), tx, move || {
let engine =
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
load_devices(profile_id, engine)
});
}
fn sync_device_context(&self) -> Option<(ProfileId, PathBuf, String)> {
let ShellState::Ready(core) = &self.state else {
return None;
};
if !core.active_profile_allows_sync() {
return None;
}
let snapshot = core.snapshot().ok()?;
let root = default_profile_data_root()?;
Some((
snapshot.active_profile_id.clone(),
sync_profile_data_dir(&root, &snapshot.active_profile_id),
format!("ELY · {}", snapshot.active_profile_name),
))
}
}
fn load_devices(
profile_id: ProfileId,
engine: SyncEngine,
) -> Result<SyncStateUpdate, ely_sync_client::SyncClientError> {
let current_code = engine.identity().verification_code()?;
let devices = engine.cloud_devices()?.devices;
Ok(SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code })
}
fn spawn_device_task<F>(
name: &str,
profile_id: ProfileId,
tx: std::sync::mpsc::Sender<SyncStateUpdate>,
task: F,
) where
F: FnOnce() -> Result<SyncStateUpdate, ely_sync_client::SyncClientError> + Send + 'static,
{
let thread_name = name.to_string();
let worker_tx = tx.clone();
let worker_profile_id = profile_id.clone();
let spawn_result = std::thread::Builder::new().name(thread_name).spawn(move || {
let update = task().unwrap_or_else(|error| SyncStateUpdate::DevicesError {
profile_id: worker_profile_id,
message: error.to_string(),
});
let _ = worker_tx.send(update);
});
if let Err(error) = spawn_result {
tracing::warn!(target: "ely::sync", error = %error, "device task spawn failed");
let _ = tx.send(SyncStateUpdate::DevicesError { profile_id, message: error.to_string() });
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn changing_profiles_clears_device_ui_state() {
let first_profile = ProfileId::new();
let second_profile = ProfileId::new();
let mut state = SyncDeviceUiState::default();
state.prepare_profile(&first_profile);
state.set_error(first_profile.clone(), "first profile error".to_string());
state.prepare_profile(&first_profile);
assert_eq!(state.error(), Some("first profile error"));
state.prepare_profile(&second_profile);
assert!(state.devices().is_empty());
assert!(state.current_code().is_none());
assert!(state.error().is_none());
}
}
+133 -45
View File
@@ -1,11 +1,21 @@
use std::{path::Path, time::Duration};
use ely_domain::{ProfileKind, SyncConnectionState};
use ely_domain::{ProfileId, ProfileKind, SyncConnectionState};
use ely_sync_client::{AuthenticatedSnapshotHead, DeviceRecord};
use gpui::{Context, Timer};
use super::{ElyShell, ShellState, auth};
const CLOUD_SYNC_UPLOAD_DEBOUNCE: Duration = Duration::from_millis(750);
const CAS_RETRY_LIMIT: u8 = 3;
const CAS_RETRY_DELAY: Duration = Duration::from_secs(2);
#[derive(Clone, Debug)]
pub(crate) struct PendingMergeUpload {
pub(super) profile_id: ProfileId,
pub(super) base: AuthenticatedSnapshotHead,
pub(super) conflict_count: u8,
}
/// Messages the off-thread sync workers push back to the shell so
/// `SyncConnectionState` on `BrowserCore` and the in-flight auth
@@ -14,14 +24,17 @@ const CLOUD_SYNC_UPLOAD_DEBOUNCE: Duration = Duration::from_millis(750);
/// on shell startup and does not flow through this channel.
#[derive(Clone, Debug)]
pub(crate) enum SyncStateUpdate {
SignedOut,
AwaitingDeviceApproval,
RemoteSnapshot { bytes: Vec<u8>, logical_clock: u64 },
SyncReady { last_synced_at_secs: u64 },
SyncError { message: String },
AuthOtpSent { email: String },
AuthSucceeded { email: String },
AuthError { email: String, message: String },
SignedOut { profile_id: ProfileId },
AwaitingDeviceApproval { profile_id: ProfileId },
RemoteSnapshot { profile_id: ProfileId, bytes: Vec<u8>, merge: PendingMergeUpload },
SyncReady { profile_id: ProfileId, last_synced_at_secs: u64 },
SyncBusy { profile_id: ProfileId },
SyncError { profile_id: ProfileId, message: String },
DevicesLoaded { profile_id: ProfileId, devices: Vec<DeviceRecord>, current_code: String },
DevicesError { profile_id: ProfileId, message: String },
AuthOtpSent { profile_id: ProfileId, email: String },
AuthSucceeded { profile_id: ProfileId, email: String },
AuthError { profile_id: ProfileId, email: String, message: String },
}
/// Stable label for the current OS used by the device registration
@@ -64,13 +77,18 @@ impl ElyShell {
.detach();
}
pub(super) fn queue_cloud_sync_upload(&mut self, logical_clock_floor: Option<u64>) {
pub(super) fn queue_cloud_sync_upload(&mut self, merge: Option<PendingMergeUpload>) {
self.sync_upload_pending = true;
if let Some(floor) = logical_clock_floor {
self.sync_upload_pending_logical_clock_floor = Some(
self.sync_upload_pending_logical_clock_floor
.map_or(floor, |current| current.max(floor)),
);
if let Some(candidate) = merge {
let replace = self.sync_upload_pending_merge.as_ref().is_none_or(|current| {
candidate.profile_id != current.profile_id
|| candidate.base.revision() > current.base.revision()
|| candidate.base.revision() == current.base.revision()
&& candidate.conflict_count > current.conflict_count
});
if replace {
self.sync_upload_pending_merge = Some(candidate);
}
}
}
@@ -80,9 +98,9 @@ impl ElyShell {
}
self.sync_upload_pending = false;
let logical_clock_floor = self.sync_upload_pending_logical_clock_floor.take();
match logical_clock_floor {
Some(floor) => self.trigger_cloud_sync_upload_after_remote(floor),
let merge = self.sync_upload_pending_merge.take();
match merge {
Some(merge) => self.trigger_cloud_sync_upload_after_remote(merge),
None => self.trigger_cloud_sync_upload(),
}
true
@@ -90,7 +108,7 @@ impl ElyShell {
pub(super) fn clear_pending_cloud_sync_upload(&mut self) {
self.sync_upload_pending = false;
self.sync_upload_pending_logical_clock_floor = None;
self.sync_upload_pending_merge = None;
}
fn can_schedule_cloud_sync_upload(&self) -> bool {
@@ -118,23 +136,36 @@ impl ElyShell {
/// state on `BrowserCore`. Returns `true` when at least one
/// update was applied so callers can `cx.notify()` accordingly.
pub(super) fn drain_sync_updates(&mut self) -> bool {
let retry_due =
self.sync_retry_at.is_some_and(|deadline| deadline <= std::time::Instant::now());
if retry_due {
self.sync_retry_at = None;
}
let mut latest_connection: Option<SyncConnectionState> = None;
let mut auth_changed = false;
let mut trigger_initial_sync = false;
let mut trigger_merged_upload = None;
let mut upload_finished = false;
let mut devices_changed = false;
while let Ok(update) = self.sync_inbox_rx.try_recv() {
match update {
SyncStateUpdate::SignedOut => {
latest_connection = Some(SyncConnectionState::SignedOut);
SyncStateUpdate::SignedOut { profile_id } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::SignedOut);
}
}
SyncStateUpdate::AwaitingDeviceApproval => {
latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval);
SyncStateUpdate::AwaitingDeviceApproval { profile_id } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval);
}
}
SyncStateUpdate::RemoteSnapshot { bytes, logical_clock } => {
SyncStateUpdate::RemoteSnapshot { profile_id, bytes, merge } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() != Some(&profile_id) {
continue;
}
if let ShellState::Ready(core) = &mut self.state {
match core.apply_sync_snapshot_bytes(&bytes) {
Ok(summary) => {
@@ -145,7 +176,15 @@ impl ElyShell {
skipped = summary.skipped(),
"remote snapshot applied",
);
trigger_merged_upload = Some(logical_clock);
if merge.conflict_count >= CAS_RETRY_LIMIT {
latest_connection = Some(SyncConnectionState::SyncError {
message: "Cloud Sync is busy; retrying shortly".to_string(),
});
self.sync_retry_at =
Some(std::time::Instant::now() + CAS_RETRY_DELAY);
} else {
trigger_merged_upload = Some(merge);
}
}
Err(error) => {
latest_connection = Some(SyncConnectionState::SyncError {
@@ -155,29 +194,63 @@ impl ElyShell {
}
}
}
SyncStateUpdate::SyncReady { last_synced_at_secs } => {
latest_connection =
Some(SyncConnectionState::SyncReady { last_synced_at_secs });
SyncStateUpdate::SyncReady { profile_id, last_synced_at_secs } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection =
Some(SyncConnectionState::SyncReady { last_synced_at_secs });
}
}
SyncStateUpdate::SyncError { message } => {
latest_connection = Some(SyncConnectionState::SyncError { message });
SyncStateUpdate::SyncBusy { profile_id } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::SyncError {
message: "Cloud Sync is busy; retrying shortly".to_string(),
});
self.sync_retry_at = Some(std::time::Instant::now() + CAS_RETRY_DELAY);
}
}
SyncStateUpdate::AuthOtpSent { email } => {
self.auth_flow_phase = auth::AuthFlowPhase::AwaitingOtp { email };
auth_changed = true;
SyncStateUpdate::SyncError { profile_id, message } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::SyncError { message });
}
}
SyncStateUpdate::AuthSucceeded { email } => {
self.auth_flow_phase = auth::AuthFlowPhase::Idle;
latest_connection = Some(SyncConnectionState::SignedIn);
trigger_initial_sync = true;
tracing::info!(target: "ely::sync", email = %email, "email OTP sign-in succeeded");
auth_changed = true;
SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.sync_devices.set_ready(profile_id, devices, current_code);
devices_changed = true;
}
}
SyncStateUpdate::AuthError { email, message } => {
self.auth_flow_phase = auth::AuthFlowPhase::Error { email, message };
auth_changed = true;
SyncStateUpdate::DevicesError { profile_id, message } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.sync_devices.set_error(profile_id, message);
devices_changed = true;
}
}
SyncStateUpdate::AuthOtpSent { profile_id, email } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.auth_flow_phase =
auth::AuthFlowPhase::AwaitingOtp { profile_id, email };
auth_changed = true;
}
}
SyncStateUpdate::AuthSucceeded { profile_id, email } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.auth_flow_phase = auth::AuthFlowPhase::Idle;
self.sync_devices.reset();
latest_connection = Some(SyncConnectionState::SignedIn);
trigger_initial_sync = true;
tracing::info!(target: "ely::sync", email = %email, "email OTP sign-in succeeded");
auth_changed = true;
}
}
SyncStateUpdate::AuthError { profile_id, email, message } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.auth_flow_phase =
auth::AuthFlowPhase::Error { profile_id, email, message };
auth_changed = true;
}
}
}
}
@@ -194,17 +267,32 @@ impl ElyShell {
}
let merged_upload_requested = trigger_merged_upload.is_some();
if let Some(logical_clock_floor) = trigger_merged_upload {
if let Some(merge) = trigger_merged_upload {
self.clear_pending_cloud_sync_upload();
self.trigger_cloud_sync_upload_after_remote(logical_clock_floor);
self.trigger_cloud_sync_upload_after_remote(merge);
} else if upload_finished {
self.trigger_pending_cloud_sync_upload();
}
if retry_due {
self.trigger_cloud_sync_upload();
}
auth_changed || trigger_initial_sync || merged_upload_requested || connection_changed
auth_changed
|| devices_changed
|| trigger_initial_sync
|| merged_upload_requested
|| retry_due
|| connection_changed
}
}
fn active_profile_id(state: &ShellState) -> Option<ProfileId> {
let ShellState::Ready(core) = state else {
return None;
};
core.snapshot().ok().map(|snapshot| snapshot.active_profile_id)
}
fn probe_initial_sync_state_at(
core: &mut ely_browser_core::BrowserCore,
profile_root: &Path,
+1
View File
@@ -12,6 +12,7 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
url.workspace = true
uuid.workspace = true
[lints]
workspace = true
+217 -55
View File
@@ -4,18 +4,27 @@ use std::{
};
use ely_sync_client::{
ApiClientConfig, BearerToken, BearerTokenStore, DeviceIdentity, SnapshotPayload,
SnapshotUploadRequest, SyncApiClient, SyncClientError, SyncLatestSnapshotDocument,
AccountKey, ApiClientConfig, AuthenticatedSnapshotHead, BearerToken, BearerTokenStore,
DeviceIdentity, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, SnapshotDownloadResult,
SnapshotPayload, SnapshotUploadRequest, SnapshotUploadResult, SyncApiClient, SyncClientError,
SyncLatestSnapshotDocument,
};
use crate::state::BrowserCore;
use crate::sync_records::{SNAPSHOT_SCHEMA_REV, SyncSnapshotBody};
mod concurrency;
mod device_management;
mod vault_management;
use concurrency::{conflict_head, ensure_remote_generation_is_available};
/// Per-profile sync engine for device identity, bearer-token storage, and snapshot IO.
#[derive(Debug)]
pub struct SyncEngine {
api_config: ApiClientConfig,
bearer_store: BearerTokenStore,
account_key_lock_dir: PathBuf,
identity: DeviceIdentity,
last_outcome: Option<SyncOutcome>,
}
@@ -30,12 +39,18 @@ impl SyncEngine {
platform: impl Into<String>,
) -> Result<Self, SyncClientError> {
let sync_dir = profile_data_dir.join("sync");
let account_key_lock_dir = profile_data_dir
.parent()
.and_then(Path::parent)
.unwrap_or(profile_data_dir)
.join(".sync-key-locks");
let identity =
DeviceIdentity::load_or_create(&sync_dir.join("device.json"), device_name, platform)?;
let bearer_store = BearerTokenStore::new(sync_dir.join("bearer.token"));
Ok(Self {
api_config: ApiClientConfig::production(),
bearer_store,
account_key_lock_dir,
identity,
last_outcome: None,
})
@@ -71,40 +86,42 @@ impl SyncEngine {
self.bearer_store.load().map(|token| token.is_some())
}
/// Run the snapshot sync plan for a pre-serialised local payload.
/// The engine registers the device, checks the worker's latest
/// snapshot, downloads a newer remote payload when another device
/// wrote one, and uploads when the local payload is ready to win.
/// Reconcile a pre-serialised local payload with the authenticated global snapshot head.
pub fn sync_bytes(&mut self, bytes: Vec<u8>) -> Result<SyncOutcome, SyncClientError> {
let Some(bearer) = self.bearer_store.load()? else {
let outcome = SyncOutcome::SignedOut;
self.last_outcome = Some(outcome.clone());
return Ok(outcome);
};
let payload = SnapshotPayload::new(bytes)?;
let client = SyncApiClient::new(self.api_config.clone(), bearer)?;
let Some(client) = self.approved_client(client)? else {
let Some((client, user_id)) = self.approved_client(client)? else {
let outcome =
SyncOutcome::AwaitingDeviceApproval { device_id: self.identity.device_id.clone() };
self.last_outcome = Some(outcome.clone());
return Ok(outcome);
};
let vault = self.resolve_vault(&client, &user_id)?;
let status = client.sync_status()?;
let outcome = match status.snapshots.latest {
Some(latest) if latest.payload_hash == payload.payload_hash() => {
SyncOutcome::AlreadyCurrent {
snapshot_id: latest.snapshot_id,
logical_clock: latest.logical_clock,
payload_bytes: latest.size_bytes,
device_id: latest.device_id,
validate_sync_status(&status, &user_id, &self.identity.device_id)?;
let outcome = match status.snapshots.head {
Some(head) => {
let remote = self.download_remote_snapshot(&client, &user_id, &vault, head)?;
if remote.bytes == bytes && remote.merge_base.vault_generation() == vault.generation
{
SyncOutcome::AlreadyCurrent {
snapshot_id: remote.merge_base.snapshot_id().to_string(),
logical_clock: remote.merge_base.logical_clock(),
payload_bytes: remote.merge_base.size_bytes(),
device_id: remote.merge_base.device_id().to_string(),
}
} else if remote.bytes == bytes {
self.upload_payload(&client, &user_id, &vault, bytes, Some(&remote.merge_base))?
} else {
remote.into_outcome(false)
}
}
Some(latest) if latest.device_id != self.identity.device_id => {
self.download_remote_snapshot(&client, latest)?
}
Some(latest) => self.upload_payload(&client, payload, latest.logical_clock)?,
None => self.upload_payload(&client, payload, 0)?,
None => self.upload_payload(&client, &user_id, &vault, bytes, None)?,
};
self.last_outcome = Some(outcome.clone());
Ok(outcome)
@@ -116,22 +133,22 @@ impl SyncEngine {
pub fn upload_merged_bytes(
&mut self,
bytes: Vec<u8>,
logical_clock_floor: u64,
merge_base: AuthenticatedSnapshotHead,
) -> Result<SyncOutcome, SyncClientError> {
let Some(bearer) = self.bearer_store.load()? else {
let outcome = SyncOutcome::SignedOut;
self.last_outcome = Some(outcome.clone());
return Ok(outcome);
};
let payload = SnapshotPayload::new(bytes)?;
let client = SyncApiClient::new(self.api_config.clone(), bearer)?;
let Some(client) = self.approved_client(client)? else {
let Some((client, user_id)) = self.approved_client(client)? else {
let outcome =
SyncOutcome::AwaitingDeviceApproval { device_id: self.identity.device_id.clone() };
self.last_outcome = Some(outcome.clone());
return Ok(outcome);
};
let outcome = self.upload_payload(&client, payload, logical_clock_floor)?;
let vault = self.resolve_vault(&client, &user_id)?;
let outcome = self.upload_payload(&client, &user_id, &vault, bytes, Some(&merge_base))?;
self.last_outcome = Some(outcome.clone());
Ok(outcome)
}
@@ -139,13 +156,10 @@ impl SyncEngine {
fn approved_client(
&self,
client: SyncApiClient,
) -> Result<Option<SyncApiClient>, SyncClientError> {
let registration = client.register_device(
&self.identity,
&device_registration_idempotency_key(&self.identity),
)?;
) -> Result<Option<(SyncApiClient, String)>, SyncClientError> {
let (client, registration) = self.registered_client(client)?;
if registration.device.is_approved() {
return Ok(Some(client));
return Ok(Some((client, registration.user_id)));
}
if registration.device.approval_status == "pending" {
return Ok(None);
@@ -156,44 +170,173 @@ impl SyncEngine {
})
}
fn registered_client(
&self,
client: SyncApiClient,
) -> Result<(SyncApiClient, ely_sync_client::client::DeviceRecordDocument), SyncClientError>
{
let idempotency_key = device_registration_idempotency_key(&self.identity);
let registration = match client.register_device(&self.identity, &idempotency_key) {
Ok(registration) => registration,
Err(SyncClientError::HttpStatus { status: 409, .. }) => {
let rebound = client.rebind_device(&self.identity)?;
let registration = client.register_device(&self.identity, &idempotency_key)?;
if registration.user_id != rebound.user_id {
return Err(SyncClientError::DeviceTrust {
reason: "device rebind account does not match registration",
});
}
registration
}
Err(error) => return Err(error),
};
Ok((client, registration))
}
fn upload_payload(
&self,
client: &SyncApiClient,
payload: SnapshotPayload,
logical_clock_floor: u64,
user_id: &str,
vault: &ResolvedVault,
bytes: Vec<u8>,
base: Option<&AuthenticatedSnapshotHead>,
) -> Result<SyncOutcome, SyncClientError> {
let logical_clock_floor = base.map_or(0, AuthenticatedSnapshotHead::logical_clock);
let logical_clock = current_logical_clock().max(logical_clock_floor.saturating_add(1));
let snapshot_id = snapshot_id_for_user(&self.identity);
let request = SnapshotUploadRequest::new(
&snapshot_id,
self.api_config.region(),
SNAPSHOT_SCHEMA_REV,
let head_revision = match base {
Some(base) => base.next_revision()?,
None => 1,
};
let context = SnapshotCryptoContext {
user_id,
vault_generation: vault.generation,
snapshot_id: &snapshot_id,
schema_rev: SNAPSHOT_SCHEMA_REV,
logical_clock,
device_id: &self.identity.device_id,
head_revision,
base_head: base.map(AuthenticatedSnapshotHead::head_ref),
};
let encrypted = vault.account_key.encrypt(&context, &bytes)?;
let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?;
let request = SnapshotUploadRequest::new(
self.api_config.region(),
&context,
base,
&encrypted,
&payload,
);
let document = client.upload_snapshot(&request)?;
Ok(SyncOutcome::Uploaded {
snapshot_id: document.snapshot.snapshot_id,
logical_clock: document.snapshot.logical_clock,
payload_bytes: document.snapshot.size_bytes,
device_id: document.device_id,
})
)?;
match client.upload_snapshot(&request)? {
SnapshotUploadResult::Committed(document) => {
if document.version != 3
|| document.user_id != user_id
|| document.device_id != self.identity.device_id
|| document.snapshot.snapshot_id != snapshot_id
|| document.snapshot.head_revision != head_revision
|| document.snapshot.base_head.as_ref()
!= base.map(AuthenticatedSnapshotHead::head_ref)
|| document.snapshot.payload_hash != payload.payload_hash()
|| document.snapshot.encryption_version != SNAPSHOT_ENCRYPTION_VERSION
|| document.snapshot.key_id != vault.account_key.key_id()
|| document.snapshot.vault_generation != vault.generation
|| document.snapshot.content_hash != encrypted.content_hash()
|| document.snapshot.schema_rev != SNAPSHOT_SCHEMA_REV
|| document.snapshot.logical_clock != logical_clock
|| document.snapshot.size_bytes
!= u64::try_from(payload.bytes().len()).map_err(|_| {
SyncClientError::SnapshotEncryption {
reason: "snapshot payload size is invalid",
}
})?
{
return Err(SyncClientError::SnapshotEncryption {
reason: "snapshot upload response does not match request",
});
}
Ok(SyncOutcome::Uploaded {
snapshot_id: document.snapshot.snapshot_id,
logical_clock: document.snapshot.logical_clock,
payload_bytes: document.snapshot.size_bytes,
device_id: document.device_id,
})
}
SnapshotUploadResult::Conflict(conflict) => {
let head = conflict_head(conflict)?;
self.download_remote_snapshot(client, user_id, vault, head)
.map(|remote| remote.into_outcome(true))
}
}
}
fn download_remote_snapshot(
&self,
client: &SyncApiClient,
latest: SyncLatestSnapshotDocument,
) -> Result<SyncOutcome, SyncClientError> {
let download = client.download_snapshot(&latest.snapshot_id)?;
let payload = download.payload()?;
Ok(SyncOutcome::RemoteSnapshot {
snapshot_id: latest.snapshot_id,
logical_clock: latest.logical_clock,
payload_bytes: latest.size_bytes,
device_id: latest.device_id,
bytes: payload.into_bytes(),
})
user_id: &str,
vault: &ResolvedVault,
mut latest: SyncLatestSnapshotDocument,
) -> Result<AuthenticatedRemote, SyncClientError> {
for _ in 0..3 {
let account_key = self.key_for_snapshot(client, user_id, vault, &latest)?;
let expected_head = latest.head_ref()?;
match client.download_snapshot(&expected_head)? {
SnapshotDownloadResult::Downloaded(download) => {
let (bytes, merge_base) =
download.authenticate(&expected_head, &account_key)?.into_parts();
return Ok(AuthenticatedRemote { bytes, merge_base });
}
SnapshotDownloadResult::Conflict(conflict) => {
latest = conflict_head(conflict)?;
}
}
}
Err(SyncClientError::SnapshotBusy)
}
fn key_for_snapshot(
&self,
client: &SyncApiClient,
user_id: &str,
vault: &ResolvedVault,
snapshot: &SyncLatestSnapshotDocument,
) -> Result<AccountKey, SyncClientError> {
if !matches!(snapshot.encryption_version, 1 | SNAPSHOT_ENCRYPTION_VERSION) {
return Err(SyncClientError::SnapshotEncryption {
reason: "remote snapshot encryption version is unsupported",
});
}
ensure_remote_generation_is_available(snapshot.vault_generation, vault.generation)?;
if snapshot.vault_generation == vault.generation {
if snapshot.key_id != vault.account_key.key_id() {
return Err(SyncClientError::AccountKeyUnavailable);
}
return Ok(vault.account_key.clone());
}
self.resolve_historical_key(client, user_id, snapshot)
}
}
struct ResolvedVault {
account_key: AccountKey,
generation: u64,
}
struct AuthenticatedRemote {
bytes: Vec<u8>,
merge_base: AuthenticatedSnapshotHead,
}
impl AuthenticatedRemote {
fn into_outcome(self, cas_conflict: bool) -> SyncOutcome {
SyncOutcome::RemoteSnapshot {
snapshot_id: self.merge_base.snapshot_id().to_string(),
logical_clock: self.merge_base.logical_clock(),
payload_bytes: self.merge_base.size_bytes(),
device_id: self.merge_base.device_id().to_string(),
bytes: self.bytes,
merge_base: self.merge_base,
cas_conflict,
}
}
}
@@ -215,6 +358,8 @@ pub enum SyncOutcome {
payload_bytes: u64,
device_id: String,
bytes: Vec<u8>,
merge_base: AuthenticatedSnapshotHead,
cas_conflict: bool,
},
Uploaded {
snapshot_id: String,
@@ -224,6 +369,23 @@ pub enum SyncOutcome {
},
}
fn validate_sync_status(
status: &ely_sync_client::SyncStatusDocument,
user_id: &str,
device_id: &str,
) -> Result<(), SyncClientError> {
if status.version != 2
|| status.user_id != user_id
|| status.device_id != device_id
|| status.devices.current_device_id != device_id
|| !status.devices.current_device_approved
|| (status.snapshots.total_snapshots == 0) != status.snapshots.head.is_none()
{
return Err(SyncClientError::DeviceTrust { reason: "sync status identity is invalid" });
}
Ok(())
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SyncSnapshotApplySummary {
imported: usize,
@@ -0,0 +1,43 @@
use ely_sync_client::{
SyncClientError, SyncLatestSnapshotDocument, SyncSnapshotHeadConflictDocument,
};
pub(super) fn conflict_head(
conflict: SyncSnapshotHeadConflictDocument,
) -> Result<SyncLatestSnapshotDocument, SyncClientError> {
conflict.current_head.ok_or(SyncClientError::SnapshotBusy)
}
pub(super) fn ensure_remote_generation_is_available(
remote_generation: u64,
resolved_generation: u64,
) -> Result<(), SyncClientError> {
if remote_generation > resolved_generation {
return Err(SyncClientError::SnapshotBusy);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_conflict_head_requests_a_bounded_retry() {
let conflict = SyncSnapshotHeadConflictDocument {
version: 1,
error: "sync_snapshot_head_conflict".to_string(),
current_head: None,
};
assert!(matches!(conflict_head(conflict), Err(SyncClientError::SnapshotBusy)));
}
#[test]
fn remote_vault_generation_ahead_requests_a_bounded_retry() {
assert!(matches!(
ensure_remote_generation_is_available(2, 1),
Err(SyncClientError::SnapshotBusy)
));
assert!(ensure_remote_generation_is_available(2, 2).is_ok());
}
}
@@ -0,0 +1,459 @@
use ely_sync_client::{
AccountKey, DeviceApprovalDocument, DeviceApprovalRequest, DeviceListResponse, DeviceRecord,
DeviceRevocationDocument, DeviceRevocationRequest, SyncApiClient, SyncClientError,
VaultContext, WrappedAccountKey,
};
use uuid::Uuid;
use super::SyncEngine;
impl SyncEngine {
pub fn cloud_devices(&self) -> Result<DeviceListResponse, SyncClientError> {
let client = self.authenticated_client()?;
let (client, registration) = self.registered_client(client)?;
let devices = client.list_devices()?;
validate_device_list(
&devices,
&registration.user_id,
&self.identity.device_id,
registration.device.is_approved(),
)?;
Ok(devices)
}
pub fn approve_cloud_device(
&self,
target_device_id: &str,
verification_code: &str,
) -> Result<DeviceApprovalDocument, SyncClientError> {
let (client, user_id) = self.approved_device_client()?;
let devices = client.list_devices()?;
validate_device_list(&devices, &user_id, &self.identity.device_id, true)?;
let target = pending_device(&devices.devices, target_device_id)?;
if !target.verification_code()?.eq_ignore_ascii_case(verification_code.trim()) {
return Err(SyncClientError::DeviceTrust {
reason: "device verification code does not match",
});
}
let wrapping_public_key =
target.wrapping_public_key.as_deref().ok_or(SyncClientError::DeviceTrust {
reason: "pending device has no wrapping public key",
})?;
let vault = self.resolve_vault(&client, &user_id)?;
let key_id = vault.account_key.key_id();
let envelope = WrappedAccountKey::wrap(
&vault.account_key,
&VaultContext {
user_id: &user_id,
recipient_device_id: &target.device_id,
recipient_wrapping_public_key: wrapping_public_key,
approver_device_id: &self.identity.device_id,
generation: vault.generation,
key_id: &key_id,
},
)?;
let idempotency_key = format!("device-approval:{}", Uuid::now_v7().simple());
let request = DeviceApprovalRequest::new(
&user_id,
&self.identity,
&target.device_id,
&key_id,
vault.generation,
&envelope,
&idempotency_key,
)?;
let document = client.approve_device(&request)?;
validate_approval(&document, &user_id, &self.identity.device_id, &target.device_id)?;
Ok(document)
}
pub fn revoke_cloud_device(
&self,
target_device_id: &str,
) -> Result<DeviceRevocationDocument, SyncClientError> {
let (client, user_id) = self.approved_device_client()?;
let devices = client.list_devices()?;
validate_device_list(&devices, &user_id, &self.identity.device_id, true)?;
let target = revocable_device(&devices.devices, target_device_id)?;
let idempotency_key = format!("device-revocation:{}", Uuid::now_v7().simple());
if target.approval_status == "pending" {
let request = DeviceRevocationRequest::pending(
&user_id,
&self.identity,
&target.device_id,
&idempotency_key,
)?;
let document = client.revoke_device(&request)?;
validate_pending_revocation(
&document,
&user_id,
&self.identity.device_id,
&target.device_id,
)?;
return Ok(document);
}
let vault = self.resolve_vault(&client, &user_id)?;
let previous_key_id = vault.account_key.key_id();
let new_generation =
vault.generation.checked_add(1).ok_or(SyncClientError::DeviceTrust {
reason: "device revocation generation overflowed",
})?;
let new_key = AccountKey::generate()?;
let new_key_id = new_key.key_id();
let envelopes = rotation_envelopes(
&devices.devices,
&target.device_id,
&user_id,
&self.identity.device_id,
&new_key,
new_generation,
&new_key_id,
)?;
let request = DeviceRevocationRequest::approved_rotation(
&user_id,
&self.identity,
&target.device_id,
&previous_key_id,
vault.generation,
&new_key_id,
new_generation,
envelopes,
&idempotency_key,
)?;
let document = client.revoke_device(&request)?;
validate_approved_revocation(
&document,
&user_id,
&self.identity.device_id,
&target.device_id,
&new_key_id,
new_generation,
)?;
self.account_key_store(&user_id)?.save_current(&new_key, new_generation)?;
Ok(document)
}
fn approved_device_client(&self) -> Result<(SyncApiClient, String), SyncClientError> {
let client = self.authenticated_client()?;
self.approved_client(client)?.ok_or_else(|| SyncClientError::DeviceApprovalStatus {
device_id: self.identity.device_id.clone(),
status: "pending".to_string(),
})
}
fn authenticated_client(&self) -> Result<SyncApiClient, SyncClientError> {
let bearer = self.bearer_store.load()?.ok_or(SyncClientError::DeviceTrust {
reason: "device management requires an authenticated session",
})?;
SyncApiClient::new(self.api_config.clone(), bearer)
}
}
fn validate_device_list(
document: &DeviceListResponse,
user_id: &str,
current_device_id: &str,
require_approved: bool,
) -> Result<(), SyncClientError> {
let current = document.devices.iter().filter(|device| device.current).collect::<Vec<_>>();
if document.version != 1
|| document.user_id != user_id
|| current.len() != 1
|| current[0].device_id != current_device_id
|| (require_approved && !current[0].is_approved())
{
return Err(SyncClientError::DeviceTrust {
reason: "device list does not match the authenticated device",
});
}
Ok(())
}
fn pending_device<'a>(
devices: &'a [DeviceRecord],
target_device_id: &str,
) -> Result<&'a DeviceRecord, SyncClientError> {
let target = devices
.iter()
.find(|device| device.device_id == target_device_id)
.ok_or(SyncClientError::DeviceTrust { reason: "pending device was not found" })?;
if target.current || target.approval_status != "pending" || target.revoked_at.is_some() {
return Err(SyncClientError::DeviceTrust { reason: "device is not pending approval" });
}
Ok(target)
}
fn revocable_device<'a>(
devices: &'a [DeviceRecord],
target_device_id: &str,
) -> Result<&'a DeviceRecord, SyncClientError> {
let target = devices
.iter()
.find(|device| device.device_id == target_device_id)
.ok_or(SyncClientError::DeviceTrust { reason: "device was not found" })?;
if target.current
|| target.revoked_at.is_some()
|| !matches!(target.approval_status.as_str(), "pending" | "approved")
{
return Err(SyncClientError::DeviceTrust { reason: "device cannot be revoked" });
}
Ok(target)
}
#[allow(clippy::too_many_arguments)]
fn rotation_envelopes(
devices: &[DeviceRecord],
target_device_id: &str,
user_id: &str,
approver_device_id: &str,
new_key: &AccountKey,
new_generation: u64,
new_key_id: &str,
) -> Result<Vec<(String, WrappedAccountKey)>, SyncClientError> {
devices
.iter()
.filter(|device| device.device_id != target_device_id && device.is_approved())
.filter_map(|device| {
device
.wrapping_public_key
.as_deref()
.map(|wrapping_public_key| (device, wrapping_public_key))
})
.map(|(device, wrapping_public_key)| {
let envelope = WrappedAccountKey::wrap(
new_key,
&VaultContext {
user_id,
recipient_device_id: &device.device_id,
recipient_wrapping_public_key: wrapping_public_key,
approver_device_id,
generation: new_generation,
key_id: new_key_id,
},
)?;
Ok((device.device_id.clone(), envelope))
})
.collect()
}
fn validate_approval(
document: &DeviceApprovalDocument,
user_id: &str,
approver_device_id: &str,
target_device_id: &str,
) -> Result<(), SyncClientError> {
if document.version != 1
|| document.user_id != user_id
|| document.approved_by_device_id != approver_device_id
|| document.device.device_id != target_device_id
|| !document.device.is_approved()
|| document.device.approved_at != Some(document.approved_at)
{
return Err(SyncClientError::DeviceTrust {
reason: "device approval response does not match the request",
});
}
Ok(())
}
fn validate_approved_revocation(
document: &DeviceRevocationDocument,
user_id: &str,
approver_device_id: &str,
target_device_id: &str,
key_id: &str,
generation: u64,
) -> Result<(), SyncClientError> {
let DeviceRevocationDocument::ApprovedRotate {
version,
user_id: response_user_id,
revoked_by_device_id,
revoked_at,
key_id: response_key_id,
generation: response_generation,
device,
} = document
else {
return Err(revocation_response_error());
};
if response_key_id != key_id || *response_generation != generation {
return Err(revocation_response_error());
}
validate_revocation_common(
*version,
response_user_id,
revoked_by_device_id,
*revoked_at,
device,
user_id,
approver_device_id,
target_device_id,
)
}
fn validate_pending_revocation(
document: &DeviceRevocationDocument,
user_id: &str,
approver_device_id: &str,
target_device_id: &str,
) -> Result<(), SyncClientError> {
let DeviceRevocationDocument::PendingRevoke {
version,
user_id: response_user_id,
revoked_by_device_id,
revoked_at,
device,
} = document
else {
return Err(revocation_response_error());
};
validate_revocation_common(
*version,
response_user_id,
revoked_by_device_id,
*revoked_at,
device,
user_id,
approver_device_id,
target_device_id,
)
}
#[allow(clippy::too_many_arguments)]
fn validate_revocation_common(
version: u32,
response_user_id: &str,
revoked_by_device_id: &str,
revoked_at: u64,
device: &DeviceRecord,
user_id: &str,
approver_device_id: &str,
target_device_id: &str,
) -> Result<(), SyncClientError> {
if version == 2
&& response_user_id == user_id
&& revoked_by_device_id == approver_device_id
&& device.device_id == target_device_id
&& device.approval_status == "revoked"
&& device.revoked_at == Some(revoked_at)
&& !device.current
{
return Ok(());
}
Err(revocation_response_error())
}
fn revocation_response_error() -> SyncClientError {
SyncClientError::DeviceTrust { reason: "device revocation response does not match the request" }
}
#[cfg(test)]
mod tests {
use super::*;
fn device(device_id: &str, status: &str, current: bool) -> DeviceRecord {
DeviceRecord {
device_id: device_id.to_string(),
public_key: "01".repeat(32),
wrapping_public_key: Some("02".repeat(32)),
device_name: "Test".to_string(),
platform: "macos".to_string(),
approval_status: status.to_string(),
current,
created_at: 1,
approved_at: (status == "approved").then_some(2),
last_active_at: None,
revoked_at: None,
}
}
#[test]
fn device_list_requires_one_approved_current_device() {
let document = DeviceListResponse {
version: 1,
user_id: "user-01".to_string(),
devices: vec![device("device-01", "approved", true)],
};
assert!(validate_device_list(&document, "user-01", "device-01", true).is_ok());
assert!(validate_device_list(&document, "user-02", "device-01", true).is_err());
}
#[test]
fn approval_target_must_be_pending() {
let devices =
[device("device-01", "approved", true), device("device-02", "pending", false)];
assert!(matches!(
pending_device(&devices, "device-02"),
Ok(device) if device.device_id == "device-02"
));
assert!(pending_device(&devices, "device-01").is_err());
}
#[test]
fn approval_response_requires_matching_device() {
let device = device("device-02", "approved", false);
let document = DeviceApprovalDocument {
version: 1,
user_id: "user-01".to_string(),
approved_by_device_id: "device-01".to_string(),
approved_at: 2,
device,
};
assert!(validate_approval(&document, "user-01", "device-01", "device-02").is_ok());
assert!(validate_approval(&document, "user-01", "device-01", "device-03").is_err());
}
#[test]
fn revocation_target_must_be_another_active_device() {
let devices = [
device("device-01", "approved", true),
device("device-02", "approved", false),
device("device-03", "revoked", false),
];
assert!(matches!(
revocable_device(&devices, "device-02"),
Ok(device) if device.device_id == "device-02"
));
assert!(revocable_device(&devices, "device-01").is_err());
assert!(revocable_device(&devices, "device-03").is_err());
}
#[test]
fn revocation_response_binds_rotated_key_and_target() {
let mut revoked = device("device-02", "revoked", false);
revoked.approved_at = Some(2);
revoked.revoked_at = Some(3);
let document = DeviceRevocationDocument::ApprovedRotate {
version: 2,
user_id: "user-01".to_string(),
revoked_by_device_id: "device-01".to_string(),
revoked_at: 3,
key_id: "03".repeat(32),
generation: 2,
device: revoked,
};
assert!(
validate_approved_revocation(
&document,
"user-01",
"device-01",
"device-02",
&"03".repeat(32),
2,
)
.is_ok()
);
assert!(
validate_approved_revocation(
&document,
"user-01",
"device-01",
"device-03",
&"03".repeat(32),
2,
)
.is_err()
);
}
}
@@ -0,0 +1,109 @@
use ely_sync_client::{
AccountKey, AccountKeyStore, SyncApiClient, SyncClientError, SyncLatestSnapshotDocument,
SyncVaultBootstrapRequest, SyncVaultDocument, WrappedAccountKey,
};
use super::{ResolvedVault, SyncEngine};
impl SyncEngine {
pub(super) fn resolve_vault(
&self,
client: &SyncApiClient,
user_id: &str,
) -> Result<ResolvedVault, SyncClientError> {
match client.current_sync_vault() {
Ok(document) => self.resolve_vault_document(user_id, document),
Err(SyncClientError::HttpStatus { status: 404, .. }) => {
self.bootstrap_vault(client, user_id)
}
Err(error) => Err(error),
}
}
fn resolve_vault_document(
&self,
user_id: &str,
document: SyncVaultDocument,
) -> Result<ResolvedVault, SyncClientError> {
let store = self.account_key_store(user_id)?;
let key = document.unwrap_for(user_id, &self.identity)?;
if let Some(stored) = store.load()? {
if stored.current_generation() > document.generation {
return Err(SyncClientError::VaultCrypto {
reason: "sync vault generation rolled back",
});
}
if let Some(stored_key) = stored.key(document.generation)
&& stored_key.key_id() != key.key_id()
{
return Err(SyncClientError::VaultCrypto {
reason: "sync vault key changed within one generation",
});
}
}
store.save_current(&key, document.generation)?;
Ok(ResolvedVault { account_key: key, generation: document.generation })
}
fn bootstrap_vault(
&self,
client: &SyncApiClient,
user_id: &str,
) -> Result<ResolvedVault, SyncClientError> {
let store = self.account_key_store(user_id)?;
if store.load()?.is_some() {
return Err(SyncClientError::VaultCrypto {
reason: "sync vault bootstrap would discard stored key history",
});
}
let key = AccountKey::generate()?;
let envelope = WrappedAccountKey::self_wrap(&key, user_id, &self.identity, 1)?;
let key_id = key.key_id();
let idempotency_key = format!("vault-bootstrap:{}:{key_id}", self.identity.device_id);
let request = SyncVaultBootstrapRequest::signed(
user_id,
&self.identity,
&key_id,
&envelope,
&idempotency_key,
)?;
let document = client.bootstrap_sync_vault(&request)?;
let confirmed_key = document.unwrap_for(user_id, &self.identity)?;
if confirmed_key.key_id() != key_id {
return Err(SyncClientError::AccountKeyUnavailable);
}
store.save_current(&confirmed_key, document.generation)?;
Ok(ResolvedVault { account_key: confirmed_key, generation: document.generation })
}
pub(super) fn resolve_historical_key(
&self,
client: &SyncApiClient,
user_id: &str,
snapshot: &SyncLatestSnapshotDocument,
) -> Result<AccountKey, SyncClientError> {
let store = self.account_key_store(user_id)?;
if let Some(stored) = store.load()?
&& let Some(key) = stored.key(snapshot.vault_generation)
{
if key.key_id() != snapshot.key_id {
return Err(SyncClientError::AccountKeyUnavailable);
}
return Ok(key.clone());
}
let document = client.sync_vault_generation(snapshot.vault_generation, &snapshot.key_id)?;
let key = document.unwrap_for(user_id, &self.identity)?;
if document.generation != snapshot.vault_generation || key.key_id() != snapshot.key_id {
return Err(SyncClientError::AccountKeyUnavailable);
}
store.save_historical(&key, snapshot.vault_generation)?;
Ok(key)
}
pub(super) fn account_key_store(
&self,
user_id: &str,
) -> Result<AccountKeyStore, SyncClientError> {
AccountKeyStore::new(user_id, &self.account_key_lock_dir)
}
}
+9
View File
@@ -6,14 +6,23 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
base64.workspace = true
chacha20poly1305.workspace = true
ed25519-dalek.workspace = true
ely_domain = { path = "../ely_domain" }
fs2.workspace = true
getrandom.workspace = true
hkdf.workspace = true
hmac.workspace = true
hpke.workspace = true
keyring.workspace = true
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
ureq = { workspace = true, features = ["json"] }
uuid = { workspace = true }
zeroize.workspace = true
[lints]
workspace = true
+229 -21
View File
@@ -1,13 +1,21 @@
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::de::DeserializeOwned;
use ureq::{Agent, AgentBuilder};
use crate::{
SnapshotHeadRef,
auth::BearerToken,
device::{DeviceIdentity, DeviceListResponse, DeviceRegistration},
device_api::{
DeviceApprovalDocument, DeviceApprovalRequest, DeviceRebindChallengeDocument,
DeviceRebindChallengeRequest, DeviceRebindDocument,
},
device_revocation::{DeviceRevocationDocument, DeviceRevocationRequest},
error::SyncClientError,
snapshot::{SnapshotDownload, SnapshotUploadRequest},
vault::SyncVaultDocument,
vault_bootstrap::SyncVaultBootstrapRequest,
};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
@@ -73,13 +81,16 @@ impl SyncApiClient {
identity: &DeviceIdentity,
idempotency_key: &str,
) -> Result<DeviceRecordDocument, SyncClientError> {
let registration_proof = identity.registration_proof(idempotency_key)?;
let registration = DeviceRegistration {
version: 1,
version: 2,
device_id: &identity.device_id,
public_key: &identity.public_key,
wrapping_public_key: &identity.wrapping_public_key,
device_name: &identity.device_name,
platform: &identity.platform,
idempotency_key,
registration_proof: &registration_proof,
};
let endpoint = self.endpoint("/api/devices/register");
let response = self
@@ -107,6 +118,75 @@ impl SyncApiClient {
read_json_response::<DeviceListResponse>(&endpoint, response)
}
/// Rebind an existing v2 device to a fresh authenticated session.
pub fn rebind_device(
&self,
identity: &DeviceIdentity,
) -> Result<DeviceRebindDocument, SyncClientError> {
let challenge_endpoint = self.endpoint("/api/devices/rebind/challenge");
let challenge_request =
DeviceRebindChallengeRequest { version: 1, device_id: &identity.device_id };
let challenge_response = self
.agent
.post(&challenge_endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(&challenge_request).map_err(|source| {
SyncClientError::Json { endpoint: challenge_endpoint.clone(), source }
})?);
let challenge = read_json_response::<DeviceRebindChallengeDocument>(
&challenge_endpoint,
challenge_response,
)?;
let now_seconds = current_time_seconds()?;
let rebind_request = challenge.signed_request(identity, now_seconds)?;
let rebind_endpoint = self.endpoint("/api/devices/rebind");
let rebind_response = self
.agent
.post(&rebind_endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(&rebind_request).map_err(|source| {
SyncClientError::Json { endpoint: rebind_endpoint.clone(), source }
})?);
let document =
read_json_response::<DeviceRebindDocument>(&rebind_endpoint, rebind_response)?;
document.validate(identity, &challenge, current_time_seconds()?)?;
Ok(document)
}
pub fn approve_device(
&self,
request: &DeviceApprovalRequest<'_>,
) -> Result<DeviceApprovalDocument, SyncClientError> {
let endpoint = self.endpoint("/api/devices/approve");
let response =
self.agent
.post(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(request).map_err(|source| {
SyncClientError::Json { endpoint: endpoint.clone(), source }
})?);
read_json_response::<DeviceApprovalDocument>(&endpoint, response)
}
pub fn revoke_device(
&self,
request: &DeviceRevocationRequest,
) -> Result<DeviceRevocationDocument, SyncClientError> {
let endpoint = self.endpoint("/api/devices/revoke");
let response =
self.agent
.post(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(request).map_err(|source| {
SyncClientError::Json { endpoint: endpoint.clone(), source }
})?);
read_json_response::<DeviceRevocationDocument>(&endpoint, response)
}
/// `GET /api/sync/status` — return the worker-side cursor,
/// object, snapshot, and device summary for the authenticated
/// approved device.
@@ -120,13 +200,54 @@ impl SyncApiClient {
read_json_response::<SyncStatusDocument>(&endpoint, response)
}
pub fn current_sync_vault(&self) -> Result<SyncVaultDocument, SyncClientError> {
let endpoint = self.endpoint("/api/sync/vault");
let response = self
.agent
.get(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.call();
read_json_response::<SyncVaultDocument>(&endpoint, response)
}
pub fn sync_vault_generation(
&self,
generation: u64,
key_id: &str,
) -> Result<SyncVaultDocument, SyncClientError> {
let endpoint =
self.endpoint(&format!("/api/sync/vault?generation={generation}&key_id={key_id}"));
let response = self
.agent
.get(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.call();
read_json_response::<SyncVaultDocument>(&endpoint, response)
}
pub fn bootstrap_sync_vault(
&self,
request: &SyncVaultBootstrapRequest<'_>,
) -> Result<SyncVaultDocument, SyncClientError> {
let endpoint = self.endpoint("/api/sync/vault/bootstrap");
let response =
self.agent
.post(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(request).map_err(|error| {
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
})?);
read_json_response::<SyncVaultDocument>(&endpoint, response)
}
/// `POST /api/sync/snapshot` — push the full per-user state. The
/// worker enforces logical-clock monotonicity, so callers must
/// pass a value strictly greater than the last accepted snapshot.
pub fn upload_snapshot(
&self,
request: &SnapshotUploadRequest<'_>,
) -> Result<SnapshotUploadDocument, SyncClientError> {
) -> Result<SnapshotUploadResult, SyncClientError> {
let endpoint = self.endpoint("/api/sync/snapshot");
let response =
self.agent
@@ -136,7 +257,23 @@ impl SyncApiClient {
.send_json(serde_json::to_value(request).map_err(|error| {
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
})?);
read_json_response::<SnapshotUploadDocument>(&endpoint, response)
match response {
Ok(response) => read_json_from_response::<SnapshotUploadDocument>(&endpoint, response)
.map(SnapshotUploadResult::Committed),
Err(ureq::Error::Status(409, response)) => {
let conflict = read_json_from_response::<SyncSnapshotHeadConflictDocument>(
&endpoint, response,
)?;
if conflict.version != 1 || conflict.error != "sync_snapshot_head_conflict" {
return Err(SyncClientError::DeviceTrust {
reason: "snapshot conflict response is invalid",
});
}
Ok(SnapshotUploadResult::Conflict(conflict))
}
Err(error) => read_json_response::<SnapshotUploadDocument>(&endpoint, Err(error))
.map(SnapshotUploadResult::Committed),
}
}
/// `GET /api/sync/snapshot?snapshot_id=…` — fetch the snapshot for
@@ -145,15 +282,36 @@ impl SyncApiClient {
/// the bytes.
pub fn download_snapshot(
&self,
snapshot_id: &str,
) -> Result<SnapshotDownload, SyncClientError> {
let endpoint = self.endpoint(&format!("/api/sync/snapshot?snapshot_id={snapshot_id}"));
head: &SnapshotHeadRef,
) -> Result<SnapshotDownloadResult, SyncClientError> {
let endpoint = self.endpoint(&format!(
"/api/sync/snapshot?snapshot_id={}&head_revision={}&payload_hash={}",
head.snapshot_id(),
head.revision(),
head.payload_hash(),
));
let response = self
.agent
.get(&endpoint)
.set("Authorization", &format!("Bearer {}", self.bearer.as_str()))
.call();
read_json_response::<SnapshotDownload>(&endpoint, response)
match response {
Ok(response) => read_json_from_response::<SnapshotDownload>(&endpoint, response)
.map(SnapshotDownloadResult::Downloaded),
Err(ureq::Error::Status(409, response)) => {
let conflict = read_json_from_response::<SyncSnapshotHeadConflictDocument>(
&endpoint, response,
)?;
if conflict.version != 1 || conflict.error != "sync_snapshot_head_conflict" {
return Err(SyncClientError::DeviceTrust {
reason: "snapshot conflict response is invalid",
});
}
Ok(SnapshotDownloadResult::Conflict(conflict))
}
Err(error) => read_json_response::<SnapshotDownload>(&endpoint, Err(error))
.map(SnapshotDownloadResult::Downloaded),
}
}
fn endpoint(&self, path: &str) -> String {
@@ -205,19 +363,55 @@ pub struct SyncObjectStatusDocument {
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncSnapshotStatusDocument {
pub total_snapshots: u64,
pub latest: Option<SyncLatestSnapshotDocument>,
pub head: Option<SyncLatestSnapshotDocument>,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncLatestSnapshotDocument {
pub head_revision: u64,
pub base_head: Option<SnapshotHeadRef>,
pub snapshot_id: String,
pub payload_hash: String,
pub encryption_version: u32,
pub vault_generation: u64,
pub key_id: String,
pub content_hash: String,
pub logical_clock: u64,
pub device_id: String,
pub size_bytes: u64,
pub created_at: u64,
}
impl SyncLatestSnapshotDocument {
pub fn head_ref(&self) -> Result<SnapshotHeadRef, SyncClientError> {
SnapshotHeadRef::new(
self.head_revision,
self.snapshot_id.clone(),
self.payload_hash.clone(),
)
}
}
#[derive(Clone, Debug)]
pub enum SnapshotUploadResult {
Committed(SnapshotUploadDocument),
Conflict(SyncSnapshotHeadConflictDocument),
}
#[derive(Clone, Debug)]
pub enum SnapshotDownloadResult {
Downloaded(SnapshotDownload),
Conflict(SyncSnapshotHeadConflictDocument),
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SyncSnapshotHeadConflictDocument {
pub version: u32,
pub error: String,
pub current_head: Option<SyncLatestSnapshotDocument>,
}
#[derive(Clone, Debug, serde::Deserialize)]
pub struct SyncDeviceStatusDocument {
pub approved_count: u64,
@@ -230,18 +424,7 @@ fn read_json_response<T: DeserializeOwned>(
response: Result<ureq::Response, ureq::Error>,
) -> Result<T, SyncClientError> {
match response {
Ok(ok) => {
let status = ok.status();
let body = ok.into_string().map_err(|error| SyncClientError::HttpStatus {
endpoint: endpoint.to_string(),
status,
body: error.to_string(),
})?;
serde_json::from_str::<T>(&body).map_err(|error| SyncClientError::Json {
endpoint: endpoint.to_string(),
source: error,
})
}
Ok(ok) => read_json_from_response(endpoint, ok),
Err(ureq::Error::Status(status, raw)) => {
let body = raw.into_string().unwrap_or_default();
Err(SyncClientError::HttpStatus { endpoint: endpoint.to_string(), status, body })
@@ -251,3 +434,28 @@ fn read_json_response<T: DeserializeOwned>(
}
}
}
fn read_json_from_response<T: DeserializeOwned>(
endpoint: &str,
response: ureq::Response,
) -> Result<T, SyncClientError> {
let status = response.status();
let body = response.into_string().map_err(|error| SyncClientError::HttpStatus {
endpoint: endpoint.to_string(),
status,
body: error.to_string(),
})?;
serde_json::from_str::<T>(&body)
.map_err(|source| SyncClientError::Json { endpoint: endpoint.to_string(), source })
}
fn current_time_seconds() -> Result<u64, SyncClientError> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|_| SyncClientError::DeviceTrust { reason: "system clock is invalid" })
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;
+107
View File
@@ -0,0 +1,107 @@
use std::{
error::Error,
io::{Read, Write},
net::TcpListener,
thread::{self, JoinHandle},
};
use crate::{
AccountKey, ApiClientConfig, BearerToken, SnapshotCryptoContext, SnapshotDownloadResult,
SnapshotHeadRef, SnapshotPayload, SnapshotUploadRequest, SnapshotUploadResult, SyncApiClient,
};
type TestServer = JoinHandle<std::io::Result<()>>;
#[test]
fn upload_parses_structured_snapshot_head_conflict() -> Result<(), Box<dyn Error>> {
let (base_url, server) = spawn_conflict_server()?;
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
let key = AccountKey::from_bytes([31; 32]);
let context = SnapshotCryptoContext {
user_id: "user-01",
vault_generation: 1,
snapshot_id: "snapshot-local",
schema_rev: 1,
logical_clock: 8,
device_id: "device-local",
head_revision: 1,
base_head: None,
};
let encrypted = key.encrypt(&context, b"local snapshot")?;
let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?;
let request = SnapshotUploadRequest::new("auto", &context, None, &encrypted, &payload)?;
let SnapshotUploadResult::Conflict(conflict) = client.upload_snapshot(&request)? else {
return Err("snapshot upload conflict was not preserved".into());
};
assert_eq!(conflict.current_head.ok_or("missing conflict head")?.head_revision, 7);
join_server(server)
}
#[test]
fn download_parses_structured_snapshot_head_conflict() -> Result<(), Box<dyn Error>> {
let (base_url, server) = spawn_conflict_server()?;
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
let requested = SnapshotHeadRef::new(6, "snapshot-old", "cd".repeat(32))?;
let SnapshotDownloadResult::Conflict(conflict) = client.download_snapshot(&requested)? else {
return Err("snapshot download conflict was not preserved".into());
};
assert_eq!(conflict.current_head.ok_or("missing conflict head")?.snapshot_id, "snapshot-new");
join_server(server)
}
fn spawn_conflict_server() -> Result<(String, TestServer), Box<dyn Error>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let address = listener.local_addr()?;
let body = serde_json::json!({
"version": 1,
"error": "sync_snapshot_head_conflict",
"current_head": {
"head_revision": 7,
"base_head": {
"revision": 6,
"snapshot_id": "snapshot-old",
"payload_hash": "cd".repeat(32)
},
"snapshot_id": "snapshot-new",
"payload_hash": "ab".repeat(32),
"encryption_version": 2,
"vault_generation": 1,
"key_id": "ef".repeat(32),
"content_hash": "12".repeat(32),
"logical_clock": 9,
"device_id": "device-remote",
"size_bytes": 256,
"created_at": 1
}
})
.to_string();
let server = thread::spawn(move || -> std::io::Result<()> {
let (mut stream, _) = listener.accept()?;
let mut request = [0_u8; 16 * 1024];
let _ = stream.read(&mut request)?;
let response = format!(
"HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes())?;
stream.flush()
});
Ok((format!("http://{address}"), server))
}
fn join_server(server: TestServer) -> Result<(), Box<dyn Error>> {
match server.join() {
Ok(result) => result.map_err(Into::into),
Err(_) => Err("snapshot conflict server thread panicked".into()),
}
}
@@ -0,0 +1,28 @@
use keyring::{Entry, Error as KeyringError};
use zeroize::Zeroizing;
pub(crate) fn load_secret(
service: &str,
account: &str,
) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
match entry(service, account)?.get_secret() {
Ok(secret) => Ok(Some(Zeroizing::new(secret))),
Err(KeyringError::NoEntry) => Ok(None),
Err(error) => Err(error.to_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())
}
pub(crate) fn clear_secret(service: &str, account: &str) -> Result<(), String> {
match entry(service, account)?.delete_credential() {
Ok(()) | Err(KeyringError::NoEntry) => Ok(()),
Err(error) => Err(error.to_string()),
}
}
fn entry(service: &str, account: &str) -> Result<Entry, String> {
Entry::new(service, account).map_err(|error| error.to_string())
}
+319 -64
View File
@@ -4,59 +4,82 @@ use std::{
path::Path,
};
use ed25519_dalek::SigningKey;
use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
use hpke::{Deserializable, Kem, Serializable, kem::X25519HkdfSha256};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use zeroize::{Zeroize, Zeroizing};
use crate::error::SyncClientError;
use crate::{
device_secret_store::{DeviceSecretStore, DeviceSecrets},
error::SyncClientError,
};
/// Locally-stable device identity. Constructed once per profile data
/// directory and persisted so reinstalls don't trigger re-approval
/// requests — the same `device_id` is reused across runs.
const PUBLIC_KEY_BYTES: usize = 32;
const MAX_DEVICE_TEXT_CHARS: usize = 128;
/// Public device identity persisted in the profile directory. Both private
/// keys live in the macOS data-protection Keychain under `device_id`.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceIdentity {
pub device_id: String,
/// Ed25519 verification key encoded as lowercase hex.
pub public_key: String,
/// RFC 9180 X25519 recipient key encoded as lowercase hex.
pub wrapping_public_key: String,
pub device_name: String,
pub platform: String,
}
impl DeviceIdentity {
/// Load the persisted identity, or create-and-save a new one.
/// The identity file lives at `path`; callers usually pick
/// `<profile_data>/sync/device.json`.
/// 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
/// never persisted and cannot prove device continuity.
pub fn load_or_create(
path: &Path,
device_name: impl Into<String>,
platform: impl Into<String>,
) -> Result<Self, SyncClientError> {
let device_name = device_name.into();
let platform = platform.into();
match fs::read_to_string(path) {
Ok(contents) => {
let identity: Self = serde_json::from_str(&contents).map_err(|error| {
SyncClientError::TokenStorage(format!(
"device identity is corrupt at {}: {error}",
path.display()
))
})?;
identity.validate()?;
Ok(identity)
}
Ok(contents) => match decode_stored_identity(&contents, path)? {
StoredIdentity::V2(identity) => {
identity.validate()?;
match DeviceSecretStore::new(identity.device_id.clone())?.load()? {
Some(secrets) => {
identity.validate_secrets(&secrets)?;
Ok(identity)
}
None => {
Self::create_and_save(path, identity.device_name, identity.platform)
}
}
}
StoredIdentity::Legacy(identity) => {
identity.validate()?;
Self::create_and_save(path, identity.device_name, identity.platform)
}
},
Err(error) if error.kind() == ErrorKind::NotFound => {
let identity = Self::generate(device_name, platform);
identity.save(path)?;
Ok(identity)
Self::create_and_save(path, device_name, platform)
}
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
}
}
pub fn generate(device_name: impl Into<String>, platform: impl Into<String>) -> Self {
let device_id = format!("ely-{}", Uuid::now_v7().simple());
let public_key = public_key_hex();
Self { device_id, public_key, device_name: device_name.into(), platform: platform.into() }
pub fn generate(
device_name: impl Into<String>,
platform: impl Into<String>,
) -> Result<Self, SyncClientError> {
let (identity, secrets) = generate_key_material(device_name.into(), platform.into())?;
DeviceSecretStore::new(identity.device_id.clone())?.save(&secrets)?;
Ok(identity)
}
pub fn save(&self, path: &Path) -> Result<(), SyncClientError> {
self.validate()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(io_err)?;
}
@@ -65,30 +88,180 @@ impl DeviceIdentity {
SyncClientError::TokenStorage(format!("device identity serialize: {error}"))
})?;
fs::write(&tmp, serialized).map_err(io_err)?;
fs::rename(&tmp, path).map_err(io_err)?;
fs::rename(&tmp, path).map_err(io_err)
}
/// Signs exact canonical bytes for device registration and rebind proofs.
pub fn sign_message(&self, message: &[u8]) -> Result<String, SyncClientError> {
self.validate()?;
let secrets = DeviceSecretStore::new(self.device_id.clone())?.load_required()?;
self.validate_secrets(&secrets)?;
Ok(self.sign_message_with_secrets(message, &secrets))
}
fn sign_message_with_secrets(&self, message: &[u8], secrets: &DeviceSecrets) -> String {
let signing_key = SigningKey::from_bytes(secrets.signing_private_key());
hex_string(&signing_key.sign(message).to_bytes())
}
pub(crate) fn validate(&self) -> Result<(), SyncClientError> {
validate_common(
&self.device_id,
&self.public_key,
&self.device_name,
&self.platform,
true,
)?;
let wrapping_public_key = decode_hex_32(
&self.wrapping_public_key,
"device wrapping public key encoding is invalid",
)?;
<X25519HkdfSha256 as Kem>::PublicKey::from_bytes(&wrapping_public_key)
.map_err(|_| key_error("device wrapping public key is invalid"))?;
Ok(())
}
fn validate(&self) -> Result<(), SyncClientError> {
if !is_device_id_shape(&self.device_id) {
return Err(SyncClientError::TokenStorage(
"device_id does not match the Cloudflare worker pattern".to_string(),
));
pub(crate) fn validate_secrets(&self, secrets: &DeviceSecrets) -> Result<(), SyncClientError> {
let expected_signing_public_key =
decode_hex_32(&self.public_key, "device signing public key encoding is invalid")?;
let signing_key = SigningKey::from_bytes(secrets.signing_private_key());
if signing_key.verifying_key().to_bytes() != expected_signing_public_key {
return Err(key_error("device signing private key does not match identity"));
}
if self.public_key.trim().is_empty() {
return Err(SyncClientError::TokenStorage("device public_key is empty".to_string()));
}
if self.device_name.trim().is_empty() {
return Err(SyncClientError::TokenStorage("device_name is empty".to_string()));
}
if self.platform.trim().is_empty() {
return Err(SyncClientError::TokenStorage("platform is empty".to_string()));
let private_key =
<X25519HkdfSha256 as Kem>::PrivateKey::from_bytes(secrets.wrapping_private_key())
.map_err(|_| key_error("device wrapping private key is invalid"))?;
let expected_wrapping_public_key = decode_hex_32(
&self.wrapping_public_key,
"device wrapping public key encoding is invalid",
)?;
if X25519HkdfSha256::sk_to_pk(&private_key).to_bytes().as_slice()
!= expected_wrapping_public_key
{
return Err(key_error("device wrapping private key does not match identity"));
}
Ok(())
}
fn create_and_save(
path: &Path,
device_name: String,
platform: String,
) -> Result<Self, SyncClientError> {
let (identity, secrets) = generate_key_material(device_name, platform)?;
let store = DeviceSecretStore::new(identity.device_id.clone())?;
store.save(&secrets)?;
if let Err(error) = identity.save(path) {
let _ = store.clear();
return Err(error);
}
Ok(identity)
}
}
fn is_device_id_shape(value: &str) -> bool {
pub(crate) fn generate_key_material(
device_name: String,
platform: String,
) -> Result<(DeviceIdentity, DeviceSecrets), SyncClientError> {
let mut signing_private_key = Zeroizing::new([0_u8; PUBLIC_KEY_BYTES]);
getrandom::fill(signing_private_key.as_mut())
.map_err(|_| key_error("secure randomness unavailable"))?;
let signing_key = SigningKey::from_bytes(&signing_private_key);
let mut wrapping_ikm = Zeroizing::new([0_u8; PUBLIC_KEY_BYTES]);
getrandom::fill(wrapping_ikm.as_mut())
.map_err(|_| key_error("secure randomness unavailable"))?;
let (wrapping_private_key, wrapping_public_key) =
X25519HkdfSha256::derive_keypair(wrapping_ikm.as_slice());
let mut wrapping_private_bytes = wrapping_private_key.to_bytes();
let mut stored_wrapping_private_key = [0_u8; PUBLIC_KEY_BYTES];
stored_wrapping_private_key.copy_from_slice(&wrapping_private_bytes);
wrapping_private_bytes.zeroize();
let identity = DeviceIdentity {
device_id: format!("ely-{}", Uuid::now_v7().simple()),
public_key: hex_string(&signing_key.verifying_key().to_bytes()),
wrapping_public_key: hex_string(&wrapping_public_key.to_bytes()),
device_name: device_name.trim().to_string(),
platform: platform.trim().to_string(),
};
identity.validate()?;
let secrets = DeviceSecrets::new(*signing_private_key, stored_wrapping_private_key);
identity.validate_secrets(&secrets)?;
Ok((identity, secrets))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyDeviceIdentity {
device_id: String,
public_key: String,
device_name: String,
platform: String,
}
impl LegacyDeviceIdentity {
fn validate(&self) -> Result<(), SyncClientError> {
validate_common(&self.device_id, &self.public_key, &self.device_name, &self.platform, false)
}
}
enum StoredIdentity {
V2(DeviceIdentity),
Legacy(LegacyDeviceIdentity),
}
fn decode_stored_identity(contents: &str, path: &Path) -> Result<StoredIdentity, SyncClientError> {
let value: serde_json::Value =
serde_json::from_str(contents).map_err(|error| corrupt_identity_error(path, error))?;
if value.get("wrapping_public_key").is_some() {
serde_json::from_value(value)
.map(StoredIdentity::V2)
.map_err(|error| corrupt_identity_error(path, error))
} else {
serde_json::from_value(value)
.map(StoredIdentity::Legacy)
.map_err(|error| corrupt_identity_error(path, error))
}
}
fn validate_common(
device_id: &str,
signing_public_key: &str,
device_name: &str,
platform: &str,
require_canonical_text: bool,
) -> Result<(), SyncClientError> {
if !is_device_id_shape(device_id) {
return Err(key_error("device_id does not match the Cloudflare worker pattern"));
}
let signing_public_key =
decode_hex_32(signing_public_key, "device signing public key encoding is invalid")?;
VerifyingKey::from_bytes(&signing_public_key)
.map_err(|_| key_error("device signing public key is invalid"))?;
validate_device_text(device_name, "device_name is invalid", require_canonical_text)?;
validate_device_text(platform, "platform is invalid", require_canonical_text)?;
Ok(())
}
fn validate_device_text(
value: &str,
reason: &'static str,
require_canonical: bool,
) -> Result<(), SyncClientError> {
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed.chars().count() > MAX_DEVICE_TEXT_CHARS
|| trimmed.chars().any(char::is_control)
|| (require_canonical && value != trimmed)
{
return Err(key_error(reason));
}
Ok(())
}
pub(crate) fn is_device_id_shape(value: &str) -> bool {
(3..=128).contains(&value.len())
&& value
.as_bytes()
@@ -96,20 +269,52 @@ fn is_device_id_shape(value: &str) -> bool {
.all(|byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b':' | b'-'))
}
fn io_err(error: io::Error) -> SyncClientError {
SyncClientError::TokenStorage(error.to_string())
pub(crate) fn decode_hex_32(
value: &str,
reason: &'static str,
) -> Result<[u8; 32], SyncClientError> {
if value.len() != 64 {
return Err(key_error(reason));
}
let mut bytes = [0_u8; 32];
for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
bytes[index] = (hex_nibble(pair[0]).ok_or_else(|| key_error(reason))? << 4)
| hex_nibble(pair[1]).ok_or_else(|| key_error(reason))?;
}
Ok(bytes)
}
fn public_key_hex() -> String {
let mut seed = [0_u8; 32];
seed[..16].copy_from_slice(Uuid::now_v7().as_bytes());
seed[16..].copy_from_slice(Uuid::now_v7().as_bytes());
let signing_key = SigningKey::from_bytes(&seed);
hex_string(&signing_key.verifying_key().to_bytes())
fn hex_nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
_ => None,
}
}
fn hex_string(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
output
}
fn key_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::DeviceKeyStorage(message.into())
}
fn corrupt_identity_error(path: &Path, error: serde_json::Error) -> SyncClientError {
SyncClientError::TokenStorage(format!(
"device identity is corrupt at {}: {error}",
path.display()
))
}
fn io_err(error: io::Error) -> SyncClientError {
SyncClientError::TokenStorage(error.to_string())
}
#[derive(Clone, Debug, Serialize)]
@@ -117,6 +322,8 @@ pub struct DeviceRegistration<'a> {
pub version: u32,
pub device_id: &'a str,
pub public_key: &'a str,
pub wrapping_public_key: &'a str,
pub registration_proof: &'a str,
pub device_name: &'a str,
pub platform: &'a str,
pub idempotency_key: &'a str,
@@ -132,9 +339,12 @@ pub struct DeviceListResponse {
#[derive(Clone, Debug, Deserialize)]
pub struct DeviceRecord {
pub device_id: String,
pub public_key: String,
pub wrapping_public_key: Option<String>,
pub device_name: String,
pub platform: String,
pub approval_status: String,
pub current: bool,
pub created_at: u64,
pub approved_at: Option<u64>,
pub last_active_at: Option<u64>,
@@ -150,38 +360,83 @@ impl DeviceRecord {
#[cfg(test)]
mod tests {
use super::*;
use std::env::temp_dir;
#[test]
fn identity_round_trips() -> Result<(), SyncClientError> {
let dir = temp_dir().join(format!("ely-device-{}", Uuid::now_v7().simple()));
let path = dir.join("device.json");
let identity = DeviceIdentity::load_or_create(&path, "Test", "macos")?;
identity.validate()?;
assert_eq!(identity.public_key.len(), 64);
assert!(identity.public_key.as_bytes().iter().all(u8::is_ascii_hexdigit));
fn generated_identity_contains_public_keys_only() -> Result<(), SyncClientError> {
let (identity, _) = generate_key_material(" Test ".to_string(), " macos ".to_string())?;
let value = serde_json::to_value(&identity).map_err(|error| {
SyncClientError::TokenStorage(format!("device identity serialize: {error}"))
})?;
let again = DeviceIdentity::load_or_create(&path, "ignored", "ignored")?;
assert_eq!(identity, again);
assert_eq!(value.as_object().map(serde_json::Map::len), Some(5));
assert_eq!(identity.public_key.len(), 64);
assert_eq!(identity.wrapping_public_key.len(), 64);
assert_eq!(identity.device_name, "Test");
assert_eq!(identity.platform, "macos");
assert!(value.get("private_key").is_none());
Ok(())
}
#[test]
fn device_registration_serializes_worker_schema_version() -> Result<(), SyncClientError> {
fn device_registration_serializes_v2_worker_schema() -> Result<(), SyncClientError> {
let registration = DeviceRegistration {
version: 1,
version: 2,
device_id: "device-01",
public_key: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
public_key: "01",
wrapping_public_key: "02",
registration_proof: "03",
device_name: "MacBook Pro",
platform: "macOS",
idempotency_key: "device-register-device-01",
};
let value = serde_json::to_value(registration).map_err(|error| {
SyncClientError::TokenStorage(format!("device registration serialize: {error}"))
})?;
assert_eq!(value["version"], 1);
assert_eq!(value["version"], 2);
assert_eq!(value["wrapping_public_key"], "02");
assert_eq!(value["registration_proof"], "03");
Ok(())
}
#[test]
fn legacy_identity_is_detected_for_rotation() -> Result<(), SyncClientError> {
let (identity, _) = generate_key_material("Test".to_string(), "macos".to_string())?;
let legacy = serde_json::json!({
"device_id": identity.device_id,
"public_key": identity.public_key,
"device_name": identity.device_name,
"platform": identity.platform,
});
let stored = decode_stored_identity(&legacy.to_string(), Path::new("device.json"))?;
assert!(matches!(stored, StoredIdentity::Legacy(_)));
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn legacy_identity_rotates_to_new_device_id() -> Result<(), SyncClientError> {
let dir = std::env::temp_dir().join(format!("ely-device-{}", Uuid::now_v7().simple()));
let path = dir.join("device.json");
fs::create_dir_all(&dir).map_err(io_err)?;
let legacy_device_id = "ely-legacy-device";
let (_, secrets) = generate_key_material("Test".to_string(), "macos".to_string())?;
let signing_key = SigningKey::from_bytes(secrets.signing_private_key());
let legacy = serde_json::json!({
"device_id": legacy_device_id,
"public_key": hex_string(&signing_key.verifying_key().to_bytes()),
"device_name": "Legacy",
"platform": "macos",
});
fs::write(&path, legacy.to_string()).map_err(io_err)?;
let result = (|| {
let identity = DeviceIdentity::load_or_create(&path, "ignored", "ignored")?;
assert_ne!(identity.device_id, legacy_device_id);
assert_eq!(DeviceIdentity::load_or_create(&path, "ignored", "ignored")?, identity);
DeviceSecretStore::new(identity.device_id)?.clear()
})();
let _ = fs::remove_dir_all(dir);
result
}
}
+362
View File
@@ -0,0 +1,362 @@
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
DeviceIdentity, DeviceRecord, SyncClientError,
device_proof::{is_idempotency_key_shape, push_field},
vault::WrappedAccountKey,
};
const REBIND_CHALLENGE_VERSION: u32 = 1;
const MAX_CHALLENGE_LIFETIME_SECONDS: u64 = 600;
#[derive(Debug, Serialize)]
pub(crate) struct DeviceRebindChallengeRequest<'a> {
pub version: u32,
pub device_id: &'a str,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct DeviceRebindChallengeDocument {
pub version: u32,
pub challenge_id: String,
pub device_id: String,
pub challenge: String,
pub expires_at: u64,
}
impl DeviceRebindChallengeDocument {
pub(crate) fn signed_request(
&self,
identity: &DeviceIdentity,
now_seconds: u64,
) -> Result<DeviceRebindRequest, SyncClientError> {
let _ = self.validated_context(identity, now_seconds)?;
Ok(DeviceRebindRequest {
version: REBIND_CHALLENGE_VERSION,
challenge_id: self.challenge_id.clone(),
device_id: self.device_id.clone(),
signature: identity.sign_message(self.challenge.as_bytes())?,
})
}
fn validated_context<'a>(
&'a self,
identity: &DeviceIdentity,
now_seconds: u64,
) -> Result<(&'a str, &'a str), SyncClientError> {
if self.version != REBIND_CHALLENGE_VERSION || self.device_id != identity.device_id {
return Err(protocol_error("device rebind challenge identity does not match"));
}
if self.expires_at <= now_seconds
|| self.expires_at > now_seconds.saturating_add(MAX_CHALLENGE_LIFETIME_SECONDS)
{
return Err(protocol_error("device rebind challenge expiry is invalid"));
}
let challenge_id = Uuid::parse_str(&self.challenge_id)
.map_err(|_| protocol_error("device rebind challenge identifier is invalid"))?;
if challenge_id.hyphenated().to_string() != self.challenge_id {
return Err(protocol_error("device rebind challenge identifier is not canonical"));
}
let lines = self.challenge.split('\n').collect::<Vec<_>>();
if lines.len() != 7 || lines[0] != "elydora-device-rebind-v1" {
return Err(protocol_error("device rebind challenge format is invalid"));
}
assert_challenge_field(lines[1], "challenge_id", &self.challenge_id)?;
let user_id = challenge_value(lines[2], "user_id")?;
let session_id = challenge_value(lines[3], "session_id")?;
assert_challenge_field(lines[4], "device_id", &self.device_id)?;
assert_challenge_field(lines[5], "expires_at", &self.expires_at.to_string())?;
let nonce = challenge_value(lines[6], "nonce")?;
if !is_lower_hex(nonce, 64) || !is_subject_id(user_id) || !is_subject_id(session_id) {
return Err(protocol_error("device rebind challenge value is invalid"));
}
Ok((user_id, session_id))
}
}
#[derive(Debug, Serialize)]
pub(crate) struct DeviceRebindRequest {
pub version: u32,
pub challenge_id: String,
pub device_id: String,
pub signature: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceRebindDocument {
pub version: u32,
pub user_id: String,
pub session_id: String,
pub device_id: String,
pub bound_at: u64,
}
impl DeviceRebindDocument {
pub(crate) fn validate(
&self,
identity: &DeviceIdentity,
challenge: &DeviceRebindChallengeDocument,
now_seconds: u64,
) -> Result<(), SyncClientError> {
let (user_id, session_id) = challenge.validated_context(identity, now_seconds)?;
if self.version != REBIND_CHALLENGE_VERSION
|| self.device_id != identity.device_id
|| self.user_id != user_id
|| self.session_id != session_id
|| self.bound_at > challenge.expires_at
{
return Err(protocol_error("device rebind response does not match challenge"));
}
Ok(())
}
}
#[derive(Debug, Serialize)]
pub struct DeviceApprovalRequest<'a> {
pub version: u32,
pub device_id: &'a str,
pub key_id: &'a str,
pub generation: u64,
pub envelope: &'a WrappedAccountKey,
pub idempotency_key: &'a str,
pub proof_created_at: u64,
pub approval_proof: String,
}
impl<'a> DeviceApprovalRequest<'a> {
pub fn new(
user_id: &str,
approver: &DeviceIdentity,
device_id: &'a str,
key_id: &'a str,
generation: u64,
envelope: &'a WrappedAccountKey,
idempotency_key: &'a str,
) -> Result<Self, SyncClientError> {
let proof_created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| protocol_error("system clock is invalid"))?
.as_secs();
let message = approval_proof_message(&ApprovalProofFields {
user_id,
approver_device_id: &approver.device_id,
device_id,
key_id,
generation,
envelope,
idempotency_key,
proof_created_at,
})?;
Ok(Self {
version: 2,
device_id,
key_id,
generation,
envelope,
idempotency_key,
proof_created_at,
approval_proof: approver.sign_message(&message)?,
})
}
}
struct ApprovalProofFields<'a> {
user_id: &'a str,
approver_device_id: &'a str,
device_id: &'a str,
key_id: &'a str,
generation: u64,
envelope: &'a WrappedAccountKey,
idempotency_key: &'a str,
proof_created_at: u64,
}
fn approval_proof_message(fields: &ApprovalProofFields<'_>) -> Result<Vec<u8>, SyncClientError> {
if fields.user_id.is_empty()
|| fields.generation == 0
|| !is_idempotency_key_shape(fields.idempotency_key)
|| fields.key_id.len() != 64
{
return Err(protocol_error("device approval proof fields are invalid"));
}
fields.envelope.validate_wire()?;
let generation = fields.generation.to_string();
let envelope_version = fields.envelope.version.to_string();
let proof_created_at = fields.proof_created_at.to_string();
let mut message = Vec::with_capacity(512);
for field in [
"elydora-device-approval-v2",
fields.user_id,
fields.approver_device_id,
fields.device_id,
fields.key_id,
&generation,
&envelope_version,
&fields.envelope.suite,
&fields.envelope.encapped_key,
&fields.envelope.ciphertext,
fields.idempotency_key,
&proof_created_at,
] {
push_field(&mut message, field);
}
Ok(message)
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceApprovalDocument {
pub version: u32,
pub user_id: String,
pub approved_by_device_id: String,
pub approved_at: u64,
pub device: DeviceRecord,
}
fn assert_challenge_field(
line: &str,
name: &'static str,
expected: &str,
) -> Result<(), SyncClientError> {
if challenge_value(line, name)? != expected {
return Err(protocol_error("device rebind challenge binding does not match"));
}
Ok(())
}
fn challenge_value<'a>(line: &'a str, name: &'static str) -> Result<&'a str, SyncClientError> {
let value = line
.strip_prefix(name)
.and_then(|suffix| suffix.strip_prefix('='))
.ok_or_else(|| protocol_error("device rebind challenge field is invalid"))?;
if value.is_empty() {
return Err(protocol_error("device rebind challenge field is empty"));
}
Ok(value)
}
fn is_subject_id(value: &str) -> bool {
(3..=128).contains(&value.len())
&& value.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
}
fn is_lower_hex(value: &str, length: usize) -> bool {
value.len() == length
&& value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn protocol_error(reason: &'static str) -> SyncClientError {
SyncClientError::DeviceTrust { reason }
}
#[cfg(test)]
mod tests {
use ed25519_dalek::{Signer, SigningKey};
use super::*;
fn identity() -> DeviceIdentity {
DeviceIdentity {
device_id: "ely-018f0f4fbbcc7f36a241d1a2a1f01111".to_string(),
public_key: "01".repeat(32),
wrapping_public_key: "02".repeat(32),
device_name: "Test".to_string(),
platform: "macos".to_string(),
}
}
fn challenge() -> DeviceRebindChallengeDocument {
let challenge_id = "018f0f4f-bbcc-7f36-a241-d1a2a1f01111";
let device_id = identity().device_id;
let expires_at = 1_000;
DeviceRebindChallengeDocument {
version: 1,
challenge_id: challenge_id.to_string(),
device_id: device_id.clone(),
challenge: format!(
"elydora-device-rebind-v1\nchallenge_id={challenge_id}\nuser_id=user-01\nsession_id=session-01\ndevice_id={device_id}\nexpires_at={expires_at}\nnonce={}",
"ab".repeat(32)
),
expires_at,
}
}
#[test]
fn canonical_rebind_challenge_binds_device_and_session() -> Result<(), SyncClientError> {
let challenge = challenge();
let identity = identity();
assert_eq!(challenge.validated_context(&identity, 700)?, ("user-01", "session-01"));
Ok(())
}
#[test]
fn rebind_challenge_rejects_metadata_changes() {
let identity = identity();
for changed in [
DeviceRebindChallengeDocument { device_id: "device-02".to_string(), ..challenge() },
DeviceRebindChallengeDocument { expires_at: 701, ..challenge() },
DeviceRebindChallengeDocument {
challenge: challenge().challenge.replace("nonce=ab", "nonce=Ab"),
..challenge()
},
] {
assert!(changed.validated_context(&identity, 700).is_err());
}
}
#[test]
fn approval_proof_uses_the_frozen_worker_field_order() -> Result<(), SyncClientError> {
let envelope = WrappedAccountKey {
version: 1,
suite: crate::ACCOUNT_KEY_WRAP_SUITE.to_string(),
encapped_key: "A".repeat(43),
ciphertext: "B".repeat(64),
};
let key_id = "a".repeat(64);
let message = approval_proof_message(&ApprovalProofFields {
user_id: "user-01",
approver_device_id: "device-01",
device_id: "device-02",
key_id: &key_id,
generation: 1,
envelope: &envelope,
idempotency_key: "device-approval-0001",
proof_created_at: 1_780_000_300,
})?;
let fields = [
"elydora-device-approval-v2".to_string(),
"user-01".to_string(),
"device-01".to_string(),
"device-02".to_string(),
"a".repeat(64),
"1".to_string(),
"1".to_string(),
crate::ACCOUNT_KEY_WRAP_SUITE.to_string(),
"A".repeat(43),
"B".repeat(64),
"device-approval-0001".to_string(),
"1780000300".to_string(),
];
let expected =
fields.iter().map(|field| format!("{}:{field}", field.len())).collect::<String>();
assert_eq!(message, expected.as_bytes());
let private_key = crate::device::decode_hex_32(
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"test private key is invalid",
)?;
let signature = SigningKey::from_bytes(&private_key).sign(&message);
let signature_hex =
signature.to_bytes().iter().map(|byte| format!("{byte:02x}")).collect::<String>();
assert_eq!(
signature_hex,
"f12fb7a5f7f20551bd22d0fcf8f5787d49f6202f89e42c332c248772fd9a59c82a9d8b6ac47ea84340170fc1555fc74d70a0d6ba3541df257882d46d6d79d901"
);
Ok(())
}
}
+169
View File
@@ -0,0 +1,169 @@
use sha2::{Digest, Sha256};
use crate::{DeviceIdentity, DeviceRecord, SyncClientError, device::decode_hex_32};
const REGISTRATION_PROOF_DOMAIN: &str = "elydora-device-registration-v2";
const VERIFICATION_CODE_DOMAIN: &str = "elydora-device-verification-v1";
impl DeviceIdentity {
pub fn registration_proof(&self, idempotency_key: &str) -> Result<String, SyncClientError> {
let message = self.registration_proof_message(idempotency_key)?;
self.sign_message(&message)
}
pub fn verification_code(&self) -> Result<String, SyncClientError> {
self.validate()?;
verification_code(
&self.device_id,
&self.public_key,
&self.wrapping_public_key,
&self.device_name,
&self.platform,
)
}
fn registration_proof_message(
&self,
idempotency_key: &str,
) -> Result<Vec<u8>, SyncClientError> {
self.validate()?;
if !is_idempotency_key_shape(idempotency_key) {
return Err(proof_error("device registration idempotency key is invalid"));
}
let fields = [
REGISTRATION_PROOF_DOMAIN,
&self.device_id,
&self.public_key,
&self.wrapping_public_key,
&self.device_name,
&self.platform,
idempotency_key,
];
let mut message = Vec::with_capacity(512);
for field in fields {
push_field(&mut message, field);
}
Ok(message)
}
}
impl DeviceRecord {
pub fn verification_code(&self) -> Result<String, SyncClientError> {
let wrapping_public_key = self
.wrapping_public_key
.as_deref()
.ok_or_else(|| proof_error("device wrapping public key is unavailable"))?;
verification_code(
&self.device_id,
&self.public_key,
wrapping_public_key,
&self.device_name,
&self.platform,
)
}
}
fn verification_code(
device_id: &str,
public_key: &str,
wrapping_public_key: &str,
device_name: &str,
platform: &str,
) -> Result<String, SyncClientError> {
if !(3..=128).contains(&device_id.len())
|| !device_id.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
{
return Err(proof_error("device identifier is invalid"));
}
decode_hex_32(public_key, "device signing public key encoding is invalid")?;
decode_hex_32(wrapping_public_key, "device wrapping public key encoding is invalid")?;
let fields = [
VERIFICATION_CODE_DOMAIN,
device_id,
public_key,
wrapping_public_key,
device_name,
platform,
];
let mut message = Vec::with_capacity(512);
for field in fields {
push_field(&mut message, field);
}
let digest = Sha256::digest(message);
Ok(digest[..8]
.chunks_exact(2)
.map(|chunk| format!("{:02X}{:02X}", chunk[0], chunk[1]))
.collect::<Vec<_>>()
.join("-"))
}
pub(crate) fn is_idempotency_key_shape(value: &str) -> bool {
(16..=128).contains(&value.len())
&& value
.as_bytes()
.iter()
.all(|byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b':' | b'-'))
}
pub(crate) fn push_field(message: &mut Vec<u8>, value: &str) {
message.extend_from_slice(value.len().to_string().as_bytes());
message.push(b':');
message.extend_from_slice(value.as_bytes());
}
fn proof_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::DeviceKeyStorage(message.into())
}
#[cfg(test)]
mod tests {
use ed25519_dalek::{Signer, SigningKey, Verifier};
use super::*;
use crate::device::generate_key_material;
#[test]
fn registration_proof_matches_worker_canonical_bytes() -> Result<(), SyncClientError> {
let (identity, secrets) = generate_key_material("ELY ñ".to_string(), "macOS".to_string())?;
let idempotency_key = "device-register:01";
let message = identity.registration_proof_message(idempotency_key)?;
let fields = [
REGISTRATION_PROOF_DOMAIN,
&identity.device_id,
&identity.public_key,
&identity.wrapping_public_key,
&identity.device_name,
&identity.platform,
idempotency_key,
];
let expected =
fields.iter().map(|field| format!("{}:{field}", field.len())).collect::<String>();
assert_eq!(message, expected.as_bytes());
let signing_key = SigningKey::from_bytes(secrets.signing_private_key());
let signature = signing_key.sign(&message);
signing_key
.verifying_key()
.verify(&message, &signature)
.map_err(|_| proof_error("device registration proof verification failed"))
}
#[test]
fn verification_code_binds_every_public_identity_field() -> Result<(), SyncClientError> {
let (identity, _) = generate_key_material("ELY ñ".to_string(), "macOS".to_string())?;
let code = identity.verification_code()?;
assert_eq!(code.len(), 19);
let changed = [
DeviceIdentity { device_id: "device-02".to_string(), ..identity.clone() },
DeviceIdentity { public_key: "01".repeat(32), ..identity.clone() },
DeviceIdentity { wrapping_public_key: "02".repeat(32), ..identity.clone() },
DeviceIdentity { device_name: "Other".to_string(), ..identity.clone() },
DeviceIdentity { platform: "linux".to_string(), ..identity.clone() },
];
for candidate in changed {
assert_ne!(candidate.verification_code()?, code);
}
Ok(())
}
}
@@ -0,0 +1,380 @@
use serde::{Deserialize, Serialize};
use crate::{
DeviceIdentity, DeviceRecord, SyncClientError,
device::is_device_id_shape,
device_proof::{is_idempotency_key_shape, push_field},
vault::WrappedAccountKey,
};
const REVOCATION_PROOF_DOMAIN: &str = "elydora-device-revocation-v2";
const PENDING_REVOCATION_PROOF_DOMAIN: &str = "elydora-pending-device-revocation-v2";
const MAX_ROTATION_ENVELOPES: usize = 128;
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
#[derive(Clone, Debug, Serialize)]
struct DeviceRevocationEnvelope {
recipient_device_id: String,
envelope: WrappedAccountKey,
}
#[derive(Clone, Debug, Serialize)]
pub struct ApprovedDeviceRevocationRequest {
version: u32,
mode: &'static str,
device_id: String,
previous_key_id: String,
previous_generation: u64,
new_key_id: String,
new_generation: u64,
envelopes: Vec<DeviceRevocationEnvelope>,
idempotency_key: String,
rotation_proof: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct PendingDeviceRevocationRequest {
version: u32,
mode: &'static str,
device_id: String,
idempotency_key: String,
pending_revocation_proof: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(untagged)]
pub enum DeviceRevocationRequest {
ApprovedRotate(ApprovedDeviceRevocationRequest),
PendingRevoke(PendingDeviceRevocationRequest),
}
impl DeviceRevocationRequest {
#[allow(clippy::too_many_arguments)]
pub fn approved_rotation(
user_id: &str,
approver: &DeviceIdentity,
target_device_id: &str,
previous_key_id: &str,
previous_generation: u64,
new_key_id: &str,
new_generation: u64,
envelopes: Vec<(String, WrappedAccountKey)>,
idempotency_key: &str,
) -> Result<Self, SyncClientError> {
let envelopes = validate_and_sort_envelopes(envelopes, target_device_id)?;
validate_request_fields(
user_id,
approver,
target_device_id,
previous_key_id,
previous_generation,
new_key_id,
new_generation,
idempotency_key,
)?;
let message = revocation_proof_message(
user_id,
approver,
target_device_id,
previous_key_id,
previous_generation,
new_key_id,
new_generation,
&envelopes,
idempotency_key,
);
Ok(Self::ApprovedRotate(ApprovedDeviceRevocationRequest {
version: 2,
mode: "approved_rotate",
device_id: target_device_id.to_string(),
previous_key_id: previous_key_id.to_string(),
previous_generation,
new_key_id: new_key_id.to_string(),
new_generation,
envelopes,
idempotency_key: idempotency_key.to_string(),
rotation_proof: approver.sign_message(&message)?,
}))
}
pub fn pending(
user_id: &str,
approver: &DeviceIdentity,
target_device_id: &str,
idempotency_key: &str,
) -> Result<Self, SyncClientError> {
validate_pending_fields(user_id, approver, target_device_id, idempotency_key)?;
let message = pending_revocation_proof_message(
user_id,
&approver.device_id,
target_device_id,
idempotency_key,
);
Ok(Self::PendingRevoke(PendingDeviceRevocationRequest {
version: 2,
mode: "pending_revoke",
device_id: target_device_id.to_string(),
idempotency_key: idempotency_key.to_string(),
pending_revocation_proof: approver.sign_message(&message)?,
}))
}
}
fn pending_revocation_proof_message(
user_id: &str,
approver_device_id: &str,
target_device_id: &str,
idempotency_key: &str,
) -> Vec<u8> {
let mut message = Vec::with_capacity(256);
for field in [
PENDING_REVOCATION_PROOF_DOMAIN,
user_id,
approver_device_id,
target_device_id,
idempotency_key,
] {
push_field(&mut message, field);
}
message
}
#[derive(Clone, Debug, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
pub enum DeviceRevocationDocument {
ApprovedRotate {
version: u32,
user_id: String,
revoked_by_device_id: String,
revoked_at: u64,
key_id: String,
generation: u64,
device: DeviceRecord,
},
PendingRevoke {
version: u32,
user_id: String,
revoked_by_device_id: String,
revoked_at: u64,
device: DeviceRecord,
},
}
#[allow(clippy::too_many_arguments)]
fn validate_request_fields(
user_id: &str,
approver: &DeviceIdentity,
target_device_id: &str,
previous_key_id: &str,
previous_generation: u64,
new_key_id: &str,
new_generation: u64,
idempotency_key: &str,
) -> Result<(), SyncClientError> {
validate_pending_fields(user_id, approver, target_device_id, idempotency_key)?;
if !is_key_id(previous_key_id) || !is_key_id(new_key_id) || previous_key_id == new_key_id {
return Err(protocol_error("device revocation key identifier is invalid"));
}
if previous_generation == 0
|| previous_generation >= MAX_SAFE_INTEGER
|| new_generation != previous_generation + 1
{
return Err(protocol_error("device revocation generation is invalid"));
}
Ok(())
}
fn validate_pending_fields(
user_id: &str,
approver: &DeviceIdentity,
target_device_id: &str,
idempotency_key: &str,
) -> Result<(), SyncClientError> {
if user_id.trim().is_empty() || user_id.len() > 4096 {
return Err(protocol_error("device revocation user identifier is invalid"));
}
approver.validate()?;
if !is_device_id_shape(target_device_id) || target_device_id == approver.device_id {
return Err(protocol_error("device revocation target is invalid"));
}
if !is_idempotency_key_shape(idempotency_key) {
return Err(protocol_error("device revocation idempotency key is invalid"));
}
Ok(())
}
fn validate_and_sort_envelopes(
envelopes: Vec<(String, WrappedAccountKey)>,
target_device_id: &str,
) -> Result<Vec<DeviceRevocationEnvelope>, SyncClientError> {
if envelopes.is_empty() || envelopes.len() > MAX_ROTATION_ENVELOPES {
return Err(protocol_error("device revocation envelope count is invalid"));
}
let mut envelopes = envelopes
.into_iter()
.map(|(recipient_device_id, envelope)| {
if !is_device_id_shape(&recipient_device_id) || recipient_device_id == target_device_id
{
return Err(protocol_error("device revocation envelope recipient is invalid"));
}
envelope.validate_wire()?;
Ok(DeviceRevocationEnvelope { recipient_device_id, envelope })
})
.collect::<Result<Vec<_>, SyncClientError>>()?;
envelopes.sort_by(|left, right| left.recipient_device_id.cmp(&right.recipient_device_id));
if envelopes.windows(2).any(|pair| pair[0].recipient_device_id == pair[1].recipient_device_id) {
return Err(protocol_error("device revocation envelope recipient is duplicated"));
}
Ok(envelopes)
}
#[allow(clippy::too_many_arguments)]
fn revocation_proof_message(
user_id: &str,
approver: &DeviceIdentity,
target_device_id: &str,
previous_key_id: &str,
previous_generation: u64,
new_key_id: &str,
new_generation: u64,
envelopes: &[DeviceRevocationEnvelope],
idempotency_key: &str,
) -> Vec<u8> {
let previous_generation = previous_generation.to_string();
let new_generation = new_generation.to_string();
let envelope_count = envelopes.len().to_string();
let fields = [
REVOCATION_PROOF_DOMAIN,
user_id,
&approver.device_id,
target_device_id,
previous_key_id,
&previous_generation,
new_key_id,
&new_generation,
idempotency_key,
&envelope_count,
];
let mut message = Vec::with_capacity(1024);
for field in fields {
push_field(&mut message, field);
}
for item in envelopes {
let envelope_version = item.envelope.version.to_string();
for field in [
item.recipient_device_id.as_str(),
&envelope_version,
&item.envelope.suite,
&item.envelope.encapped_key,
&item.envelope.ciphertext,
] {
push_field(&mut message, field);
}
}
message
}
fn is_key_id(value: &str) -> bool {
value.len() == 64
&& value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn protocol_error(reason: &'static str) -> SyncClientError {
SyncClientError::DeviceTrust { reason }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
AccountKey, VaultContext, device::generate_key_material, vault::WrappedAccountKey,
};
#[test]
fn pending_proof_matches_worker_vector() {
let message = pending_revocation_proof_message(
"user-01",
"device-01",
"device-02",
"device-revocation-0001",
);
assert_eq!(
message,
b"36:elydora-pending-device-revocation-v27:user-019:device-019:device-0222:device-revocation-0001"
);
}
#[test]
fn proof_matches_worker_order_and_canonical_bytes() -> Result<(), SyncClientError> {
let (approver, _) = generate_key_material("Approver".to_string(), "macos".to_string())?;
let (recipient_b, _) = generate_key_material("B".to_string(), "macos".to_string())?;
let (recipient_a, _) = generate_key_material("A".to_string(), "macos".to_string())?;
let previous_key = AccountKey::from_bytes([41; 32]);
let new_key = AccountKey::from_bytes([43; 32]);
let new_key_id = new_key.key_id();
let envelopes = validate_and_sort_envelopes(
vec![
wrapped(&new_key, &new_key_id, &approver, &recipient_b)?,
wrapped(&new_key, &new_key_id, &approver, &recipient_a)?,
],
"device-target",
)?;
let message = revocation_proof_message(
"user-01",
&approver,
"device-target",
&previous_key.key_id(),
1,
&new_key_id,
2,
&envelopes,
"device-revocation:01",
);
assert!(envelopes[0].recipient_device_id < envelopes[1].recipient_device_id);
let mut fields = vec![
REVOCATION_PROOF_DOMAIN.to_string(),
"user-01".to_string(),
approver.device_id,
"device-target".to_string(),
previous_key.key_id(),
"1".to_string(),
new_key_id,
"2".to_string(),
"device-revocation:01".to_string(),
envelopes.len().to_string(),
];
for item in envelopes {
fields.extend([
item.recipient_device_id,
item.envelope.version.to_string(),
item.envelope.suite,
item.envelope.encapped_key,
item.envelope.ciphertext,
]);
}
let expected =
fields.iter().map(|field| format!("{}:{field}", field.len())).collect::<String>();
assert_eq!(message, expected.as_bytes());
Ok(())
}
fn wrapped(
key: &AccountKey,
key_id: &str,
approver: &DeviceIdentity,
recipient: &DeviceIdentity,
) -> Result<(String, WrappedAccountKey), SyncClientError> {
let envelope = WrappedAccountKey::wrap(
key,
&VaultContext {
user_id: "user-01",
recipient_device_id: &recipient.device_id,
recipient_wrapping_public_key: &recipient.wrapping_public_key,
approver_device_id: &approver.device_id,
generation: 2,
key_id,
},
)?;
Ok((recipient.device_id.clone(), envelope))
}
}
@@ -0,0 +1,121 @@
use zeroize::{Zeroize, Zeroizing};
use crate::{
SyncClientError,
credential_store::{clear_secret, load_secret, save_secret},
device::is_device_id_shape,
};
const KEYCHAIN_SERVICE: &str = "com.elydora.ely-browser.sync.device-secrets.v2";
const RECORD_VERSION: u8 = 2;
const SECRET_BYTES: usize = 32;
const RECORD_BYTES: usize = 1 + 2 * SECRET_BYTES;
#[derive(Clone, Debug)]
pub struct DeviceSecretStore {
device_id: String,
}
impl DeviceSecretStore {
pub fn new(device_id: impl Into<String>) -> Result<Self, SyncClientError> {
let device_id = device_id.into();
if !is_device_id_shape(&device_id) {
return Err(storage_error("device identifier is invalid"));
}
Ok(Self { device_id })
}
pub(crate) fn load(&self) -> Result<Option<DeviceSecrets>, SyncClientError> {
match load_secret(KEYCHAIN_SERVICE, &self.device_id).map_err(storage_error)? {
Some(record) => decode_secret_record(record).map(Some),
None => Ok(None),
}
}
pub(crate) fn load_required(&self) -> Result<DeviceSecrets, SyncClientError> {
self.load()?.ok_or_else(|| SyncClientError::DeviceKeyUnavailable {
device_id: self.device_id.clone(),
})
}
pub(crate) fn save(&self, secrets: &DeviceSecrets) -> Result<(), SyncClientError> {
let record = encode_secret_record(secrets);
save_secret(KEYCHAIN_SERVICE, &self.device_id, record.as_slice()).map_err(storage_error)
}
pub fn clear(&self) -> Result<(), SyncClientError> {
clear_secret(KEYCHAIN_SERVICE, &self.device_id).map_err(storage_error)
}
}
pub(crate) struct DeviceSecrets {
signing_private_key: Zeroizing<[u8; SECRET_BYTES]>,
wrapping_private_key: Zeroizing<[u8; SECRET_BYTES]>,
}
impl DeviceSecrets {
pub(crate) fn new(
signing_private_key: [u8; SECRET_BYTES],
wrapping_private_key: [u8; SECRET_BYTES],
) -> Self {
Self {
signing_private_key: Zeroizing::new(signing_private_key),
wrapping_private_key: Zeroizing::new(wrapping_private_key),
}
}
pub(crate) fn signing_private_key(&self) -> &[u8; SECRET_BYTES] {
&self.signing_private_key
}
pub(crate) fn wrapping_private_key(&self) -> &[u8; SECRET_BYTES] {
&self.wrapping_private_key
}
}
fn encode_secret_record(secrets: &DeviceSecrets) -> Zeroizing<[u8; RECORD_BYTES]> {
let mut record = Zeroizing::new([0_u8; RECORD_BYTES]);
record[0] = RECORD_VERSION;
record[1..1 + SECRET_BYTES].copy_from_slice(secrets.signing_private_key());
record[1 + SECRET_BYTES..].copy_from_slice(secrets.wrapping_private_key());
record
}
fn decode_secret_record(mut record: Zeroizing<Vec<u8>>) -> Result<DeviceSecrets, SyncClientError> {
if record.len() != RECORD_BYTES || record[0] != RECORD_VERSION {
return Err(storage_error("device secret record is invalid"));
}
let mut signing_private_key = [0_u8; SECRET_BYTES];
let mut wrapping_private_key = [0_u8; SECRET_BYTES];
signing_private_key.copy_from_slice(&record[1..1 + SECRET_BYTES]);
wrapping_private_key.copy_from_slice(&record[1 + SECRET_BYTES..]);
record.zeroize();
Ok(DeviceSecrets::new(signing_private_key, wrapping_private_key))
}
fn storage_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::DeviceKeyStorage(message.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_record_round_trips_both_private_keys() -> Result<(), SyncClientError> {
let secrets = DeviceSecrets::new([7; SECRET_BYTES], [19; SECRET_BYTES]);
let record = encode_secret_record(&secrets);
let decoded = decode_secret_record(Zeroizing::new(record.to_vec()))?;
assert_eq!(decoded.signing_private_key(), &[7; SECRET_BYTES]);
assert_eq!(decoded.wrapping_private_key(), &[19; SECRET_BYTES]);
Ok(())
}
#[test]
fn secret_record_rejects_unknown_versions() {
let mut record = vec![0_u8; RECORD_BYTES];
record[0] = RECORD_VERSION + 1;
assert!(decode_secret_record(Zeroizing::new(record)).is_err());
}
}
+371
View File
@@ -0,0 +1,371 @@
use std::fmt;
use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, Payload},
};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use crate::{error::SyncClientError, snapshot::MAX_SNAPSHOT_BYTES};
pub const SNAPSHOT_ENCRYPTION_VERSION: u32 = 2;
const ENVELOPE_MAGIC: &[u8; 8] = b"ELYSYNC\0";
const ENVELOPE_VERSION: u8 = 1;
const ALGORITHM_XCHACHA20_POLY1305: u8 = 1;
const KEY_BYTES: usize = 32;
const HASH_BYTES: usize = 32;
const NONCE_BYTES: usize = 24;
const TAG_BYTES: usize = 16;
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
const HEADER_BYTES: usize = ENVELOPE_MAGIC.len() + 2 + KEY_BYTES + HASH_BYTES + NONCE_BYTES;
const MAX_PLAINTEXT_BYTES: usize = MAX_SNAPSHOT_BYTES - HEADER_BYTES - TAG_BYTES;
const HKDF_SALT: &[u8] = b"ely-sync-account-key-v1";
const ENCRYPTION_KEY_INFO: &[u8] = b"snapshot-encryption-key";
const CONTENT_KEY_INFO: &[u8] = b"snapshot-content-authentication-key";
const KEY_ID_DOMAIN: &[u8] = b"ely-sync-key-id-v1\0";
const AAD_DOMAIN_V1: &[u8] = b"ely-sync-snapshot-aad-v1\0";
const AAD_DOMAIN_V2: &[u8] = b"ely-sync-snapshot-aad-v2\0";
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
pub struct AccountKey(Zeroizing<[u8; KEY_BYTES]>);
impl AccountKey {
pub fn generate() -> Result<Self, SyncClientError> {
let mut bytes = Zeroizing::new([0_u8; KEY_BYTES]);
getrandom::fill(bytes.as_mut())
.map_err(|_| encryption_error("secure randomness unavailable"))?;
Ok(Self::from_secret(bytes))
}
pub fn from_bytes(bytes: [u8; KEY_BYTES]) -> Self {
Self::from_secret(Zeroizing::new(bytes))
}
pub fn key_id(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(KEY_ID_DOMAIN);
hasher.update(self.0.as_slice());
hex_string(&hasher.finalize())
}
pub fn content_hash(&self, plaintext: &[u8]) -> Result<String, SyncClientError> {
let key = self.derived_key(CONTENT_KEY_INFO)?;
let mut mac = <HmacSha256 as Mac>::new_from_slice(key.as_slice())
.map_err(|_| encryption_error("content authentication key is invalid"))?;
mac.update(plaintext);
Ok(hex_string(&mac.finalize().into_bytes()))
}
pub fn encrypt(
&self,
context: &SnapshotCryptoContext<'_>,
plaintext: &[u8],
) -> Result<EncryptedSnapshot, SyncClientError> {
self.encrypt_with_version(context, plaintext, SNAPSHOT_ENCRYPTION_VERSION)
}
fn encrypt_with_version(
&self,
context: &SnapshotCryptoContext<'_>,
plaintext: &[u8],
encryption_version: u32,
) -> Result<EncryptedSnapshot, SyncClientError> {
if !matches!(encryption_version, 1 | SNAPSHOT_ENCRYPTION_VERSION) {
return Err(encryption_error("snapshot encryption version is unsupported"));
}
if plaintext.is_empty() || plaintext.len() > MAX_PLAINTEXT_BYTES {
return Err(SyncClientError::SnapshotTooLarge {
bytes: plaintext.len(),
limit: MAX_PLAINTEXT_BYTES,
});
}
let key_id = self.key_id();
let content_hash = self.content_hash(plaintext)?;
let key_id_bytes = decode_hex_32(&key_id)?;
let content_hash_bytes = decode_hex_32(&content_hash)?;
let aad = snapshot_aad(context, encryption_version, &key_id_bytes, &content_hash_bytes)?;
let encryption_key = self.derived_key(ENCRYPTION_KEY_INFO)?;
let cipher = XChaCha20Poly1305::new_from_slice(encryption_key.as_slice())
.map_err(|_| encryption_error("snapshot encryption key is invalid"))?;
let mut nonce = [0_u8; NONCE_BYTES];
getrandom::fill(&mut nonce)
.map_err(|_| encryption_error("secure randomness unavailable"))?;
let nonce = nonce_ref(&nonce)?;
let ciphertext = cipher
.encrypt(nonce, Payload { msg: plaintext, aad: &aad })
.map_err(|_| encryption_error("snapshot encryption failed"))?;
let mut bytes = Vec::with_capacity(HEADER_BYTES + ciphertext.len());
bytes.extend_from_slice(ENVELOPE_MAGIC);
bytes.push(ENVELOPE_VERSION);
bytes.push(ALGORITHM_XCHACHA20_POLY1305);
bytes.extend_from_slice(&key_id_bytes);
bytes.extend_from_slice(&content_hash_bytes);
bytes.extend_from_slice(nonce);
bytes.extend_from_slice(&ciphertext);
Ok(EncryptedSnapshot { bytes, key_id, content_hash })
}
pub fn decrypt(
&self,
context: &SnapshotCryptoContext<'_>,
encryption_version: u32,
expected_key_id: &str,
expected_content_hash: &str,
envelope: &[u8],
) -> Result<Vec<u8>, SyncClientError> {
if !matches!(encryption_version, 1 | SNAPSHOT_ENCRYPTION_VERSION) {
return Err(encryption_error("snapshot encryption version is unsupported"));
}
if envelope.len() < HEADER_BYTES + TAG_BYTES {
return Err(encryption_error("snapshot envelope is truncated"));
}
if &envelope[..ENVELOPE_MAGIC.len()] != ENVELOPE_MAGIC {
return Err(encryption_error("snapshot envelope magic is invalid"));
}
if envelope[ENVELOPE_MAGIC.len()] != ENVELOPE_VERSION
|| envelope[ENVELOPE_MAGIC.len() + 1] != ALGORITHM_XCHACHA20_POLY1305
{
return Err(encryption_error("snapshot envelope algorithm is unsupported"));
}
let mut offset = ENVELOPE_MAGIC.len() + 2;
let key_id_bytes = array_at::<KEY_BYTES>(envelope, offset)?;
offset += KEY_BYTES;
let content_hash_bytes = array_at::<HASH_BYTES>(envelope, offset)?;
offset += HASH_BYTES;
let nonce = array_at::<NONCE_BYTES>(envelope, offset)?;
offset += NONCE_BYTES;
let key_id = hex_string(&key_id_bytes);
let content_hash = hex_string(&content_hash_bytes);
if key_id != expected_key_id || key_id != self.key_id() {
return Err(encryption_error("snapshot key identifier does not match"));
}
if content_hash != expected_content_hash {
return Err(encryption_error("snapshot content hash does not match"));
}
let aad = snapshot_aad(context, encryption_version, &key_id_bytes, &content_hash_bytes)?;
let encryption_key = self.derived_key(ENCRYPTION_KEY_INFO)?;
let cipher = XChaCha20Poly1305::new_from_slice(encryption_key.as_slice())
.map_err(|_| encryption_error("snapshot encryption key is invalid"))?;
let nonce = nonce_ref(&nonce)?;
let plaintext = cipher
.decrypt(nonce, Payload { msg: &envelope[offset..], aad: &aad })
.map_err(|_| encryption_error("snapshot authentication failed"))?;
if self.content_hash(&plaintext)? != content_hash {
return Err(encryption_error("snapshot plaintext authentication failed"));
}
Ok(plaintext)
}
pub(crate) fn bytes(&self) -> &[u8; KEY_BYTES] {
&self.0
}
pub(crate) fn from_secret(bytes: Zeroizing<[u8; KEY_BYTES]>) -> Self {
Self(bytes)
}
fn derived_key(&self, info: &[u8]) -> Result<Zeroizing<[u8; KEY_BYTES]>, SyncClientError> {
let hkdf = Hkdf::<Sha256>::new(Some(HKDF_SALT), self.0.as_slice());
let mut output = Zeroizing::new([0_u8; KEY_BYTES]);
hkdf.expand(info, output.as_mut())
.map_err(|_| encryption_error("account key derivation failed"))?;
Ok(output)
}
}
impl fmt::Debug for AccountKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.debug_tuple("AccountKey").field(&"[REDACTED]").finish()
}
}
#[derive(Clone, Copy, Debug)]
pub struct SnapshotCryptoContext<'a> {
pub user_id: &'a str,
pub vault_generation: u64,
pub snapshot_id: &'a str,
pub schema_rev: u32,
pub logical_clock: u64,
pub device_id: &'a str,
pub head_revision: u64,
pub base_head: Option<&'a SnapshotHeadRef>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SnapshotHeadRef {
pub(crate) revision: u64,
pub(crate) snapshot_id: String,
pub(crate) payload_hash: String,
}
impl SnapshotHeadRef {
pub(crate) fn new(
revision: u64,
snapshot_id: impl Into<String>,
payload_hash: impl Into<String>,
) -> Result<Self, SyncClientError> {
let head =
Self { revision, snapshot_id: snapshot_id.into(), payload_hash: payload_hash.into() };
head.validate()?;
Ok(head)
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn snapshot_id(&self) -> &str {
&self.snapshot_id
}
pub fn payload_hash(&self) -> &str {
&self.payload_hash
}
fn validate(&self) -> Result<(), SyncClientError> {
if self.revision == 0
|| self.revision > MAX_SAFE_INTEGER
|| self.snapshot_id.is_empty()
|| self.snapshot_id.len() > 128
|| !self.snapshot_id.as_bytes()[0].is_ascii_lowercase()
&& !self.snapshot_id.as_bytes()[0].is_ascii_digit()
|| !self.snapshot_id.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
})
|| decode_hex_32(&self.payload_hash).is_err()
{
return Err(encryption_error("snapshot head reference is invalid"));
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EncryptedSnapshot {
bytes: Vec<u8>,
key_id: String,
content_hash: String,
}
impl EncryptedSnapshot {
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
pub fn key_id(&self) -> &str {
&self.key_id
}
pub fn content_hash(&self) -> &str {
&self.content_hash
}
}
fn snapshot_aad(
context: &SnapshotCryptoContext<'_>,
encryption_version: u32,
key_id: &[u8; KEY_BYTES],
content_hash: &[u8; HASH_BYTES],
) -> Result<Vec<u8>, SyncClientError> {
let domain = if encryption_version == 1 { AAD_DOMAIN_V1 } else { AAD_DOMAIN_V2 };
let mut aad = Vec::with_capacity(domain.len() + 3 * 130 + 128);
aad.extend_from_slice(domain);
push_text(&mut aad, context.user_id)?;
aad.extend_from_slice(&context.vault_generation.to_be_bytes());
push_text(&mut aad, context.snapshot_id)?;
aad.extend_from_slice(&context.schema_rev.to_be_bytes());
aad.extend_from_slice(&context.logical_clock.to_be_bytes());
push_text(&mut aad, context.device_id)?;
aad.extend_from_slice(key_id);
aad.extend_from_slice(content_hash);
if encryption_version == SNAPSHOT_ENCRYPTION_VERSION {
push_head_lineage(&mut aad, context)?;
}
Ok(aad)
}
fn push_head_lineage(
aad: &mut Vec<u8>,
context: &SnapshotCryptoContext<'_>,
) -> Result<(), SyncClientError> {
if context.head_revision == 0 {
return Err(encryption_error("snapshot head revision is invalid"));
}
aad.extend_from_slice(&context.head_revision.to_be_bytes());
match context.base_head {
None if context.head_revision == 1 => aad.push(0),
Some(base) if base.revision.checked_add(1) == Some(context.head_revision) => {
base.validate()?;
aad.push(1);
aad.extend_from_slice(&base.revision.to_be_bytes());
push_text(aad, &base.snapshot_id)?;
aad.extend_from_slice(&decode_hex_32(&base.payload_hash)?);
}
_ => return Err(encryption_error("snapshot head lineage is invalid")),
}
Ok(())
}
fn push_text(output: &mut Vec<u8>, value: &str) -> Result<(), SyncClientError> {
let length = u16::try_from(value.len())
.map_err(|_| encryption_error("snapshot authenticated metadata is too long"))?;
output.extend_from_slice(&length.to_be_bytes());
output.extend_from_slice(value.as_bytes());
Ok(())
}
fn array_at<const N: usize>(bytes: &[u8], offset: usize) -> Result<[u8; N], SyncClientError> {
bytes
.get(offset..offset + N)
.and_then(|slice| slice.try_into().ok())
.ok_or_else(|| encryption_error("snapshot envelope is truncated"))
}
fn nonce_ref(bytes: &[u8; NONCE_BYTES]) -> Result<&XNonce, SyncClientError> {
bytes.as_slice().try_into().map_err(|_| encryption_error("snapshot nonce is invalid"))
}
fn decode_hex_32(value: &str) -> Result<[u8; 32], SyncClientError> {
if value.len() != 64 {
return Err(encryption_error("snapshot hash encoding is invalid"));
}
let mut bytes = [0_u8; 32];
for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
bytes[index] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?;
}
Ok(bytes)
}
fn hex_nibble(byte: u8) -> Result<u8, SyncClientError> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
_ => Err(encryption_error("snapshot hash encoding is invalid")),
}
}
fn hex_string(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn encryption_error(reason: &'static str) -> SyncClientError {
SyncClientError::SnapshotEncryption { reason }
}
#[cfg(test)]
#[path = "encryption_tests.rs"]
mod tests;
@@ -0,0 +1,202 @@
use super::*;
const CONTEXT: SnapshotCryptoContext<'static> = SnapshotCryptoContext {
user_id: "user-01",
vault_generation: 1,
snapshot_id: "device-01",
schema_rev: 1,
logical_clock: 42,
device_id: "device-01",
head_revision: 1,
base_head: None,
};
#[test]
fn snapshot_encryption_round_trips_and_hides_plaintext() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([7; 32]);
let plaintext = br#"{"tabs":[{"url":"https://private.example"}]}"#;
let encrypted = key.encrypt(&CONTEXT, plaintext)?;
assert!(!encrypted.bytes().windows(plaintext.len()).any(|window| window == plaintext));
assert_eq!(
key.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)?,
plaintext
);
Ok(())
}
#[test]
fn snapshot_encryption_uses_fresh_nonces_and_stable_keyed_content_hashes()
-> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([9; 32]);
let first = key.encrypt(&CONTEXT, b"same payload")?;
let second = key.encrypt(&CONTEXT, b"same payload")?;
assert_ne!(first.bytes(), second.bytes());
assert_eq!(first.content_hash(), second.content_hash());
assert_ne!(
first.content_hash(),
AccountKey::from_bytes([10; 32]).content_hash(b"same payload")?
);
Ok(())
}
#[test]
fn snapshot_authentication_rejects_metadata_and_ciphertext_tampering() -> Result<(), SyncClientError>
{
let key = AccountKey::from_bytes([11; 32]);
let encrypted = key.encrypt(&CONTEXT, b"authenticated payload")?;
let changed_context = SnapshotCryptoContext { logical_clock: 43, ..CONTEXT };
assert!(
key.decrypt(
&changed_context,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
let mut tampered = encrypted.bytes().to_vec();
let last = tampered.len() - 1;
tampered[last] ^= 1;
assert!(
key.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
&tampered,
)
.is_err()
);
Ok(())
}
#[test]
fn snapshot_authentication_binds_every_routing_field() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([13; 32]);
let encrypted = key.encrypt(&CONTEXT, b"routing metadata")?;
let changed = [
SnapshotCryptoContext { user_id: "user-02", ..CONTEXT },
SnapshotCryptoContext { vault_generation: 2, ..CONTEXT },
SnapshotCryptoContext { snapshot_id: "device-02", ..CONTEXT },
SnapshotCryptoContext { schema_rev: 2, ..CONTEXT },
SnapshotCryptoContext { logical_clock: 41, ..CONTEXT },
SnapshotCryptoContext { device_id: "device-02", ..CONTEXT },
];
for context in changed {
assert!(
key.decrypt(
&context,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
}
Ok(())
}
#[test]
fn snapshot_authentication_binds_head_lineage() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([14; 32]);
let base = SnapshotHeadRef::new(7, "device-02", "31".repeat(32))?;
let context = SnapshotCryptoContext { head_revision: 8, base_head: Some(&base), ..CONTEXT };
let encrypted = key.encrypt(&context, b"head lineage")?;
for changed_base in [
SnapshotHeadRef::new(6, "device-02", "31".repeat(32))?,
SnapshotHeadRef::new(7, "device-03", "31".repeat(32))?,
SnapshotHeadRef::new(7, "device-02", "32".repeat(32))?,
] {
let changed = SnapshotCryptoContext {
head_revision: changed_base.revision + 1,
base_head: Some(&changed_base),
..CONTEXT
};
assert!(
key.decrypt(
&changed,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
}
let changed_revision = SnapshotCryptoContext { head_revision: 9, ..context };
assert!(
key.decrypt(
&changed_revision,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
Ok(())
}
#[test]
fn legacy_v1_aad_remains_decryptable() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([16; 32]);
let encrypted = key.encrypt_with_version(&CONTEXT, b"legacy snapshot", 1)?;
assert_eq!(
key.decrypt(&CONTEXT, 1, encrypted.key_id(), encrypted.content_hash(), encrypted.bytes(),)?,
b"legacy snapshot"
);
Ok(())
}
#[test]
fn snapshot_envelope_rejects_unknown_versions_and_wrong_keys() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([15; 32]);
let encrypted = key.encrypt(&CONTEXT, b"versioned payload")?;
assert!(
key.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION + 1,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
assert!(
AccountKey::from_bytes([16; 32])
.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
Ok(())
}
#[test]
fn snapshot_envelope_honors_the_transport_size_limit() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([17; 32]);
let maximum = vec![0_u8; MAX_PLAINTEXT_BYTES];
let encrypted = key.encrypt(&CONTEXT, &maximum)?;
assert_eq!(encrypted.bytes().len(), MAX_SNAPSHOT_BYTES);
assert!(key.encrypt(&CONTEXT, &vec![0_u8; MAX_PLAINTEXT_BYTES + 1]).is_err());
Ok(())
}
+24
View File
@@ -34,6 +34,30 @@ pub enum SyncClientError {
#[error("Snapshot schema is invalid: {0}")]
SnapshotSchema(String),
#[error("Snapshot encryption failed: {reason}")]
SnapshotEncryption { reason: &'static str },
#[error("Cloud Sync snapshot head is changing; retry shortly")]
SnapshotBusy,
#[error("Sync account key storage is unavailable: {0}")]
AccountKeyStorage(String),
#[error("Sync account key is unavailable for encrypted cloud data")]
AccountKeyUnavailable,
#[error("Device private key storage is unavailable: {0}")]
DeviceKeyStorage(String),
#[error("Device private keys are unavailable for {device_id}")]
DeviceKeyUnavailable { device_id: String },
#[error("Device trust protocol failed: {reason}")]
DeviceTrust { reason: &'static str },
#[error("Sync account key vault operation failed: {reason}")]
VaultCrypto { reason: &'static str },
#[error("Sync policy blocks this operation: {reason}")]
SyncPolicy { reason: String },
+310
View File
@@ -0,0 +1,310 @@
use std::{
collections::BTreeMap,
fs::{File, OpenOptions},
path::{Path, PathBuf},
};
use fs2::FileExt;
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use crate::{
AccountKey, SyncClientError,
credential_store::{clear_secret, load_secret, save_secret},
};
const KEYCHAIN_SERVICE: &str = "com.elydora.ely-browser.sync.account-key.v3";
const KEY_RECORD_VERSION: u8 = 3;
const KEY_RECORD_HEADER_BYTES: usize = 11;
const KEY_RECORD_ENTRY_BYTES: usize = 40;
const MAX_STORED_KEYS: usize = 1024;
#[derive(Clone, Debug)]
pub struct StoredAccountKeys {
current_generation: u64,
keys: BTreeMap<u64, AccountKey>,
}
impl StoredAccountKeys {
pub fn current_generation(&self) -> u64 {
self.current_generation
}
pub fn current_key(&self) -> Option<&AccountKey> {
self.keys.get(&self.current_generation)
}
pub fn key(&self, generation: u64) -> Option<&AccountKey> {
self.keys.get(&generation)
}
fn from_current(key: &AccountKey, generation: u64) -> Result<Self, SyncClientError> {
assert_generation(generation)?;
let mut keys = BTreeMap::new();
keys.insert(generation, key.clone());
Ok(Self { current_generation: generation, keys })
}
fn set_current(&mut self, key: &AccountKey, generation: u64) -> Result<bool, SyncClientError> {
assert_generation(generation)?;
if generation < self.current_generation {
return Err(storage_error("sync account key generation would roll back"));
}
let changed = self.insert_key(key, generation)?;
if generation == self.current_generation {
return Ok(changed);
}
self.current_generation = generation;
Ok(true)
}
fn insert_historical(
&mut self,
key: &AccountKey,
generation: u64,
) -> Result<bool, SyncClientError> {
assert_generation(generation)?;
if generation > self.current_generation {
return Err(storage_error("historical sync key exceeds current generation"));
}
self.insert_key(key, generation)
}
fn insert_key(&mut self, key: &AccountKey, generation: u64) -> Result<bool, SyncClientError> {
if let Some(stored) = self.keys.get(&generation) {
if stored.key_id() != key.key_id() {
return Err(storage_error("sync account key changed within one generation"));
}
return Ok(false);
}
if self.keys.len() >= MAX_STORED_KEYS {
return Err(storage_error("sync account key history is full"));
}
self.keys.insert(generation, key.clone());
Ok(true)
}
}
#[derive(Clone, Debug)]
pub struct AccountKeyStore {
user_id: String,
lock_path: PathBuf,
}
impl AccountKeyStore {
pub fn new(
user_id: impl Into<String>,
lock_directory: impl Into<PathBuf>,
) -> Result<Self, SyncClientError> {
let user_id = user_id.into();
if user_id.trim().is_empty() {
return Err(storage_error("sync user identifier is empty"));
}
let lock_name = format!("{}.lock", hex_string(&Sha256::digest(user_id.as_bytes())));
Ok(Self { user_id, lock_path: lock_directory.into().join(lock_name) })
}
pub fn load(&self) -> Result<Option<StoredAccountKeys>, SyncClientError> {
match load_secret(KEYCHAIN_SERVICE, &self.user_id).map_err(storage_error)? {
Some(record) => decode_key_record(record).map(Some),
None => Ok(None),
}
}
pub fn save_current(&self, key: &AccountKey, generation: u64) -> Result<(), SyncClientError> {
self.with_lock(|| {
let Some(mut stored) = self.load()? else {
return self.write(&StoredAccountKeys::from_current(key, generation)?);
};
if !stored.set_current(key, generation)? {
return Ok(());
}
self.write(&stored)
})
}
pub fn save_historical(
&self,
key: &AccountKey,
generation: u64,
) -> Result<(), SyncClientError> {
self.with_lock(|| {
let mut stored = self
.load()?
.ok_or_else(|| storage_error("current sync account key is unavailable"))?;
if !stored.insert_historical(key, generation)? {
return Ok(());
}
self.write(&stored)
})
}
pub fn clear(&self) -> Result<(), SyncClientError> {
self.with_lock(|| clear_secret(KEYCHAIN_SERVICE, &self.user_id).map_err(storage_error))
}
fn write(&self, stored: &StoredAccountKeys) -> Result<(), SyncClientError> {
let record = encode_key_record(stored)?;
save_secret(KEYCHAIN_SERVICE, &self.user_id, record.as_slice()).map_err(storage_error)
}
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()))?;
let result = operation();
let unlock_result =
FileExt::unlock(&lock).map_err(|error| storage_error(error.to_string()));
match (result, unlock_result) {
(Err(error), _) => Err(error),
(Ok(_), Err(error)) => Err(error),
(Ok(value), Ok(())) => Ok(value),
}
}
}
fn open_lock_file(path: &Path) -> Result<File, SyncClientError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| storage_error(error.to_string()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.map_err(|error| storage_error(error.to_string()))?;
}
}
let mut options = OpenOptions::new();
options.create(true).read(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(path).map_err(|error| storage_error(error.to_string()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|error| storage_error(error.to_string()))?;
}
Ok(file)
}
fn encode_key_record(stored: &StoredAccountKeys) -> Result<Zeroizing<Vec<u8>>, SyncClientError> {
if stored.keys.is_empty()
|| stored.keys.len() > MAX_STORED_KEYS
|| !stored.keys.contains_key(&stored.current_generation)
{
return Err(storage_error("sync account key history is invalid"));
}
let count = u16::try_from(stored.keys.len())
.map_err(|_| storage_error("sync account key history is too large"))?;
let mut record = Zeroizing::new(Vec::with_capacity(
KEY_RECORD_HEADER_BYTES + stored.keys.len() * KEY_RECORD_ENTRY_BYTES,
));
record.push(KEY_RECORD_VERSION);
record.extend_from_slice(&stored.current_generation.to_be_bytes());
record.extend_from_slice(&count.to_be_bytes());
for (generation, key) in &stored.keys {
record.extend_from_slice(&generation.to_be_bytes());
record.extend_from_slice(key.bytes());
}
Ok(record)
}
fn decode_key_record(record: Zeroizing<Vec<u8>>) -> Result<StoredAccountKeys, SyncClientError> {
if record.len() < KEY_RECORD_HEADER_BYTES || record[0] != KEY_RECORD_VERSION {
return Err(storage_error("sync account key record is invalid"));
}
let current_generation = u64::from_be_bytes(
record[1..9]
.try_into()
.map_err(|_| storage_error("sync account key generation is invalid"))?,
);
assert_generation(current_generation)?;
let count = usize::from(u16::from_be_bytes(
record[9..11].try_into().map_err(|_| storage_error("sync account key count is invalid"))?,
));
if count == 0
|| count > MAX_STORED_KEYS
|| record.len() != KEY_RECORD_HEADER_BYTES + count * KEY_RECORD_ENTRY_BYTES
{
return Err(storage_error("sync account key record size is invalid"));
}
let mut keys = BTreeMap::new();
for entry in record[KEY_RECORD_HEADER_BYTES..].chunks_exact(KEY_RECORD_ENTRY_BYTES) {
let generation = u64::from_be_bytes(
entry[..8]
.try_into()
.map_err(|_| storage_error("sync account key generation is invalid"))?,
);
assert_generation(generation)?;
let mut bytes = Zeroizing::new([0_u8; 32]);
bytes.copy_from_slice(&entry[8..]);
if keys.insert(generation, AccountKey::from_secret(bytes)).is_some() {
return Err(storage_error("sync account key generation is duplicated"));
}
}
if !keys.contains_key(&current_generation) {
return Err(storage_error("current sync account key is missing"));
}
Ok(StoredAccountKeys { current_generation, keys })
}
fn assert_generation(generation: u64) -> Result<(), SyncClientError> {
if generation == 0 {
return Err(storage_error("sync account key generation is invalid"));
}
Ok(())
}
fn storage_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::AccountKeyStorage(message.into())
}
fn hex_string(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_record_round_trips_current_and_historical_keys() -> Result<(), SyncClientError> {
let current = AccountKey::from_bytes([19; 32]);
let historical = AccountKey::from_bytes([17; 32]);
let mut stored = StoredAccountKeys::from_current(&current, 7)?;
stored.insert_historical(&historical, 3)?;
let record = encode_key_record(&stored)?;
let decoded = decode_key_record(Zeroizing::new(record.to_vec()))?;
assert_eq!(decoded.current_key().map(AccountKey::key_id), Some(current.key_id()));
assert_eq!(decoded.key(3).map(AccountKey::key_id), Some(historical.key_id()));
assert_eq!(decoded.current_generation(), 7);
Ok(())
}
#[test]
fn key_record_rejects_unknown_versions() {
let record = vec![KEY_RECORD_VERSION + 1; KEY_RECORD_HEADER_BYTES];
assert!(decode_key_record(Zeroizing::new(record)).is_err());
}
#[test]
fn key_record_rejects_zero_generation() {
assert!(StoredAccountKeys::from_current(&AccountKey::from_bytes([21; 32]), 0).is_err());
}
#[test]
fn current_generation_cannot_roll_back() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([23; 32]);
let mut stored = StoredAccountKeys::from_current(&key, 4)?;
assert!(stored.set_current(&AccountKey::from_bytes([25; 32]), 3).is_err());
assert!(stored.insert_historical(&AccountKey::from_bytes([27; 32]), 3)?);
assert_eq!(stored.current_generation(), 4);
Ok(())
}
}
+30 -2
View File
@@ -18,14 +18,42 @@
pub mod auth;
pub mod client;
mod credential_store;
pub mod device;
mod device_api;
mod device_proof;
mod device_revocation;
pub mod device_secret_store;
pub mod email_otp;
pub mod encryption;
pub mod error;
pub mod key_store;
pub mod snapshot;
pub mod vault;
mod vault_bootstrap;
pub use auth::{BearerToken, BearerTokenStore};
pub use client::{ApiClientConfig, SyncApiClient, SyncLatestSnapshotDocument, SyncStatusDocument};
pub use client::{
ApiClientConfig, SnapshotDownloadResult, SnapshotUploadResult, SyncApiClient,
SyncLatestSnapshotDocument, SyncSnapshotHeadConflictDocument, SyncStatusDocument,
};
pub use device::{DeviceIdentity, DeviceListResponse, DeviceRecord, DeviceRegistration};
pub use device_api::{DeviceApprovalDocument, DeviceApprovalRequest, DeviceRebindDocument};
pub use device_revocation::{DeviceRevocationDocument, DeviceRevocationRequest};
pub use device_secret_store::DeviceSecretStore;
pub use email_otp::{send_email_otp, verify_email_otp};
pub use encryption::{
AccountKey, EncryptedSnapshot, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext,
SnapshotHeadRef,
};
pub use error::SyncClientError;
pub use snapshot::{SnapshotDownload, SnapshotPayload, SnapshotUploadRequest};
pub use key_store::{AccountKeyStore, StoredAccountKeys};
pub use snapshot::{
AuthenticatedSnapshot, AuthenticatedSnapshotHead, SnapshotDownload, SnapshotPayload,
SnapshotUploadRequest,
};
pub use vault::{
ACCOUNT_KEY_WRAP_SUITE, ACCOUNT_KEY_WRAP_VERSION, SyncVaultDocument, VaultContext,
WrappedAccountKey,
};
pub use vault_bootstrap::SyncVaultBootstrapRequest;
+268 -11
View File
@@ -1,7 +1,13 @@
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::SyncClientError;
use crate::{
encryption::{
AccountKey, EncryptedSnapshot, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext,
SnapshotHeadRef,
},
error::SyncClientError,
};
/// Hard cap from `cloudflare/src/sync_snapshot.ts`: a single snapshot
/// upload may not exceed 10 MiB. We enforce the same limit client-side
@@ -56,28 +62,51 @@ pub struct SnapshotUploadRequest<'a> {
pub snapshot_id: &'a str,
pub region: &'a str,
pub payload_hash: &'a str,
pub encryption_version: u32,
pub vault_generation: u64,
pub key_id: &'a str,
pub content_hash: &'a str,
pub schema_rev: u32,
pub logical_clock: u64,
pub head_revision: u64,
pub base_head: Option<&'a SnapshotHeadRef>,
pub data_base64: String,
}
impl<'a> SnapshotUploadRequest<'a> {
pub fn new(
snapshot_id: &'a str,
region: &'a str,
schema_rev: u32,
logical_clock: u64,
context: &SnapshotCryptoContext<'a>,
base_head: Option<&'a AuthenticatedSnapshotHead>,
encrypted: &'a EncryptedSnapshot,
payload: &'a SnapshotPayload,
) -> Self {
Self {
version: 1,
snapshot_id,
) -> Result<Self, SyncClientError> {
let head_revision = match base_head {
Some(base) => base.next_revision()?,
None => 1,
};
if context.head_revision != head_revision
|| context.base_head != base_head.map(AuthenticatedSnapshotHead::head_ref)
{
return Err(SyncClientError::SnapshotEncryption {
reason: "snapshot upload context does not match authenticated base",
});
}
Ok(Self {
version: 3,
snapshot_id: context.snapshot_id,
region,
payload_hash: payload.payload_hash(),
schema_rev,
logical_clock,
encryption_version: SNAPSHOT_ENCRYPTION_VERSION,
vault_generation: context.vault_generation,
key_id: encrypted.key_id(),
content_hash: encrypted.content_hash(),
schema_rev: context.schema_rev,
logical_clock: context.logical_clock,
head_revision,
base_head: base_head.map(AuthenticatedSnapshotHead::head_ref),
data_base64: encode_base64(payload.bytes()),
}
})
}
}
@@ -96,6 +125,12 @@ impl SnapshotDownload {
/// worker enforces on upload — we re-check on download so a
/// tampered storage layer doesn't silently desync the user.
pub fn payload(&self) -> Result<SnapshotPayload, SyncClientError> {
if self.data_base64.len() > MAX_SNAPSHOT_BYTES.div_ceil(3) * 4 {
return Err(SyncClientError::SnapshotTooLarge {
bytes: self.data_base64.len(),
limit: MAX_SNAPSHOT_BYTES.div_ceil(3) * 4,
});
}
let bytes = decode_base64(&self.data_base64)
.map_err(|error| SyncClientError::SnapshotBase64(error.to_string()))?;
let payload = SnapshotPayload::new(bytes)?;
@@ -106,6 +141,54 @@ impl SnapshotDownload {
}
Ok(payload)
}
pub fn authenticate(
&self,
expected_head: &SnapshotHeadRef,
key: &AccountKey,
) -> Result<AuthenticatedSnapshot, SyncClientError> {
if self.version != 3 {
return Err(SyncClientError::SnapshotEncryption {
reason: "snapshot response version is unsupported",
});
}
let actual_head = self.snapshot.head_ref()?;
if &actual_head != expected_head {
return Err(SyncClientError::SnapshotEncryption {
reason: "snapshot response head does not match request",
});
}
let payload = self.payload()?;
let context = SnapshotCryptoContext {
user_id: &self.user_id,
vault_generation: self.snapshot.vault_generation,
snapshot_id: &self.snapshot.snapshot_id,
schema_rev: self.snapshot.schema_rev,
logical_clock: self.snapshot.logical_clock,
device_id: &self.snapshot.device_id,
head_revision: self.snapshot.head_revision,
base_head: self.snapshot.base_head.as_ref(),
};
let plaintext = key.decrypt(
&context,
self.snapshot.encryption_version,
&self.snapshot.key_id,
&self.snapshot.content_hash,
payload.bytes(),
)?;
Ok(AuthenticatedSnapshot {
plaintext,
head: AuthenticatedSnapshotHead {
head: actual_head,
logical_clock: self.snapshot.logical_clock,
content_hash: self.snapshot.content_hash.clone(),
vault_generation: self.snapshot.vault_generation,
key_id: self.snapshot.key_id.clone(),
device_id: self.snapshot.device_id.clone(),
size_bytes: self.snapshot.size_bytes,
},
})
}
}
#[derive(Clone, Debug, Deserialize)]
@@ -113,13 +196,101 @@ pub struct SnapshotDocument {
pub snapshot_id: String,
pub r2_key: String,
pub payload_hash: String,
pub encryption_version: u32,
pub vault_generation: u64,
pub key_id: String,
pub content_hash: String,
pub schema_rev: u32,
pub logical_clock: u64,
pub head_revision: u64,
pub base_head: Option<SnapshotHeadRef>,
pub device_id: String,
pub size_bytes: u64,
pub created_at: u64,
}
impl SnapshotDocument {
fn head_ref(&self) -> Result<SnapshotHeadRef, SyncClientError> {
SnapshotHeadRef::new(
self.head_revision,
self.snapshot_id.clone(),
self.payload_hash.clone(),
)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthenticatedSnapshotHead {
head: SnapshotHeadRef,
logical_clock: u64,
content_hash: String,
vault_generation: u64,
key_id: String,
device_id: String,
size_bytes: u64,
}
impl AuthenticatedSnapshotHead {
pub fn revision(&self) -> u64 {
self.head.revision()
}
pub fn logical_clock(&self) -> u64 {
self.logical_clock
}
pub fn content_hash(&self) -> &str {
&self.content_hash
}
pub fn vault_generation(&self) -> u64 {
self.vault_generation
}
pub fn key_id(&self) -> &str {
&self.key_id
}
pub fn snapshot_id(&self) -> &str {
self.head.snapshot_id()
}
pub fn device_id(&self) -> &str {
&self.device_id
}
pub fn size_bytes(&self) -> u64 {
self.size_bytes
}
pub fn next_revision(&self) -> Result<u64, SyncClientError> {
if self.head.revision >= 9_007_199_254_740_991 {
return Err(SyncClientError::SnapshotEncryption {
reason: "snapshot head revision exceeds the wire limit",
});
}
self.head.revision.checked_add(1).ok_or(SyncClientError::SnapshotEncryption {
reason: "snapshot head revision overflowed",
})
}
pub fn head_ref(&self) -> &SnapshotHeadRef {
&self.head
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthenticatedSnapshot {
plaintext: Vec<u8>,
head: AuthenticatedSnapshotHead,
}
impl AuthenticatedSnapshot {
pub fn into_parts(self) -> (Vec<u8>, AuthenticatedSnapshotHead) {
(self.plaintext, self.head)
}
}
const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn encode_base64(bytes: &[u8]) -> String {
@@ -228,4 +399,90 @@ mod tests {
}
Ok(())
}
#[test]
fn upload_request_serializes_encrypted_wire_v3() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([23; 32]);
let context = SnapshotCryptoContext {
user_id: "user-01",
vault_generation: 1,
snapshot_id: "device-01",
schema_rev: 1,
logical_clock: 42,
device_id: "device-01",
head_revision: 1,
base_head: None,
};
let encrypted = key.encrypt(&context, br#"{"secret":"value"}"#)?;
let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?;
let request = SnapshotUploadRequest::new("auto", &context, None, &encrypted, &payload)?;
let value = serde_json::to_value(request)
.map_err(|source| SyncClientError::Json { endpoint: "test".to_string(), source })?;
assert_eq!(value["version"], 3);
assert_eq!(value["encryption_version"], SNAPSHOT_ENCRYPTION_VERSION);
assert_eq!(value["head_revision"], 1);
assert!(value["base_head"].is_null());
assert_eq!(value["key_id"], encrypted.key_id());
assert_eq!(value["content_hash"], encrypted.content_hash());
assert!(!value["data_base64"].as_str().unwrap_or_default().contains("secret"));
Ok(())
}
#[test]
fn downloaded_head_becomes_a_merge_base_after_authentication() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([25; 32]);
let expected_head = SnapshotHeadRef::new(1, "device-01", "00".repeat(32))?;
let context = SnapshotCryptoContext {
user_id: "user-01",
vault_generation: 1,
snapshot_id: expected_head.snapshot_id(),
schema_rev: 1,
logical_clock: 42,
device_id: "device-01",
head_revision: 1,
base_head: None,
};
let encrypted = key.encrypt(&context, b"authenticated head")?;
let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?;
let expected_head =
SnapshotHeadRef::new(1, context.snapshot_id, payload.payload_hash().to_string())?;
let download = SnapshotDownload {
version: 3,
user_id: context.user_id.to_string(),
device_id: context.device_id.to_string(),
snapshot: SnapshotDocument {
snapshot_id: context.snapshot_id.to_string(),
r2_key: "sync-snapshots/test".to_string(),
payload_hash: payload.payload_hash().to_string(),
encryption_version: SNAPSHOT_ENCRYPTION_VERSION,
vault_generation: context.vault_generation,
key_id: encrypted.key_id().to_string(),
content_hash: encrypted.content_hash().to_string(),
schema_rev: context.schema_rev,
logical_clock: context.logical_clock,
head_revision: context.head_revision,
base_head: None,
device_id: context.device_id.to_string(),
size_bytes: u64::try_from(payload.bytes().len()).unwrap_or_default(),
created_at: 1,
},
data_base64: encode_base64(payload.bytes()),
};
let (plaintext, authenticated_head) =
download.authenticate(&expected_head, &key)?.into_parts();
assert_eq!(plaintext, b"authenticated head");
assert_eq!(authenticated_head.revision(), 1);
assert_eq!(authenticated_head.content_hash(), encrypted.content_hash());
assert!(
download
.authenticate(
&SnapshotHeadRef::new(1, "device-02", payload.payload_hash().to_string())?,
&key,
)
.is_err()
);
Ok(())
}
}
+396
View File
@@ -0,0 +1,396 @@
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use hpke::{
Deserializable, Kem, OpModeR, OpModeS, Serializable, aead::ChaCha20Poly1305, kdf::HkdfSha256,
kem::X25519HkdfSha256, single_shot_open, single_shot_seal,
};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::{
AccountKey, DeviceIdentity, DeviceSecretStore, SyncClientError,
device::{decode_hex_32, is_device_id_shape},
};
pub const ACCOUNT_KEY_WRAP_VERSION: u32 = 1;
pub const ACCOUNT_KEY_WRAP_SUITE: &str = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
const INFO_DOMAIN: &[u8] = b"ely-sync-account-key-hpke-info-v1\0";
const AAD_DOMAIN: &[u8] = b"ely-sync-account-key-hpke-aad-v1\0";
const ACCOUNT_KEY_BYTES: usize = 32;
const ENCAPSULATED_KEY_BYTES: usize = 32;
const CIPHERTEXT_BYTES: usize = ACCOUNT_KEY_BYTES + 16;
const MAX_CONTEXT_TEXT_BYTES: usize = 4096;
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SyncVaultDocument {
pub version: u32,
pub user_id: String,
pub key_id: String,
pub generation: u64,
pub recipient_device_id: String,
pub approver_device_id: String,
pub envelope: WrappedAccountKey,
pub created_at: u64,
}
impl SyncVaultDocument {
pub fn unwrap_for(
&self,
expected_user_id: &str,
identity: &DeviceIdentity,
) -> Result<AccountKey, SyncClientError> {
if self.version != 1
|| self.user_id != expected_user_id
|| self.recipient_device_id != identity.device_id
{
return Err(vault_error("vault document identity does not match"));
}
self.envelope.unwrap(
&VaultContext {
user_id: &self.user_id,
recipient_device_id: &self.recipient_device_id,
recipient_wrapping_public_key: &identity.wrapping_public_key,
approver_device_id: &self.approver_device_id,
generation: self.generation,
key_id: &self.key_id,
},
identity,
)
}
}
#[derive(Clone, Copy, Debug)]
pub struct VaultContext<'a> {
pub user_id: &'a str,
pub recipient_device_id: &'a str,
pub recipient_wrapping_public_key: &'a str,
pub approver_device_id: &'a str,
pub generation: u64,
pub key_id: &'a str,
}
impl VaultContext<'_> {
fn validate(&self) -> Result<(), SyncClientError> {
validate_text(self.user_id, "vault user identifier is invalid")?;
if !is_device_id_shape(self.recipient_device_id) {
return Err(vault_error("vault recipient device identifier is invalid"));
}
if !is_device_id_shape(self.approver_device_id) {
return Err(vault_error("vault approver device identifier is invalid"));
}
decode_vault_hex_32(
self.recipient_wrapping_public_key,
"vault recipient wrapping public key is invalid",
)?;
decode_vault_hex_32(self.key_id, "vault account key identifier is invalid")?;
if self.generation == 0 {
return Err(vault_error("vault generation is invalid"));
}
Ok(())
}
}
/// Strict JSON envelope for an AccountKey wrapped to one approved device.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WrappedAccountKey {
pub version: u32,
pub suite: String,
pub encapped_key: String,
pub ciphertext: String,
}
impl WrappedAccountKey {
pub fn wrap(
account_key: &AccountKey,
context: &VaultContext<'_>,
) -> Result<Self, SyncClientError> {
context.validate()?;
if account_key.key_id() != context.key_id {
return Err(vault_error("vault account key identifier does not match"));
}
let public_key_bytes = decode_vault_hex_32(
context.recipient_wrapping_public_key,
"vault recipient wrapping public key is invalid",
)?;
let public_key = <X25519HkdfSha256 as Kem>::PublicKey::from_bytes(&public_key_bytes)
.map_err(|_| vault_error("vault recipient wrapping public key is invalid"))?;
let info = context_bytes(INFO_DOMAIN, context)?;
let aad = context_bytes(AAD_DOMAIN, context)?;
let (encapped_key, ciphertext) =
single_shot_seal::<ChaCha20Poly1305, HkdfSha256, X25519HkdfSha256>(
&OpModeS::Base,
&public_key,
&info,
account_key.bytes(),
&aad,
)
.map_err(|_| vault_error("account key wrapping failed"))?;
Ok(Self {
version: ACCOUNT_KEY_WRAP_VERSION,
suite: ACCOUNT_KEY_WRAP_SUITE.to_string(),
encapped_key: URL_SAFE_NO_PAD.encode(encapped_key.to_bytes()),
ciphertext: URL_SAFE_NO_PAD.encode(ciphertext),
})
}
pub fn unwrap(
&self,
context: &VaultContext<'_>,
recipient: &DeviceIdentity,
) -> Result<AccountKey, SyncClientError> {
context.validate()?;
recipient.validate()?;
if recipient.device_id != context.recipient_device_id
|| recipient.wrapping_public_key != context.recipient_wrapping_public_key
{
return Err(vault_error("vault recipient identity does not match context"));
}
let store = DeviceSecretStore::new(recipient.device_id.clone())?;
let secrets = store.load_required()?;
recipient.validate_secrets(&secrets)?;
self.unwrap_with_private_key(context, secrets.wrapping_private_key())
}
pub fn self_wrap(
account_key: &AccountKey,
user_id: &str,
identity: &DeviceIdentity,
generation: u64,
) -> Result<Self, SyncClientError> {
identity.validate()?;
Self::wrap(
account_key,
&VaultContext {
user_id,
recipient_device_id: &identity.device_id,
recipient_wrapping_public_key: &identity.wrapping_public_key,
approver_device_id: &identity.device_id,
generation,
key_id: &account_key.key_id(),
},
)
}
pub fn self_unwrap(
&self,
user_id: &str,
identity: &DeviceIdentity,
generation: u64,
key_id: &str,
) -> Result<AccountKey, SyncClientError> {
self.unwrap(
&VaultContext {
user_id,
recipient_device_id: &identity.device_id,
recipient_wrapping_public_key: &identity.wrapping_public_key,
approver_device_id: &identity.device_id,
generation,
key_id,
},
identity,
)
}
fn unwrap_with_private_key(
&self,
context: &VaultContext<'_>,
private_key_bytes: &[u8; ACCOUNT_KEY_BYTES],
) -> Result<AccountKey, SyncClientError> {
self.validate_wire()?;
let private_key = <X25519HkdfSha256 as Kem>::PrivateKey::from_bytes(private_key_bytes)
.map_err(|_| vault_error("vault recipient private key is invalid"))?;
let encapped_key_bytes = decode_base64url_exact::<ENCAPSULATED_KEY_BYTES>(
&self.encapped_key,
"vault encapsulated key encoding is invalid",
)?;
let encapped_key = <X25519HkdfSha256 as Kem>::EncappedKey::from_bytes(&encapped_key_bytes)
.map_err(|_| vault_error("vault encapsulated key is invalid"))?;
let ciphertext = decode_base64url_exact::<CIPHERTEXT_BYTES>(
&self.ciphertext,
"vault ciphertext encoding is invalid",
)?;
let info = context_bytes(INFO_DOMAIN, context)?;
let aad = context_bytes(AAD_DOMAIN, context)?;
let plaintext = Zeroizing::new(
single_shot_open::<ChaCha20Poly1305, HkdfSha256, X25519HkdfSha256>(
&OpModeR::Base,
&private_key,
&encapped_key,
&info,
&ciphertext,
&aad,
)
.map_err(|_| vault_error("account key unwrap authentication failed"))?,
);
if plaintext.len() != ACCOUNT_KEY_BYTES {
return Err(vault_error("unwrapped account key has invalid length"));
}
let mut bytes = Zeroizing::new([0_u8; ACCOUNT_KEY_BYTES]);
bytes.copy_from_slice(&plaintext);
let account_key = AccountKey::from_secret(bytes);
if account_key.key_id() != context.key_id {
return Err(vault_error("unwrapped account key identifier does not match"));
}
Ok(account_key)
}
pub(crate) fn validate_wire(&self) -> Result<(), SyncClientError> {
if self.version != ACCOUNT_KEY_WRAP_VERSION {
return Err(vault_error("vault envelope version is unsupported"));
}
if self.suite != ACCOUNT_KEY_WRAP_SUITE {
return Err(vault_error("vault envelope suite is unsupported"));
}
decode_base64url_exact::<ENCAPSULATED_KEY_BYTES>(
&self.encapped_key,
"vault encapsulated key encoding is invalid",
)?;
decode_base64url_exact::<CIPHERTEXT_BYTES>(
&self.ciphertext,
"vault ciphertext encoding is invalid",
)?;
Ok(())
}
}
fn context_bytes(domain: &[u8], context: &VaultContext<'_>) -> Result<Vec<u8>, SyncClientError> {
context.validate()?;
let mut bytes = Vec::with_capacity(domain.len() + 512);
bytes.extend_from_slice(domain);
bytes.extend_from_slice(&ACCOUNT_KEY_WRAP_VERSION.to_be_bytes());
push_text(&mut bytes, ACCOUNT_KEY_WRAP_SUITE)?;
push_text(&mut bytes, context.user_id)?;
push_text(&mut bytes, context.recipient_device_id)?;
push_text(&mut bytes, context.recipient_wrapping_public_key)?;
push_text(&mut bytes, context.approver_device_id)?;
bytes.extend_from_slice(&context.generation.to_be_bytes());
push_text(&mut bytes, context.key_id)?;
Ok(bytes)
}
fn validate_text(value: &str, reason: &'static str) -> Result<(), SyncClientError> {
if value.trim().is_empty() || value.len() > MAX_CONTEXT_TEXT_BYTES {
return Err(vault_error(reason));
}
Ok(())
}
fn push_text(output: &mut Vec<u8>, value: &str) -> Result<(), SyncClientError> {
validate_text(value, "vault authenticated metadata is invalid")?;
let length = u16::try_from(value.len())
.map_err(|_| vault_error("vault authenticated metadata is too long"))?;
output.extend_from_slice(&length.to_be_bytes());
output.extend_from_slice(value.as_bytes());
Ok(())
}
fn decode_base64url_exact<const N: usize>(
value: &str,
reason: &'static str,
) -> Result<[u8; N], SyncClientError> {
let decoded = URL_SAFE_NO_PAD.decode(value).map_err(|_| vault_error(reason))?;
if URL_SAFE_NO_PAD.encode(&decoded) != value {
return Err(vault_error(reason));
}
decoded.try_into().map_err(|_| vault_error(reason))
}
fn decode_vault_hex_32(value: &str, reason: &'static str) -> Result<[u8; 32], SyncClientError> {
decode_hex_32(value, reason).map_err(|_| vault_error(reason))
}
fn vault_error(reason: &'static str) -> SyncClientError {
SyncClientError::VaultCrypto { reason }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::device::generate_key_material;
fn context<'a>(identity: &'a DeviceIdentity, key_id: &'a str) -> VaultContext<'a> {
VaultContext {
user_id: "user-01",
recipient_device_id: &identity.device_id,
recipient_wrapping_public_key: &identity.wrapping_public_key,
approver_device_id: &identity.device_id,
generation: 1,
key_id,
}
}
#[test]
fn account_key_round_trips_through_hpke() -> Result<(), SyncClientError> {
let (identity, secrets) = generate_key_material("Test".to_string(), "macos".to_string())?;
let account_key = AccountKey::from_bytes([41; ACCOUNT_KEY_BYTES]);
let key_id = account_key.key_id();
let context = context(&identity, &key_id);
let wrapped = WrappedAccountKey::self_wrap(&account_key, "user-01", &identity, 1)?;
let unwrapped =
wrapped.unwrap_with_private_key(&context, secrets.wrapping_private_key())?;
assert_eq!(unwrapped.key_id(), account_key.key_id());
assert_eq!(wrapped.encapped_key.len(), 43);
assert_eq!(wrapped.ciphertext.len(), 64);
Ok(())
}
#[test]
fn authenticated_context_rejects_metadata_changes() -> Result<(), SyncClientError> {
let (identity, secrets) = generate_key_material("Test".to_string(), "macos".to_string())?;
let account_key = AccountKey::from_bytes([43; ACCOUNT_KEY_BYTES]);
let key_id = account_key.key_id();
let context = context(&identity, &key_id);
let wrapped = WrappedAccountKey::wrap(&account_key, &context)?;
let other_key_id = AccountKey::from_bytes([44; ACCOUNT_KEY_BYTES]).key_id();
let other_wrapping_public_key = "01".repeat(ACCOUNT_KEY_BYTES);
let changed = [
VaultContext { user_id: "user-02", ..context },
VaultContext { recipient_device_id: "device-02", ..context },
VaultContext { recipient_wrapping_public_key: &other_wrapping_public_key, ..context },
VaultContext { approver_device_id: "device-02", ..context },
VaultContext { generation: 2, ..context },
VaultContext { key_id: &other_key_id, ..context },
];
for changed_context in changed {
assert!(
wrapped
.unwrap_with_private_key(&changed_context, secrets.wrapping_private_key())
.is_err()
);
}
Ok(())
}
#[test]
fn wire_schema_rejects_unknown_fields() -> Result<(), SyncClientError> {
let json = format!(
r#"{{"version":1,"suite":"{ACCOUNT_KEY_WRAP_SUITE}","encapped_key":"{}","ciphertext":"{}","unknown":true}}"#,
"A".repeat(43),
"A".repeat(64)
);
assert!(serde_json::from_str::<WrappedAccountKey>(&json).is_err());
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn self_wrap_uses_native_credential_store() -> Result<(), SyncClientError> {
let identity = DeviceIdentity::generate("Test", "macos")?;
let store = DeviceSecretStore::new(identity.device_id.clone())?;
let result = (|| {
let account_key = AccountKey::from_bytes([47; ACCOUNT_KEY_BYTES]);
let key_id = account_key.key_id();
let wrapped = WrappedAccountKey::self_wrap(&account_key, "user-01", &identity, 1)?;
let unwrapped = wrapped.self_unwrap("user-01", &identity, 1, &key_id)?;
assert_eq!(unwrapped.key_id(), key_id);
Ok(())
})();
store.clear()?;
result
}
}
@@ -0,0 +1,130 @@
use serde::Serialize;
use crate::{
DeviceIdentity, SyncClientError,
device::decode_hex_32,
device_proof::{is_idempotency_key_shape, push_field},
vault::WrappedAccountKey,
};
const BOOTSTRAP_PROOF_DOMAIN: &str = "elydora-sync-vault-bootstrap-v2";
const MAX_USER_ID_BYTES: usize = 4096;
#[derive(Clone, Debug, Serialize)]
pub struct SyncVaultBootstrapRequest<'a> {
pub version: u32,
pub key_id: &'a str,
pub generation: u64,
pub envelope: &'a WrappedAccountKey,
pub idempotency_key: &'a str,
pub bootstrap_proof: String,
}
impl<'a> SyncVaultBootstrapRequest<'a> {
pub fn signed(
user_id: &str,
identity: &DeviceIdentity,
key_id: &'a str,
envelope: &'a WrappedAccountKey,
idempotency_key: &'a str,
) -> Result<Self, SyncClientError> {
let generation = 1;
let message = bootstrap_proof_message(
user_id,
identity,
key_id,
generation,
envelope,
idempotency_key,
)?;
Ok(Self {
version: 2,
key_id,
generation,
envelope,
idempotency_key,
bootstrap_proof: identity.sign_message(&message)?,
})
}
}
fn bootstrap_proof_message(
user_id: &str,
identity: &DeviceIdentity,
key_id: &str,
generation: u64,
envelope: &WrappedAccountKey,
idempotency_key: &str,
) -> Result<Vec<u8>, SyncClientError> {
if user_id.trim().is_empty() || user_id.len() > MAX_USER_ID_BYTES {
return Err(bootstrap_error("vault user identifier is invalid"));
}
identity.validate()?;
decode_hex_32(key_id, "vault account key identifier is invalid")?;
if generation != 1 {
return Err(bootstrap_error("vault bootstrap generation is invalid"));
}
envelope.validate_wire()?;
if !is_idempotency_key_shape(idempotency_key) {
return Err(bootstrap_error("vault bootstrap idempotency key is invalid"));
}
let generation = generation.to_string();
let envelope_version = envelope.version.to_string();
let fields = [
BOOTSTRAP_PROOF_DOMAIN,
user_id,
&identity.device_id,
key_id,
&generation,
&envelope_version,
&envelope.suite,
&envelope.encapped_key,
&envelope.ciphertext,
idempotency_key,
];
let mut message = Vec::with_capacity(512);
for field in fields {
push_field(&mut message, field);
}
Ok(message)
}
fn bootstrap_error(reason: &'static str) -> SyncClientError {
SyncClientError::VaultCrypto { reason }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AccountKey, device::generate_key_material, vault::WrappedAccountKey};
#[test]
fn proof_matches_worker_canonical_bytes() -> Result<(), SyncClientError> {
let (identity, _) = generate_key_material("Test".to_string(), "macos".to_string())?;
let account_key = AccountKey::from_bytes([37; 32]);
let key_id = account_key.key_id();
let user_id = "usér-01";
let envelope = WrappedAccountKey::self_wrap(&account_key, user_id, &identity, 1)?;
let idempotency_key = "vault-bootstrap:01";
let message =
bootstrap_proof_message(user_id, &identity, &key_id, 1, &envelope, idempotency_key)?;
let fields = [
BOOTSTRAP_PROOF_DOMAIN.to_string(),
user_id.to_string(),
identity.device_id,
key_id,
"1".to_string(),
envelope.version.to_string(),
envelope.suite,
envelope.encapped_key,
envelope.ciphertext,
idempotency_key.to_string(),
];
let expected =
fields.iter().map(|field| format!("{}:{field}", field.len())).collect::<String>();
assert_eq!(message, expected.as_bytes());
Ok(())
}
}