aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/atuin-client/src/record/sync.rs83
-rw-r--r--crates/atuin-daemon/src/components/sync.rs2
-rw-r--r--crates/atuin/src/command/client/account/login.rs46
-rw-r--r--crates/atuin/src/command/client/history.rs3
-rw-r--r--crates/atuin/src/command/client/store/pull.rs13
-rw-r--r--crates/atuin/src/command/client/store/push.rs13
-rw-r--r--crates/atuin/src/command/client/sync.rs4
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?;