fix(sync): secure encrypted snapshot lifecycle
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -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,
|
||||
®istration.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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: ®istration_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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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(¤t_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(¤t, 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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user