summaryrefslogtreecommitdiffstats
path: root/atuin-client/src/record/key_mgmt/paseto_seal.rs
blob: f012062c298abd6ec8cbc0fe3f23c797d508c665 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
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");
    }
}