diff options
| -rw-r--r-- | crates/atuin-client/src/record/sync.rs | 83 | ||||
| -rw-r--r-- | crates/atuin-daemon/src/components/sync.rs | 2 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/account/login.rs | 46 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/history.rs | 3 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/store/pull.rs | 13 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/store/push.rs | 13 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/sync.rs | 4 |
7 files changed, 130 insertions, 34 deletions
diff --git a/crates/atuin-client/src/record/sync.rs b/crates/atuin-client/src/record/sync.rs index 37840b75..b785b5dc 100644 --- a/crates/atuin-client/src/record/sync.rs +++ b/crates/atuin-client/src/record/sync.rs @@ -4,7 +4,7 @@ use std::{cmp::Ordering, fmt::Write}; use eyre::Result; use thiserror::Error; -use super::store::Store; +use super::{encryption::PASETO_V4, store::Store}; use crate::{api_client::Client, settings::Settings}; use atuin_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus}; @@ -26,6 +26,14 @@ pub enum SyncError { #[error("a request to the sync server failed: {msg:?}")] RemoteRequestError { msg: String }, + + #[error( + "the encryption key on this machine does not match the data on the server. \ + this usually means a new machine was set up without copying the existing key. \ + to fix: run `atuin key` on a machine that already syncs correctly, then run \ + `atuin store rekey <key>` on this machine with the value from the other machine" + )] + WrongKey, } #[derive(Debug, Eq, PartialEq)] @@ -49,11 +57,8 @@ pub enum Operation { }, } -pub async fn diff( - settings: &Settings, - store: &impl Store, -) -> Result<(Vec<Diff>, RecordStatus), SyncError> { - let client = Client::new( +pub async fn build_client(settings: &Settings) -> Result<Client<'_>, SyncError> { + Client::new( &settings.sync_address, settings .sync_auth_token() @@ -62,8 +67,13 @@ pub async fn diff( settings.network_connect_timeout, settings.network_timeout, ) - .map_err(|e| SyncError::OperationalError { msg: e.to_string() })?; + .map_err(|e| SyncError::OperationalError { msg: e.to_string() }) +} +pub async fn diff( + client: &Client<'_>, + store: &impl Store, +) -> Result<(Vec<Diff>, RecordStatus), SyncError> { let local_index = store .status() .await @@ -273,22 +283,11 @@ async fn sync_download( } pub async fn sync_remote( + client: &Client<'_>, operations: Vec<Operation>, local_store: &impl Store, - settings: &Settings, page_size: u64, ) -> Result<(i64, Vec<RecordId>), SyncError> { - let client = Client::new( - &settings.sync_address, - settings - .sync_auth_token() - .await - .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?, - settings.network_connect_timeout, - settings.network_timeout, - ) - .expect("failed to create client"); - let mut uploaded = 0; let mut downloaded = Vec::new(); @@ -302,7 +301,7 @@ pub async fn sync_remote( remote, } => { uploaded += - sync_upload(local_store, &client, host, tag, local, remote, page_size).await? + sync_upload(local_store, client, host, tag, local, remote, page_size).await? } Operation::Download { @@ -312,8 +311,7 @@ pub async fn sync_remote( remote, } => { let mut d = - sync_download(local_store, &client, host, tag, local, remote, page_size) - .await?; + sync_download(local_store, client, host, tag, local, remote, page_size).await?; downloaded.append(&mut d) } @@ -324,13 +322,50 @@ pub async fn sync_remote( Ok((uploaded, downloaded)) } +pub async fn check_encryption_key( + client: &Client<'_>, + remote_index: &RecordStatus, + encryption_key: &[u8; 32], +) -> Result<(), SyncError> { + let sample = remote_index + .hosts + .iter() + .flat_map(|(host, tags)| tags.keys().map(move |tag| (*host, tag.clone()))) + .next(); + + let Some((host, tag)) = sample else { + return Ok(()); + }; + + let records = client + .next_records(host, tag, 0, 1) + .await + .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?; + + let Some(record) = records.into_iter().next() else { + return Ok(()); + }; + + record + .decrypt::<PASETO_V4>(encryption_key) + .map_err(|_| SyncError::WrongKey)?; + + Ok(()) +} + pub async fn sync( settings: &Settings, store: &impl Store, + encryption_key: &[u8; 32], ) -> Result<(i64, Vec<RecordId>), SyncError> { - let (diff, _) = diff(settings, store).await?; + let client = build_client(settings).await?; + let (diff, remote_index) = diff(&client, store).await?; + + // Bail before mutating either side if the local key can't read the remote. + check_encryption_key(&client, &remote_index, encryption_key).await?; + let operations = operations(diff, store).await?; - let (uploaded, downloaded) = sync_remote(operations, store, settings, 100).await?; + let (uploaded, downloaded) = sync_remote(&client, operations, store, 100).await?; Ok((uploaded, downloaded)) } diff --git a/crates/atuin-daemon/src/components/sync.rs b/crates/atuin-daemon/src/components/sync.rs index 314b375e..a342f700 100644 --- a/crates/atuin-daemon/src/components/sync.rs +++ b/crates/atuin-daemon/src/components/sync.rs @@ -213,7 +213,7 @@ async fn do_sync_tick( } // Perform the sync - let res = sync::sync(settings, handle.store()).await; + let res = sync::sync(settings, handle.store(), handle.encryption_key()).await; match res { Err(e) => { diff --git a/crates/atuin/src/command/client/account/login.rs b/crates/atuin/src/command/client/account/login.rs index 70cf3a72..852cb2a1 100644 --- a/crates/atuin/src/command/client/account/login.rs +++ b/crates/atuin/src/command/client/account/login.rs @@ -9,6 +9,7 @@ use atuin_client::{ encryption::{Key, decode_key, encode_key, load_key}, record::sqlite_store::SqliteStore, record::store::Store, + record::sync::{self, SyncError}, settings::{Settings, SyncAuth}, }; use rpassword::prompt_password; @@ -62,10 +63,12 @@ impl Cmd { } if settings.is_hub_sync() { - self.run_hub_login(settings, store).await + self.run_hub_login(settings, store).await?; } else { - self.run_legacy_login(settings, store).await + self.run_legacy_login(settings, store).await?; } + + verify_key_against_remote(settings).await } /// Hub login: use the browser flow unless the username was provided for headless use. @@ -269,6 +272,45 @@ impl Cmd { } } +async fn verify_key_against_remote(settings: &Settings) -> Result<()> { + let key: [u8; 32] = load_key(settings) + .context("could not load encryption key for verification")? + .into(); + + let client = sync::build_client(settings).await?; + let remote_index = match client.record_status().await { + Ok(idx) => idx, + Err(e) => { + tracing::warn!("could not fetch remote status to verify key: {e}"); + return Ok(()); + } + }; + + match sync::check_encryption_key(&client, &remote_index, &key).await { + Ok(()) => Ok(()), + Err(SyncError::WrongKey) => { + // Roll back the saved session so the user is not left in a + // half-authenticated state with a key that can't read the data. + if let Ok(meta) = Settings::meta_store().await { + let _ = meta.delete_session().await; + let _ = meta.delete_hub_session().await; + } + bail!( + "The provided encryption key does not match the data on the server. \ + You have been logged out — please run `atuin login` again with the correct key. \ + Find it by running `atuin key` on a machine that already syncs successfully." + ); + } + Err(e) => { + // Non-key error (e.g. transient network issue). Don't fail the + // login — the user is authenticated and can sync later when the + // network recovers. + tracing::warn!("could not verify encryption key against remote: {e}"); + Ok(()) + } + } +} + pub(super) fn or_user_input(value: Option<String>, name: &'static str) -> String { value.unwrap_or_else(|| read_user_input(name)) } diff --git a/crates/atuin/src/command/client/history.rs b/crates/atuin/src/command/client/history.rs index 836556b4..98381e77 100644 --- a/crates/atuin/src/command/client/history.rs +++ b/crates/atuin/src/command/client/history.rs @@ -531,7 +531,8 @@ async fn handle_end( #[cfg(feature = "sync")] { if settings.sync.records { - let (_, downloaded) = record::sync::sync(settings, &store).await?; + let (_, downloaded) = + record::sync::sync(settings, &store, &history_store.encryption_key).await?; Settings::save_sync_time().await?; crate::sync::build(settings, &store, db, Some(&downloaded)).await?; diff --git a/crates/atuin/src/command/client/store/pull.rs b/crates/atuin/src/command/client/store/pull.rs index af7ca00e..cda0d741 100644 --- a/crates/atuin/src/command/client/store/pull.rs +++ b/crates/atuin/src/command/client/store/pull.rs @@ -3,6 +3,7 @@ use eyre::Result; use atuin_client::{ database::Database, + encryption::load_key, record::store::Store, record::sync::Operation, record::{sqlite_store::SqliteStore, sync}, @@ -46,7 +47,15 @@ impl Pull { // 3. Filter operations by // a) are they a download op? // b) are they for the host/tag we are pushing here? - let (diff, _) = sync::diff(settings, &store).await?; + let client = sync::build_client(settings).await?; + let (diff, remote_index) = sync::diff(&client, &store).await?; + + // Skip on --force: local was already wiped above, mismatch is the user's call. + if !self.force { + let key: [u8; 32] = load_key(settings)?.into(); + sync::check_encryption_key(&client, &remote_index, &key).await?; + } + let operations = sync::operations(diff, &store).await?; let operations = operations @@ -72,7 +81,7 @@ impl Pull { }) .collect(); - let (_, downloaded) = sync::sync_remote(operations, &store, settings, self.page).await?; + let (_, downloaded) = sync::sync_remote(&client, operations, &store, self.page).await?; println!("Downloaded {} records", downloaded.len()); diff --git a/crates/atuin/src/command/client/store/push.rs b/crates/atuin/src/command/client/store/push.rs index 23958d7f..14a6d9dd 100644 --- a/crates/atuin/src/command/client/store/push.rs +++ b/crates/atuin/src/command/client/store/push.rs @@ -5,6 +5,7 @@ use uuid::Uuid; use atuin_client::{ api_client::Client, + encryption::load_key, record::sync::Operation, record::{sqlite_store::SqliteStore, sync}, settings::Settings, @@ -58,7 +59,15 @@ impl Push { // 3. Filter operations by // a) are they an upload op? // b) are they for the host/tag we are pushing here? - let (diff, _) = sync::diff(settings, &store).await?; + let client = sync::build_client(settings).await?; + let (diff, remote_index) = sync::diff(&client, &store).await?; + + // Skip on --force: that path intentionally replaces remote with local. + if !self.force { + let key: [u8; 32] = load_key(settings)?.into(); + sync::check_encryption_key(&client, &remote_index, &key).await?; + } + let operations = sync::operations(diff, &store).await?; let operations = operations @@ -92,7 +101,7 @@ impl Push { }) .collect(); - let (uploaded, _) = sync::sync_remote(operations, &store, settings, self.page).await?; + let (uploaded, _) = sync::sync_remote(&client, operations, &store, self.page).await?; println!("Uploaded {uploaded} records"); diff --git a/crates/atuin/src/command/client/sync.rs b/crates/atuin/src/command/client/sync.rs index 250e98aa..954b07de 100644 --- a/crates/atuin/src/command/client/sync.rs +++ b/crates/atuin/src/command/client/sync.rs @@ -88,7 +88,7 @@ async fn run( let host_id = Settings::host_id().await?; let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); - let (uploaded, downloaded) = sync::sync(settings, &store).await?; + let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key).await?; crate::sync::build(settings, &store, db, Some(&downloaded)).await?; @@ -111,7 +111,7 @@ async fn run( println!("Re-running sync due to new records locally"); // we'll want to run sync once more, as there will now be stuff to upload - let (uploaded, downloaded) = sync::sync(settings, &store).await?; + let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key).await?; crate::sync::build(settings, &store, db, Some(&downloaded)).await?; |
