1use std::fmt;
42use std::time::{Duration, SystemTime, UNIX_EPOCH};
43
44use argon2::{Algorithm, Argon2, Params, Version};
45use chacha20poly1305::aead::{Aead, KeyInit, Payload};
46use chacha20poly1305::{XChaCha20Poly1305, XNonce};
47use sha2::{Digest, Sha256};
48use zeroize::{Zeroize, Zeroizing};
49
50pub const KEY_LEN: usize = 32;
52pub const KEY_ID_LEN: usize = 8;
54pub const SALT_LEN: usize = 16;
56pub const NONCE_LEN: usize = 24;
58pub const TAG_LEN: usize = 16;
60
61const SEALED_KIND: u8 = b'S';
63const LOCKED_KIND: u8 = b'L';
65const VERSION: u8 = 1;
67
68const SEALED_HEADER_LEN: usize = 2 + KEY_ID_LEN + NONCE_LEN;
70pub const SEALED_OVERHEAD: usize = SEALED_HEADER_LEN + TAG_LEN;
72
73const LOCKED_HEADER_LEN: usize = 2 + 4 + 4 + 4 + 8 + SALT_LEN + KEY_ID_LEN + NONCE_LEN;
76pub const LOCKED_LEN: usize = LOCKED_HEADER_LEN + KEY_LEN + TAG_LEN;
78
79const KEY_ID_DOMAIN: &[u8] = b"lacodda-seal/v1/key-id";
83const SEALED_DOMAIN: &[u8] = b"lacodda-seal/v1/sealed";
84const LOCKED_DOMAIN: &[u8] = b"lacodda-seal/v1/locked";
85
86#[derive(Debug, thiserror::Error, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum Error {
90 #[error("not a {expected}: {reason}")]
93 Malformed {
94 expected: &'static str,
96 reason: &'static str,
98 },
99 #[error("a {kind} of format version {found}, and this release reads up to {VERSION}")]
102 NewerFormat {
103 kind: &'static str,
105 found: u8,
107 },
108 #[error("sealed under key {found}, not under key {expected}")]
110 WrongKey {
111 expected: KeyId,
113 found: KeyId,
115 },
116 #[error("the blob does not open: it was sealed for another purpose, or changed after sealing")]
120 Inauthentic,
121 #[error("the passphrase does not unlock this key")]
125 WrongPassphrase,
126 #[error("Argon2id parameters out of bounds: {0}")]
128 Parameters(String),
129 #[error("the operating system's random source failed")]
131 Random,
132}
133
134#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
140pub struct KeyId([u8; KEY_ID_LEN]);
141
142impl KeyId {
143 pub const fn from_bytes(bytes: [u8; KEY_ID_LEN]) -> Self {
145 Self(bytes)
146 }
147
148 pub const fn as_bytes(&self) -> &[u8; KEY_ID_LEN] {
150 &self.0
151 }
152}
153
154impl fmt::Display for KeyId {
155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 for byte in self.0 {
157 write!(f, "{byte:02x}")?;
158 }
159 Ok(())
160 }
161}
162
163impl fmt::Debug for KeyId {
164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165 write!(f, "KeyId({self})")
166 }
167}
168
169#[derive(Clone)]
173pub struct Key {
174 bytes: Zeroizing<[u8; KEY_LEN]>,
175 id: KeyId,
176}
177
178impl Key {
179 pub fn generate() -> Result<Self, Error> {
185 Ok(Self::from_bytes(*random::<KEY_LEN>()?))
186 }
187
188 pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
191 let bytes = Zeroizing::new(bytes);
192 let mut hasher = Sha256::new();
193 hasher.update(KEY_ID_DOMAIN);
194 hasher.update(bytes.as_slice());
195 let digest = hasher.finalize();
196 let mut id = [0u8; KEY_ID_LEN];
197 id.copy_from_slice(&digest[..KEY_ID_LEN]);
198 Self { bytes, id: KeyId(id) }
199 }
200
201 pub fn to_bytes(&self) -> Zeroizing<[u8; KEY_LEN]> {
204 self.bytes.clone()
205 }
206
207 pub fn id(&self) -> KeyId {
209 self.id
210 }
211
212 pub fn seal(&self, context: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, Error> {
221 Ok(self.seal_with_nonce(context, plaintext, *random::<NONCE_LEN>()?))
222 }
223
224 fn seal_with_nonce(&self, context: &[u8], plaintext: &[u8], nonce: [u8; NONCE_LEN]) -> Vec<u8> {
225 let mut blob = Vec::with_capacity(SEALED_OVERHEAD + plaintext.len());
226 blob.extend_from_slice(&[SEALED_KIND, VERSION]);
227 blob.extend_from_slice(&self.id.0);
228 blob.extend_from_slice(&nonce);
229 let aad = associated(SEALED_DOMAIN, &blob, context);
230 let ciphertext = self
231 .cipher()
232 .encrypt(&XNonce::from(nonce), Payload { msg: plaintext, aad: &aad })
233 .expect("XChaCha20-Poly1305 seals any length a Vec can hold");
234 blob.extend_from_slice(&ciphertext);
235 blob
236 }
237
238 pub fn open(&self, context: &[u8], sealed: &[u8]) -> Result<Zeroizing<Vec<u8>>, Error> {
247 let found = sealed_key_id(sealed)?;
248 if found != self.id {
249 return Err(Error::WrongKey { expected: self.id, found });
250 }
251 let (header, ciphertext) = sealed.split_at(SEALED_HEADER_LEN);
252 let nonce: [u8; NONCE_LEN] = header[2 + KEY_ID_LEN..].try_into().expect("the header has a nonce");
253 let aad = associated(SEALED_DOMAIN, header, context);
254 self.cipher()
255 .decrypt(&XNonce::from(nonce), Payload { msg: ciphertext, aad: &aad })
256 .map(Zeroizing::new)
257 .map_err(|_| Error::Inauthentic)
258 }
259
260 fn cipher(&self) -> XChaCha20Poly1305 {
261 XChaCha20Poly1305::new(&(*self.bytes).into())
262 }
263}
264
265impl fmt::Debug for Key {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 write!(f, "Key({})", self.id)
268 }
269}
270
271pub fn sealed_key_id(sealed: &[u8]) -> Result<KeyId, Error> {
279 const KIND: &str = "sealed blob";
280 check_header(sealed, SEALED_KIND, KIND)?;
281 if sealed.len() < SEALED_OVERHEAD {
282 return Err(Error::Malformed { expected: KIND, reason: "shorter than a header and a tag" });
283 }
284 Ok(KeyId(sealed[2..2 + KEY_ID_LEN].try_into().expect("the header has a key identity")))
285}
286
287#[derive(Clone, Copy, Debug, PartialEq, Eq)]
294pub struct KdfParams {
295 memory_kib: u32,
296 iterations: u32,
297 parallelism: u32,
298}
299
300impl KdfParams {
301 pub const DEFAULT: Self = Self { memory_kib: 64 * 1024, iterations: 3, parallelism: 4 };
304 pub const MIN: Self = Self { memory_kib: 19 * 1024, iterations: 2, parallelism: 1 };
307 pub const MAX: Self = Self { memory_kib: 1024 * 1024, iterations: 16, parallelism: 16 };
309
310 pub fn new(memory_kib: u32, iterations: u32, parallelism: u32) -> Result<Self, Error> {
316 let params = Self { memory_kib, iterations, parallelism };
317 params.check()?;
318 Ok(params)
319 }
320
321 pub fn memory_kib(&self) -> u32 {
323 self.memory_kib
324 }
325
326 pub fn iterations(&self) -> u32 {
328 self.iterations
329 }
330
331 pub fn parallelism(&self) -> u32 {
333 self.parallelism
334 }
335
336 fn check(&self) -> Result<(), Error> {
337 let within = |value: u32, min: u32, max: u32, what: &str| {
338 if (min..=max).contains(&value) {
339 Ok(())
340 } else {
341 Err(Error::Parameters(format!("{what} is {value}, and it must be from {min} to {max}")))
342 }
343 };
344 within(self.memory_kib, Self::MIN.memory_kib, Self::MAX.memory_kib, "memory (KiB)")?;
345 within(self.iterations, Self::MIN.iterations, Self::MAX.iterations, "iterations")?;
346 within(self.parallelism, Self::MIN.parallelism, Self::MAX.parallelism, "parallelism")
347 }
348
349 fn derive(&self, passphrase: &[u8], salt: &[u8; SALT_LEN]) -> Result<Key, Error> {
350 let params = Params::new(self.memory_kib, self.iterations, self.parallelism, Some(KEY_LEN))
351 .map_err(|e| Error::Parameters(e.to_string()))?;
352 let mut kek = Zeroizing::new([0u8; KEY_LEN]);
353 Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
354 .hash_password_into(passphrase, salt, kek.as_mut())
355 .map_err(|e| Error::Parameters(e.to_string()))?;
356 Ok(Key::from_bytes(*kek))
357 }
358}
359
360impl fmt::Display for KdfParams {
361 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362 let memory = if self.memory_kib.is_multiple_of(1024) {
363 format!("{} MiB", self.memory_kib / 1024)
364 } else {
365 format!("{} KiB", self.memory_kib)
366 };
367 write!(f, "argon2id {memory} × {} × {}", self.iterations, self.parallelism)
368 }
369}
370
371#[derive(Clone, PartialEq, Eq)]
378pub struct LockedKey {
379 params: KdfParams,
380 locked_at: u64,
381 salt: [u8; SALT_LEN],
382 key_id: KeyId,
383 nonce: [u8; NONCE_LEN],
384 wrapped: [u8; KEY_LEN + TAG_LEN],
385}
386
387impl LockedKey {
388 pub fn lock(key: &Key, passphrase: &[u8], context: &[u8]) -> Result<Self, Error> {
395 Self::lock_with(key, passphrase, context, KdfParams::DEFAULT)
396 }
397
398 pub fn lock_with(key: &Key, passphrase: &[u8], context: &[u8], params: KdfParams) -> Result<Self, Error> {
405 let salt = *random::<SALT_LEN>()?;
406 let nonce = *random::<NONCE_LEN>()?;
407 Self::lock_exactly(key, passphrase, context, params, unix_now(), salt, nonce)
408 }
409
410 fn lock_exactly(
411 key: &Key,
412 passphrase: &[u8],
413 context: &[u8],
414 params: KdfParams,
415 locked_at: u64,
416 salt: [u8; SALT_LEN],
417 nonce: [u8; NONCE_LEN],
418 ) -> Result<Self, Error> {
419 params.check()?;
420 let mut lock = Self { params, locked_at, salt, key_id: key.id, nonce, wrapped: [0; KEY_LEN + TAG_LEN] };
421 let kek = params.derive(passphrase, &salt)?;
422 let aad = associated(LOCKED_DOMAIN, &lock.header(), context);
423 let wrapped = kek
424 .cipher()
425 .encrypt(&XNonce::from(nonce), Payload { msg: key.bytes.as_slice(), aad: &aad })
426 .expect("XChaCha20-Poly1305 seals 32 bytes");
427 lock.wrapped.copy_from_slice(&wrapped);
428 Ok(lock)
429 }
430
431 pub fn unlock(&self, passphrase: &[u8], context: &[u8]) -> Result<Key, Error> {
441 let kek = self.params.derive(passphrase, &self.salt)?;
442 let aad = associated(LOCKED_DOMAIN, &self.header(), context);
443 let mut bytes = kek
444 .cipher()
445 .decrypt(&XNonce::from(self.nonce), Payload { msg: &self.wrapped, aad: &aad })
446 .map_err(|_| Error::WrongPassphrase)?;
447 let mut array = [0u8; KEY_LEN];
448 array.copy_from_slice(&bytes);
449 bytes.zeroize();
450 let key = Key::from_bytes(array);
451 array.zeroize();
452 if key.id != self.key_id {
456 return Err(Error::WrongPassphrase);
457 }
458 Ok(key)
459 }
460
461 pub fn key_id(&self) -> KeyId {
463 self.key_id
464 }
465
466 pub fn params(&self) -> KdfParams {
468 self.params
469 }
470
471 pub fn locked_at(&self) -> SystemTime {
473 UNIX_EPOCH + Duration::from_secs(self.locked_at)
474 }
475
476 pub fn to_bytes(&self) -> Vec<u8> {
478 let mut bytes = self.header();
479 bytes.extend_from_slice(&self.wrapped);
480 bytes
481 }
482
483 pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
492 const KIND: &str = "locked key";
493 check_header(bytes, LOCKED_KIND, KIND)?;
494 if bytes.len() != LOCKED_LEN {
495 return Err(Error::Malformed { expected: KIND, reason: "not the length of a locked key" });
496 }
497 let mut fields = Fields { bytes, at: 2 };
498 let params = KdfParams {
499 memory_kib: u32::from_be_bytes(fields.take()),
500 iterations: u32::from_be_bytes(fields.take()),
501 parallelism: u32::from_be_bytes(fields.take()),
502 };
503 let locked_at = u64::from_be_bytes(fields.take());
504 let salt = fields.take();
505 let key_id = KeyId(fields.take());
506 let nonce = fields.take();
507 let wrapped = fields.take();
508 params.check()?;
509 Ok(Self { params, locked_at, salt, key_id, nonce, wrapped })
510 }
511
512 fn header(&self) -> Vec<u8> {
513 let mut header = Vec::with_capacity(LOCKED_LEN);
514 header.extend_from_slice(&[LOCKED_KIND, VERSION]);
515 header.extend_from_slice(&self.params.memory_kib.to_be_bytes());
516 header.extend_from_slice(&self.params.iterations.to_be_bytes());
517 header.extend_from_slice(&self.params.parallelism.to_be_bytes());
518 header.extend_from_slice(&self.locked_at.to_be_bytes());
519 header.extend_from_slice(&self.salt);
520 header.extend_from_slice(&self.key_id.0);
521 header.extend_from_slice(&self.nonce);
522 header
523 }
524}
525
526impl fmt::Debug for LockedKey {
527 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
528 f.debug_struct("LockedKey")
529 .field("key_id", &self.key_id)
530 .field("params", &self.params)
531 .field("locked_at", &self.locked_at)
532 .finish_non_exhaustive()
533 }
534}
535
536pub fn is_sealed(bytes: &[u8]) -> bool {
540 bytes.first() == Some(&SEALED_KIND)
541}
542
543pub fn is_locked_key(bytes: &[u8]) -> bool {
545 bytes.first() == Some(&LOCKED_KIND)
546}
547
548struct Fields<'a> {
551 bytes: &'a [u8],
552 at: usize,
553}
554
555impl Fields<'_> {
556 fn take<const N: usize>(&mut self) -> [u8; N] {
557 let field = self.bytes[self.at..self.at + N].try_into().expect("the length was checked");
558 self.at += N;
559 field
560 }
561}
562
563fn check_header(bytes: &[u8], kind: u8, name: &'static str) -> Result<(), Error> {
564 match bytes {
565 [] | [_] => Err(Error::Malformed { expected: name, reason: "shorter than a header" }),
566 [first, ..] if *first != kind => Err(Error::Malformed { expected: name, reason: "another kind of blob" }),
567 [_, version, ..] if *version == VERSION => Ok(()),
568 [_, version, ..] if *version > VERSION => Err(Error::NewerFormat { kind: name, found: *version }),
569 _ => Err(Error::Malformed { expected: name, reason: "format version 0 does not exist" }),
570 }
571}
572
573fn associated(domain: &[u8], header: &[u8], context: &[u8]) -> Vec<u8> {
577 [domain, header, context].concat()
578}
579
580fn random<const N: usize>() -> Result<Zeroizing<[u8; N]>, Error> {
581 let mut bytes = Zeroizing::new([0u8; N]);
582 getrandom::fill(bytes.as_mut()).map_err(|_| Error::Random)?;
583 Ok(bytes)
584}
585
586fn unix_now() -> u64 {
587 SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 fn key() -> Key {
595 Key::from_bytes([7; KEY_LEN])
596 }
597
598 #[test]
599 fn a_sealed_blob_opens_under_its_key_and_context_only() {
600 let key = key();
601 let sealed = key.seal(b"ctx", b"payload").unwrap();
602 assert_eq!(key.open(b"ctx", &sealed).unwrap().as_slice(), b"payload");
603 assert_eq!(key.open(b"other", &sealed), Err(Error::Inauthentic));
604 let other = Key::from_bytes([8; KEY_LEN]);
605 assert_eq!(other.open(b"ctx", &sealed), Err(Error::WrongKey { expected: other.id(), found: key.id() }));
606 }
607
608 #[test]
609 fn every_byte_of_a_sealed_blob_is_covered() {
610 let key = key();
612 let sealed = key.seal(b"ctx", b"payload").unwrap();
613 for index in 0..sealed.len() {
614 let mut changed = sealed.clone();
615 changed[index] ^= 0x01;
616 assert!(key.open(b"ctx", &changed).is_err(), "byte {index} is not authenticated");
617 }
618 }
619
620 #[test]
621 fn sealing_twice_gives_two_blobs_of_a_known_size() {
622 let key = key();
623 let first = key.seal(b"", b"payload").unwrap();
624 let second = key.seal(b"", b"payload").unwrap();
625 assert_ne!(first, second);
626 assert_eq!(first.len(), b"payload".len() + SEALED_OVERHEAD);
627 assert_eq!(key.seal(b"", b"").unwrap().len(), SEALED_OVERHEAD);
628 }
629
630 #[test]
631 fn a_blob_of_another_kind_or_a_newer_version_is_named_not_misread() {
632 let key = key();
633 let mut sealed = key.seal(b"", b"x").unwrap();
634 assert!(matches!(key.open(b"", &sealed[..1]), Err(Error::Malformed { .. })));
635 assert!(matches!(key.open(b"", &sealed[..SEALED_OVERHEAD - 1]), Err(Error::Malformed { .. })));
636 sealed[1] = VERSION + 1;
637 assert_eq!(key.open(b"", &sealed), Err(Error::NewerFormat { kind: "sealed blob", found: VERSION + 1 }));
638 sealed[0] = LOCKED_KIND;
639 assert!(matches!(key.open(b"", &sealed), Err(Error::Malformed { .. })));
640 }
641
642 #[test]
643 fn a_key_round_trips_through_its_lock() {
644 let key = key();
645 let lock = LockedKey::lock_with(&key, b"pass phrase", b"ctx", KdfParams::MIN).unwrap();
646 assert_eq!(lock.key_id(), key.id());
647 let bytes = lock.to_bytes();
648 assert_eq!(bytes.len(), LOCKED_LEN);
649 let read = LockedKey::from_bytes(&bytes).unwrap();
650 assert_eq!(read, lock);
651 assert_eq!(*read.unlock(b"pass phrase", b"ctx").unwrap().to_bytes(), *key.to_bytes());
652 assert_eq!(read.unlock(b"pass phrasE", b"ctx").unwrap_err(), Error::WrongPassphrase);
653 assert_eq!(read.unlock(b"pass phrase", b"other").unwrap_err(), Error::WrongPassphrase);
654 }
655
656 #[test]
657 fn every_byte_of_a_lock_is_covered() {
658 let key = key();
659 let lock = LockedKey::lock_with(&key, b"pw", b"ctx", KdfParams::MIN).unwrap().to_bytes();
660 for index in 0..lock.len() {
664 let mut changed = lock.clone();
665 changed[index] ^= 0x01;
666 let outcome = LockedKey::from_bytes(&changed).and_then(|l| l.unlock(b"pw", b"ctx"));
667 assert!(outcome.is_err(), "byte {index} of a lock is not authenticated");
668 }
669 }
670
671 #[test]
672 fn a_lock_out_of_bounds_is_refused_before_any_work() {
673 let key = key();
674 let lock = LockedKey::lock_with(&key, b"pw", b"ctx", KdfParams::MIN).unwrap().to_bytes();
675 let mut greedy = lock.clone();
676 greedy[2..6].copy_from_slice(&u32::MAX.to_be_bytes());
678 assert!(matches!(LockedKey::from_bytes(&greedy), Err(Error::Parameters(_))));
679 let mut weak = lock;
680 weak[6..10].copy_from_slice(&1u32.to_be_bytes());
681 assert!(matches!(LockedKey::from_bytes(&weak), Err(Error::Parameters(_))));
682 assert!(KdfParams::new(KdfParams::MAX.memory_kib + 1, 3, 4).is_err());
683 assert_eq!(KdfParams::new(65536, 3, 4).unwrap(), KdfParams::DEFAULT);
684 }
685
686 #[test]
687 fn kinds_are_told_apart_by_their_first_byte() {
688 let key = key();
689 let sealed = key.seal(b"", b"x").unwrap();
690 let lock = LockedKey::lock_with(&key, b"pw", b"", KdfParams::MIN).unwrap().to_bytes();
691 assert!(is_sealed(&sealed) && !is_locked_key(&sealed));
692 assert!(is_locked_key(&lock) && !is_sealed(&lock));
693 assert!(matches!(LockedKey::from_bytes(&sealed), Err(Error::Malformed { .. })));
694 assert!(!is_sealed(&[]) && !is_locked_key(&[]));
695 }
696
697 #[test]
698 fn debug_never_shows_the_key() {
699 let key = key();
700 let shown = format!("{key:?}");
701 assert!(shown.starts_with("Key(") && !shown.contains("07, 07"), "{shown}");
702 assert_eq!(shown, format!("Key({})", key.id()));
703 }
704
705 #[test]
711 fn the_formats_are_frozen() {
712 let key = Key::from_bytes(*b"0123456789abcdef0123456789abcdef");
713 assert_eq!(key.id().to_string(), FROZEN_KEY_ID);
714
715 let sealed = key.seal_with_nonce(b"context", b"hello", *b"nonce-nonce-nonce-nonce!");
716 assert_eq!(hex(&sealed), FROZEN_SEALED);
717
718 let lock = LockedKey::lock_exactly(
719 &key,
720 b"correct horse battery staple",
721 b"context",
722 KdfParams::MIN,
723 1_760_000_000,
724 *b"salt-salt-salt-s",
725 *b"nonce-nonce-nonce-nonce!",
726 )
727 .unwrap();
728 assert_eq!(hex(&lock.to_bytes()), FROZEN_LOCKED);
729 }
730
731 const FROZEN_KEY_ID: &str = "fbcf86ef40348bea";
732 const FROZEN_SEALED: &str = concat!(
733 "5301fbcf86ef40348bea6e6f6e63652d6e6f6e63652d6e6f6e63652d6e6f6e63652143f707c8a51d90bc9391bd9cb2776f280e",
734 "74c75af6"
735 );
736 const FROZEN_LOCKED: &str = concat!(
737 "4c0100004c0000000002000000010000000068e7780073616c742d73616c742d73616c742d73fbcf86ef40348bea6e6f6e",
738 "63652d6e6f6e63652d6e6f6e63652d6e6f6e63652148cf6c30e1cc7edbd043709d3dbbd646ce6cb2a0cb26a692270f6818",
739 "e32c3e4df95dc80e3323b31aea6cbfc49a917243"
740 );
741
742 fn hex(bytes: &[u8]) -> String {
743 bytes.iter().map(|b| format!("{b:02x}")).collect()
744 }
745}