1use base64::Engine;
20use base64::engine::general_purpose::STANDARD as B64;
21use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
22use chacha20poly1305::{XChaCha20Poly1305, XNonce};
23use sha2::Sha256;
24
25const PREFIX: &str = "t1:";
27const BOUND: &[u8] = b"sioul-texts line";
29
30pub struct TextSeal {
32 cipher: XChaCha20Poly1305,
33}
34
35impl TextSeal {
36 pub fn new(sharing_key: &[u8; 32]) -> TextSeal {
37 let mut key = [0u8; 32];
38 let _ = hkdf::Hkdf::<Sha256>::new(None, sharing_key).expand(b"sioul-texts 1", &mut key);
40 TextSeal { cipher: XChaCha20Poly1305::new((&key).into()) }
41 }
42}
43
44const BOUND_FILE: &[u8] = b"sioul-texts media";
46
47impl TextSeal {
48 pub fn seal_bytes(&self, plain: &[u8]) -> Vec<u8> {
50 let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
51 let Ok(sealed) = self.cipher.encrypt(&nonce, Payload { msg: plain, aad: BOUND_FILE }) else { return Vec::new() };
52 let mut bytes = nonce.to_vec();
53 bytes.extend(sealed);
54 bytes
55 }
56
57 pub fn open_bytes(&self, sealed: &[u8]) -> Option<Vec<u8>> {
59 if sealed.len() < 24 {
60 return None;
61 }
62 let (nonce, rest) = sealed.split_at(24);
63 self.cipher.decrypt(XNonce::from_slice(nonce), Payload { msg: rest, aad: BOUND_FILE }).ok()
64 }
65}
66
67impl sioul_core::texts::Sealer for TextSeal {
68 fn seal(&self, plain: &str) -> String {
69 let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
70 let Ok(sealed) = self.cipher.encrypt(&nonce, Payload { msg: plain.as_bytes(), aad: BOUND }) else { return String::new() };
71 let mut bytes = nonce.to_vec();
72 bytes.extend(sealed);
73 format!("{PREFIX}{}", B64.encode(bytes))
74 }
75
76 fn open(&self, sealed: &str) -> Option<String> {
77 let bytes = B64.decode(sealed.trim().strip_prefix(PREFIX)?).ok()?;
78 if bytes.len() < 24 {
79 return None;
80 }
81 let (nonce, rest) = bytes.split_at(24);
82 let plain = self.cipher.decrypt(XNonce::from_slice(nonce), Payload { msg: rest, aad: BOUND }).ok()?;
83 String::from_utf8(plain).ok()
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use sioul_core::texts::Sealer;
91
92 #[test]
93 fn a_line_opens_with_its_key_only_and_never_reads_plain() {
94 let seal = TextSeal::new(&[7u8; 32]);
95 let line = seal.seal("{\"body\":\"Votre rendez-vous est déplacé.\"}");
96 assert!(line.starts_with(PREFIX) && !line.contains("rendez") && !line.contains('\n'));
97 assert_eq!(seal.open(&line).as_deref(), Some("{\"body\":\"Votre rendez-vous est déplacé.\"}"));
98 assert_ne!(seal.seal("same"), seal.seal("same"), "a nonce of its own each time");
99 assert!(TextSeal::new(&[8u8; 32]).open(&line).is_none(), "another vault's key");
100 assert!(seal.open("{\"body\":\"plain\"}").is_none());
101 let mid = line.len() / 2;
103 let swapped = if &line[mid..=mid] == "A" { "B" } else { "A" };
104 let broken = format!("{}{swapped}{}", &line[..mid], &line[mid + 1..]);
105 assert!(seal.open(&broken).is_none());
106 let picture = vec![0x89u8, b'P', b'N', b'G', 1, 2, 3];
108 let sealed = seal.seal_bytes(&picture);
109 assert!(!sealed.windows(3).any(|w| w == b"PNG"));
110 assert_eq!(seal.open_bytes(&sealed), Some(picture));
111 assert!(TextSeal::new(&[8u8; 32]).open_bytes(&sealed).is_none());
112 assert!(seal.open(&String::from_utf8_lossy(&sealed)).is_none());
113 }
114}