Skip to main content

sioul_sync/
textseal.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Texts sealed at rest (`sioul_core::texts::Sealer`; docs/texts.md, "Sealed
5//! at rest"): each line of the texts' stores, on every device, is sealed by
6//! the device that writes it, so that a computer's copy of your texts is
7//! unreadable without the sharing key, which each device keeps in its
8//! keyring (the Secret Service, the Credential Manager, the Keychain, or
9//! Android's KeyStore). The sharing then seals the lines again in the folder,
10//! as it seals every record.
11//!
12//! The key: made from the sharing key (HKDF-SHA-256, "sioul-texts 1"), so
13//! that it seals nothing else and the sharing key itself seals no text. Each
14//! line: XChaCha20-Poly1305, a random nonce, bound to its use; written
15//! "t1:" and the nonce and the sealed bytes in base64, on one line. Each
16//! media file of a multimedia message kept on a computer: the same, bound to
17//! another use, the nonce and the sealed bytes (`seal_bytes`).
18
19use 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
25/// What a line sealed this way starts with: its format.
26const PREFIX: &str = "t1:";
27/// What each line is bound to: a line sealed for another use does not open here.
28const BOUND: &[u8] = b"sioul-texts line";
29
30/// The sealer of the texts' lines, from the sharing key.
31pub 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        // 32 bytes is always a length HKDF-SHA-256 gives.
39        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
44/// What a media file sealed this way is bound to.
45const BOUND_FILE: &[u8] = b"sioul-texts media";
46
47impl TextSeal {
48    /// A media file's bytes sealed whole: the nonce, then the sealed bytes (a part of a multimedia message is small: carriers cap them).
49    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    /// A media file opened; none when it does not open.
58    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        // One character changed inside: the seal does not open.
102        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        // A media file: whole, opened with its key only, never as a line.
107        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}