1use base64::Engine;
26use base64::engine::general_purpose::STANDARD as B64;
27use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
28use chacha20poly1305::{XChaCha20Poly1305, XNonce};
29use sha2::Sha256;
30
31const PREFIX: &str = "t1:";
33const BOUND: &[u8] = b"sioul-texts line";
35
36pub struct TextSeal {
38 cipher: XChaCha20Poly1305,
39}
40
41impl TextSeal {
42 pub fn new(sharing_key: &[u8; 32]) -> TextSeal {
43 let mut key = [0u8; 32];
44 let _ = hkdf::Hkdf::<Sha256>::new(None, sharing_key).expand(b"sioul-texts 1", &mut key);
46 TextSeal { cipher: XChaCha20Poly1305::new((&key).into()) }
47 }
48}
49
50const BOUND_FILE: &[u8] = b"sioul-texts media";
52
53impl TextSeal {
54 pub fn seal_bytes(&self, plain: &[u8]) -> Vec<u8> {
56 let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
57 let Ok(sealed) = self.cipher.encrypt(&nonce, Payload { msg: plain, aad: BOUND_FILE }) else { return Vec::new() };
58 let mut bytes = nonce.to_vec();
59 bytes.extend(sealed);
60 bytes
61 }
62
63 pub fn open_bytes(&self, sealed: &[u8]) -> Option<Vec<u8>> {
65 if sealed.len() < 24 {
66 return None;
67 }
68 let (nonce, rest) = sealed.split_at(24);
69 self.cipher.decrypt(XNonce::from_slice(nonce), Payload { msg: rest, aad: BOUND_FILE }).ok()
70 }
71}
72
73impl sioul_core::texts::Sealer for TextSeal {
74 fn seal(&self, plain: &str) -> String {
75 let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
76 let Ok(sealed) = self.cipher.encrypt(&nonce, Payload { msg: plain.as_bytes(), aad: BOUND }) else { return String::new() };
77 let mut bytes = nonce.to_vec();
78 bytes.extend(sealed);
79 format!("{PREFIX}{}", B64.encode(bytes))
80 }
81
82 fn open(&self, sealed: &str) -> Option<String> {
83 let bytes = B64.decode(sealed.trim().strip_prefix(PREFIX)?).ok()?;
84 if bytes.len() < 24 {
85 return None;
86 }
87 let (nonce, rest) = bytes.split_at(24);
88 let plain = self.cipher.decrypt(XNonce::from_slice(nonce), Payload { msg: rest, aad: BOUND }).ok()?;
89 String::from_utf8(plain).ok()
90 }
91}
92
93const REPLY_PREFIX: &str = "r1:";
95const REPLY_BOUND: &[u8] = b"sioul-replies line";
97
98pub struct ReplySeal {
102 cipher: XChaCha20Poly1305,
103}
104
105impl ReplySeal {
106 pub fn new(sharing_key: &[u8; 32]) -> ReplySeal {
107 let mut key = [0u8; 32];
108 let _ = hkdf::Hkdf::<Sha256>::new(None, sharing_key).expand(b"sioul-replies 1", &mut key);
110 ReplySeal { cipher: XChaCha20Poly1305::new((&key).into()) }
111 }
112}
113
114impl sioul_core::texts::Sealer for ReplySeal {
115 fn seal(&self, plain: &str) -> String {
116 let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
117 let Ok(sealed) = self.cipher.encrypt(&nonce, Payload { msg: plain.as_bytes(), aad: REPLY_BOUND }) else { return String::new() };
118 let mut bytes = nonce.to_vec();
119 bytes.extend(sealed);
120 format!("{REPLY_PREFIX}{}", B64.encode(bytes))
121 }
122
123 fn open(&self, sealed: &str) -> Option<String> {
124 let bytes = B64.decode(sealed.trim().strip_prefix(REPLY_PREFIX)?).ok()?;
125 if bytes.len() < 24 {
126 return None;
127 }
128 let (nonce, rest) = bytes.split_at(24);
129 let plain = self.cipher.decrypt(XNonce::from_slice(nonce), Payload { msg: rest, aad: REPLY_BOUND }).ok()?;
130 String::from_utf8(plain).ok()
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use sioul_core::texts::Sealer;
138
139 #[test]
142 fn a_reply_s_line_opens_with_its_own_key_only() {
143 let seal = ReplySeal::new(&[7u8; 32]);
144 let line = seal.seal("{\"words\":\"J'arrive dans dix minutes.\"}");
145 assert!(line.starts_with(REPLY_PREFIX) && !line.contains("arrive") && !line.contains('\n'));
146 assert_eq!(seal.open(&line).as_deref(), Some("{\"words\":\"J'arrive dans dix minutes.\"}"));
147 assert!(TextSeal::new(&[7u8; 32]).open(&line).is_none(), "the texts' key, of the same sharing");
148 assert!(seal.open(&TextSeal::new(&[7u8; 32]).seal("{}")).is_none(), "nor the other way");
149 assert!(ReplySeal::new(&[8u8; 32]).open(&line).is_none(), "another sharing's key");
150 assert!(seal.open("{\"words\":\"plain\"}").is_none());
151 }
152
153 #[test]
154 fn a_line_opens_with_its_key_only_and_never_reads_plain() {
155 let seal = TextSeal::new(&[7u8; 32]);
156 let line = seal.seal("{\"body\":\"Votre rendez-vous est déplacé.\"}");
157 assert!(line.starts_with(PREFIX) && !line.contains("rendez") && !line.contains('\n'));
158 assert_eq!(seal.open(&line).as_deref(), Some("{\"body\":\"Votre rendez-vous est déplacé.\"}"));
159 assert_ne!(seal.seal("same"), seal.seal("same"), "a nonce of its own each time");
160 assert!(TextSeal::new(&[8u8; 32]).open(&line).is_none(), "another vault's key");
161 assert!(seal.open("{\"body\":\"plain\"}").is_none());
162 let mid = line.len() / 2;
164 let swapped = if &line[mid..=mid] == "A" { "B" } else { "A" };
165 let broken = format!("{}{swapped}{}", &line[..mid], &line[mid + 1..]);
166 assert!(seal.open(&broken).is_none());
167 let picture = vec![0x89u8, b'P', b'N', b'G', 1, 2, 3];
169 let sealed = seal.seal_bytes(&picture);
170 assert!(!sealed.windows(3).any(|w| w == b"PNG"));
171 assert_eq!(seal.open_bytes(&sealed), Some(picture));
172 assert!(TextSeal::new(&[8u8; 32]).open_bytes(&sealed).is_none());
173 assert!(seal.open(&String::from_utf8_lossy(&sealed)).is_none());
174 }
175}