1use 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
29pub const MASK: &str = "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}";
32
33pub const FIELD_PURPOSE: &str = "field";
35
36#[derive(Iden)]
38pub(crate) enum CryptColumns {
39 #[iden = "crypt_columns"]
40 Table,
41 TableName,
42 ColumnName,
43 Purpose,
44 RegisteredAt,
45}
46
47pub 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
60pub 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
88pub 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
118pub struct Rotated {
119 pub resealed: u64,
120 pub current: u64,
121 pub unreadable: u64,
122}
123
124pub 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
184const VERSION: u8 = 1;
186const NONCE_LEN: usize = 24;
187const TAG_LEN: usize = 32;
188
189#[derive(Debug, thiserror::Error, PartialEq, Eq)]
191pub enum CryptError {
192 #[error("the value cannot be opened")]
195 Invalid,
196 #[error("the token has expired")]
198 Expired,
199 #[error("not a key: {0}")]
201 BadKey(String),
202}
203
204pub struct MasterKey {
206 id: u8,
207 secret: SecretBox<[u8; 32]>,
208}
209
210impl MasterKey {
211 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 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 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 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#[derive(Debug)]
289pub struct Crypt {
290 current: MasterKey,
291 previous: Vec<MasterKey>,
292}
293
294impl Crypt {
295 pub fn new(current: MasterKey, previous: Vec<MasterKey>) -> Self {
297 Self { current, previous }
298 }
299
300 pub fn ephemeral() -> Self {
303 Self::new(MasterKey::generate(1), Vec::new())
304 }
305
306 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 pub fn current_id(&self) -> u8 {
318 self.current.id
319 }
320
321 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 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 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 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 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 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 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 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}