1use std::time::{SystemTime, UNIX_EPOCH};
4
5use chacha20poly1305::aead::{Aead, KeyInit};
6use chacha20poly1305::{XChaCha20Poly1305, XNonce};
7use dilithium::{DilithiumKeyPair, DilithiumSignature, ML_DSA_65};
8use hkdf::Hkdf;
9use ml_kem::kem::{Decapsulate, Encapsulate};
10use ml_kem::{Ciphertext, EncodedSizeUser, KemCore, MlKem1024};
11use rand::rngs::OsRng;
12use rand::{CryptoRng, RngCore};
13use sha2::Sha256;
14use zeroize::{Zeroize, Zeroizing};
15
16use crate::errors::TholosError;
17use crate::types::*;
18
19pub struct RecipientPriv {
23 pub kid: String,
25 pub sk_kyber: <MlKem1024 as KemCore>::DecapsulationKey,
27}
28
29pub struct SenderKeypair {
33 pub sid: String,
35 keypair: DilithiumKeyPair,
36}
37
38impl Drop for SenderKeypair {
39 fn drop(&mut self) {
40 self.sid.zeroize();
41 }
42}
43
44impl SenderKeypair {
45 pub fn public_key_bytes(&self) -> &[u8] {
47 self.keypair.public_key()
48 }
49
50 pub fn private_key_bytes(&self) -> &[u8] {
52 self.keypair.private_key()
53 }
54}
55
56pub struct EncryptOptions<'a, R: RngCore + CryptoRng> {
58 pub rng: &'a mut R,
60 pub msg_id: String,
62 pub timestamp_unix: u64,
64}
65
66pub fn gen_recipient_keypair(kid: &str) -> (RecipientPub, RecipientPriv) {
68 let mut rng = OsRng;
69 gen_recipient_keypair_with(kid, &mut rng)
70}
71
72pub fn gen_recipient_keypair_with<R: RngCore + CryptoRng>(
74 kid: &str,
75 rng: &mut R,
76) -> (RecipientPub, RecipientPriv) {
77 let (sk, pk) = MlKem1024::generate(rng);
78 let pub_bytes = pk.as_bytes().to_vec();
79 (
80 RecipientPub {
81 kid: kid.to_string(),
82 pk_kyber: pub_bytes,
83 },
84 RecipientPriv {
85 kid: kid.to_string(),
86 sk_kyber: sk,
87 },
88 )
89}
90
91pub fn gen_sender_keypair(sid: &str) -> SenderKeypair {
93 #[allow(clippy::expect_used)]
94 let keypair = DilithiumKeyPair::generate(ML_DSA_65).expect("ML-DSA-65 key generation failed");
95 SenderKeypair {
96 sid: sid.to_string(),
97 keypair,
98 }
99}
100
101pub fn gen_sender_keypair_deterministic(sid: &str, seed: &[u8; 32]) -> SenderKeypair {
103 SenderKeypair {
104 sid: sid.to_string(),
105 keypair: DilithiumKeyPair::generate_deterministic(ML_DSA_65, seed),
106 }
107}
108
109pub fn sender_keypair_from_bytes(
111 sid: &str,
112 pk_bytes: &[u8],
113 sk_bytes: &[u8],
114) -> Result<SenderKeypair, TholosError> {
115 let keypair = DilithiumKeyPair::from_keys(sk_bytes, pk_bytes, ML_DSA_65)
116 .map_err(|_| TholosError::Malformed("ml-dsa keys"))?;
117 Ok(SenderKeypair {
118 sid: sid.to_string(),
119 keypair,
120 })
121}
122
123pub fn sender_pub(sender: &SenderKeypair) -> SenderPub {
125 SenderPub {
126 sid: sender.sid.clone(),
127 pk_dilithium: sender.public_key_bytes().to_vec(),
128 }
129}
130
131fn unix_timestamp_secs() -> Result<u64, TholosError> {
132 SystemTime::now()
133 .duration_since(UNIX_EPOCH)
134 .map_err(|_| TholosError::Malformed("system clock before unix epoch"))
135 .map(|d| d.as_secs())
136}
137
138fn hkdf32(shared: &[u8], kid: &str, header_cbor: &[u8]) -> Zeroizing<[u8; 32]> {
139 let hk = Hkdf::<Sha256>::new(Some(kid.as_bytes()), shared);
140 let mut okm = Zeroizing::new([0u8; 32]);
141 #[allow(clippy::expect_used)]
142 hk.expand(header_cbor, okm.as_mut())
143 .expect("HKDF expand failed - this should never happen with 32-byte output");
144 okm
145}
146
147fn aead_enc(
148 key: &[u8; 32],
149 nonce24: &[u8; 24],
150 aad: &[u8],
151 pt: &[u8],
152) -> Result<Vec<u8>, TholosError> {
153 let cipher = XChaCha20Poly1305::new(key.into());
154 let nonce = XNonce::from(*nonce24);
155 cipher
156 .encrypt(&nonce, chacha20poly1305::aead::Payload { msg: pt, aad })
157 .map_err(|_| TholosError::Aead)
158}
159
160fn aead_dec(
161 key: &[u8; 32],
162 nonce24: &[u8; 24],
163 aad: &[u8],
164 ct: &[u8],
165) -> Result<Vec<u8>, TholosError> {
166 let cipher = XChaCha20Poly1305::new(key.into());
167 let nonce = XNonce::from(*nonce24);
168 cipher
169 .decrypt(&nonce, chacha20poly1305::aead::Payload { msg: ct, aad })
170 .map_err(|_| TholosError::Aead)
171}
172
173fn expect_self_describe_tag(wire_cbor: &[u8]) -> Result<(), TholosError> {
174 if !has_self_describe_tag(wire_cbor) {
175 return Err(TholosError::Malformed("wire self-describe tag"));
176 }
177 Ok(())
178}
179
180fn sign_inner<R: RngCore>(
181 inner_cbor: &[u8],
182 sender: &SenderKeypair,
183 rng: &mut R,
184) -> Result<Vec<u8>, TholosError> {
185 let mut rnd = [0u8; 32];
186 rng.fill_bytes(&mut rnd);
187 let sig = sender
188 .keypair
189 .sign_deterministic(inner_cbor, b"", &rnd)
190 .map_err(|_| TholosError::Malformed("ml-dsa sign"))?;
191 rnd.zeroize();
192 Ok(sig.as_bytes().to_vec())
193}
194
195fn validate_inner(inner: &BundleUnsigned) -> Result<(), TholosError> {
196 if inner.header.v != 1 || inner.header.suite != SUITE_V1 {
197 return Err(TholosError::UnsupportedSuite {
198 v: inner.header.v,
199 suite: inner.header.suite.clone(),
200 });
201 }
202
203 if inner.header.recipients.len() != inner.recipients.len() {
204 return Err(TholosError::Malformed("recipient list mismatch"));
205 }
206
207 let mut seen = std::collections::HashSet::new();
208 for (header_kid, env) in inner.header.recipients.iter().zip(&inner.recipients) {
209 if header_kid != &env.kid {
210 return Err(TholosError::Malformed("recipient order mismatch"));
211 }
212 if !seen.insert(env.kid.clone()) {
213 return Err(TholosError::Malformed("duplicate recipient kid"));
214 }
215 }
216
217 Ok(())
218}
219
220fn verify_signed_bundle(
221 bundle: &BundleSigned,
222 allowed_senders: &[(String, Vec<u8>)],
223) -> Result<BundleUnsigned, TholosError> {
224 if bundle.inner.is_empty() {
225 return Err(TholosError::Malformed("empty inner bundle"));
226 }
227
228 let inner: BundleUnsigned = from_cbor(&bundle.inner)?;
229 validate_inner(&inner)?;
230
231 let sender_sid = &inner.header.sender;
232 let Some((_, pk_bytes)) = allowed_senders.iter().find(|(sid, _)| sid == sender_sid) else {
233 return Err(TholosError::BadSignature);
234 };
235
236 if pk_bytes.len() != DILITHIUM3_PK_LEN {
237 return Err(TholosError::Malformed("dilithium pk"));
238 }
239 if bundle.sig_dilithium.len() != DILITHIUM3_SIG_LEN {
240 return Err(TholosError::Malformed("signature"));
241 }
242
243 let sig = DilithiumSignature::from_slice(&bundle.sig_dilithium);
244 if !DilithiumKeyPair::verify(pk_bytes, &sig, &bundle.inner, b"", ML_DSA_65) {
245 return Err(TholosError::BadSignature);
246 }
247
248 Ok(inner)
249}
250
251pub fn verify_header(
253 wire_cbor: &[u8],
254 allowed_senders: &[(String, Vec<u8>)],
255) -> Result<Header, TholosError> {
256 expect_self_describe_tag(wire_cbor)?;
257 let bundle: BundleSigned = from_cbor(wire_cbor)?;
258 let inner = verify_signed_bundle(&bundle, allowed_senders)?;
259 Ok(inner.header)
260}
261
262pub fn encrypt(
264 plaintext: &[u8],
265 sender: &SenderKeypair,
266 recipients: &[RecipientPub],
267) -> Result<Vec<u8>, TholosError> {
268 let mut rng = OsRng;
269 encrypt_with(
270 plaintext,
271 sender,
272 recipients,
273 EncryptOptions {
274 rng: &mut rng,
275 msg_id: uuid::Uuid::new_v4().to_string(),
276 timestamp_unix: unix_timestamp_secs()?,
277 },
278 )
279}
280
281pub fn encrypt_with<R: RngCore + CryptoRng>(
283 plaintext: &[u8],
284 sender: &SenderKeypair,
285 recipients: &[RecipientPub],
286 opts: EncryptOptions<'_, R>,
287) -> Result<Vec<u8>, TholosError> {
288 if recipients.is_empty() {
289 return Err(TholosError::NoRecipients);
290 }
291
292 let header = Header {
293 v: 1,
294 suite: SUITE_V1.to_string(),
295 sender: sender.sid.clone(),
296 recipients: recipients.iter().map(|r| r.kid.clone()).collect(),
297 msg_id: opts.msg_id,
298 timestamp_unix: opts.timestamp_unix,
299 };
300 let header_cbor = to_cbor(&header)?;
301
302 let rng = opts.rng;
303 let mut cek = Zeroizing::new([0u8; 32]);
304 rng.fill_bytes(cek.as_mut());
305
306 let mut pay_nonce = [0u8; 24];
307 rng.fill_bytes(&mut pay_nonce);
308 let ciphertext = aead_enc(&cek, &pay_nonce, &header_cbor, plaintext)?;
309
310 let mut envs = Vec::with_capacity(recipients.len());
311 for r in recipients {
312 let pk_bytes: &[u8] = &r.pk_kyber;
313 let pk = <MlKem1024 as KemCore>::EncapsulationKey::from_bytes(
314 &pk_bytes
315 .try_into()
316 .map_err(|_| TholosError::Malformed("ml-kem pk"))?,
317 );
318 let (kem_ct, shared) = pk
319 .encapsulate(rng)
320 .map_err(|_| TholosError::Malformed("encapsulation"))?;
321
322 let kek = hkdf32(shared.as_slice(), &r.kid, &header_cbor);
323
324 let mut wrap_nonce = [0u8; 24];
325 rng.fill_bytes(&mut wrap_nonce);
326 let wrapped_cek = aead_enc(&kek, &wrap_nonce, &header_cbor, cek.as_ref())?;
327
328 envs.push(RecipientEnvelope {
329 kid: r.kid.clone(),
330 kem_ct: kem_ct.as_slice().to_vec(),
331 wrap_nonce: wrap_nonce.to_vec(),
332 wrapped_cek,
333 });
334 }
335
336 let inner = BundleUnsigned {
337 header,
338 pay_nonce: pay_nonce.to_vec(),
339 ciphertext,
340 recipients: envs,
341 };
342
343 let inner_cbor = to_cbor(&inner)?;
344 let sig_dilithium = sign_inner(&inner_cbor, sender, rng)?;
345
346 let bundle = BundleSigned {
347 inner: inner_cbor,
348 sig_dilithium,
349 };
350
351 to_cbor(&bundle)
352}
353
354pub fn decrypt(
356 wire_cbor: &[u8],
357 my_kid: &str,
358 my_sk: &<MlKem1024 as KemCore>::DecapsulationKey,
359 allowed_senders: &[(String, Vec<u8>)],
360) -> Result<Vec<u8>, TholosError> {
361 expect_self_describe_tag(wire_cbor)?;
362 let bundle: BundleSigned = from_cbor(wire_cbor)?;
363 let inner = verify_signed_bundle(&bundle, allowed_senders)?;
364
365 let env = inner
366 .recipients
367 .iter()
368 .find(|e| e.kid == my_kid)
369 .ok_or_else(|| TholosError::MissingEnvelope(my_kid.to_string()))?;
370
371 if env.wrap_nonce.len() != 24 {
372 return Err(TholosError::Malformed("wrap nonce"));
373 }
374 let kem_ct_bytes: &[u8] = &env.kem_ct;
375 let kem_ct: Ciphertext<MlKem1024> = kem_ct_bytes
376 .try_into()
377 .map_err(|_| TholosError::Malformed("kem_ct"))?;
378 let shared = my_sk
379 .decapsulate(&kem_ct)
380 .map_err(|_| TholosError::Malformed("decapsulation"))?;
381
382 let header_cbor = to_cbor(&inner.header)?;
383 let kek = hkdf32(shared.as_slice(), my_kid, &header_cbor);
384
385 let mut wrap_nonce = [0u8; 24];
386 wrap_nonce.copy_from_slice(&env.wrap_nonce);
387 let cek = aead_dec(&kek, &wrap_nonce, &header_cbor, &env.wrapped_cek)?;
388
389 if cek.len() != 32 {
390 return Err(TholosError::Malformed("cek length"));
391 }
392 let mut cek_arr = Zeroizing::new([0u8; 32]);
393 cek_arr.copy_from_slice(&cek);
394
395 if inner.pay_nonce.len() != 24 {
396 return Err(TholosError::Malformed("pay nonce"));
397 }
398 let mut pay_nonce = [0u8; 24];
399 pay_nonce.copy_from_slice(&inner.pay_nonce);
400
401 aead_dec(&cek_arr, &pay_nonce, &header_cbor, &inner.ciphertext)
402}
403
404pub(crate) mod test_support {
405 use super::*;
406
407 pub fn resign_bundle(
408 inner: &BundleUnsigned,
409 sender: &SenderKeypair,
410 ) -> Result<BundleSigned, TholosError> {
411 let inner_cbor = to_cbor(inner)?;
412 let mut rng = OsRng;
413 let sig_dilithium = sign_inner(&inner_cbor, sender, &mut rng)?;
414 Ok(BundleSigned {
415 inner: inner_cbor,
416 sig_dilithium,
417 })
418 }
419
420 pub fn encode_bundle(bundle: &BundleSigned) -> Result<Vec<u8>, TholosError> {
421 to_cbor(bundle)
422 }
423
424 pub fn decode_bundle(wire: &[u8]) -> Result<BundleSigned, TholosError> {
425 from_cbor(wire)
426 }
427
428 pub fn decode_inner(bytes: &[u8]) -> Result<BundleUnsigned, TholosError> {
429 from_cbor(bytes)
430 }
431
432 pub fn encode_cbor<T: serde::Serialize>(v: &T) -> Result<Vec<u8>, TholosError> {
433 to_cbor(v)
434 }
435
436 pub fn decode_cbor<T: serde::de::DeserializeOwned>(data: &[u8]) -> Result<T, TholosError> {
437 from_cbor(data)
438 }
439}