aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2026-05-12 13:45:02 -0700
committerGitHub <noreply@github.com>2026-05-12 13:45:02 -0700
commit8c8730e1d559442832a794c91471bd3a0426e856 (patch)
treec2934fb4f2de096f7c00bb7d216a11f9d35a3c07
parentchore: Rename 'atuin hex' to 'atuin pty-proxy' (#3473) (diff)
downloadatuin-8c8730e1d559442832a794c91471bd3a0426e856.zip
fix: ensure local key matches remote data before syncing (#3474)
We rely on the user to manage their keys. This is ok, and is intentionally part of our security model However. If the user messes up, they corrupt their remote store. It is possible to work around and fix, but not without difficulty. This change ensures that if the local key does not match the remote data, no data is synced and the user has a chance to fix it before breaking things. ## Checks - [ ] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle - [ ] I have checked that there are no existing pull requests for the same thing
-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?;