use atuin_common::record::{
AdditionalData, DecryptedData, EncryptedData, Encryption, HostId, RecordId, RecordIdx,
};
use base64::{engine::general_purpose, Engine};
use eyre::{ensure, Context, Result};
use rusty_paserk::{Key, KeyId, Local, PieWrappedKey};
use rusty_paseto::core::{
ImplicitAssertion, Key as DataKey, Local as LocalPurpose, Paseto, PasetoNonce, Payload, V4,
};
use serde::{Deserialize, Serialize};
#[allow(non_camel_case_types)]
pub struct PASETO_V4;
impl Encryption for PASETO_V4 {
fn re_encrypt(
mut data: EncryptedData,
_ad: AdditionalData,
old_key: &[u8; 32],
new_key: &[u8; 32],
) -> Result<EncryptedData> {
let cek = Self::decrypt_cek(data.content_encryption_key, old_key)?;
data.content_encryption_key = Self::encrypt_cek(cek, new_key);
Ok(data)
}
fn encrypt(data: DecryptedData, ad: AdditionalData, key: &[u8; 32]) -> EncryptedData {
let random_key = Key::<V4, Local>::new_os_random();
let assertions = Assertions::from(ad).encode();
let payload = serde_json::to_string(&AtuinPayload {
data: general_purpose::URL_SAFE_NO_PAD.encode(data.0),
})
.expect("json encoding can't fail");
let nonce = DataKey::<32>::try_new_random().expect("could not source from random");
let nonce = PasetoNonce::<V4, LocalPurpose>::from(&nonce);
let token = Paseto::<V4, LocalPurpose>::builder()
.set_payload(Payload::from(payload.as_str()))
.set_implicit_assertion(ImplicitAssertion::from(assertions.as_str()))
.try_encrypt(&random_key.into(), &nonce)
.expect("error encrypting atuin data");
EncryptedData {
data: token,
content_encryption_key: Self::encrypt_cek(random_key, key),
}
}
fn decrypt(data: EncryptedData, ad: AdditionalData, key: &[u8; 32]) -> Result<DecryptedData> {
let token = data.data;
let cek = Self::decrypt_cek(data.content_encryption_key, key)?;
let assertions = Assertions::from(ad).encode();
let payload = Paseto::<V4, LocalPurpose>::try_decrypt(
&token,
&cek.into(),
None,
ImplicitAssertion::from(&*assertions),
)
.context("could not decrypt entry")?;
let payload: AtuinPayload = serde_json::from_str(&payload)?;
let data = general_purpose::URL_SAFE_NO_PAD.decode(payload.data)?;
Ok(DecryptedData(data))
}
}
impl PASETO_V4 {
fn decrypt_cek(wrapped_cek: String, key: &[u8; 32]) -> Result<Key<V4, Local>> {
let wrapping_key = Key::<V4, Local>::from_bytes(*key);
let AtuinFooter { kid, wpk } = serde_json::from_str(&wrapped_cek)
.context("wrapped cek did not contain the correct contents")?;
let current_kid = wrapping_key.to_id();
ensure!(
current_kid == kid,
"attempting to decrypt with incorrect key. currently using {current_kid}, expecting {kid}"
);
Ok(wpk.unwrap_key(&wrapping_key)?)
}
fn encrypt_cek(cek: Key<V4, Local>, key: &[u8; 32]) -> String {
let wrapping_key = Key::<V4, Local>::from_bytes(*key);
let wrapped_cek = AtuinFooter {
wpk: cek.wrap_pie(&wrapping_key),
kid: wrapping_key.to_id(),
};
serde_json::to_string(&wrapped_cek).expect("could not serialize wrapped cek")
}
}
#[derive(Serialize, Deserialize)]
struct AtuinPayload {
data: String,
}
#[derive(Serialize, Deserialize)]
struct AtuinFooter {
wpk: PieWrappedKey<V4, Local>,
kid: KeyId<V4, Local>,
}
#[derive(Debug, Copy, Clone, Serialize)]
struct Assertions<'a> {
id: &'a RecordId,
idx: &'a RecordIdx,
version: &'a str,
tag: &'a str,
host: &'a HostId,
}
impl<'a> From<AdditionalData<'a>> for Assertions<'a> {
fn from(ad: AdditionalData<'a>) -> Self {
Self {
id: ad.id,
version: ad.version,
tag: ad.tag,
host: ad.host,
idx: ad.idx,
}
}
}
impl Assertions<'_> {
fn encode(&self) -> String {
serde_json::to_string(self).expect("could not serialize implicit assertions")
}
}
#[cfg(test)]
mod tests {
use atuin_common::{
record::{Host, Record},
utils::uuid_v7,
};
use super::*;
#[test]
fn round_trip() {
let key = Key::<V4, Local>::new_os_random();
let ad = AdditionalData {
id: &RecordId(uuid_v7()),
version: "v0",
tag: "kv",
host: &HostId(uuid_v7()),
idx: &0,
};
let data = DecryptedData(vec![1, 2, 3, 4]);
let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
let decrypted = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap();
assert_eq!(decrypted, data);
}
#[test]
fn same_entry_different_output() {
let key = Key::<V4, Local>::new_os_random();
let ad = AdditionalData {
id: &RecordId(uuid_v7()),
version: "v0",
tag: "kv",
host: &HostId(uuid_v7()),
idx: &0,
};
let data = DecryptedData(vec![1, 2, 3, 4]);
let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
let encrypted2 = PASETO_V4::encrypt(data, ad, &key.to_bytes());
assert_ne!(
encrypted.data, encrypted2.data,
"re-encrypting the same contents should have different output due to key randomization"
);
}
#[test]
fn cannot_decrypt_different_key() {
let key = Key::<V4, Local>::new_os_random();
let fake_key = Key::<V4, Local>::new_os_random();
let ad = AdditionalData {
id: &RecordId(uuid_v7()),
version: "v0",
tag: "kv",
host: &HostId(uuid_v7()),
idx: &0,
};
let data = DecryptedData(vec![1, 2, 3, 4]);
let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
let _ = PASETO_V4::decrypt(encrypted, ad, &fake_key.to_bytes()).unwrap_err();
}
#[test]
fn cannot_decrypt_different_id() {
let key = Key::<V4, Local>::new_os_random();
let ad = AdditionalData {
id: &RecordId(uuid_v7()),
version: "v0",
tag: "kv",
host: &HostId(uuid_v7()),
idx: &0,
};
let data = DecryptedData(vec![1, 2, 3, 4]);
let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
let ad = AdditionalData {
id: &RecordId(uuid_v7()),
..ad
};
let _ = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap_err();
}
#[test]
fn re_encrypt_round_trip() {
let key1 = Key::<V4, Local>::new_os_random();
let key2 = Key::<V4, Local>::new_os_random();
let ad = AdditionalData {
id: &RecordId(uuid_v7()),
version: "v0",
tag: "kv",
host: &HostId(uuid_v7()),
idx: &0,
};
let data = DecryptedData(vec![1, 2, 3, 4]);
let encrypted1 = PASETO_V4::encrypt(data.clone(), ad, &key1.to_bytes());
let encrypted2 =
PASETO_V4::re_encrypt(encrypted1.clone(), ad, &key1.to_bytes(), &key2.to_bytes())
.unwrap();
assert_eq!(encrypted1.data, encrypted2.data);
assert_ne!(
encrypted1.content_encryption_key,
encrypted2.content_encryption_key
);
let decrypted = PASETO_V4::decrypt(encrypted2, ad, &key2.to_bytes()).unwrap();
assert_eq!(decrypted, data);
}
#[test]
fn full_record_round_trip() {
let key = [0x55; 32];
let record = Record::builder()
.id(RecordId(uuid_v7()))
.version("v0".to_owned())
.tag("kv".to_owned())
.host(Host::new(HostId(uuid_v7())))
.timestamp(1687244806000000)
.data(DecryptedData(vec![1, 2, 3, 4]))
.idx(0)
.build();
let encrypted = record.encrypt::<PASETO_V4>(&key);
assert!(!encrypted.data.data.is_empty());
assert!(!encrypted.data.content_encryption_key.is_empty());
let decrypted = encrypted.decrypt::<PASETO_V4>(&key).unwrap();
assert_eq!(decrypted.data.0, [1, 2, 3, 4]);
}
#[test]
fn full_record_round_trip_fail() {
let key = [0x55; 32];
let record = Record::builder()
.id(RecordId(uuid_v7()))
.version("v0".to_owned())
.tag("kv".to_owned())
.host(Host::new(HostId(uuid_v7())))
.timestamp(1687244806000000)
.data(DecryptedData(vec![1, 2, 3, 4]))
.idx(0)
.build();
let encrypted = record.encrypt::<PASETO_V4>(&key);
let mut enc1 = encrypted.clone();
enc1.host = Host::new(HostId(uuid_v7()));
let _ = enc1
.decrypt::<PASETO_V4>(&key)
.expect_err("tampering with the host should result in auth failure");
let mut enc2 = encrypted;
enc2.id = RecordId(uuid_v7());
let _ = enc2
.decrypt::<PASETO_V4>(&key)
.expect_err("tampering with the id should result in auth failure");
}
}