Skip to main content

tessarikv_format/seal/
mod.rs

1//! Sealing: authenticated encryption of the units the engine writes whole.
2//!
3//! A sealed file starts with a [`SealHeader`] in the clear: the cipher, the id of the caller's
4//! root key, and 32 random bytes of salt. Its key is derived from the root key with HKDF-SHA256,
5//! salted by that header and labelled by the file's kind, so every file has its own key. Each
6//! unit the engine writes once and never rewrites (a table block, a log frame, a manifest record)
7//! is sealed with that key under the AEAD the header names, its nonce the unit's position in
8//! the file and its associated data the header, the kind and the position: a unit opens only
9//! with its key, in its file, at its place. A file is written once, front to back, and a unit is
10//! never rewritten in place, so a nonce never repeats under a key and nothing about it needs
11//! storing; a file cut short by a crash is not appended to again.
12//!
13//! Opening returns the plaintext or one error, [`Error::Unauthenticated`], for every cause.
14//! What stays visible: names, sizes and the lengths of units.
15
16mod header;
17#[cfg(test)]
18mod tests;
19
20pub use header::SealHeader;
21
22use aes_gcm::Aes256Gcm;
23use aes_gcm::aead::{Aead, AeadInOut, KeyInit, Payload};
24use chacha20poly1305::ChaCha20Poly1305;
25use hkdf::Hkdf;
26use sha2::Sha256;
27use zeroize::Zeroizing;
28
29use crate::{Error, Result};
30
31/// Bytes of a [`SealHeader`].
32pub const HEADER_BYTES: usize = 42;
33/// Bytes a seal adds to a unit: the authentication tag.
34pub const TAG_BYTES: usize = 16;
35/// Bytes of a root key.
36pub const ROOT_KEY_BYTES: usize = 32;
37
38/// The largest unit either cipher seals: 2^36 - 32 bytes, the AES-GCM bound.
39const MAX_UNIT_BYTES: u64 = (1 << 36) - 32;
40
41const LABEL: &[u8] = b"tessarikv/sealed-file/v1/";
42
43/// The authenticated encryption a file is sealed with.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum Cipher {
47    /// AES-256-GCM: the choice where the processor has AES instructions.
48    Aes256Gcm,
49    /// ChaCha20-Poly1305: constant-time in software, the choice elsewhere.
50    ChaCha20Poly1305,
51}
52
53impl Cipher {
54    /// AES-256-GCM where this processor has AES instructions, ChaCha20-Poly1305 otherwise.
55    #[must_use]
56    pub fn preferred() -> Self {
57        if aes_instructions() {
58            Self::Aes256Gcm
59        } else {
60            Self::ChaCha20Poly1305
61        }
62    }
63
64    const fn code(self) -> u8 {
65        match self {
66            Self::Aes256Gcm => 1,
67            Self::ChaCha20Poly1305 => 2,
68        }
69    }
70
71    const fn from_code(code: u8) -> Option<Self> {
72        match code {
73            1 => Some(Self::Aes256Gcm),
74            2 => Some(Self::ChaCha20Poly1305),
75            _ => None,
76        }
77    }
78}
79
80#[cfg(target_arch = "x86_64")]
81fn aes_instructions() -> bool {
82    std::arch::is_x86_feature_detected!("aes")
83}
84
85#[cfg(target_arch = "aarch64")]
86fn aes_instructions() -> bool {
87    std::arch::is_aarch64_feature_detected!("aes")
88}
89
90#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
91fn aes_instructions() -> bool {
92    false
93}
94
95/// What a sealed file holds, part of its key's label so no two kinds share a key.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97#[non_exhaustive]
98pub enum FileKind {
99    /// A table file.
100    Table,
101    /// A write-ahead log segment.
102    Log,
103    /// A manifest.
104    Manifest,
105    /// A record of the options a database was opened with.
106    Options,
107    /// The file an open checks its key against.
108    KeyCheck,
109}
110
111impl FileKind {
112    const fn label(self) -> &'static [u8] {
113        match self {
114            Self::Table => b"table",
115            Self::Log => b"log",
116            Self::Manifest => b"manifest",
117            Self::Options => b"options",
118            Self::KeyCheck => b"key-check",
119        }
120    }
121}
122
123/// A caller's root key; the bytes are wiped when it is dropped.
124pub struct RootKey(Zeroizing<[u8; ROOT_KEY_BYTES]>);
125
126impl RootKey {
127    /// The root key `bytes`.
128    #[must_use]
129    pub fn new(bytes: [u8; ROOT_KEY_BYTES]) -> Self {
130        Self(Zeroizing::new(bytes))
131    }
132}
133
134impl std::fmt::Debug for RootKey {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.write_str("RootKey(..)")
137    }
138}
139
140/// The key of one sealed file, ready to seal and open its units.
141pub struct FileSealer {
142    aead: SealingAead,
143    /// The header and kind, the start of every unit's associated data.
144    context: Vec<u8>,
145}
146
147enum SealingAead {
148    Aes(Box<Aes256Gcm>),
149    ChaCha(Box<ChaCha20Poly1305>),
150}
151
152impl FileSealer {
153    /// The key of the file `header` starts, of `kind`, derived from `root`.
154    #[must_use]
155    pub fn new(root: &RootKey, header: &SealHeader, kind: FileKind) -> Self {
156        let encoded = header.encode();
157        let mut label = Vec::with_capacity(LABEL.len().saturating_add(kind.label().len()));
158        label.extend_from_slice(LABEL);
159        label.extend_from_slice(kind.label());
160        let mut key = Zeroizing::new([0_u8; 32]);
161        // A 32-byte output is far below HKDF-SHA256's limit of 255 blocks, so expanding cannot fail.
162        let _ = Hkdf::<Sha256>::new(Some(&encoded), root.0.as_slice())
163            .expand(&label, key.as_mut_slice());
164        let aead = match header.cipher {
165            Cipher::Aes256Gcm => SealingAead::Aes(Box::new(Aes256Gcm::new((&*key).into()))),
166            Cipher::ChaCha20Poly1305 => {
167                SealingAead::ChaCha(Box::new(ChaCha20Poly1305::new((&*key).into())))
168            }
169        };
170        let mut context = Vec::with_capacity(HEADER_BYTES.saturating_add(label.len()));
171        context.extend_from_slice(&encoded);
172        context.extend_from_slice(&label);
173        Self { aead, context }
174    }
175
176    /// `plaintext` sealed as the unit at `position`: its ciphertext then its tag.
177    ///
178    /// The caller seals at most one unit at each position of a file; a second, different unit
179    /// at the same position would reuse the nonce under the file's key.
180    ///
181    /// # Errors
182    ///
183    /// [`Error::TooLarge`] for a plaintext past the cipher's limit of 64 GiB.
184    pub fn seal(&self, position: u64, plaintext: &[u8]) -> Result<Vec<u8>> {
185        let aad = self.aad(position);
186        let nonce = nonce(position);
187        let payload = Payload {
188            msg: plaintext,
189            aad: &aad,
190        };
191        match &self.aead {
192            SealingAead::Aes(aead) => aead.encrypt((&nonce).into(), payload),
193            SealingAead::ChaCha(aead) => aead.encrypt((&nonce).into(), payload),
194        }
195        .map_err(|_| too_large(plaintext.len()))
196    }
197
198    /// `buffer` sealed in place as the unit at `position`, exactly as [`FileSealer::seal`]
199    /// seals it, without a copy: `buffer` then holds the ciphertext, and the returned tag
200    /// follows it in the sealed unit.
201    ///
202    /// # Errors
203    ///
204    /// [`Error::TooLarge`] as for [`FileSealer::seal`].
205    pub fn seal_in_place(&self, position: u64, buffer: &mut [u8]) -> Result<[u8; TAG_BYTES]> {
206        let aad = self.aad(position);
207        let nonce = nonce(position);
208        let len = buffer.len();
209        let tag = match &self.aead {
210            SealingAead::Aes(aead) => {
211                aead.encrypt_inout_detached((&nonce).into(), &aad, buffer.into())
212            }
213            SealingAead::ChaCha(aead) => {
214                aead.encrypt_inout_detached((&nonce).into(), &aad, buffer.into())
215            }
216        }
217        .map_err(|_| too_large(len))?;
218        let mut bytes = [0; TAG_BYTES];
219        bytes.copy_from_slice(&tag);
220        Ok(bytes)
221    }
222
223    /// The plaintext of the unit sealed at `position`.
224    ///
225    /// # Errors
226    ///
227    /// [`Error::Unauthenticated`] when the unit was sealed with another key, in another file or
228    /// place, or was changed: one error for every cause, and no plaintext.
229    pub fn open(&self, position: u64, sealed: &[u8]) -> Result<Vec<u8>> {
230        let aad = self.aad(position);
231        let nonce = nonce(position);
232        let payload = Payload {
233            msg: sealed,
234            aad: &aad,
235        };
236        match &self.aead {
237            SealingAead::Aes(aead) => aead.decrypt((&nonce).into(), payload),
238            SealingAead::ChaCha(aead) => aead.decrypt((&nonce).into(), payload),
239        }
240        .map_err(|_| Error::Unauthenticated {
241            what: "sealed unit",
242        })
243    }
244
245    fn aad(&self, position: u64) -> Vec<u8> {
246        let mut aad = Vec::with_capacity(self.context.len().saturating_add(8));
247        aad.extend_from_slice(&self.context);
248        aad.extend_from_slice(&position.to_le_bytes());
249        aad
250    }
251}
252
253impl std::fmt::Debug for FileSealer {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        f.write_str("FileSealer(..)")
256    }
257}
258
259fn too_large(len: usize) -> Error {
260    Error::TooLarge {
261        what: "sealed unit",
262        size: u64::try_from(len).unwrap_or(u64::MAX),
263        limit: MAX_UNIT_BYTES,
264    }
265}
266
267/// The nonce of the unit at `position`: unique within the file, whose key is its own.
268fn nonce(position: u64) -> [u8; 12] {
269    let mut nonce = [0; 12];
270    if let Some(start) = nonce.get_mut(..8) {
271        start.copy_from_slice(&position.to_le_bytes());
272    }
273    nonce
274}