use keyring for encryption key

This commit is contained in:
Conrad Ludgate 2023-05-27 16:59:54 +01:00
parent 6118da2ee2
commit 10dd02dd8f
No known key found for this signature in database
GPG Key ID: 197E3CACA1C980B5
10 changed files with 745 additions and 162 deletions

683
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@ sync = [
"base64",
"generic-array",
"xsalsa20poly1305",
"keyring",
]
[dependencies]
@ -63,6 +64,7 @@ tokio = { workspace = true }
semver = { workspace = true }
xsalsa20poly1305 = { version = "0.9.0", optional = true }
generic-array = { version = "0.14", optional = true, features = ["serde"] }
keyring = { version = "2", optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["full"] }

View File

@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::collections::HashSet;
use chrono::Utc;
use eyre::{bail, Result};
@ -13,22 +12,13 @@ use atuin_common::api::{
LoginRequest, LoginResponse, RegisterResponse, StatusResponse, SyncHistoryResponse,
};
use semver::Version;
use xsalsa20poly1305::Key;
use crate::{
encryption::{decode_key, decrypt},
history::History,
sync::hash_str,
};
use crate::{history::History, sync::hash_str};
static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"),);
// TODO: remove all references to the encryption key from this
// It should be handled *elsewhere*
pub struct Client<'a> {
sync_addr: &'a str,
key: Key,
client: reqwest::Client,
}
@ -110,13 +100,12 @@ pub async fn latest_version() -> Result<Version> {
}
impl<'a> Client<'a> {
pub fn new(sync_addr: &'a str, session_token: &'a str, key: String) -> Result<Self> {
pub fn new(sync_addr: &'a str, session_token: &'a str) -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, format!("Token {session_token}").parse()?);
Ok(Client {
sync_addr,
key: decode_key(key)?,
client: reqwest::Client::builder()
.user_agent(APP_USER_AGENT)
.default_headers(headers)
@ -154,13 +143,12 @@ impl<'a> Client<'a> {
Ok(status)
}
pub async fn get_history(
pub async fn get_encrypted_history(
&self,
sync_ts: chrono::DateTime<Utc>,
history_ts: chrono::DateTime<Utc>,
host: Option<String>,
deleted: HashSet<String>,
) -> Result<Vec<History>> {
) -> Result<Vec<String>> {
let host = match host {
None => hash_str(&format!("{}:{}", whoami::hostname(), whoami::username())),
Some(h) => h,
@ -177,23 +165,7 @@ impl<'a> Client<'a> {
let resp = self.client.get(url).send().await?;
let history = resp.json::<SyncHistoryResponse>().await?;
let history = history
.history
.iter()
// TODO: handle deletion earlier in this chain
.map(|h| serde_json::from_str(h).expect("invalid base64"))
.map(|h| decrypt(h, &self.key).expect("failed to decrypt history! check your key"))
.map(|mut h| {
if deleted.contains(&h.id) {
h.deleted_at = Some(chrono::Utc::now());
h.command = String::from("");
}
h
})
.collect();
Ok(history)
Ok(history.history)
}
pub async fn post_history(&self, history: &[AddHistoryRequest]) -> Result<()> {

View File

@ -8,11 +8,10 @@
// clients must share the secret in order to be able to sync, as it is needed
// to decrypt
use std::{io::prelude::*, path::PathBuf};
use std::path::Path;
use base64::prelude::{Engine, BASE64_STANDARD};
use eyre::{eyre, Context, Result};
use fs_err as fs;
use serde::{Deserialize, Serialize};
pub use xsalsa20poly1305::Key;
use xsalsa20poly1305::{
@ -31,49 +30,57 @@ pub struct EncryptedHistory {
pub nonce: Nonce<XSalsa20Poly1305>,
}
pub fn new_key(settings: &Settings) -> Result<Key> {
pub fn new_key(username: &str, settings: &Settings) -> Result<Key> {
let entry = keyring::Entry::new("atuin", username).ok();
new_key_inner(entry, settings)
}
pub fn save_key(username: &str, settings: &Settings, key: &Key) -> Result<()> {
let entry = keyring::Entry::new("atuin", username).ok();
save_key_inner(entry, settings, key)
}
fn new_key_inner(entry: Option<keyring::Entry>, settings: &Settings) -> Result<Key> {
let key = XSalsa20Poly1305::generate_key(&mut OsRng);
save_key_inner(entry, settings, &key)?;
Ok(key)
}
fn save_key_inner(entry: Option<keyring::Entry>, settings: &Settings, key: &Key) -> Result<()> {
let path = settings.key_path.as_str();
let key = XSalsa20Poly1305::generate_key(&mut OsRng);
let encoded = encode_key(&key)?;
let encoded = encode_key(key)?;
let mut file = fs::File::create(path)?;
file.write_all(encoded.as_bytes())?;
// prefer keyring
if let Some(entry) = entry {
entry.set_password(&encoded)?;
}
Ok(key)
// write to the file system too for now
fs_err::write(path, encoded.as_bytes())?;
Ok(())
}
// Loads the secret key, will create + save if it doesn't exist
pub fn load_key(settings: &Settings) -> Result<Key> {
pub fn load_key(username: &str, settings: &Settings) -> Result<Key> {
let path = settings.key_path.as_str();
let key = if PathBuf::from(path).exists() {
let key = fs_err::read_to_string(path)?;
decode_key(key)?
} else {
new_key(settings)?
let entry = keyring::Entry::new("atuin", username).ok();
// prefer the keyring
let key = match entry.as_ref().map(|e| e.get_password()) {
Some(Ok(key)) => decode_key(key)?,
_ if Path::new(path).exists() => decode_key(fs_err::read_to_string(path)?)?,
_ => {
let key = XSalsa20Poly1305::generate_key(&mut OsRng);
save_key_inner(entry, settings, &key)?;
key
}
};
Ok(key)
}
pub fn load_encoded_key(settings: &Settings) -> Result<String> {
let path = settings.key_path.as_str();
if PathBuf::from(path).exists() {
let key = fs::read_to_string(path)?;
Ok(key)
} else {
let key = XSalsa20Poly1305::generate_key(&mut OsRng);
let encoded = encode_key(&key)?;
let mut file = fs::File::create(path)?;
file.write_all(encoded.as_bytes())?;
Ok(encoded)
}
}
pub fn encode_key(key: &Key) -> Result<String> {
let buf = rmp_serde::to_vec(key.as_slice()).wrap_err("could not encode key to message pack")?;
let buf = BASE64_STANDARD.encode(buf);

View File

@ -3,14 +3,14 @@ use std::convert::TryInto;
use std::iter::FromIterator;
use chrono::prelude::*;
use eyre::Result;
use eyre::{Context, Result};
use atuin_common::api::AddHistoryRequest;
use crate::{
api_client,
database::Database,
encryption::{encrypt, load_encoded_key, load_key},
encryption::{decrypt, encrypt, load_key},
settings::Settings,
};
@ -33,17 +33,21 @@ pub fn hash_str(string: &str) -> String {
// Check if remote has things we don't, and if so, download them.
// Returns (num downloaded, total local)
async fn sync_download(
settings: &Settings,
force: bool,
client: &api_client::Client<'_>,
db: &mut (impl Database + Send),
) -> Result<(i64, i64)> {
) -> Result<()> {
debug!("starting sync download");
let remote_status = client.status().await?;
let remote_count = remote_status.count;
let key = load_key(&remote_status.username, settings)?; // encryption key
// useful to ensure we don't even save something that hasn't yet been synced + deleted
let remote_deleted = HashSet::from_iter(remote_status.deleted.clone());
let remote_deleted =
HashSet::<&str>::from_iter(remote_status.deleted.iter().map(|s| s.as_str()));
let initial_local = db.history_count().await?;
let mut local_count = initial_local;
@ -60,23 +64,30 @@ async fn sync_download(
while remote_count > local_count {
let page = client
.get_history(
last_sync,
last_timestamp,
host.clone(),
remote_deleted.clone(),
)
.get_encrypted_history(last_sync, last_timestamp, host.clone())
.await?;
db.save_bulk(&page).await?;
let mut history = Vec::with_capacity(page.len());
for entry in page {
let entry = serde_json::from_str(&entry).context("invalid base64")?;
let mut entry =
decrypt(entry, &key).context("failed to decrypt history! check your key")?;
if remote_deleted.contains(&entry.id.as_str()) {
entry.deleted_at = Some(chrono::Utc::now());
entry.command = String::from("");
}
history.push(entry);
}
db.save_bulk(&history).await?;
local_count = db.history_count().await?;
if page.len() < remote_status.page_size.try_into().unwrap() {
if history.len() < remote_status.page_size.try_into().unwrap() {
break;
}
let page_last = page
let page_last = history
.last()
.expect("could not get last element of page")
.timestamp;
@ -104,8 +115,8 @@ async fn sync_download(
);
}
}
Ok((local_count - initial_local, local_count))
debug!("sync downloaded {}", local_count - initial_local);
Ok(())
}
// Check if we have things remote doesn't, and if so, upload them
@ -127,7 +138,7 @@ async fn sync_upload(
debug!("remote has {}, we have {}", remote_count, local_count);
let key = load_key(settings)?; // encryption key
let key = load_key(&remote_status.username, settings)?; // encryption key
// first just try the most recent set
@ -178,17 +189,10 @@ async fn sync_upload(
}
pub async fn sync(settings: &Settings, force: bool, db: &mut (impl Database + Send)) -> Result<()> {
let client = api_client::Client::new(
&settings.sync_address,
&settings.session_token,
load_encoded_key(settings)?,
)?;
let client = api_client::Client::new(&settings.sync_address, &settings.session_token)?;
sync_upload(settings, force, &client, db).await?;
let download = sync_download(force, &client, db).await?;
debug!("sync downloaded {}", download.0);
sync_download(settings, force, &client, db).await?;
Settings::save_sync_time()?;

View File

@ -1,4 +1,4 @@
use atuin_client::{api_client, encryption::load_encoded_key, settings::Settings};
use atuin_client::{api_client, settings::Settings};
use eyre::{bail, Result};
use std::path::PathBuf;
@ -9,11 +9,7 @@ pub async fn run(settings: &Settings) -> Result<()> {
bail!("You are not logged in");
}
let client = api_client::Client::new(
&settings.sync_address,
&settings.session_token,
load_encoded_key(settings)?,
)?;
let client = api_client::Client::new(&settings.sync_address, &settings.session_token)?;
client.delete().await?;

View File

@ -1,12 +1,12 @@
use std::{io, path::PathBuf};
use clap::Parser;
use eyre::{bail, Context, Result};
use eyre::{bail, Result};
use tokio::{fs::File, io::AsyncWriteExt};
use atuin_client::{
api_client,
encryption::{decode_key, encode_key, new_key, Key},
encryption::{decode_key, load_key, save_key, Key},
settings::Settings,
};
use atuin_common::api::LoginRequest;
@ -47,27 +47,24 @@ impl Cmd {
let key = or_user_input(&self.key, "encryption key [blank to use existing key file]");
let password = self.password.clone().unwrap_or_else(read_user_password);
let key_path = settings.key_path.as_str();
if key.is_empty() {
if PathBuf::from(key_path).exists() {
let bytes = fs_err::read_to_string(key_path)
.context("existing key file couldn't be read")?;
if decode_key(bytes).is_err() {
bail!("the key in existing key file was invalid");
}
} else {
println!("No key file exists, creating a new");
let _key = new_key(settings)?;
}
load_key(&username, settings)?;
} else {
// try parse the key as a mnemonic...
let key = match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) {
Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?,
// from_slice panics if the wrong length
Ok(mnemonic) if mnemonic.entropy().len() == 32 => {
*Key::from_slice(mnemonic.entropy())
}
// if we parsed ok, it was a mnemonic, but it wasn't the full mnemonic
Ok(_) => {
bail!("key was not the correct length")
}
Err(err) => {
if let Some(err) = err.downcast_ref::<bip39::ErrorKind>() {
match err {
// assume they copied in the base64 key
bip39::ErrorKind::InvalidWord => key,
bip39::ErrorKind::InvalidWord => decode_key(key.clone())?,
bip39::ErrorKind::InvalidChecksum => {
bail!("key mnemonic was not valid")
}
@ -79,17 +76,12 @@ impl Cmd {
}
} else {
// unknown error. assume they copied the base64 key
key
decode_key(key.clone())?
}
}
};
if decode_key(key.clone()).is_err() {
bail!("the specified key was invalid");
}
let mut file = File::create(key_path).await?;
file.write_all(key.as_bytes()).await?;
save_key(&username, settings, &key)?;
}
let session = api_client::login(

View File

@ -43,7 +43,7 @@ pub async fn run(
file.write_all(session.session.as_bytes()).await?;
// Create a new key, and save it to disk
let _key = atuin_client::encryption::new_key(settings)?;
let _key = atuin_client::encryption::new_key(&username, settings)?;
Ok(())
}

View File

@ -46,7 +46,10 @@ impl Cmd {
Self::Status => status::run(&settings, db).await,
Self::Key { base64 } => {
use atuin_client::encryption::{encode_key, load_key};
let key = load_key(&settings).wrap_err("could not load encryption key")?;
// jank... we need the username to get the key, I guess we can store the username in a file at some point :D
// for now this should still load the key from the fs. This is the only place it's needed, alternatively we can
// require the user provide the username to this command. idk
let key = load_key("", &settings).wrap_err("could not load encryption key")?;
if base64 {
let encode = encode_key(&key).wrap_err("could not encode encryption key")?;

View File

@ -1,15 +1,9 @@
use atuin_client::{
api_client, database::Database, encryption::load_encoded_key, settings::Settings,
};
use atuin_client::{api_client, database::Database, settings::Settings};
use colored::Colorize;
use eyre::Result;
pub async fn run(settings: &Settings, db: &impl Database) -> Result<()> {
let client = api_client::Client::new(
&settings.sync_address,
&settings.session_token,
load_encoded_key(settings)?,
)?;
let client = api_client::Client::new(&settings.sync_address, &settings.session_token)?;
let status = client.status().await?;
let last_sync = Settings::last_sync()?;