diff options
Diffstat (limited to 'atuin-client/src/record/key_mgmt/paseto_seal.rs')
-rw-r--r-- | atuin-client/src/record/key_mgmt/paseto_seal.rs | 239 |
1 files changed, 239 insertions, 0 deletions
diff --git a/atuin-client/src/record/key_mgmt/paseto_seal.rs b/atuin-client/src/record/key_mgmt/paseto_seal.rs new file mode 100644 index 00000000..f012062c --- /dev/null +++ b/atuin-client/src/record/key_mgmt/paseto_seal.rs @@ -0,0 +1,239 @@ +use crate::record::encryption::{KeyEncapsulation, PASETO_V4_ENVELOPE}; +use eyre::{ensure, Context, Result}; +use rusty_paserk::{Key, KeyId, Local, Public, SealedKey, Secret}; +use rusty_paseto::core::V4; +use serde::{Deserialize, Serialize}; + +/// Use PASETO V4 Local encryption with PASERK key sealing using the additional data as an implicit assertion. +#[allow(non_camel_case_types)] +pub type PASETO_V4_SEAL = PASETO_V4_ENVELOPE<Seal>; + +/// Key sealing +pub struct Seal; + +impl KeyEncapsulation for Seal { + type DecryptionKey = rusty_paserk::Key<V4, Secret>; + type EncryptionKey = rusty_paserk::Key<V4, Public>; + + fn decrypt_cek( + wrapped_cek: String, + key: &rusty_paserk::Key<V4, Secret>, + ) -> Result<Key<V4, Local>> { + // let wrapping_key = PasetoSymmetricKey::from(Key::from(key)); + + let AtuinFooter { kid, wpk } = serde_json::from_str(&wrapped_cek) + .context("wrapped cek did not contain the correct contents")?; + + // check that the wrapping key matches the required key to decrypt. + // In future, we could support multiple keys and use this key to + // look up the key rather than only allow one key. + // For now though we will only support the one key and key rotation will + // have to be a hard reset + let current_kid = key.public_key().to_id(); + ensure!( + current_kid == kid, + "attempting to decrypt with incorrect key. currently using {current_kid}, expecting {kid}" + ); + + // decrypt the random key + Ok(wpk.unseal(key)?) + } + + fn encrypt_cek(cek: Key<V4, Local>, key: &rusty_paserk::Key<V4, Public>) -> String { + // wrap the random key so we can decrypt it later + let wrapped_cek = AtuinFooter { + wpk: cek.seal(key), + kid: 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)] +/// Well-known footer claims for decrypting. This is not encrypted but is stored in the record. +/// <https://github.com/paseto-standard/paseto-spec/blob/master/docs/02-Implementation-Guide/04-Claims.md#optional-footer-claims> +pub struct AtuinFooter { + /// Wrapped key + wpk: SealedKey<V4>, + /// ID of the key which was used to wrap + pub kid: KeyId<V4, Public>, +} + +#[cfg(test)] +mod tests { + use atuin_common::{ + record::{AdditionalData, DecryptedData, Encryption, HostId, Record, RecordId}, + utils::uuid_v7, + }; + + use super::*; + + #[test] + fn round_trip() { + let key = Key::<V4, Secret>::new_os_random(); + + let ad = AdditionalData { + id: &RecordId(uuid_v7()), + version: "v0", + tag: "kv", + host: &HostId(uuid_v7()), + parent: None, + }; + + let data = DecryptedData(vec![1, 2, 3, 4]); + + let encrypted = PASETO_V4_SEAL::encrypt(data.clone(), ad, &key.public_key()); + let decrypted = PASETO_V4_SEAL::decrypt(encrypted, ad, &key).unwrap(); + assert_eq!(decrypted, data); + } + + #[test] + fn same_entry_different_output() { + let key = Key::<V4, Secret>::new_os_random(); + + let ad = AdditionalData { + id: &RecordId(uuid_v7()), + version: "v0", + tag: "kv", + host: &HostId(uuid_v7()), + parent: None, + }; + + let data = DecryptedData(vec![1, 2, 3, 4]); + + let encrypted = PASETO_V4_SEAL::encrypt(data.clone(), ad, &key.public_key()); + let encrypted2 = PASETO_V4_SEAL::encrypt(data, ad, &key.public_key()); + + 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, Secret>::new_os_random(); + let fake_key = Key::<V4, Secret>::new_os_random(); + + let ad = AdditionalData { + id: &RecordId(uuid_v7()), + version: "v0", + tag: "kv", + host: &HostId(uuid_v7()), + parent: None, + }; + + let data = DecryptedData(vec![1, 2, 3, 4]); + + let encrypted = PASETO_V4_SEAL::encrypt(data, ad, &key.public_key()); + let _ = PASETO_V4_SEAL::decrypt(encrypted, ad, &fake_key).unwrap_err(); + } + + #[test] + fn cannot_decrypt_different_id() { + let key = Key::<V4, Secret>::new_os_random(); + + let ad = AdditionalData { + id: &RecordId(uuid_v7()), + version: "v0", + tag: "kv", + host: &HostId(uuid_v7()), + parent: None, + }; + + let data = DecryptedData(vec![1, 2, 3, 4]); + + let encrypted = PASETO_V4_SEAL::encrypt(data, ad, &key.public_key()); + + let ad = AdditionalData { + id: &RecordId(uuid_v7()), + ..ad + }; + let _ = PASETO_V4_SEAL::decrypt(encrypted, ad, &key).unwrap_err(); + } + + #[test] + fn re_encrypt_round_trip() { + let key1 = Key::<V4, Secret>::new_os_random(); + let key2 = Key::<V4, Secret>::new_os_random(); + + let ad = AdditionalData { + id: &RecordId(uuid_v7()), + version: "v0", + tag: "kv", + host: &HostId(uuid_v7()), + parent: None, + }; + + let data = DecryptedData(vec![1, 2, 3, 4]); + + let encrypted1 = PASETO_V4_SEAL::encrypt(data.clone(), ad, &key1.public_key()); + let encrypted2 = + PASETO_V4_SEAL::re_encrypt(encrypted1.clone(), ad, &key1, &key2.public_key()).unwrap(); + + // we only re-encrypt the content keys + assert_eq!(encrypted1.data, encrypted2.data); + assert_ne!( + encrypted1.content_encryption_key, + encrypted2.content_encryption_key + ); + + let decrypted = PASETO_V4_SEAL::decrypt(encrypted2, ad, &key2).unwrap(); + + assert_eq!(decrypted, data); + } + + #[test] + fn full_record_round_trip() { + let key = Key::from_secret_key([0x55; 32]); + let record = Record::builder() + .id(RecordId(uuid_v7())) + .version("v0".to_owned()) + .tag("kv".to_owned()) + .host(HostId(uuid_v7())) + .timestamp(1687244806000000) + .data(DecryptedData(vec![1, 2, 3, 4])) + .build(); + + let encrypted = record.encrypt::<PASETO_V4_SEAL>(&key.public_key()); + + assert!(!encrypted.data.data.is_empty()); + assert!(!encrypted.data.content_encryption_key.is_empty()); + + let decrypted = encrypted.decrypt::<PASETO_V4_SEAL>(&key).unwrap(); + + assert_eq!(decrypted.data.0, [1, 2, 3, 4]); + } + + #[test] + fn full_record_round_trip_fail() { + let key = Key::from_secret_key([0x55; 32]); + let record = Record::builder() + .id(RecordId(uuid_v7())) + .version("v0".to_owned()) + .tag("kv".to_owned()) + .host(HostId(uuid_v7())) + .timestamp(1687244806000000) + .data(DecryptedData(vec![1, 2, 3, 4])) + .build(); + + let encrypted = record.encrypt::<PASETO_V4_SEAL>(&key.public_key()); + + let mut enc1 = encrypted.clone(); + enc1.host = HostId(uuid_v7()); + let _ = enc1 + .decrypt::<PASETO_V4_SEAL>(&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_SEAL>(&key) + .expect_err("tampering with the id should result in auth failure"); + } +} |