Skip to main content

laterite_core/crypt/
mod.rs

1//! Encryption and signing under the application key.
2//!
3//! One master key (`app.key`, 32 bytes, base64) with an id, plus previous
4//! keys kept for reading data written under them. Every operation names a
5//! purpose; a subkey is derived per purpose, so a value encrypted for one
6//! purpose never decrypts under another and a token signed for one flow never
7//! verifies in another.
8//!
9//! ```ignore
10//! let sealed = crypt.encrypt("api-secret", b"table.column.42", b"hunter2")?;
11//! let plain = crypt.decrypt("api-secret", b"table.column.42", &sealed)?;
12//! let token = crypt.token("password-reset", b"user:7", Duration::from_secs(3600));
13//! let payload = crypt.check_token("password-reset", &token)?;
14//! ```
15//!
16//! AEAD is XChaCha20-Poly1305 with a random 24-byte nonce; signing is keyed
17//! BLAKE3. A sealed value is `[version][key id][nonce][ciphertext]` and a
18//! signature `[version][key id][tag]`, both base64url without padding.
19
20use std::fmt;
21use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23use crate::query::{bind_values, build, AnyRowExt};
24use crate::{CoreResult, Db, Module, ModuleId};
25use sea_query::{Alias, Expr, Iden, OnConflict, Query};
26
27pub mod migrations;
28
29/// The text a sealed field shows in place of its value; submitting it back
30/// means "unchanged".
31pub const MASK: &str = "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}";
32
33/// The purpose a sealed form field uses; its AAD is the field's name.
34pub const FIELD_PURPOSE: &str = "field";
35
36/// The registry of sealed columns: what `lat crypt rotate` re-seals.
37#[derive(Iden)]
38pub(crate) enum CryptColumns {
39    #[iden = "crypt_columns"]
40    Table,
41    TableName,
42    ColumnName,
43    Purpose,
44    RegisteredAt,
45}
46
47/// The module that owns the registry table, listed among an application's
48/// built-ins.
49pub struct CryptModule;
50
51impl Module for CryptModule {
52    fn id(&self) -> ModuleId {
53        ModuleId::new(migrations::MODULE_ID)
54    }
55    fn migrations(&self) -> crate::MigrationSet {
56        migrations::migrations()
57    }
58}
59
60/// Records that `table.column` holds values sealed for `purpose` (with the
61/// column's name as AAD), so a rotation finds them. Idempotent.
62pub async fn register_column(db: &Db, table: &str, column: &str, purpose: &str) -> CoreResult<()> {
63    let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
64    let (sql, values) = build(
65        db.backend,
66        Query::insert()
67            .into_table(CryptColumns::Table)
68            .columns([
69                CryptColumns::TableName,
70                CryptColumns::ColumnName,
71                CryptColumns::Purpose,
72                CryptColumns::RegisteredAt,
73            ])
74            .values_panic([table.into(), column.into(), purpose.into(), now.into()])
75            .on_conflict(
76                OnConflict::columns([CryptColumns::TableName, CryptColumns::ColumnName])
77                    .update_columns([CryptColumns::Purpose, CryptColumns::RegisteredAt])
78                    .to_owned(),
79            )
80            .to_owned(),
81    );
82    bind_values(sqlx::query(&sql), values)
83        .execute(&db.pool)
84        .await?;
85    Ok(())
86}
87
88/// Every registered sealed column: `(table, column, purpose)`.
89pub async fn registered_columns(db: &Db) -> CoreResult<Vec<(String, String, String)>> {
90    let (sql, values) = build(
91        db.backend,
92        Query::select()
93            .columns([
94                CryptColumns::TableName,
95                CryptColumns::ColumnName,
96                CryptColumns::Purpose,
97            ])
98            .from(CryptColumns::Table)
99            .to_owned(),
100    );
101    let rows = bind_values(sqlx::query(&sql), values)
102        .fetch_all(&db.pool)
103        .await?;
104    rows.iter()
105        .map(|r| {
106            Ok((
107                r.get_text("table_name")?,
108                r.get_text("column_name")?,
109                r.get_text("purpose")?,
110            ))
111        })
112        .collect()
113}
114
115/// What a rotation did: rows re-sealed under the current key, rows already
116/// under it, and rows that would not open (left as they were).
117#[derive(Debug, Default, Clone, PartialEq, Eq)]
118pub struct Rotated {
119    pub resealed: u64,
120    pub current: u64,
121    pub unreadable: u64,
122}
123
124/// Re-seals every registered column's values under the current key: a value
125/// sealed under a previous key is opened and sealed again; one already under
126/// the current key is left; one that does not open is counted and left.
127pub async fn rotate(db: &Db, crypt: &Crypt) -> CoreResult<Rotated> {
128    let mut out = Rotated::default();
129    for (table, column, purpose) in registered_columns(db).await? {
130        let (t, c) = (Alias::new(&table), Alias::new(&column));
131        let id = Alias::new("id");
132        let (sql, values) = build(
133            db.backend,
134            Query::select()
135                .column(id.clone())
136                .column(c.clone())
137                .from(t.clone())
138                .and_where(Expr::col(c.clone()).is_not_null())
139                .to_owned(),
140        );
141        let rows = bind_values(sqlx::query(&sql), values)
142            .fetch_all(&db.pool)
143            .await?;
144        for row in rows {
145            let row_id: i64 = row.get_int("id")?;
146            let sealed = row.get_text(&column)?;
147            if sealed.is_empty() {
148                continue;
149            }
150            if crypt.key_id_of(&sealed) == Some(crypt.current_id()) {
151                out.current += 1;
152                continue;
153            }
154            let Ok(plain) = crypt.decrypt(&purpose, column.as_bytes(), &sealed) else {
155                out.unreadable += 1;
156                continue;
157            };
158            let resealed = crypt
159                .encrypt(&purpose, column.as_bytes(), &plain)
160                .map_err(|e| crate::CoreError::Internal(e.to_string()))?;
161            let (sql, values) = build(
162                db.backend,
163                Query::update()
164                    .table(t.clone())
165                    .value(c.clone(), resealed)
166                    .and_where(Expr::col(id.clone()).eq(row_id))
167                    .to_owned(),
168            );
169            bind_values(sqlx::query(&sql), values)
170                .execute(&db.pool)
171                .await?;
172            out.resealed += 1;
173        }
174    }
175    Ok(out)
176}
177
178use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
179use base64::Engine;
180use chacha20poly1305::aead::{Aead, Generate, KeyInit, Payload};
181use chacha20poly1305::{XChaCha20Poly1305, XNonce};
182use secrecy::{ExposeSecret, SecretBox};
183
184/// The format version written into every sealed value and signature.
185const VERSION: u8 = 1;
186const NONCE_LEN: usize = 24;
187const TAG_LEN: usize = 32;
188
189/// What went wrong sealing, opening or checking.
190#[derive(Debug, thiserror::Error, PartialEq, Eq)]
191pub enum CryptError {
192    /// The value is not one this crypt wrote: malformed, another version, a
193    /// key id it does not hold, a wrong purpose or AAD, or tampered with.
194    #[error("the value cannot be opened")]
195    Invalid,
196    /// A token whose expiry has passed.
197    #[error("the token has expired")]
198    Expired,
199    /// A key string that is not an id and 32 base64 bytes.
200    #[error("not a key: {0}")]
201    BadKey(String),
202}
203
204/// A 32-byte master secret with its id.
205pub struct MasterKey {
206    id: u8,
207    secret: SecretBox<[u8; 32]>,
208}
209
210impl MasterKey {
211    /// A key from `id:<base64>` (standard or url-safe base64, 32 bytes). A bare
212    /// base64 string takes id 1.
213    pub fn parse(text: &str) -> Result<Self, CryptError> {
214        let text = text.trim();
215        let (id, body) = match text.split_once(':') {
216            Some((id, body)) => (
217                id.trim()
218                    .parse::<u8>()
219                    .map_err(|_| CryptError::BadKey(format!("id `{id}` is not 0 to 255")))?,
220                body.trim(),
221            ),
222            None => (1, text),
223        };
224        let bytes = decode_any(body).ok_or_else(|| CryptError::BadKey("not base64".into()))?;
225        let secret: [u8; 32] = bytes
226            .try_into()
227            .map_err(|_| CryptError::BadKey("not 32 bytes".into()))?;
228        Ok(Self {
229            id,
230            secret: SecretBox::new(Box::new(secret)),
231        })
232    }
233
234    /// A fresh random key with `id`.
235    pub fn generate(id: u8) -> Self {
236        let secret: [u8; 32] = rand::random();
237        Self {
238            id,
239            secret: SecretBox::new(Box::new(secret)),
240        }
241    }
242
243    /// A key derived from arbitrary secret bytes (a legacy secret string), so a
244    /// deployment that configured one keeps a stable key.
245    pub fn derived(id: u8, material: &[u8]) -> Self {
246        let secret = blake3::derive_key("laterite v1 master from material", material);
247        Self {
248            id,
249            secret: SecretBox::new(Box::new(secret)),
250        }
251    }
252
253    /// The key as `id:<base64>`, for writing into a configuration file.
254    pub fn export(&self) -> String {
255        format!("{}:{}", self.id, B64.encode(self.secret.expose_secret()))
256    }
257
258    pub fn id(&self) -> u8 {
259        self.id
260    }
261
262    fn subkey(&self, context: &str) -> [u8; 32] {
263        blake3::derive_key(context, self.secret.expose_secret())
264    }
265}
266
267impl fmt::Debug for MasterKey {
268    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269        f.debug_struct("MasterKey")
270            .field("id", &self.id)
271            .finish_non_exhaustive()
272    }
273}
274
275fn decode_any(text: &str) -> Option<Vec<u8>> {
276    B64.decode(text)
277        .ok()
278        .or_else(|| base64::engine::general_purpose::STANDARD.decode(text).ok())
279        .or_else(|| {
280            base64::engine::general_purpose::STANDARD_NO_PAD
281                .decode(text)
282                .ok()
283        })
284        .or_else(|| base64::engine::general_purpose::URL_SAFE.decode(text).ok())
285}
286
287/// The application's keys: the current one writes, every one reads.
288#[derive(Debug)]
289pub struct Crypt {
290    current: MasterKey,
291    previous: Vec<MasterKey>,
292}
293
294impl Crypt {
295    /// A crypt over `current` and the keys still readable.
296    pub fn new(current: MasterKey, previous: Vec<MasterKey>) -> Self {
297        Self { current, previous }
298    }
299
300    /// A crypt for one process: a random key nobody else holds, for tests and
301    /// for a development run with nothing configured.
302    pub fn ephemeral() -> Self {
303        Self::new(MasterKey::generate(1), Vec::new())
304    }
305
306    /// The configured keys: `key` as `id:<base64>` and `previous` likewise.
307    pub fn from_config(key: &str, previous: &[String]) -> Result<Self, CryptError> {
308        let current = MasterKey::parse(key)?;
309        let previous = previous
310            .iter()
311            .map(|k| MasterKey::parse(k))
312            .collect::<Result<Vec<_>, _>>()?;
313        Ok(Self::new(current, previous))
314    }
315
316    /// The id the next write uses.
317    pub fn current_id(&self) -> u8 {
318        self.current.id
319    }
320
321    /// The id of the key a sealed value or signature was written under, when
322    /// it is well-formed.
323    pub fn key_id_of(&self, sealed: &str) -> Option<u8> {
324        let bytes = B64.decode(sealed.trim()).ok()?;
325        (bytes.len() >= 2 && bytes[0] == VERSION).then_some(bytes[1])
326    }
327
328    fn key(&self, id: u8) -> Option<&MasterKey> {
329        if self.current.id == id {
330            return Some(&self.current);
331        }
332        self.previous.iter().find(|k| k.id == id)
333    }
334
335    /// Seals `plain` for `purpose`, bound to `aad` (a table, column and row,
336    /// or whatever must match on open). Base64url text.
337    pub fn encrypt(&self, purpose: &str, aad: &[u8], plain: &[u8]) -> Result<String, CryptError> {
338        let sub = self.current.subkey(&format!("laterite v1 enc:{purpose}"));
339        let cipher = XChaCha20Poly1305::new_from_slice(&sub).map_err(|_| CryptError::Invalid)?;
340        let nonce = XNonce::generate();
341        let sealed = cipher
342            .encrypt(&nonce, Payload { msg: plain, aad })
343            .map_err(|_| CryptError::Invalid)?;
344        let mut out = Vec::with_capacity(2 + NONCE_LEN + sealed.len());
345        out.push(VERSION);
346        out.push(self.current.id);
347        out.extend_from_slice(&nonce);
348        out.extend_from_slice(&sealed);
349        Ok(B64.encode(out))
350    }
351
352    /// Opens a value [`encrypt`](Self::encrypt) sealed under any key the crypt
353    /// holds, for the same purpose and AAD.
354    pub fn decrypt(&self, purpose: &str, aad: &[u8], sealed: &str) -> Result<Vec<u8>, CryptError> {
355        let bytes = B64.decode(sealed.trim()).map_err(|_| CryptError::Invalid)?;
356        if bytes.len() < 2 + NONCE_LEN + 16 || bytes[0] != VERSION {
357            return Err(CryptError::Invalid);
358        }
359        let key = self.key(bytes[1]).ok_or(CryptError::Invalid)?;
360        let sub = key.subkey(&format!("laterite v1 enc:{purpose}"));
361        let cipher = XChaCha20Poly1305::new_from_slice(&sub).map_err(|_| CryptError::Invalid)?;
362        let nonce = XNonce::try_from(&bytes[2..2 + NONCE_LEN]).map_err(|_| CryptError::Invalid)?;
363        cipher
364            .decrypt(
365                &nonce,
366                Payload {
367                    msg: &bytes[2 + NONCE_LEN..],
368                    aad,
369                },
370            )
371            .map_err(|_| CryptError::Invalid)
372    }
373
374    /// Signs `message` for `purpose`. Base64url text of the version, key id
375    /// and tag.
376    pub fn sign(&self, purpose: &str, message: &[u8]) -> String {
377        let sub = self.current.subkey(&format!("laterite v1 mac:{purpose}"));
378        let tag = blake3::keyed_hash(&sub, message);
379        let mut out = Vec::with_capacity(2 + TAG_LEN);
380        out.push(VERSION);
381        out.push(self.current.id);
382        out.extend_from_slice(tag.as_bytes());
383        B64.encode(out)
384    }
385
386    /// Whether `signature` is [`sign`](Self::sign) of `message` under any key
387    /// the crypt holds, compared in constant time.
388    pub fn verify(&self, purpose: &str, message: &[u8], signature: &str) -> bool {
389        let Ok(bytes) = B64.decode(signature.trim()) else {
390            return false;
391        };
392        if bytes.len() != 2 + TAG_LEN || bytes[0] != VERSION {
393            return false;
394        }
395        let Some(key) = self.key(bytes[1]) else {
396            return false;
397        };
398        let sub = key.subkey(&format!("laterite v1 mac:{purpose}"));
399        let expected = blake3::keyed_hash(&sub, message);
400        let mut given = [0u8; TAG_LEN];
401        given.copy_from_slice(&bytes[2..]);
402        expected == blake3::Hash::from(given)
403    }
404
405    /// A signed token carrying `payload` and an expiry `ttl` from now.
406    pub fn token(&self, purpose: &str, payload: &[u8], ttl: Duration) -> String {
407        let expires = now_secs().saturating_add(ttl.as_secs());
408        let body = token_body(expires, payload);
409        let signature = self.sign(purpose, &body);
410        format!("{}.{}", B64.encode(body), signature)
411    }
412
413    /// The payload of a live token minted by [`token`](Self::token) for the
414    /// same purpose.
415    pub fn check_token(&self, purpose: &str, token: &str) -> Result<Vec<u8>, CryptError> {
416        let (body, signature) = token.trim().split_once('.').ok_or(CryptError::Invalid)?;
417        let body = B64.decode(body).map_err(|_| CryptError::Invalid)?;
418        if !self.verify(purpose, &body, signature) || body.len() < 8 {
419            return Err(CryptError::Invalid);
420        }
421        let expires = u64::from_be_bytes(body[..8].try_into().map_err(|_| CryptError::Invalid)?);
422        if expires < now_secs() {
423            return Err(CryptError::Expired);
424        }
425        Ok(body[8..].to_vec())
426    }
427
428    /// A keyed digest of `plain` for looking a sealed value up by equality
429    /// (a blind index), under its own subkey. Base64url text.
430    pub fn blind_index(&self, purpose: &str, plain: &[u8]) -> String {
431        let sub = self.current.subkey(&format!("laterite v1 index:{purpose}"));
432        B64.encode(blake3::keyed_hash(&sub, plain).as_bytes())
433    }
434}
435
436fn token_body(expires: u64, payload: &[u8]) -> Vec<u8> {
437    let mut body = Vec::with_capacity(8 + payload.len());
438    body.extend_from_slice(&expires.to_be_bytes());
439    body.extend_from_slice(payload);
440    body
441}
442
443fn now_secs() -> u64 {
444    SystemTime::now()
445        .duration_since(UNIX_EPOCH)
446        .map(|d| d.as_secs())
447        .unwrap_or(0)
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn a_value_round_trips_and_opens_under_its_purpose_and_aad_alone() {
456        let crypt = Crypt::ephemeral();
457        let sealed = crypt.encrypt("secret", b"t.c.1", b"hunter2").unwrap();
458        assert_eq!(
459            crypt.decrypt("secret", b"t.c.1", &sealed).unwrap(),
460            b"hunter2"
461        );
462        assert_eq!(
463            crypt.decrypt("other", b"t.c.1", &sealed),
464            Err(CryptError::Invalid)
465        );
466        assert_eq!(
467            crypt.decrypt("secret", b"t.c.2", &sealed),
468            Err(CryptError::Invalid)
469        );
470        let mut bent = sealed.clone();
471        bent.replace_range(10..11, if &sealed[10..11] == "A" { "B" } else { "A" });
472        assert_eq!(
473            crypt.decrypt("secret", b"t.c.1", &bent),
474            Err(CryptError::Invalid)
475        );
476        assert_ne!(
477            crypt.encrypt("secret", b"t.c.1", b"hunter2").unwrap(),
478            sealed
479        );
480    }
481
482    #[test]
483    fn a_previous_key_still_opens_and_signs_are_checked_by_id() {
484        let old = MasterKey::generate(1);
485        let old_export = old.export();
486        let older = Crypt::new(old, Vec::new());
487        let sealed = older.encrypt("secret", b"", b"x").unwrap();
488        let sig = older.sign("url", b"m");
489        let rotated = Crypt::new(
490            MasterKey::generate(2),
491            vec![MasterKey::parse(&old_export).unwrap()],
492        );
493        assert_eq!(rotated.decrypt("secret", b"", &sealed).unwrap(), b"x");
494        assert!(rotated.verify("url", b"m", &sig));
495        assert_eq!(rotated.current_id(), 2);
496        let fresh = Crypt::new(MasterKey::generate(2), Vec::new());
497        assert_eq!(
498            fresh.decrypt("secret", b"", &sealed),
499            Err(CryptError::Invalid)
500        );
501        assert!(!fresh.verify("url", b"m", &sig));
502    }
503
504    #[test]
505    fn a_signature_binds_message_and_purpose() {
506        let crypt = Crypt::ephemeral();
507        let sig = crypt.sign("url", b"media:abc:99");
508        assert!(crypt.verify("url", b"media:abc:99", &sig));
509        assert!(!crypt.verify("url", b"media:abc:98", &sig));
510        assert!(!crypt.verify("token", b"media:abc:99", &sig));
511        assert!(!crypt.verify("url", b"media:abc:99", "nope"));
512    }
513
514    #[test]
515    fn a_token_carries_its_payload_until_it_expires() {
516        let crypt = Crypt::ephemeral();
517        let token = crypt.token("reset", b"user:7", Duration::from_secs(60));
518        assert_eq!(crypt.check_token("reset", &token).unwrap(), b"user:7");
519        assert_eq!(
520            crypt.check_token("invite", &token),
521            Err(CryptError::Invalid)
522        );
523        let dead = crypt.token("reset", b"user:7", Duration::from_secs(0));
524        std::thread::sleep(Duration::from_millis(1100));
525        assert_eq!(crypt.check_token("reset", &dead), Err(CryptError::Expired));
526    }
527
528    #[test]
529    fn keys_parse_in_either_alphabet_and_export_with_their_id() {
530        let key = MasterKey::generate(3);
531        let text = key.export();
532        assert!(text.starts_with("3:"));
533        let back = MasterKey::parse(&text).unwrap();
534        assert_eq!(back.id(), 3);
535        assert_eq!(back.subkey("x"), key.subkey("x"));
536        let bare = base64::engine::general_purpose::STANDARD.encode([7u8; 32]);
537        assert_eq!(MasterKey::parse(&bare).unwrap().id(), 1);
538        assert!(matches!(
539            MasterKey::parse("1:short"),
540            Err(CryptError::BadKey(_))
541        ));
542        assert!(matches!(
543            MasterKey::parse("300:AAAA"),
544            Err(CryptError::BadKey(_))
545        ));
546        let a = MasterKey::derived(0, b"legacy secret");
547        let b = MasterKey::derived(0, b"legacy secret");
548        assert_eq!(a.subkey("x"), b.subkey("x"));
549        let index = Crypt::ephemeral();
550        assert_eq!(
551            index.blind_index("pan", b"1234"),
552            index.blind_index("pan", b"1234")
553        );
554        assert_ne!(
555            index.blind_index("pan", b"1234"),
556            index.blind_index("pan", b"1235")
557        );
558    }
559}