atuin_client/record/
encryption.rs

1use atuin_common::record::{
2    AdditionalData, DecryptedData, EncryptedData, Encryption, HostId, RecordId, RecordIdx,
3};
4use base64::{Engine, engine::general_purpose};
5use eyre::{Context, Result, ensure};
6use rusty_paserk::{Key, KeyId, Local, PieWrappedKey};
7use rusty_paseto::core::{
8    ImplicitAssertion, Key as DataKey, Local as LocalPurpose, Paseto, PasetoNonce, Payload, V4,
9};
10use serde::{Deserialize, Serialize};
11
12/// Use PASETO V4 Local encryption using the additional data as an implicit assertion.
13#[allow(non_camel_case_types)]
14pub struct PASETO_V4;
15
16/*
17Why do we use a random content-encryption key?
18Originally I was planning on using a derived key for encryption based on additional data.
19This would be a lot more secure than using the master key directly.
20
21However, there's an established norm of using a random key. This scheme might be otherwise known as
22- client-side encryption
23- envelope encryption
24- key wrapping
25
26A HSM (Hardware Security Module) provider, eg: AWS, Azure, GCP, or even a physical device like a YubiKey
27will have some keys that they keep to themselves. These keys never leave their physical hardware.
28If they never leave the hardware, then encrypting large amounts of data means giving them the data and waiting.
29This is not a practical solution. Instead, generate a unique key for your data, encrypt that using your HSM
30and then store that with your data.
31
32See
33 - <https://docs.aws.amazon.com/wellarchitected/latest/financial-services-industry-lens/use-envelope-encryption-with-customer-master-keys.html>
34 - <https://cloud.google.com/kms/docs/envelope-encryption>
35 - <https://learn.microsoft.com/en-us/azure/storage/blobs/client-side-encryption?tabs=dotnet#encryption-and-decryption-via-the-envelope-technique>
36 - <https://www.yubico.com/gb/product/yubihsm-2-fips/>
37 - <https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#encrypting-stored-keys>
38
39Why would we care? In the past we have received some requests for company solutions. If in future we can configure a
40KMS service with little effort, then that would solve a lot of issues for their security team.
41
42Even for personal use, if a user is not comfortable with sharing keys between hosts,
43GCP HSM costs $1/month and $0.03 per 10,000 key operations. Assuming an active user runs
441000 atuin records a day, that would only cost them $1 and 10 cent a month.
45
46Additionally, key rotations are much simpler using this scheme. Rotating a key is as simple as re-encrypting the CEK, and not the message contents.
47This makes it very fast to rotate a key in bulk.
48
49For future reference, with asymmetric encryption, you can encrypt the CEK without the HSM's involvement, but decrypting
50will need the HSM. This allows the encryption path to still be extremely fast (no network calls) but downloads/decryption
51that happens in the background can make the network calls to the HSM
52*/
53
54impl Encryption for PASETO_V4 {
55    fn re_encrypt(
56        mut data: EncryptedData,
57        _ad: AdditionalData,
58        old_key: &[u8; 32],
59        new_key: &[u8; 32],
60    ) -> Result<EncryptedData> {
61        let cek = Self::decrypt_cek(data.content_encryption_key, old_key)?;
62        data.content_encryption_key = Self::encrypt_cek(cek, new_key);
63        Ok(data)
64    }
65
66    fn encrypt(data: DecryptedData, ad: AdditionalData, key: &[u8; 32]) -> EncryptedData {
67        // generate a random key for this entry
68        // aka content-encryption-key (CEK)
69        let random_key = Key::<V4, Local>::new_os_random();
70
71        // encode the implicit assertions
72        let assertions = Assertions::from(ad).encode();
73
74        // build the payload and encrypt the token
75        let payload = serde_json::to_string(&AtuinPayload {
76            data: general_purpose::URL_SAFE_NO_PAD.encode(data.0),
77        })
78        .expect("json encoding can't fail");
79        let nonce = DataKey::<32>::try_new_random().expect("could not source from random");
80        let nonce = PasetoNonce::<V4, LocalPurpose>::from(&nonce);
81
82        let token = Paseto::<V4, LocalPurpose>::builder()
83            .set_payload(Payload::from(payload.as_str()))
84            .set_implicit_assertion(ImplicitAssertion::from(assertions.as_str()))
85            .try_encrypt(&random_key.into(), &nonce)
86            .expect("error encrypting atuin data");
87
88        EncryptedData {
89            data: token,
90            content_encryption_key: Self::encrypt_cek(random_key, key),
91        }
92    }
93
94    fn decrypt(data: EncryptedData, ad: AdditionalData, key: &[u8; 32]) -> Result<DecryptedData> {
95        let token = data.data;
96        let cek = Self::decrypt_cek(data.content_encryption_key, key)?;
97
98        // encode the implicit assertions
99        let assertions = Assertions::from(ad).encode();
100
101        // decrypt the payload with the footer and implicit assertions
102        let payload = Paseto::<V4, LocalPurpose>::try_decrypt(
103            &token,
104            &cek.into(),
105            None,
106            ImplicitAssertion::from(&*assertions),
107        )
108        .context("could not decrypt entry")?;
109
110        let payload: AtuinPayload = serde_json::from_str(&payload)?;
111        let data = general_purpose::URL_SAFE_NO_PAD.decode(payload.data)?;
112        Ok(DecryptedData(data))
113    }
114}
115
116impl PASETO_V4 {
117    fn decrypt_cek(wrapped_cek: String, key: &[u8; 32]) -> Result<Key<V4, Local>> {
118        let wrapping_key = Key::<V4, Local>::from_bytes(*key);
119
120        // let wrapping_key = PasetoSymmetricKey::from(Key::from(key));
121
122        let AtuinFooter { kid, wpk } = serde_json::from_str(&wrapped_cek)
123            .context("wrapped cek did not contain the correct contents")?;
124
125        // check that the wrapping key matches the required key to decrypt.
126        // In future, we could support multiple keys and use this key to
127        // look up the key rather than only allow one key.
128        // For now though we will only support the one key and key rotation will
129        // have to be a hard reset
130        let current_kid = wrapping_key.to_id();
131
132        ensure!(
133            current_kid == kid,
134            "attempting to decrypt with incorrect key. currently using {current_kid}, expecting {kid}"
135        );
136
137        // decrypt the random key
138        Ok(wpk.unwrap_key(&wrapping_key)?)
139    }
140
141    fn encrypt_cek(cek: Key<V4, Local>, key: &[u8; 32]) -> String {
142        // aka key-encryption-key (KEK)
143        let wrapping_key = Key::<V4, Local>::from_bytes(*key);
144
145        // wrap the random key so we can decrypt it later
146        let wrapped_cek = AtuinFooter {
147            wpk: cek.wrap_pie(&wrapping_key),
148            kid: wrapping_key.to_id(),
149        };
150        serde_json::to_string(&wrapped_cek).expect("could not serialize wrapped cek")
151    }
152}
153
154#[derive(Serialize, Deserialize)]
155struct AtuinPayload {
156    data: String,
157}
158
159#[derive(Serialize, Deserialize)]
160/// Well-known footer claims for decrypting. This is not encrypted but is stored in the record.
161/// <https://github.com/paseto-standard/paseto-spec/blob/master/docs/02-Implementation-Guide/04-Claims.md#optional-footer-claims>
162struct AtuinFooter {
163    /// Wrapped key
164    wpk: PieWrappedKey<V4, Local>,
165    /// ID of the key which was used to wrap
166    kid: KeyId<V4, Local>,
167}
168
169/// Used in the implicit assertions. This is not encrypted and not stored in the data blob.
170// This cannot be changed, otherwise it breaks the authenticated encryption.
171#[derive(Debug, Copy, Clone, Serialize)]
172struct Assertions<'a> {
173    id: &'a RecordId,
174    idx: &'a RecordIdx,
175    version: &'a str,
176    tag: &'a str,
177    host: &'a HostId,
178}
179
180impl<'a> From<AdditionalData<'a>> for Assertions<'a> {
181    fn from(ad: AdditionalData<'a>) -> Self {
182        Self {
183            id: ad.id,
184            version: ad.version,
185            tag: ad.tag,
186            host: ad.host,
187            idx: ad.idx,
188        }
189    }
190}
191
192impl Assertions<'_> {
193    fn encode(&self) -> String {
194        serde_json::to_string(self).expect("could not serialize implicit assertions")
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use atuin_common::{
201        record::{Host, Record},
202        utils::uuid_v7,
203    };
204
205    use super::*;
206
207    #[test]
208    fn round_trip() {
209        let key = Key::<V4, Local>::new_os_random();
210
211        let ad = AdditionalData {
212            id: &RecordId(uuid_v7()),
213            version: "v0",
214            tag: "kv",
215            host: &HostId(uuid_v7()),
216            idx: &0,
217        };
218
219        let data = DecryptedData(vec![1, 2, 3, 4]);
220
221        let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
222        let decrypted = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap();
223        assert_eq!(decrypted, data);
224    }
225
226    #[test]
227    fn same_entry_different_output() {
228        let key = Key::<V4, Local>::new_os_random();
229
230        let ad = AdditionalData {
231            id: &RecordId(uuid_v7()),
232            version: "v0",
233            tag: "kv",
234            host: &HostId(uuid_v7()),
235            idx: &0,
236        };
237
238        let data = DecryptedData(vec![1, 2, 3, 4]);
239
240        let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
241        let encrypted2 = PASETO_V4::encrypt(data, ad, &key.to_bytes());
242
243        assert_ne!(
244            encrypted.data, encrypted2.data,
245            "re-encrypting the same contents should have different output due to key randomization"
246        );
247    }
248
249    #[test]
250    fn cannot_decrypt_different_key() {
251        let key = Key::<V4, Local>::new_os_random();
252        let fake_key = Key::<V4, Local>::new_os_random();
253
254        let ad = AdditionalData {
255            id: &RecordId(uuid_v7()),
256            version: "v0",
257            tag: "kv",
258            host: &HostId(uuid_v7()),
259            idx: &0,
260        };
261
262        let data = DecryptedData(vec![1, 2, 3, 4]);
263
264        let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
265        let _ = PASETO_V4::decrypt(encrypted, ad, &fake_key.to_bytes()).unwrap_err();
266    }
267
268    #[test]
269    fn cannot_decrypt_different_id() {
270        let key = Key::<V4, Local>::new_os_random();
271
272        let ad = AdditionalData {
273            id: &RecordId(uuid_v7()),
274            version: "v0",
275            tag: "kv",
276            host: &HostId(uuid_v7()),
277            idx: &0,
278        };
279
280        let data = DecryptedData(vec![1, 2, 3, 4]);
281
282        let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
283
284        let ad = AdditionalData {
285            id: &RecordId(uuid_v7()),
286            ..ad
287        };
288        let _ = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap_err();
289    }
290
291    #[test]
292    fn re_encrypt_round_trip() {
293        let key1 = Key::<V4, Local>::new_os_random();
294        let key2 = Key::<V4, Local>::new_os_random();
295
296        let ad = AdditionalData {
297            id: &RecordId(uuid_v7()),
298            version: "v0",
299            tag: "kv",
300            host: &HostId(uuid_v7()),
301            idx: &0,
302        };
303
304        let data = DecryptedData(vec![1, 2, 3, 4]);
305
306        let encrypted1 = PASETO_V4::encrypt(data.clone(), ad, &key1.to_bytes());
307        let encrypted2 =
308            PASETO_V4::re_encrypt(encrypted1.clone(), ad, &key1.to_bytes(), &key2.to_bytes())
309                .unwrap();
310
311        // we only re-encrypt the content keys
312        assert_eq!(encrypted1.data, encrypted2.data);
313        assert_ne!(
314            encrypted1.content_encryption_key,
315            encrypted2.content_encryption_key
316        );
317
318        let decrypted = PASETO_V4::decrypt(encrypted2, ad, &key2.to_bytes()).unwrap();
319
320        assert_eq!(decrypted, data);
321    }
322
323    #[test]
324    fn full_record_round_trip() {
325        let key = [0x55; 32];
326        let record = Record::builder()
327            .id(RecordId(uuid_v7()))
328            .version("v0".to_owned())
329            .tag("kv".to_owned())
330            .host(Host::new(HostId(uuid_v7())))
331            .timestamp(1687244806000000)
332            .data(DecryptedData(vec![1, 2, 3, 4]))
333            .idx(0)
334            .build();
335
336        let encrypted = record.encrypt::<PASETO_V4>(&key);
337
338        assert!(!encrypted.data.data.is_empty());
339        assert!(!encrypted.data.content_encryption_key.is_empty());
340
341        let decrypted = encrypted.decrypt::<PASETO_V4>(&key).unwrap();
342
343        assert_eq!(decrypted.data.0, [1, 2, 3, 4]);
344    }
345
346    #[test]
347    fn full_record_round_trip_fail() {
348        let key = [0x55; 32];
349        let record = Record::builder()
350            .id(RecordId(uuid_v7()))
351            .version("v0".to_owned())
352            .tag("kv".to_owned())
353            .host(Host::new(HostId(uuid_v7())))
354            .timestamp(1687244806000000)
355            .data(DecryptedData(vec![1, 2, 3, 4]))
356            .idx(0)
357            .build();
358
359        let encrypted = record.encrypt::<PASETO_V4>(&key);
360
361        let mut enc1 = encrypted.clone();
362        enc1.host = Host::new(HostId(uuid_v7()));
363        let _ = enc1
364            .decrypt::<PASETO_V4>(&key)
365            .expect_err("tampering with the host should result in auth failure");
366
367        let mut enc2 = encrypted;
368        enc2.id = RecordId(uuid_v7());
369        let _ = enc2
370            .decrypt::<PASETO_V4>(&key)
371            .expect_err("tampering with the id should result in auth failure");
372    }
373}