1#![doc = include_str!("../README.md")]
2
3use hpke::{
4 Deserializable, HpkeError, OpModeR, OpModeS, Serializable,
5 aead::ChaCha20Poly1305,
6 kdf::HkdfSha256,
7 kem::{Kem as KemTrait, XWing},
8 single_shot_open, single_shot_seal_with_rng,
9};
10
11mod keys;
12mod rng;
13pub use keys::{PublicKey, SecretKey};
14
15pub(crate) type XKem = XWing;
18pub(crate) type Aead = ChaCha20Poly1305;
22pub(crate) type Kdf = HkdfSha256;
25
26const VERSION: u8 = 0x01;
30const KEM_ID: u16 = 0x647A;
34const KDF_ID: u16 = 0x0001;
38const AEAD_ID: u16 = 0x0003;
42
43const HEADER: [u8; 7] = {
55 let kem = KEM_ID.to_be_bytes();
56 let kdf = KDF_ID.to_be_bytes();
57 let aead = AEAD_ID.to_be_bytes();
58 [VERSION, kem[0], kem[1], kdf[0], kdf[1], aead[0], aead[1]]
59};
60const HEADER_LEN: usize = HEADER.len();
61const ENC_LEN: usize = 1120;
65const MAX_INFO_LEN: usize = (1 << 16) - 1 - 5;
69
70impl PublicKey {
71 pub fn seal(
101 recipient: &PublicKey,
102 plaintext: &[u8],
103 info: Option<&[u8]>,
104 ) -> Result<Vec<u8>, Error> {
105 if info.unwrap_or_default().len() > MAX_INFO_LEN {
106 return Err(Error::InfoExceedsSize);
107 }
108
109 let (enc, ciphertext) = single_shot_seal_with_rng::<Aead, Kdf, XKem>(
110 &OpModeS::Base,
111 recipient.as_hpke(),
112 info.unwrap_or_default(),
113 plaintext,
114 &[],
118 &mut rng::os_csprng()?,
119 )?;
120 let enc = enc.to_bytes();
121 let mut out = Vec::with_capacity(HEADER_LEN + enc.len() + ciphertext.len());
122 out.extend_from_slice(&HEADER);
123 out.extend_from_slice(enc.as_slice());
124 out.extend_from_slice(&ciphertext);
125 Ok(out)
126 }
127}
128
129impl SecretKey {
130 pub fn unseal(
142 recipient: &SecretKey,
143 ciphertext: &[u8],
144 info: Option<&[u8]>,
145 ) -> Result<Vec<u8>, Error> {
146 if info.unwrap_or_default().len() > MAX_INFO_LEN {
147 return Err(Error::InfoExceedsSize);
148 }
149
150 if ciphertext.is_empty() {
151 return Err(Error::EmptyCiphertext);
152 }
153
154 let Some((&[version, kem0, kem1, kdf0, kdf1, aead0, adead1], ciphertext)) =
155 ciphertext.split_first_chunk::<HEADER_LEN>()
156 else {
157 return Err(Error::Decode);
158 };
159
160 if version != VERSION {
161 return Err(Error::UnsupportedVersion(version));
162 }
163
164 let (kem_id, kdf_id, aead_id) = (
165 u16::from_be_bytes([kem0, kem1]),
166 u16::from_be_bytes([kdf0, kdf1]),
167 u16::from_be_bytes([aead0, adead1]),
168 );
169
170 if (kem_id, kdf_id, aead_id) != (KEM_ID, KDF_ID, AEAD_ID) {
171 return Err(Error::UnsupportedSuite);
172 }
173
174 let Some((enc_bytes, ciphertext)) = ciphertext.split_first_chunk::<ENC_LEN>() else {
175 return Err(Error::Decode);
176 };
177
178 let enc = <XKem as KemTrait>::EncappedKey::from_bytes(enc_bytes)?;
179
180 let plaintext = single_shot_open::<Aead, Kdf, XKem>(
181 &OpModeR::Base,
182 recipient.as_hpke(),
183 &enc,
184 info.unwrap_or_default(),
185 ciphertext,
186 &[],
188 )?;
189
190 Ok(plaintext)
191 }
192}
193
194#[derive(Debug, PartialEq, Eq, thiserror::Error)]
196#[non_exhaustive]
197pub enum Error {
198 #[error("provided key is malformed")]
200 KeyFormat,
201 #[error("sealed message is malformed")]
203 Decode,
204 #[error("decapsulation failed")]
206 Decap,
207 #[error("unable to unseal: AEAD authentication failed")]
209 Unseal,
210 #[error("seal unexpectedly failed")]
212 Seal,
213 #[error("operating system CSPRNG is unavailable")]
216 Rng,
217 #[error("internal critical bug")]
219 Internal,
220 #[error("unsupported version: {0}")]
222 UnsupportedVersion(u8),
223 #[error("unsupported suite")]
225 UnsupportedSuite,
226 #[error("info exceeds max size")]
228 InfoExceedsSize,
229 #[error("empty ciphertext")]
231 EmptyCiphertext,
232}
233
234impl From<HpkeError> for Error {
235 fn from(e: HpkeError) -> Self {
236 match e {
237 HpkeError::OpenError => Error::Unseal,
238 HpkeError::DecapError => Error::Decap,
239 HpkeError::EncapError | HpkeError::SealError => Error::Seal,
240 HpkeError::ValidationError | HpkeError::IncorrectInputLength(_, _) => Error::Decode,
241 HpkeError::MessageLimitReached
242 | HpkeError::KdfOutputTooLong
243 | HpkeError::InvalidPskBundle => Error::Internal,
244 }
245 }
246}
247
248#[expect(clippy::unwrap_used, reason = "clearer in tests")]
249#[cfg(test)]
250mod tests {
251 use super::{ENC_LEN, Error, HEADER, HEADER_LEN, MAX_INFO_LEN, PublicKey, SecretKey, VERSION};
252 use std::collections::HashSet;
253
254 const TAG_LEN: usize = 16;
258
259 fn keypair(seed: &[u8; 32]) -> (SecretKey, PublicKey) {
260 let sk = SecretKey::from_seed(seed);
261 let pk = sk.public_key();
262 (sk, pk)
263 }
264
265 #[test]
266 fn seal_unseal_roundtrip() {
267 let (sk, pk) = keypair(&[7u8; 32]);
268 let msg: &[u8] = b"execute order 66";
269
270 let sealed = PublicKey::seal(&pk, msg, None).unwrap();
271
272 let unsealed = SecretKey::unseal(&sk, &sealed, None).unwrap();
273
274 assert_eq!(unsealed, msg);
275 }
276
277 #[test]
278 fn roundtrips_empty_plaintext() {
279 let (sk, pk) = keypair(&[0u8; 32]);
280
281 let sealed = PublicKey::seal(&pk, &[], None).unwrap();
282 assert!(!sealed.is_empty());
283
284 let unsealed = SecretKey::unseal(&sk, &sealed, None).unwrap();
285
286 assert!(unsealed.is_empty());
287 }
288
289 #[test]
290 fn roundtrips_with_matching_info() {
291 let (sk, pk) = keypair(&[3u8; 32]);
292 let msg: &[u8] = b"never tell me the odds";
293 let info: &[u8] = b"com.example";
294
295 let sealed = PublicKey::seal(&pk, msg, Some(info)).unwrap();
296
297 let unsealed = SecretKey::unseal(&sk, &sealed, Some(info)).unwrap();
298
299 assert_eq!(unsealed, msg);
300 }
301
302 #[test]
303 fn unseal_fails_with_mismatched_info() {
304 let (sk, pk) = keypair(&[4u8; 32]);
305
306 let sealed = PublicKey::seal(&pk, b"it's a trap", Some(b"context-a")).unwrap();
307
308 assert_eq!(
309 SecretKey::unseal(&sk, &sealed, Some(b"context-b")),
310 Err(Error::Unseal)
311 );
312 }
313
314 #[test]
315 fn unseal_fails_with_wrong_recipient() {
316 let (_sk, pk) = keypair(&[1u8; 32]);
317 let (other_sk, _other_pk) = keypair(&[2u8; 32]);
318
319 let sealed = PublicKey::seal(&pk, b"for my eyes only", None).unwrap();
320
321 assert_eq!(
322 SecretKey::unseal(&other_sk, &sealed, None),
323 Err(Error::Unseal)
324 );
325 }
326
327 #[test]
328 fn unseal_fails_on_tampered_ciphertext() {
329 let (sk, pk) = keypair(&[9u8; 32]);
330
331 let sealed = PublicKey::seal(&pk, b"execute order 66", None).unwrap();
332
333 let mut tampered = sealed.clone();
334 let last = tampered.len() - 1;
335 tampered[last] ^= 0x01;
336
337 assert_eq!(SecretKey::unseal(&sk, &tampered, None), Err(Error::Unseal));
338 }
339
340 #[test]
341 fn unseal_rejects_empty_ciphertext() {
342 let (sk, _pk) = keypair(&[1u8; 32]);
343
344 assert_eq!(
345 SecretKey::unseal(&sk, b"", None),
346 Err(Error::EmptyCiphertext)
347 );
348 }
349
350 #[test]
351 fn unseal_rejects_ciphertext_shorter_than_header() {
352 let (sk, _pk) = keypair(&[1u8; 32]);
353
354 assert_eq!(SecretKey::unseal(&sk, b"short", None), Err(Error::Decode));
355 }
356
357 #[test]
358 fn unseal_rejects_truncated_encapsulated_key() {
359 let (sk, pk) = keypair(&[6u8; 32]);
360
361 let sealed = PublicKey::seal(&pk, b"this message will self-destruct", None).unwrap();
362
363 let truncated = &sealed[..HEADER_LEN + 10];
365
366 assert_eq!(SecretKey::unseal(&sk, truncated, None), Err(Error::Decode));
367 }
368
369 #[test]
370 fn unseal_rejects_unsupported_version() {
371 let (sk, pk) = keypair(&[1u8; 32]);
372
373 let sealed = PublicKey::seal(&pk, b"hello there", None).unwrap();
374 let mut bad = sealed.clone();
375 let bad_version = VERSION.wrapping_add(1);
376 bad[0] = bad_version;
377
378 assert_eq!(
379 SecretKey::unseal(&sk, &bad, None),
380 Err(Error::UnsupportedVersion(bad_version))
381 );
382 }
383
384 #[test]
385 fn unseal_rejects_unsupported_suite() {
386 let (sk, pk) = keypair(&[1u8; 32]);
387
388 let sealed = PublicKey::seal(&pk, b"hello there", None).unwrap();
389 let mut bad = sealed.clone();
390 bad[1] ^= 0xFF; assert_eq!(
393 SecretKey::unseal(&sk, &bad, None),
394 Err(Error::UnsupportedSuite)
395 );
396 }
397
398 #[test]
399 fn seal_prepends_wire_header() {
400 let (_sk, pk) = keypair(&[1u8; 32]);
401
402 let sealed = PublicKey::seal(&pk, b"hello there", None).unwrap();
403
404 assert_eq!(&sealed[..HEADER_LEN], &HEADER);
405 }
406
407 #[test]
408 fn seal_output_has_expected_length() {
409 let (_sk, pk) = keypair(&[1u8; 32]);
410 let msg: &[u8] = b"hello there";
411
412 let sealed = PublicKey::seal(&pk, msg, None).unwrap();
413
414 assert_eq!(sealed.len(), HEADER_LEN + ENC_LEN + msg.len() + TAG_LEN);
416 }
417
418 #[test]
419 fn seal_is_non_deterministic() {
420 let (_sk, pk) = keypair(&[1u8; 32]);
421 let msg: &[u8] = b"same message";
422
423 let sealed = PublicKey::seal(&pk, msg, None).unwrap();
424 let sealed2 = PublicKey::seal(&pk, msg, None).unwrap();
425
426 assert_ne!(sealed, sealed2);
427 }
428
429 #[test]
434 fn seal_draws_fresh_encapsulation_randomness_each_call() {
435 let (_sk, pk) = keypair(&[1u8; 32]);
436 let msg: &[u8] = b"same message, same recipient, same info";
437 let info: &[u8] = b"com.example";
438
439 let mut enc_keys = HashSet::new();
440 for _ in 0..64 {
441 let sealed = PublicKey::seal(&pk, msg, Some(info)).unwrap();
442 let enc = sealed[HEADER_LEN..HEADER_LEN + ENC_LEN].to_vec();
443 assert!(
444 enc_keys.insert(enc),
445 "encapsulated key repeated: encapsulation randomness was reused"
446 );
447 }
448 assert_eq!(enc_keys.len(), 64);
449 }
450
451 #[test]
452 fn seal_and_unseal_accept_info_at_max_len() {
453 let (sk, pk) = keypair(&[1u8; 32]);
454 let msg: &[u8] = b"boundary";
455 let info = vec![0x2a; 2_usize.pow(16) - 5 - 1];
456
457 let sealed = PublicKey::seal(&pk, msg, Some(&info)).unwrap();
458 let unsealed = SecretKey::unseal(&sk, &sealed, Some(&info)).unwrap();
459
460 assert_eq!(unsealed, msg);
461 }
462
463 #[test]
464 fn seal_rejects_info_over_max_len() {
465 let (_sk, pk) = keypair(&[1u8; 32]);
466 let info = vec![0x2a; MAX_INFO_LEN + 1];
467
468 assert_eq!(
469 PublicKey::seal(&pk, b"nope", Some(&info)),
470 Err(Error::InfoExceedsSize)
471 );
472 }
473
474 #[test]
475 fn unseal_rejects_info_over_max_len() {
476 let (sk, pk) = keypair(&[1u8; 32]);
477 let sealed = PublicKey::seal(&pk, b"nope", None).unwrap();
478 let info = vec![0x2a; MAX_INFO_LEN + 1];
479
480 assert_eq!(
481 SecretKey::unseal(&sk, &sealed, Some(&info)),
482 Err(Error::InfoExceedsSize)
483 );
484 }
485}