Skip to main content

thingd/
encryption.rs

1//! Optional authenticated encryption for persistent thingd storage.
2
3#![allow(clippy::redundant_pub_crate)]
4
5use std::path::Path;
6use std::sync::Arc;
7
8use chacha20poly1305::aead::{Aead, KeyInit, Payload};
9use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
10use hkdf::Hkdf;
11use hmac::{Hmac, Mac};
12use sha2::Sha256;
13
14use crate::{ThingdError, ThingdResult};
15
16const MANIFEST: &[u8] = b"THINGD_ENCRYPTED_V1\n";
17const CHECK_PLAINTEXT: &[u8] = b"thingd encryption check v1";
18const FORMAT_VERSION: u8 = 1;
19
20/// Supplies the key used to open an encrypted database.
21pub trait KeyProvider: Send + Sync {
22    /// Resolve a 32-byte encryption key.
23    ///
24    /// # Errors
25    ///
26    /// Returns an error when the key cannot be resolved or is invalid.
27    fn key(&self) -> ThingdResult<[u8; 32]>;
28}
29
30/// A key provider backed by a caller-supplied 32-byte key.
31#[derive(Clone)]
32pub struct StaticKeyProvider {
33    key: [u8; 32],
34}
35
36impl StaticKeyProvider {
37    /// Construct a provider from exactly 32 bytes.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error when `key` is not exactly 32 bytes.
42    pub fn new(key: &[u8]) -> ThingdResult<Self> {
43        let key: [u8; 32] = key.try_into().map_err(|_| {
44            ThingdError::InvalidEncryptionKey("encryption key must be exactly 32 bytes".to_string())
45        })?;
46        Ok(Self { key })
47    }
48}
49
50impl KeyProvider for StaticKeyProvider {
51    fn key(&self) -> ThingdResult<[u8; 32]> {
52        Ok(self.key)
53    }
54}
55
56/// Encryption configuration supplied at database-open time.
57#[derive(Clone)]
58pub struct EncryptionConfig {
59    /// Provider that resolves the database key.
60    pub key_provider: Arc<dyn KeyProvider>,
61}
62
63impl EncryptionConfig {
64    /// Construct a configuration from a raw 32-byte key.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error when `key` is not exactly 32 bytes.
69    pub fn from_key(key: &[u8]) -> ThingdResult<Self> {
70        Ok(Self {
71            key_provider: Arc::new(StaticKeyProvider::new(key)?),
72        })
73    }
74}
75
76#[derive(Clone)]
77#[allow(clippy::redundant_pub_crate)]
78pub(crate) struct StorageCrypto {
79    data_key: [u8; 32],
80    index_key: [u8; 32],
81}
82
83/// Internal codec boundary shared by persistent storage adapters.
84pub(crate) trait StorageCodec: Send + Sync {
85    fn encode_value(&self, domain: &str, value: &[u8]) -> ThingdResult<Vec<u8>>;
86    fn decode_value(&self, domain: &str, value: &[u8]) -> ThingdResult<Vec<u8>>;
87    fn encode_key(&self, domain: &str, key: &[u8]) -> Vec<u8>;
88    fn encode_scoped_key(&self, domain: &str, namespace: &[u8], suffix: &[u8]) -> Vec<u8>;
89    fn encode_scoped_prefix(&self, domain: &str, namespace: &[u8]) -> Vec<u8>;
90    fn encrypted(&self) -> bool;
91}
92
93pub(crate) struct RawStorageCodec;
94
95impl StorageCodec for RawStorageCodec {
96    fn encode_value(&self, _domain: &str, value: &[u8]) -> ThingdResult<Vec<u8>> {
97        Ok(value.to_vec())
98    }
99
100    fn decode_value(&self, _domain: &str, value: &[u8]) -> ThingdResult<Vec<u8>> {
101        Ok(value.to_vec())
102    }
103
104    fn encode_key(&self, _domain: &str, key: &[u8]) -> Vec<u8> {
105        key.to_vec()
106    }
107
108    fn encode_scoped_key(&self, _domain: &str, namespace: &[u8], suffix: &[u8]) -> Vec<u8> {
109        let mut key = Vec::with_capacity(namespace.len() + 1 + suffix.len());
110        key.extend_from_slice(namespace);
111        key.push(0);
112        key.extend_from_slice(suffix);
113        key
114    }
115
116    fn encode_scoped_prefix(&self, _domain: &str, namespace: &[u8]) -> Vec<u8> {
117        let mut prefix = namespace.to_vec();
118        prefix.push(0);
119        prefix
120    }
121
122    fn encrypted(&self) -> bool {
123        false
124    }
125}
126
127pub(crate) struct EncryptedStorageCodec {
128    crypto: StorageCrypto,
129}
130
131impl StorageCodec for EncryptedStorageCodec {
132    fn encode_value(&self, domain: &str, value: &[u8]) -> ThingdResult<Vec<u8>> {
133        self.crypto.encrypt(value, domain)
134    }
135
136    fn decode_value(&self, domain: &str, value: &[u8]) -> ThingdResult<Vec<u8>> {
137        self.crypto.decrypt(value, domain)
138    }
139
140    fn encode_key(&self, domain: &str, key: &[u8]) -> Vec<u8> {
141        self.crypto.hash_key(domain, key)
142    }
143
144    fn encode_scoped_key(&self, domain: &str, namespace: &[u8], suffix: &[u8]) -> Vec<u8> {
145        let mut key = self
146            .crypto
147            .hash_key(&format!("{domain}:namespace"), namespace);
148        key.extend_from_slice(&self.crypto.hash_key(&format!("{domain}:suffix"), suffix));
149        key
150    }
151
152    fn encode_scoped_prefix(&self, domain: &str, namespace: &[u8]) -> Vec<u8> {
153        self.crypto
154            .hash_key(&format!("{domain}:namespace"), namespace)
155    }
156
157    fn encrypted(&self) -> bool {
158        true
159    }
160}
161
162pub(crate) fn make_codec(crypto: Option<StorageCrypto>) -> Box<dyn StorageCodec> {
163    match crypto {
164        Some(crypto) => Box::new(EncryptedStorageCodec { crypto }),
165        None => Box::new(RawStorageCodec),
166    }
167}
168
169impl StorageCrypto {
170    pub(crate) fn open(
171        path: &Path,
172        config: Option<&EncryptionConfig>,
173    ) -> ThingdResult<Option<Self>> {
174        let marker = path.join(".thingd-encryption");
175        let exists = marker.exists();
176        if exists && config.is_none() {
177            return Err(ThingdError::EncryptionRequired(
178                "database requires an encryption key".to_string(),
179            ));
180        }
181
182        let Some(config) = config else {
183            return Ok(None);
184        };
185
186        let has_existing_data = path.is_dir()
187            && std::fs::read_dir(path)
188                .map_err(|e| ThingdError::Storage(e.to_string()))?
189                .next()
190                .transpose()
191                .map_err(|e| ThingdError::Storage(e.to_string()))?
192                .is_some();
193        if !exists && has_existing_data {
194            // A key supplied for an existing unencrypted database must not
195            // silently convert it or make plaintext records unreadable.
196            return Ok(None);
197        }
198
199        let key = config.key_provider.key()?;
200        let crypto = Self::from_key(key);
201
202        if exists {
203            let found = std::fs::read(&marker).map_err(|e| ThingdError::Storage(e.to_string()))?;
204            if found != MANIFEST {
205                return Err(ThingdError::UnsupportedEncryptionVersion(
206                    "unknown encryption manifest".to_string(),
207                ));
208            }
209            let check_path = path.join(".thingd-encryption-check");
210            let check =
211                std::fs::read(&check_path).map_err(|e| ThingdError::Storage(e.to_string()))?;
212            let decrypted = crypto.decrypt(&check, "manifest")?;
213            if decrypted != CHECK_PLAINTEXT {
214                return Err(ThingdError::EncryptionAuthentication(
215                    "encryption key authentication failed".to_string(),
216                ));
217            }
218            return Ok(Some(crypto));
219        }
220
221        std::fs::create_dir_all(path).map_err(|e| ThingdError::Storage(e.to_string()))?;
222        std::fs::write(&marker, MANIFEST).map_err(|e| ThingdError::Storage(e.to_string()))?;
223        std::fs::write(
224            path.join(".thingd-encryption-check"),
225            crypto.encrypt(CHECK_PLAINTEXT, "manifest")?,
226        )
227        .map_err(|e| ThingdError::Storage(e.to_string()))?;
228        Ok(Some(crypto))
229    }
230
231    fn from_key(key: [u8; 32]) -> Self {
232        let hk = Hkdf::<Sha256>::new(None, &key);
233        let mut data_key = [0; 32];
234        let mut index_key = [0; 32];
235        hk.expand(b"thingd/data/v1", &mut data_key)
236            .expect("fixed HKDF output");
237        hk.expand(b"thingd/index/v1", &mut index_key)
238            .expect("fixed HKDF output");
239        Self {
240            data_key,
241            index_key,
242        }
243    }
244
245    pub(crate) fn encrypt(&self, plaintext: &[u8], domain: &str) -> ThingdResult<Vec<u8>> {
246        let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.data_key));
247        let mut nonce = [0_u8; 24];
248        getrandom::fill(&mut nonce).map_err(|e| ThingdError::Storage(e.to_string()))?;
249        let associated = format!("thingd:{FORMAT_VERSION}:{domain}");
250        let ciphertext = cipher
251            .encrypt(
252                XNonce::from_slice(&nonce),
253                Payload {
254                    msg: plaintext,
255                    aad: associated.as_bytes(),
256                },
257            )
258            .map_err(|_| ThingdError::EncryptionAuthentication("encryption failed".to_string()))?;
259        let mut output = Vec::with_capacity(1 + nonce.len() + ciphertext.len());
260        output.push(FORMAT_VERSION);
261        output.extend_from_slice(&nonce);
262        output.extend_from_slice(&ciphertext);
263        Ok(output)
264    }
265
266    pub(crate) fn decrypt(&self, ciphertext: &[u8], domain: &str) -> ThingdResult<Vec<u8>> {
267        if ciphertext.len() < 1 + 24 + 16 || ciphertext[0] != FORMAT_VERSION {
268            return Err(ThingdError::UnsupportedEncryptionVersion(
269                "invalid encrypted value envelope".to_string(),
270            ));
271        }
272        let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.data_key));
273        let associated = format!("thingd:{FORMAT_VERSION}:{domain}");
274        cipher
275            .decrypt(
276                XNonce::from_slice(&ciphertext[1..25]),
277                Payload {
278                    msg: &ciphertext[25..],
279                    aad: associated.as_bytes(),
280                },
281            )
282            .map_err(|_| {
283                ThingdError::EncryptionAuthentication(
284                    "encrypted value authentication failed".to_string(),
285                )
286            })
287    }
288
289    /// Derive a stable opaque key for a logical storage key.
290    #[allow(dead_code)]
291    pub(crate) fn hash_key(&self, domain: &str, key: &[u8]) -> Vec<u8> {
292        let mut mac =
293            <Hmac<Sha256> as Mac>::new_from_slice(&self.index_key).expect("fixed HMAC key");
294        mac.update(domain.as_bytes());
295        mac.update(b"\0");
296        mac.update(key);
297        mac.finalize().into_bytes().to_vec()
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn round_trip_and_nonce_uniqueness() {
307        let crypto = StorageCrypto::from_key([7; 32]);
308        let first = crypto.encrypt(b"secret", "test").unwrap();
309        let second = crypto.encrypt(b"secret", "test").unwrap();
310        assert_ne!(first, second);
311        assert_eq!(crypto.decrypt(&first, "test").unwrap(), b"secret");
312        assert!(crypto.decrypt(&first, "other").is_err());
313    }
314
315    #[test]
316    fn tampering_and_wrong_key_fail_authentication() {
317        let crypto = StorageCrypto::from_key([1; 32]);
318        let mut value = crypto.encrypt(b"secret", "test").unwrap();
319        value[30] ^= 1;
320        assert!(matches!(
321            crypto.decrypt(&value, "test"),
322            Err(ThingdError::EncryptionAuthentication(_))
323        ));
324        let wrong = StorageCrypto::from_key([2; 32]);
325        let value = crypto.encrypt(b"secret", "test").unwrap();
326        assert!(matches!(
327            wrong.decrypt(&value, "test"),
328            Err(ThingdError::EncryptionAuthentication(_))
329        ));
330    }
331}