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//!
19//! Replies from your computers to the phone's notifications (`ReplySeal`;
20//! `sioul_core::replies`) are sealed the same way with a key of their own
21//! ("sioul-replies 1"), their lines written "r1:…": a computer's requests in
22//! the sharing, and the phone's private file of what is due, which holds
23//! their words; neither opens with the other's key.
24
25use 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
31/// What a line sealed this way starts with: its format.
32const PREFIX: &str = "t1:";
33/// What each line is bound to: a line sealed for another use does not open here.
34const BOUND: &[u8] = b"sioul-texts line";
35
36/// The sealer of the texts' lines, from the sharing key.
37pub 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        // 32 bytes is always a length HKDF-SHA-256 gives.
45        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
50/// What a media file sealed this way is bound to.
51const BOUND_FILE: &[u8] = b"sioul-texts media";
52
53impl TextSeal {
54    /// A media file's bytes sealed whole: the nonce, then the sealed bytes (a part of a multimedia message is small: carriers cap them).
55    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    /// A media file opened; none when it does not open.
64    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
93/// What a line of the replies' stores starts with, sealed so (`ReplySeal`).
94const REPLY_PREFIX: &str = "r1:";
95/// What each of their lines is bound to.
96const REPLY_BOUND: &[u8] = b"sioul-replies line";
97
98/// The sealer of the replies' lines, from the sharing key (HKDF-SHA-256,
99/// "sioul-replies 1"): it seals nothing else, and the texts' key opens none
100/// of them.
101pub 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        // 32 bytes is always a length HKDF-SHA-256 gives.
109        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    /// A reply's line opens with the replies' key only: never the texts'
140    /// key, never another sharing's, never plain.
141    #[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        // One character changed inside: the seal does not open.
163        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        // A media file: whole, opened with its key only, never as a line.
168        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}