darkbio_crypto/cose/mod.rs
1// crypto-rs: cryptography primitives and wrappers
2// Copyright 2025 Dark Bio AG. All rights reserved.
3//
4// Use of this source code is governed by a BSD-style
5// license that can be found in the LICENSE file.
6
7//! COSE wrappers for xDSA and xHPKE.
8//!
9//! <https://datatracker.ietf.org/doc/html/rfc9052>
10//! <https://datatracker.ietf.org/doc/html/draft-ietf-cose-hpke>
11//!
12//! Signatures are [`CoseSign1`] envelopes carrying the signer's fingerprint and
13//! a timestamp in the protected header. Encryption is [`CoseEncrypt0`] around a
14//! signed envelope, so every message created by [`seal`] is also signed.
15//! Payloads and authenticated messages can be any types implementing the crate's
16//! CBOR traits. Signing, verification, encryption, and decryption use an
17//! application domain, prefixed with [`DOMAIN_PREFIX`], which both sides must
18//! agree on.
19//!
20//! ```
21//! use darkbio_crypto::{cose, xdsa, xhpke};
22//!
23//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
24//! let signer = xdsa::SecretKey::generate();
25//!
26//! // Sign a payload, binding a second message supplied separately
27//! let envelope = cose::sign("hello".to_string(), "context", &signer, b"example")?;
28//! let payload: String =
29//! cose::verify(&envelope, "context", &signer.public_key(), b"example", Some(60))?;
30//! assert_eq!(payload, "hello");
31//!
32//! // Sign and encrypt to a recipient in one step, then open and verify it back
33//! let recipient = xhpke::SecretKey::generate();
34//! let sealed = cose::seal(
35//! "secret".to_string(),
36//! "context",
37//! &signer,
38//! &recipient.public_key(),
39//! b"example",
40//! &cose::Padding::Buckets { floor: 8192, step: 20 },
41//! )?;
42//! let opened: String = cose::open(
43//! &sealed,
44//! "context",
45//! &recipient,
46//! &signer.public_key(),
47//! b"example",
48//! Some(60),
49//! )?;
50//! assert_eq!(opened, "secret");
51//! # Ok(())
52//! # }
53//! ```
54//!
55//! # Domain separation and freshness
56//!
57//! Choose distinct domains for distinct application operations. Domains prevent
58//! a message for one purpose from being accepted for another; they do not stop
59//! repeated use within the same domain. Verification accepts a signature whose
60//! timestamp is at most `max_drift` seconds in the past or future. `None` skips
61//! this timestamp check. Applications that require one-time acceptance must
62//! also track a message identifier, nonce, or challenge to reject replays.
63//!
64//! # Wire profile
65//!
66//! Interoperating implementations must match these Dark Bio conventions:
67//!
68//! - Envelopes are untagged [`CoseSign1`] and [`CoseEncrypt0`] arrays. CBOR tags
69//! are not accepted. Headers use this crate's deterministic integer-key maps.
70//! - The private algorithm IDs are [`ALGORITHM_ID_XDSA`] (`-70000`) and
71//! [`ALGORITHM_ID_XHPKE`] (`-70001`). The protected `kid` is the appropriate
72//! public key's fingerprint. Signatures require the private timestamp header
73//! [`HEADER_TIMESTAMP`] (`-70002`) and name it in `crit`.
74//! - For signatures, [`SigStructure::external_aad`] is the CBOR encoding of
75//! `[bstr(DOMAIN_PREFIX || domain), msg_to_auth]`. An embedded payload is the
76//! CBOR encoding of the caller's value.
77//! - For [`sign_detached`], the caller's message is authenticated in that
78//! `external_aad`, while [`SigStructure::payload`] is an empty byte string
79//! and the envelope payload is null. A generic COSE detached-payload API that
80//! puts the caller's message in `Sig_structure.payload` must be adapted to
81//! this convention.
82//! - For encryption, [`EncStructure::external_aad`] is the CBOR encoding of
83//! `msg_to_auth`; the complete encoded `EncStructure` is passed as HPKE AAD.
84//! HPKE key derivation uses `DOMAIN_PREFIX || domain` as its info. The X-Wing
85//! encapsulated key is carried in unprotected header `-4`.
86//! - The encryption plaintext is the encoded [`CoseSign1`] followed by zero
87//! bytes, as many as the sender's [`Padding`] policy picks. The signature does
88//! not cover them, while the encryption authenticates them. A receiver finds
89//! the end of the [`CoseSign1`] by decoding it and refuses a nonzero byte after
90//! it. It accepts any number of zeros, none included, so a sender can change
91//! its policy without its receivers.
92//!
93//! Here `bstr` denotes a CBOR byte string and `||` denotes byte concatenation.
94//! The domain and `msg_to_auth` are not included in the returned envelope;
95//! both parties must know them or transmit them separately.
96
97mod types;
98
99pub use types::{
100 CoseEncrypt0, CoseSign1, CritHeader, EmptyHeader, EncProtectedHeader, EncStructure,
101 EncapKeyHeader, HEADER_TIMESTAMP, SigProtectedHeader, SigStructure,
102};
103
104// Use an indirect time package that mostly defers to sts::time on most platforms,
105// except on wasm, where it uses the JS engine's time subsystem.
106use web_time::{SystemTime, UNIX_EPOCH};
107use zeroize::{Zeroize, Zeroizing};
108
109use crate::cbor::{self, Decode, Encode, Raw};
110use crate::{xdsa, xhpke};
111
112/// Prefix prepended to the caller's application domain for signature
113/// authentication and HPKE key derivation. Both parties must use the same bytes.
114/// Distinct domains separate application purposes; replay detection within a
115/// domain is the application's responsibility.
116pub const DOMAIN_PREFIX: &[u8] = crate::xhpke::DOMAIN_PREFIX;
117
118/// How many zero bytes a sender appends to the signed envelope inside the
119/// encryption, so the ciphertext's length shows little about the message.
120///
121/// Receivers strip any number of zeros, so the policy is the sender's alone and
122/// can change without them.
123#[derive(Clone, Debug, PartialEq, Eq)]
124#[non_exhaustive]
125pub enum Padding {
126 /// No padding: the plaintext is the signed envelope alone.
127 None,
128 /// Zeros after the signed envelope, up to the smallest size that fits.
129 /// Sizes start at `floor`, and each next one is the previous one plus
130 /// `1/step` of it, rounded up.
131 Buckets {
132 /// Smallest padded size in bytes, which must be nonzero.
133 floor: usize,
134 /// Each size grows by itself divided by `step`, rounded up, and `step`
135 /// must be nonzero.
136 step: usize,
137 },
138}
139
140impl Padding {
141 /// Returns the plaintext size after padding an envelope of `len` bytes.
142 ///
143 /// # Panics
144 ///
145 /// Panics if a bucket's `floor` or `step` is zero, or the required bucket
146 /// size exceeds [`usize::MAX`].
147 fn padded_len(&self, len: usize) -> usize {
148 let Self::Buckets { floor, step } = *self else {
149 return len;
150 };
151 assert!(floor > 0, "padding floor must be nonzero");
152 assert!(step > 0, "padding step must be nonzero");
153
154 // Grow by the rounded-up fraction until the envelope fits
155 let mut size = floor;
156 while size < len {
157 size = size
158 .checked_add(size.div_ceil(step))
159 .expect("padding bucket size overflow");
160 }
161 size
162 }
163}
164
165/// Failures of the COSE operations.
166#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
167pub enum Error {
168 /// The envelope, a header or a payload is not valid CBOR under this
169 /// crate's rules.
170 #[error("cbor: {0}")]
171 CborError(#[from] cbor::Error),
172 /// The protected header names the first algorithm where the second was
173 /// required. Also raised when the [`CritHeader`] list is not the expected
174 /// one.
175 #[error("unexpected algorithm: have {0}, want {1}")]
176 UnexpectedAlgorithm(i64, i64),
177 /// The envelope was signed by the first fingerprint, the verifier's key
178 /// has the second. [`signer`] looks the right key up without verifying.
179 #[error("unexpected signing key: have {0:x?}, want {1:x?}")]
180 UnexpectedSigningKey(xdsa::Fingerprint, xdsa::Fingerprint),
181 /// The xDSA signature does not verify. Carries the underlying error text.
182 #[error("signature verification failed: {0}")]
183 InvalidSignature(String),
184 /// The signature timestamp is the first number of seconds away from now,
185 /// more than the second, which is the allowed drift.
186 #[error("signature stale: time drift {0}s exceeds max {1}s")]
187 StaleSignature(u64, u64),
188 /// [`verify_detached`] found an embedded payload.
189 #[error("unexpected payload in detached signature")]
190 UnexpectedPayload,
191 /// [`verify`] or [`peek`] found no payload.
192 #[error("missing payload in embedded signature")]
193 MissingPayload,
194 /// The envelope was encrypted to the first fingerprint, the recipient's
195 /// key has the second. [`recipient`] looks the right key up without
196 /// decrypting.
197 #[error("unexpected encryption key: have {0:x?}, want {1:x?}")]
198 UnexpectedEncryptionKey(xhpke::Fingerprint, xhpke::Fingerprint),
199 /// The [`EncapKeyHeader`] carries an encapsulated key of the first size
200 /// where the second, [`xhpke::ENCAP_KEY_SIZE`], is required.
201 #[error("invalid encapsulated key size: {0}, expected {1}")]
202 InvalidEncapKeySize(usize, usize),
203 /// Opening the ciphertext failed, the wrong key, tampered data or a
204 /// mismatched authenticated message. Also raised when [`seal`] or [`encrypt`]
205 /// fails.
206 /// Carries the xHPKE error text.
207 #[error("decryption failed: {0}")]
208 DecryptionFailed(String),
209 /// The decrypted plaintext holds a nonzero byte after its COSE_Sign1.
210 #[error("invalid padding")]
211 InvalidPadding,
212}
213
214/// Private COSE algorithm identifier for composite ML-DSA-65 + Ed25519 signatures.
215pub const ALGORITHM_ID_XDSA: i64 = -70000;
216
217/// Private COSE algorithm identifier for X-Wing (ML-KEM-768 + X25519).
218pub const ALGORITHM_ID_XHPKE: i64 = -70001;
219
220/// Creates a COSE_Sign1 digital signature without an embedded payload, whose
221/// envelope payload is null.
222///
223/// The caller's message is included in `external_aad`, and the payload in the
224/// signature input is empty. See the module's wire profile for interoperability.
225///
226/// Uses the current system time as the signature timestamp. For testing or custom
227/// timestamps, use [`sign_detached_at`].
228///
229/// - `msg_to_auth`: The message to sign (not embedded in COSE_Sign1)
230/// - `signer`: The xDSA secret key to sign with
231/// - `domain`: Application domain for separating protocol purposes
232///
233/// Returns the serialized COSE_Sign1 structure.
234pub fn sign_detached<A: Encode>(
235 msg_to_auth: A,
236 signer: &xdsa::SecretKey,
237 domain: &[u8],
238) -> Result<Vec<u8>, Error> {
239 let timestamp = SystemTime::now()
240 .duration_since(UNIX_EPOCH)
241 .expect("system time before Unix epoch")
242 .as_secs() as i64;
243 sign_detached_at(msg_to_auth, signer, domain, timestamp)
244}
245
246/// Creates a COSE_Sign1 digital signature with an embedded payload.
247///
248/// Uses the current system time as the signature timestamp. For testing or custom
249/// timestamps, use [`sign_at`].
250///
251/// - `msg_to_embed`: The message to sign (embedded in COSE_Sign1)
252/// - `msg_to_auth`: Additional authenticated data (not embedded, but signed)
253/// - `signer`: The xDSA secret key to sign with
254/// - `domain`: Application domain for separating protocol purposes
255///
256/// Returns the serialized COSE_Sign1 structure.
257pub fn sign<E: Encode, A: Encode>(
258 msg_to_embed: E,
259 msg_to_auth: A,
260 signer: &xdsa::SecretKey,
261 domain: &[u8],
262) -> Result<Vec<u8>, Error> {
263 let timestamp = SystemTime::now()
264 .duration_since(UNIX_EPOCH)
265 .expect("system time before Unix epoch")
266 .as_secs() as i64;
267 sign_at(msg_to_embed, msg_to_auth, signer, domain, timestamp)
268}
269
270/// Creates a COSE_Sign1 digital signature without an embedded payload, with an
271/// explicit timestamp.
272///
273/// Uses the same external-AAD convention as [`sign_detached`].
274///
275/// - `msg_to_auth`: The message to sign (not embedded in COSE_Sign1)
276/// - `signer`: The xDSA secret key to sign with
277/// - `domain`: Application domain for separating protocol purposes
278/// - `timestamp`: Unix timestamp in seconds to embed in the protected header
279///
280/// Returns the serialized COSE_Sign1 structure.
281pub fn sign_detached_at<A: Encode>(
282 msg_to_auth: A,
283 signer: &xdsa::SecretKey,
284 domain: &[u8],
285 timestamp: i64,
286) -> Result<Vec<u8>, Error> {
287 // Restrict the user's domain to the context of this library
288 let info = [DOMAIN_PREFIX, domain].concat();
289 let aad = cbor::encode(&(&info, msg_to_auth))?;
290
291 let protected = cbor::encode(&SigProtectedHeader {
292 algorithm: ALGORITHM_ID_XDSA,
293 crit: CritHeader {
294 timestamp: HEADER_TIMESTAMP,
295 },
296 kid: signer.fingerprint(),
297 timestamp,
298 })?;
299 // Build and sign Sig_structure with empty payload for detached mode
300 let signature = signer.sign(&cbor::encode(SigStructure {
301 context: "Signature1",
302 protected: &protected,
303 external_aad: &aad,
304 payload: &[],
305 })?);
306 // Build and encode COSE_Sign1 with null payload
307 Ok(cbor::encode(&CoseSign1 {
308 protected,
309 unprotected: EmptyHeader {},
310 payload: None,
311 signature,
312 })?)
313}
314
315/// Creates a COSE_Sign1 digital signature with an embedded payload and an
316/// explicit timestamp.
317///
318/// - `msg_to_embed`: The message to sign (embedded in COSE_Sign1)
319/// - `msg_to_auth`: Additional authenticated data (not embedded, but signed)
320/// - `signer`: The xDSA secret key to sign with
321/// - `domain`: Application domain for separating protocol purposes
322/// - `timestamp`: Unix timestamp in seconds to embed in the protected header
323///
324/// Returns the serialized COSE_Sign1 structure.
325pub fn sign_at<E: Encode, A: Encode>(
326 msg_to_embed: E,
327 msg_to_auth: A,
328 signer: &xdsa::SecretKey,
329 domain: &[u8],
330 timestamp: i64,
331) -> Result<Vec<u8>, Error> {
332 // The payload can hold secrets, so every buffer holding it is wiped on drop
333 let mut msg_to_embed = Zeroizing::new(cbor::encode(msg_to_embed)?);
334
335 // Restrict the user's domain to the context of this library
336 let info = [DOMAIN_PREFIX, domain].concat();
337 let aad = cbor::encode(&(&info, msg_to_auth))?;
338
339 let protected = cbor::encode(&SigProtectedHeader {
340 algorithm: ALGORITHM_ID_XDSA,
341 crit: CritHeader {
342 timestamp: HEADER_TIMESTAMP,
343 },
344 kid: signer.fingerprint(),
345 timestamp,
346 })?;
347 // Build and sign Sig_structure
348 let blob = encode_wiped(
349 SigStructure {
350 context: "Signature1",
351 protected: &protected,
352 external_aad: &aad,
353 payload: &msg_to_embed,
354 },
355 protected.len() + aad.len() + msg_to_embed.len(),
356 )?;
357 let signature = signer.sign(&blob);
358
359 // Build and encode COSE_Sign1, wiping the payload it holds once encoded
360 let len = protected.len() + msg_to_embed.len() + xdsa::SIGNATURE_SIZE;
361 let mut sign1 = CoseSign1 {
362 protected,
363 unprotected: EmptyHeader {},
364 payload: Some(std::mem::take(&mut *msg_to_embed)),
365 signature,
366 };
367 let encoded = encode_wiped(&sign1, len);
368 sign1.payload.zeroize();
369
370 // Hand the encoded COSE_Sign1 over to the caller
371 Ok(std::mem::take(&mut *encoded?))
372}
373
374/// Validates a COSE_Sign1 digital signature with a detached payload.
375///
376/// Uses the current system time for drift checking. For testing or custom
377/// timestamps, use [`verify_detached_at`].
378///
379/// - `msg_to_check`: The serialized COSE_Sign1 structure (with null payload)
380/// - `msg_to_auth`: The same message used during signing (verified but not embedded)
381/// - `verifier`: The xDSA public key to verify against
382/// - `domain`: Application domain for separating protocol purposes
383/// - `max_drift`: Maximum allowed timestamp difference in seconds, past or future.
384/// `Some(n)` accepts differences up to and including `n`; `None` skips the check.
385pub fn verify_detached<A: Encode>(
386 msg_to_check: &[u8],
387 msg_to_auth: A,
388 verifier: &xdsa::PublicKey,
389 domain: &[u8],
390 max_drift: Option<u64>,
391) -> Result<(), Error> {
392 // Read the clock only if max_drift is specified
393 let now = match max_drift {
394 Some(_) => SystemTime::now()
395 .duration_since(UNIX_EPOCH)
396 .expect("system time before Unix epoch")
397 .as_secs() as i64,
398 None => 0,
399 };
400 verify_detached_at(msg_to_check, msg_to_auth, verifier, domain, max_drift, now)
401}
402
403/// Validates a COSE_Sign1 digital signature with a detached payload, checking
404/// drift against an explicit current time.
405///
406/// - `msg_to_check`: The serialized COSE_Sign1 structure (with null payload)
407/// - `msg_to_auth`: The same message used during signing (verified but not embedded)
408/// - `verifier`: The xDSA public key to verify against
409/// - `domain`: Application domain for separating protocol purposes
410/// - `max_drift`: Maximum allowed timestamp difference in seconds, past or future.
411/// `Some(n)` accepts differences up to and including `n`; `None` skips the check.
412/// - `now`: Unix timestamp in seconds to use for drift checking
413pub fn verify_detached_at<A: Encode>(
414 msg_to_check: &[u8],
415 msg_to_auth: A,
416 verifier: &xdsa::PublicKey,
417 domain: &[u8],
418 max_drift: Option<u64>,
419 now: i64,
420) -> Result<(), Error> {
421 // Restrict the user's domain to the context of this library
422 let info = [DOMAIN_PREFIX, domain].concat();
423 let aad = cbor::encode(&(&info, msg_to_auth))?;
424
425 // Parse COSE_Sign1
426 let sign1: CoseSign1 = cbor::decode(msg_to_check)?;
427
428 // Verify payload is null (detached)
429 if sign1.payload.is_some() {
430 return Err(Error::UnexpectedPayload);
431 }
432 // Verify the protected header
433 let header = verify_sig_protected_header(&sign1.protected, ALGORITHM_ID_XDSA, verifier)?;
434
435 // Check signature timestamp drift if max_drift is specified
436 if let Some(max) = max_drift {
437 let drift = now.abs_diff(header.timestamp);
438 if drift > max {
439 return Err(Error::StaleSignature(drift, max));
440 }
441 }
442 // Reconstruct Sig_structure to verify (empty payload for detached mode)
443 let blob = cbor::encode(SigStructure {
444 context: "Signature1",
445 protected: &sign1.protected,
446 external_aad: &aad,
447 payload: &[],
448 })?;
449
450 // Verify signature
451 verifier
452 .verify(&blob, &sign1.signature)
453 .map_err(|e| Error::InvalidSignature(e.to_string()))?;
454
455 Ok(())
456}
457
458/// Validates a COSE_Sign1 digital signature and returns the embedded payload.
459///
460/// Uses the current system time for drift checking. For testing or custom
461/// timestamps, use [`verify_at`].
462///
463/// - `msg_to_check`: The serialized COSE_Sign1 structure
464/// - `msg_to_auth`: The same additional authenticated data used during signing
465/// - `verifier`: The xDSA public key to verify against
466/// - `domain`: Application domain for separating protocol purposes
467/// - `max_drift`: Maximum allowed timestamp difference in seconds, past or future.
468/// `Some(n)` accepts differences up to and including `n`; `None` skips the check.
469///
470/// Returns the CBOR-decoded embedded payload if verification succeeds.
471pub fn verify<E: Decode, A: Encode>(
472 msg_to_check: &[u8],
473 msg_to_auth: A,
474 verifier: &xdsa::PublicKey,
475 domain: &[u8],
476 max_drift: Option<u64>,
477) -> Result<E, Error> {
478 // Read the clock only if max_drift is specified
479 let now = match max_drift {
480 Some(_) => SystemTime::now()
481 .duration_since(UNIX_EPOCH)
482 .expect("system time before Unix epoch")
483 .as_secs() as i64,
484 None => 0,
485 };
486 verify_at(msg_to_check, msg_to_auth, verifier, domain, max_drift, now)
487}
488
489/// Validates a COSE_Sign1 digital signature and returns the embedded payload,
490/// checking drift against an explicit current time.
491///
492/// - `msg_to_check`: The serialized COSE_Sign1 structure
493/// - `msg_to_auth`: The same additional authenticated data used during signing
494/// - `verifier`: The xDSA public key to verify against
495/// - `domain`: Application domain for separating protocol purposes
496/// - `max_drift`: Maximum allowed timestamp difference in seconds, past or future.
497/// `Some(n)` accepts differences up to and including `n`; `None` skips the check.
498/// - `now`: Unix timestamp in seconds to use for drift checking
499///
500/// Returns the CBOR-decoded embedded payload if verification succeeds.
501pub fn verify_at<E: Decode, A: Encode>(
502 msg_to_check: &[u8],
503 msg_to_auth: A,
504 verifier: &xdsa::PublicKey,
505 domain: &[u8],
506 max_drift: Option<u64>,
507 now: i64,
508) -> Result<E, Error> {
509 // Restrict the user's domain to the context of this library
510 let info = [DOMAIN_PREFIX, domain].concat();
511 let aad = cbor::encode(&(&info, msg_to_auth))?;
512
513 // Parse COSE_Sign1
514 let sign1: CoseSign1 = cbor::decode(msg_to_check)?;
515
516 // Verify payload is present (embedded). It can hold secrets, so every
517 // buffer holding it is wiped on drop.
518 let payload = Zeroizing::new(sign1.payload.ok_or(Error::MissingPayload)?);
519
520 // Verify the protected header
521 let header = verify_sig_protected_header(&sign1.protected, ALGORITHM_ID_XDSA, verifier)?;
522
523 // Check signature timestamp drift if max_drift is specified
524 if let Some(max) = max_drift {
525 let drift = now.abs_diff(header.timestamp);
526 if drift > max {
527 return Err(Error::StaleSignature(drift, max));
528 }
529 }
530 // Reconstruct Sig_structure to verify
531 let blob = encode_wiped(
532 SigStructure {
533 context: "Signature1",
534 protected: &sign1.protected,
535 external_aad: &aad,
536 payload: &payload,
537 },
538 sign1.protected.len() + aad.len() + payload.len(),
539 )?;
540
541 // Verify signature
542 verifier
543 .verify(&blob, &sign1.signature)
544 .map_err(|e| Error::InvalidSignature(e.to_string()))?;
545
546 Ok(cbor::decode(&payload)?)
547}
548
549/// Extracts the signer's fingerprint from a COSE_Sign1 signature without
550/// verifying it.
551///
552/// This allows looking up the appropriate verification key before attempting
553/// full signature verification.
554///
555/// - `signature`: The serialized COSE_Sign1 structure
556///
557/// Returns the signer's fingerprint from the protected header's `kid` field.
558pub fn signer(signature: &[u8]) -> Result<xdsa::Fingerprint, Error> {
559 let sign1: CoseSign1 = cbor::decode(signature)?;
560
561 // The payload can hold secrets and is never read, so it is wiped right away
562 drop(Zeroizing::new(sign1.payload));
563
564 let header: SigProtectedHeader = cbor::decode(&sign1.protected)?;
565 Ok(header.kid)
566}
567
568/// Extracts the embedded payload from a COSE_Sign1 signature without
569/// verifying it.
570///
571/// **Warning**: This function does NOT verify the signature. The returned payload
572/// is unauthenticated and should not be trusted until verified with [`verify`].
573/// Use [`signer`] to extract the signer's fingerprint for key lookup.
574///
575/// - `signature`: The serialized COSE_Sign1 structure
576///
577/// Returns the CBOR-decoded payload.
578pub fn peek<E: Decode>(signature: &[u8]) -> Result<E, Error> {
579 let sign1: CoseSign1 = cbor::decode(signature)?;
580
581 // The payload can hold secrets, so it is wiped on drop
582 let payload = Zeroizing::new(sign1.payload.ok_or(Error::MissingPayload)?);
583 Ok(cbor::decode(&payload)?)
584}
585
586/// Signs a message, then encrypts it to a recipient.
587///
588/// Uses the current system time as the signature timestamp. For testing or custom
589/// timestamps, use [`seal_at`].
590///
591/// - `msg_to_seal`: The message to sign and encrypt
592/// - `msg_to_auth`: Additional authenticated data (signed and bound to encryption, but not embedded)
593/// - `signer`: The xDSA secret key to sign with
594/// - `recipient`: The xHPKE public key to encrypt to
595/// - `domain`: Application domain for HPKE key derivation
596/// - `padding`: Sender's policy for zeros after the signed envelope
597///
598/// Returns the serialized COSE_Encrypt0 structure containing the encrypted COSE_Sign1.
599///
600/// # Panics
601///
602/// Panics if [`Padding::Buckets`] has a zero `floor` or `step`, or the required
603/// bucket size exceeds [`usize::MAX`]. Also panics if the system time precedes
604/// the Unix epoch.
605pub fn seal<E: Encode, A: Encode>(
606 msg_to_seal: E,
607 msg_to_auth: A,
608 signer: &xdsa::SecretKey,
609 recipient: &xhpke::PublicKey,
610 domain: &[u8],
611 padding: &Padding,
612) -> Result<Vec<u8>, Error> {
613 let timestamp = SystemTime::now()
614 .duration_since(UNIX_EPOCH)
615 .expect("system time before Unix epoch")
616 .as_secs() as i64;
617 seal_at(
618 msg_to_seal,
619 msg_to_auth,
620 signer,
621 recipient,
622 domain,
623 padding,
624 timestamp,
625 )
626}
627
628/// Signs a message with an explicit timestamp, then encrypts it to a
629/// recipient.
630///
631/// - `msg_to_seal`: The message to sign and encrypt
632/// - `msg_to_auth`: Additional authenticated data (signed and bound to encryption, but not embedded)
633/// - `signer`: The xDSA secret key to sign with
634/// - `recipient`: The xHPKE public key to encrypt to
635/// - `domain`: Application domain for HPKE key derivation
636/// - `padding`: Sender's policy for zeros after the signed envelope
637/// - `timestamp`: Unix timestamp in seconds to embed in the signature's protected header
638///
639/// Returns the serialized COSE_Encrypt0 structure containing the encrypted COSE_Sign1.
640///
641/// # Panics
642///
643/// Panics if [`Padding::Buckets`] has a zero `floor` or `step`, or the required
644/// bucket size exceeds [`usize::MAX`].
645pub fn seal_at<E: Encode, A: Encode>(
646 msg_to_seal: E,
647 msg_to_auth: A,
648 signer: &xdsa::SecretKey,
649 recipient: &xhpke::PublicKey,
650 domain: &[u8],
651 padding: &Padding,
652 timestamp: i64,
653) -> Result<Vec<u8>, Error> {
654 // Pre-encode for EncStructure (which needs raw bytes for external_aad)
655 let msg_to_auth = cbor::encode(msg_to_auth)?;
656
657 // Create a COSE_Sign1 with the payload, binding the AAD (use Raw to avoid
658 // re-encoding). It holds the plaintext, so it is wiped once encrypted.
659 let signed = Zeroizing::new(sign_at(
660 msg_to_seal,
661 Raw(msg_to_auth.clone()),
662 signer,
663 domain,
664 timestamp,
665 )?);
666 // Encrypt the signed message to the recipient
667 encrypt(&signed, Raw(msg_to_auth), recipient, domain, padding)
668}
669
670/// Encrypts an already signed COSE_Sign1 to a recipient.
671///
672/// For most use cases, prefer [`seal`] which signs and encrypts in one step.
673/// Use this only when re-encrypting a message (from [`decrypt`]) to a different
674/// recipient without access to the original signer's key.
675///
676/// - `sign1`: The COSE_Sign1 structure (e.g., from [`decrypt`])
677/// - `msg_to_auth`: The same additional authenticated data used during sealing
678/// - `recipient`: The xHPKE public key to encrypt to
679/// - `domain`: Application domain for HPKE key derivation
680/// - `padding`: Sender's policy for zeros after the signed envelope
681///
682/// Returns the serialized COSE_Encrypt0 structure.
683///
684/// # Panics
685///
686/// Panics if [`Padding::Buckets`] has a zero `floor` or `step`, or the required
687/// bucket size exceeds [`usize::MAX`].
688pub fn encrypt<A: Encode>(
689 sign1: &[u8],
690 msg_to_auth: A,
691 recipient: &xhpke::PublicKey,
692 domain: &[u8],
693 padding: &Padding,
694) -> Result<Vec<u8>, Error> {
695 // Allocate the final size before copying plaintext, so it never reallocates
696 let len = padding.padded_len(sign1.len());
697 let mut plaintext = Zeroizing::new(Vec::with_capacity(len));
698 plaintext.extend_from_slice(sign1);
699 plaintext.resize(len, 0);
700
701 // Pre-encode for EncStructure (which needs raw bytes for external_aad)
702 let msg_to_auth = cbor::encode(msg_to_auth)?;
703
704 // Build protected header with recipient's fingerprint
705 let protected = cbor::encode(&EncProtectedHeader {
706 algorithm: ALGORITHM_ID_XHPKE,
707 kid: recipient.fingerprint(),
708 })?;
709 // Build and seal Enc_structure (domain prefixing is handled by xHPKE)
710 let (encap_key, ciphertext) = recipient
711 .seal(
712 &plaintext,
713 &cbor::encode(EncStructure {
714 context: "Encrypt0",
715 protected: &protected,
716 external_aad: &msg_to_auth,
717 })?,
718 domain,
719 )
720 .map_err(|e| Error::DecryptionFailed(e.to_string()))?;
721
722 // Build and encode COSE_Encrypt0
723 Ok(cbor::encode(&CoseEncrypt0 {
724 protected,
725 unprotected: EncapKeyHeader {
726 encap_key: encap_key.to_vec(),
727 },
728 ciphertext,
729 })?)
730}
731
732/// Decrypts and verifies a sealed message.
733///
734/// Uses the current system time for drift checking. For testing or custom
735/// timestamps, use [`open_at`].
736///
737/// - `msg_to_open`: The serialized COSE_Encrypt0 structure
738/// - `msg_to_auth`: The same additional authenticated data used during sealing
739/// - `recipient`: The xHPKE secret key to decrypt with
740/// - `sender`: The xDSA public key to verify the signature against
741/// - `domain`: Application domain for HPKE key derivation
742/// - `max_drift`: Maximum allowed timestamp difference in seconds, past or future.
743/// `Some(n)` accepts differences up to and including `n`; `None` skips the check.
744///
745/// Returns the CBOR-decoded payload if decryption and verification succeed.
746pub fn open<E: Decode, A: Encode + Clone>(
747 msg_to_open: &[u8],
748 msg_to_auth: A,
749 recipient: &xhpke::SecretKey,
750 sender: &xdsa::PublicKey,
751 domain: &[u8],
752 max_drift: Option<u64>,
753) -> Result<E, Error> {
754 // Read the clock only if max_drift is specified
755 let now = match max_drift {
756 Some(_) => SystemTime::now()
757 .duration_since(UNIX_EPOCH)
758 .expect("system time before Unix epoch")
759 .as_secs() as i64,
760 None => 0,
761 };
762 open_at(
763 msg_to_open,
764 msg_to_auth,
765 recipient,
766 sender,
767 domain,
768 max_drift,
769 now,
770 )
771}
772
773/// Decrypts and verifies a sealed message, checking drift against an explicit
774/// current time.
775///
776/// - `msg_to_open`: The serialized COSE_Encrypt0 structure
777/// - `msg_to_auth`: The same additional authenticated data used during sealing
778/// - `recipient`: The xHPKE secret key to decrypt with
779/// - `sender`: The xDSA public key to verify the signature against
780/// - `domain`: Application domain for HPKE key derivation
781/// - `max_drift`: Maximum allowed timestamp difference in seconds, past or future.
782/// `Some(n)` accepts differences up to and including `n`; `None` skips the check.
783/// - `now`: Unix timestamp in seconds to use for drift checking
784///
785/// Returns the CBOR-decoded payload if decryption and verification succeed.
786pub fn open_at<E: Decode, A: Encode + Clone>(
787 msg_to_open: &[u8],
788 msg_to_auth: A,
789 recipient: &xhpke::SecretKey,
790 sender: &xdsa::PublicKey,
791 domain: &[u8],
792 max_drift: Option<u64>,
793 now: i64,
794) -> Result<E, Error> {
795 // Decrypt the COSE_Encrypt0 to get the COSE_Sign1. It holds the plaintext,
796 // so it is wiped on drop, as is the payload extracted from it.
797 let sign1 = Zeroizing::new(decrypt(
798 msg_to_open,
799 msg_to_auth.clone(),
800 recipient,
801 domain,
802 )?);
803
804 // Verify the signature and extract the payload
805 let raw: Raw = verify_at::<Raw, _>(&sign1, &msg_to_auth, sender, domain, max_drift, now)?;
806 let payload = Zeroizing::new(raw.0);
807 Ok(cbor::decode(&payload)?)
808}
809
810/// Decrypts a sealed message without verifying its signature.
811///
812/// This allows inspecting the signer before verification. Use [`signer`] to
813/// extract the signer's fingerprint, then [`verify`] or [`verify_at`] to verify.
814///
815/// It strips the zero bytes after the COSE_Sign1, accepting any number of them,
816/// and returns the COSE_Sign1 as encoded. A plaintext that does not start with
817/// one CBOR item fails with [`Error::CborError`], and a nonzero byte after it
818/// with [`Error::InvalidPadding`].
819///
820/// - `msg_to_open`: The serialized COSE_Encrypt0 structure
821/// - `msg_to_auth`: The same additional authenticated data used during sealing
822/// - `recipient`: The xHPKE secret key to decrypt with
823/// - `domain`: Application domain for HPKE key derivation
824///
825/// Returns the decrypted COSE_Sign1 structure (not yet verified).
826pub fn decrypt<A: Encode>(
827 msg_to_open: &[u8],
828 msg_to_auth: A,
829 recipient: &xhpke::SecretKey,
830 domain: &[u8],
831) -> Result<Vec<u8>, Error> {
832 // Pre-encode for EncStructure (which needs raw bytes for external_aad)
833 let msg_to_auth = cbor::encode(msg_to_auth)?;
834
835 // Parse COSE_Encrypt0
836 let encrypt0: CoseEncrypt0 = cbor::decode(msg_to_open)?;
837
838 // Verify protected header
839 verify_enc_protected_header(&encrypt0.protected, ALGORITHM_ID_XHPKE, recipient)?;
840
841 // Extract encapsulated key from the unprotected headers
842 let encap_key: &[u8; xhpke::ENCAP_KEY_SIZE] = encrypt0
843 .unprotected
844 .encap_key
845 .as_slice()
846 .try_into()
847 .map_err(|_| {
848 Error::InvalidEncapKeySize(encrypt0.unprotected.encap_key.len(), xhpke::ENCAP_KEY_SIZE)
849 })?;
850
851 // Rebuild and open Enc_structure (domain prefixing is handled by xHPKE)
852 let decrypted = Zeroizing::new(
853 recipient
854 .open(
855 encap_key,
856 &encrypt0.ciphertext,
857 &cbor::encode(EncStructure {
858 context: "Encrypt0",
859 protected: &encrypt0.protected,
860 external_aad: &msg_to_auth,
861 })?,
862 domain,
863 )
864 .map_err(|e| Error::DecryptionFailed(e.to_string()))?,
865 );
866
867 // Traverse one item without re-encoding, keeping both plaintext buffers wiped
868 let mut decoder = cbor::Decoder::new(&decrypted);
869 let mut sign1 = Zeroizing::new(Raw::decode_cbor_notrail(&mut decoder)?.0);
870 if decrypted[sign1.len()..].iter().any(|&byte| byte != 0) {
871 return Err(Error::InvalidPadding);
872 }
873 Ok(std::mem::take(&mut *sign1))
874}
875
876/// Extracts the recipient's fingerprint from a COSE_Encrypt0 message without
877/// decrypting it.
878///
879/// This allows looking up the appropriate decryption key before attempting
880/// full decryption.
881///
882/// - `ciphertext`: The serialized COSE_Encrypt0 structure
883///
884/// Returns the recipient's fingerprint from the protected header's `kid` field.
885pub fn recipient(ciphertext: &[u8]) -> Result<xhpke::Fingerprint, Error> {
886 let encrypt0: CoseEncrypt0 = cbor::decode(ciphertext)?;
887 let header: EncProtectedHeader = cbor::decode(&encrypt0.protected)?;
888 Ok(header.kid)
889}
890
891/// Verifies the signature protected header contains exactly the expected algorithm
892/// and that the key identifier matches the provided verifier.
893fn verify_sig_protected_header(
894 bytes: &[u8],
895 exp_algo: i64,
896 verifier: &xdsa::PublicKey,
897) -> Result<SigProtectedHeader, Error> {
898 let header: SigProtectedHeader = cbor::decode(bytes)?;
899 if header.algorithm != exp_algo {
900 return Err(Error::UnexpectedAlgorithm(header.algorithm, exp_algo));
901 }
902 if header.crit.timestamp != HEADER_TIMESTAMP {
903 return Err(Error::UnexpectedAlgorithm(
904 header.crit.timestamp,
905 HEADER_TIMESTAMP,
906 ));
907 }
908 if header.kid != verifier.fingerprint() {
909 return Err(Error::UnexpectedSigningKey(
910 header.kid,
911 verifier.fingerprint(),
912 ));
913 }
914 Ok(header)
915}
916
917/// Verifies the encryption protected header contains exactly the expected algorithm
918/// and that the key identifier matches the provided recipient.
919fn verify_enc_protected_header(
920 bytes: &[u8],
921 exp_algo: i64,
922 recipient: &xhpke::SecretKey,
923) -> Result<EncProtectedHeader, Error> {
924 let header: EncProtectedHeader = cbor::decode(bytes)?;
925 if header.algorithm != exp_algo {
926 return Err(Error::UnexpectedAlgorithm(header.algorithm, exp_algo));
927 }
928 if header.kid != recipient.fingerprint() {
929 return Err(Error::UnexpectedEncryptionKey(
930 header.kid,
931 recipient.fingerprint(),
932 ));
933 }
934 Ok(header)
935}
936
937/// Upper bound in bytes on the CBOR framing around the variable length fields
938/// of a COSE structure, covering the array and byte string headers, the context
939/// string and the empty header map.
940const FRAMING_OVERHEAD: usize = 64;
941
942/// Encodes a structure holding a payload into a buffer wiped on drop. The
943/// buffer is sized up front for `len` bytes of fields plus their framing, so it
944/// never reallocates and leaves no partial copy of the payload behind.
945fn encode_wiped<T: Encode>(value: T, len: usize) -> Result<Zeroizing<Vec<u8>>, Error> {
946 let mut buf = Zeroizing::new(Vec::with_capacity(len + FRAMING_OVERHEAD));
947 value.encode_cbor_to(&mut buf)?;
948 debug_assert!(buf.len() <= len + FRAMING_OVERHEAD);
949 Ok(buf)
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955
956 /// Tests the bucket sequences and their boundary targets.
957 #[test]
958 fn test_padding_buckets() {
959 // Pin each rounded-up step independently of the bucket calculation
960 for (floor, step, sizes) in [
961 (
962 8192,
963 20,
964 [
965 8192, 8602, 9033, 9485, 9960, 10458, 10981, 11531, 12108, 12714,
966 ]
967 .as_slice(),
968 ),
969 (100, 3, [100, 134, 179, 239, 319, 426].as_slice()),
970 ] {
971 let padding = Padding::Buckets { floor, step };
972 for pair in sizes.windows(2) {
973 assert_eq!(padding.padded_len(pair[0]), pair[0], "{floor}/{step}");
974 assert_eq!(padding.padded_len(pair[0] + 1), pair[1], "{floor}/{step}");
975 }
976 }
977
978 // Check the floor, exact fits, transitions, and a larger envelope
979 let padding = Padding::Buckets {
980 floor: 8192,
981 step: 20,
982 };
983 for (len, expected) in [
984 (0, 8192),
985 (1, 8192),
986 (8192, 8192),
987 (8193, 8602),
988 (8602, 8602),
989 (8603, 9033),
990 (300000, 303278),
991 ] {
992 assert_eq!(padding.padded_len(len), expected, "{len}");
993 }
994
995 // No-padding preserves all lengths, and ceiling division cannot overflow
996 for len in [0, 1, 8192, 8193, 300000, usize::MAX] {
997 assert_eq!(Padding::None.padded_len(len), len, "{len}");
998 }
999 assert_eq!(
1000 Padding::Buckets {
1001 floor: usize::MAX - 1,
1002 step: usize::MAX,
1003 }
1004 .padded_len(usize::MAX),
1005 usize::MAX,
1006 );
1007 }
1008
1009 /// Tests that a zero floor panics when sealing.
1010 #[test]
1011 #[should_panic(expected = "padding floor must be nonzero")]
1012 fn test_padding_zero_floor() {
1013 let recipient = xhpke::SecretKey::from_bytes(&[7; 32]);
1014 let _ = encrypt(
1015 &[0],
1016 (),
1017 &recipient.public_key(),
1018 b"padding",
1019 &Padding::Buckets { floor: 0, step: 20 },
1020 );
1021 }
1022
1023 /// Tests that a zero step panics, even when the envelope fits the floor.
1024 #[test]
1025 #[should_panic(expected = "padding step must be nonzero")]
1026 fn test_padding_zero_step() {
1027 let recipient = xhpke::SecretKey::from_bytes(&[7; 32]);
1028 let _ = encrypt(
1029 &[0],
1030 (),
1031 &recipient.public_key(),
1032 b"padding",
1033 &Padding::Buckets {
1034 floor: 8192,
1035 step: 0,
1036 },
1037 );
1038 }
1039
1040 /// Tests that a bucket size past usize::MAX panics instead of wrapping.
1041 #[test]
1042 #[should_panic(expected = "padding bucket size overflow")]
1043 fn test_padding_bucket_overflow() {
1044 Padding::Buckets {
1045 floor: usize::MAX - 1,
1046 step: 1,
1047 }
1048 .padded_len(usize::MAX);
1049 }
1050
1051 /// Opens the raw AEAD plaintext through xHPKE, keeping the padding.
1052 fn open_plaintext<A: Encode>(
1053 envelope: &[u8],
1054 aad: A,
1055 recipient: &xhpke::SecretKey,
1056 domain: &[u8],
1057 ) -> Zeroizing<Vec<u8>> {
1058 let envelope: CoseEncrypt0 = cbor::decode(envelope).unwrap();
1059 let aad = cbor::encode(EncStructure {
1060 context: "Encrypt0",
1061 protected: &envelope.protected,
1062 external_aad: &cbor::encode(aad).unwrap(),
1063 })
1064 .unwrap();
1065 Zeroizing::new(
1066 recipient
1067 .open(
1068 envelope
1069 .unprotected
1070 .encap_key
1071 .as_slice()
1072 .try_into()
1073 .unwrap(),
1074 &envelope.ciphertext,
1075 &aad,
1076 domain,
1077 )
1078 .unwrap(),
1079 )
1080 }
1081
1082 /// Encrypts any plaintext through xHPKE, to test the COSE reader.
1083 fn seal_plaintext<A: Encode>(
1084 plaintext: &[u8],
1085 aad: A,
1086 recipient: &xhpke::PublicKey,
1087 domain: &[u8],
1088 ) -> Vec<u8> {
1089 // Bind the same headers and external AAD as the wire profile
1090 let protected = cbor::encode(EncProtectedHeader {
1091 algorithm: ALGORITHM_ID_XHPKE,
1092 kid: recipient.fingerprint(),
1093 })
1094 .unwrap();
1095 let aad = cbor::encode(EncStructure {
1096 context: "Encrypt0",
1097 protected: &protected,
1098 external_aad: &cbor::encode(aad).unwrap(),
1099 })
1100 .unwrap();
1101
1102 // Bypass the COSE sender's padding policy
1103 let (encap_key, ciphertext) = recipient.seal(plaintext, &aad, domain).unwrap();
1104 cbor::encode(CoseEncrypt0 {
1105 protected,
1106 unprotected: EncapKeyHeader {
1107 encap_key: encap_key.to_vec(),
1108 },
1109 ciphertext,
1110 })
1111 .unwrap()
1112 }
1113
1114 /// Tests both policies against a fixed COSE_Sign1 and its exact padded plaintext.
1115 #[test]
1116 fn test_sealed_plaintext_padding() {
1117 // Reuse the fixed v0.16 signature as the expected plaintext prefix
1118 let corpus: serde_json::Value = serde_json::from_str(FIXTURES).unwrap();
1119 let signer =
1120 xdsa::SecretKey::from_bytes(&fixture(&corpus, "xdsa_seed").try_into().unwrap());
1121 let recipient =
1122 xhpke::SecretKey::from_bytes(&fixture(&corpus, "xhpke_seed").try_into().unwrap());
1123 let sign1 = Zeroizing::new(fixture(&corpus, "sign1"));
1124
1125 // Seal a payload and re-encrypt an existing signature under each policy
1126 for (padding, expected_len, expected_zeros) in [
1127 (Padding::None, 3461, 0),
1128 (
1129 Padding::Buckets {
1130 floor: 8192,
1131 step: 20,
1132 },
1133 8192,
1134 4731,
1135 ),
1136 ] {
1137 let sealed = seal_at(
1138 b"cose fixture payload".as_slice(),
1139 b"cose fixture aad".as_slice(),
1140 &signer,
1141 &recipient.public_key(),
1142 b"v016-fixtures",
1143 &padding,
1144 1700000000,
1145 )
1146 .unwrap();
1147 let encrypted = encrypt(
1148 &sign1,
1149 b"cose fixture aad".as_slice(),
1150 &recipient.public_key(),
1151 b"v016-fixtures",
1152 &padding,
1153 )
1154 .unwrap();
1155
1156 // Inspect through xHPKE, then check stripping and signature verification
1157 for envelope in [sealed, encrypted] {
1158 let plaintext = open_plaintext(
1159 &envelope,
1160 b"cose fixture aad".as_slice(),
1161 &recipient,
1162 b"v016-fixtures",
1163 );
1164 assert_eq!(plaintext.len(), expected_len, "{padding:?}");
1165 assert_eq!(&plaintext[..3461], sign1.as_slice(), "{padding:?}");
1166 assert_eq!(&plaintext[3461..], vec![0; expected_zeros], "{padding:?}");
1167 let decrypted = Zeroizing::new(
1168 decrypt(
1169 &envelope,
1170 b"cose fixture aad".as_slice(),
1171 &recipient,
1172 b"v016-fixtures",
1173 )
1174 .unwrap(),
1175 );
1176 assert_eq!(decrypted, sign1, "{padding:?}");
1177 let payload: Vec<u8> = open(
1178 &envelope,
1179 b"cose fixture aad".as_slice(),
1180 &recipient,
1181 &signer.public_key(),
1182 b"v016-fixtures",
1183 None,
1184 )
1185 .unwrap();
1186 assert_eq!(payload, b"cose fixture payload", "{padding:?}");
1187 }
1188 }
1189 }
1190
1191 /// Tests that padding outside the sender's sizes opens, while a nonzero byte fails.
1192 #[test]
1193 fn test_decrypt_padding_validation() {
1194 // Append 37 zeros to a fixed Sign1, independently of the sender's policy
1195 let corpus: serde_json::Value = serde_json::from_str(FIXTURES).unwrap();
1196 let signer =
1197 xdsa::SecretKey::from_bytes(&fixture(&corpus, "xdsa_seed").try_into().unwrap());
1198 let recipient = xhpke::SecretKey::from_bytes(&[7; 32]);
1199 let sign1 = Zeroizing::new(fixture(&corpus, "sign1"));
1200 let mut plaintext = Zeroizing::new(vec![0; 3498]);
1201 plaintext[..3461].copy_from_slice(&sign1);
1202 let envelope = seal_plaintext(
1203 &plaintext,
1204 b"cose fixture aad".as_slice(),
1205 &recipient.public_key(),
1206 b"v016-fixtures",
1207 );
1208
1209 // The reader accepts any zero padding length and returns the bare Sign1
1210 let decrypted = Zeroizing::new(
1211 decrypt(
1212 &envelope,
1213 b"cose fixture aad".as_slice(),
1214 &recipient,
1215 b"v016-fixtures",
1216 )
1217 .unwrap(),
1218 );
1219 assert_eq!(decrypted, sign1);
1220 let payload: Vec<u8> = open_at(
1221 &envelope,
1222 b"cose fixture aad".as_slice(),
1223 &recipient,
1224 &signer.public_key(),
1225 b"v016-fixtures",
1226 Some(0),
1227 1700000000,
1228 )
1229 .unwrap();
1230 assert_eq!(payload, b"cose fixture payload");
1231
1232 // Refuse a nonzero at the start, middle, or end of authenticated padding
1233 for position in [3461, 3479, 3497] {
1234 for byte in [1, 255] {
1235 plaintext[position] = byte;
1236 let envelope = seal_plaintext(
1237 &plaintext,
1238 b"cose fixture aad".as_slice(),
1239 &recipient.public_key(),
1240 b"v016-fixtures",
1241 );
1242 assert_eq!(
1243 decrypt(
1244 &envelope,
1245 b"cose fixture aad".as_slice(),
1246 &recipient,
1247 b"v016-fixtures",
1248 ),
1249 Err(Error::InvalidPadding),
1250 "{position}/{byte}",
1251 );
1252 assert_eq!(
1253 open::<Vec<u8>, _>(
1254 &envelope,
1255 b"cose fixture aad".as_slice(),
1256 &recipient,
1257 &signer.public_key(),
1258 b"v016-fixtures",
1259 None,
1260 ),
1261 Err(Error::InvalidPadding),
1262 "{position}/{byte}",
1263 );
1264 plaintext[position] = 0;
1265 }
1266 }
1267 }
1268
1269 /// Tests that a malformed plaintext fails in decrypt itself.
1270 #[test]
1271 fn test_decrypt_malformed_cbor() {
1272 let recipient = xhpke::SecretKey::from_bytes(&[7; 32]);
1273 for (plaintext, expected) in [
1274 (vec![], cbor::Error::UnexpectedEof),
1275 (vec![0x82, 0], cbor::Error::UnexpectedEof),
1276 (vec![0x81, 0x42, 0], cbor::Error::UnexpectedEof),
1277 (vec![0x18, 0], cbor::Error::NonCanonical),
1278 (vec![0xc0, 0], cbor::Error::UnsupportedType(6)),
1279 (vec![0x9f, 0xff], cbor::Error::InvalidAdditionalInfo(31)),
1280 (
1281 vec![0x5b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
1282 cbor::Error::UnexpectedEof,
1283 ),
1284 (
1285 [vec![0x81; 32], vec![0]].concat(),
1286 cbor::Error::MaxDepthExceeded(32),
1287 ),
1288 ] {
1289 let envelope = seal_plaintext(&plaintext, (), &recipient.public_key(), b"padding");
1290 assert_eq!(
1291 decrypt(&envelope, (), &recipient, b"padding"),
1292 Err(Error::CborError(expected)),
1293 "{plaintext:x?}",
1294 );
1295 }
1296 }
1297
1298 /// Tests that decrypt keeps the COSE_Sign1's encoding, zeros inside it included.
1299 #[test]
1300 fn test_decrypt_preserves_cbor_item() {
1301 // The item contains out-of-order map keys and a byte string ending in zero
1302 let recipient = xhpke::SecretKey::from_bytes(&[7; 32]);
1303 let plaintext = [0x82, 0xa2, 2, 0, 1, 0, 0x43, 0, 1, 0, 0, 0, 0];
1304 let envelope = seal_plaintext(&plaintext, (), &recipient.public_key(), b"padding");
1305
1306 // Schema validation belongs to verification, and padding starts after the item
1307 let decrypted = Zeroizing::new(decrypt(&envelope, (), &recipient, b"padding").unwrap());
1308 assert_eq!(
1309 decrypted.as_slice(),
1310 [0x82, 0xa2, 2, 0, 1, 0, 0x43, 0, 1, 0]
1311 );
1312 }
1313
1314 /// Tests various combinations of signing and verifying ops.
1315 #[test]
1316 fn test_sign_verify() {
1317 struct TestCase {
1318 msg_to_sign: &'static [u8],
1319 msg_to_auth: &'static [u8],
1320 verifier_msg_to_auth: &'static [u8],
1321 domain: &'static [u8],
1322 verifier_domain: &'static [u8],
1323 timestamp: Option<i64>,
1324 max_drift: Option<u64>,
1325 wrong_key: bool,
1326 want_ok: bool,
1327 }
1328 let now = SystemTime::now()
1329 .duration_since(UNIX_EPOCH)
1330 .unwrap()
1331 .as_secs() as i64;
1332
1333 let tests = [
1334 // Valid signature with aad
1335 TestCase {
1336 msg_to_sign: b"foo",
1337 msg_to_auth: b"bar",
1338 verifier_msg_to_auth: b"bar",
1339 domain: b"baz",
1340 verifier_domain: b"baz",
1341 timestamp: None,
1342 max_drift: None,
1343 wrong_key: false,
1344 want_ok: true,
1345 },
1346 // Valid signature, empty aad
1347 TestCase {
1348 msg_to_sign: b"foo",
1349 msg_to_auth: b"",
1350 verifier_msg_to_auth: b"",
1351 domain: b"baz",
1352 verifier_domain: b"baz",
1353 timestamp: None,
1354 max_drift: None,
1355 wrong_key: false,
1356 want_ok: true,
1357 },
1358 // Valid signature with explicit timestamp, no drift check
1359 TestCase {
1360 msg_to_sign: b"foo",
1361 msg_to_auth: b"bar",
1362 verifier_msg_to_auth: b"bar",
1363 domain: b"baz",
1364 verifier_domain: b"baz",
1365 timestamp: Some(now),
1366 max_drift: None,
1367 wrong_key: false,
1368 want_ok: true,
1369 },
1370 // Valid signature within drift tolerance
1371 TestCase {
1372 msg_to_sign: b"foo",
1373 msg_to_auth: b"bar",
1374 verifier_msg_to_auth: b"bar",
1375 domain: b"baz",
1376 verifier_domain: b"baz",
1377 timestamp: Some(now - 30),
1378 max_drift: Some(60),
1379 wrong_key: false,
1380 want_ok: true,
1381 },
1382 // Signature too old (exceeds max_drift)
1383 TestCase {
1384 msg_to_sign: b"foo",
1385 msg_to_auth: b"bar",
1386 verifier_msg_to_auth: b"bar",
1387 domain: b"baz",
1388 verifier_domain: b"baz",
1389 timestamp: Some(now - 120),
1390 max_drift: Some(60),
1391 wrong_key: false,
1392 want_ok: false,
1393 },
1394 // Signature too far in the future (exceeds max_drift)
1395 TestCase {
1396 msg_to_sign: b"foo",
1397 msg_to_auth: b"bar",
1398 verifier_msg_to_auth: b"bar",
1399 domain: b"baz",
1400 verifier_domain: b"baz",
1401 timestamp: Some(now + 120),
1402 max_drift: Some(60),
1403 wrong_key: false,
1404 want_ok: false,
1405 },
1406 // Wrong domain
1407 TestCase {
1408 msg_to_sign: b"foo",
1409 msg_to_auth: b"bar",
1410 verifier_msg_to_auth: b"bar",
1411 domain: b"baz",
1412 verifier_domain: b"baz2",
1413 timestamp: Some(now + 120),
1414 max_drift: Some(60),
1415 wrong_key: false,
1416 want_ok: false,
1417 },
1418 // Wrong aad
1419 TestCase {
1420 msg_to_sign: b"foo",
1421 msg_to_auth: b"bar",
1422 verifier_msg_to_auth: b"bar2",
1423 domain: b"baz",
1424 verifier_domain: b"baz",
1425 timestamp: None,
1426 max_drift: None,
1427 wrong_key: false,
1428 want_ok: false,
1429 },
1430 // Wrong key
1431 TestCase {
1432 msg_to_sign: b"foo",
1433 msg_to_auth: b"",
1434 verifier_msg_to_auth: b"",
1435 domain: b"baz",
1436 verifier_domain: b"baz",
1437 timestamp: None,
1438 max_drift: None,
1439 wrong_key: true,
1440 want_ok: false,
1441 },
1442 ];
1443
1444 for (i, test) in tests.iter().enumerate() {
1445 let alice = xdsa::SecretKey::generate();
1446 let bobby = xdsa::SecretKey::generate();
1447
1448 let signed = match test.timestamp {
1449 Some(ts) => sign_at(
1450 test.msg_to_sign.to_vec(),
1451 test.msg_to_auth.to_vec(),
1452 &alice,
1453 test.domain,
1454 ts,
1455 ),
1456 None => sign(
1457 test.msg_to_sign.to_vec(),
1458 test.msg_to_auth.to_vec(),
1459 &alice,
1460 test.domain,
1461 ),
1462 };
1463 let verifier = if test.wrong_key {
1464 bobby.public_key()
1465 } else {
1466 alice.public_key()
1467 };
1468 let result: Result<Vec<u8>, _> = verify(
1469 &signed.unwrap(),
1470 test.verifier_msg_to_auth.to_vec(),
1471 &verifier,
1472 test.verifier_domain,
1473 test.max_drift,
1474 );
1475
1476 if test.want_ok {
1477 let recovered = result.unwrap_or_else(|_| panic!("test {}: expected success", i));
1478 assert_eq!(recovered, test.msg_to_sign, "test {}: payload mismatch", i);
1479 } else {
1480 assert!(result.is_err(), "test {}: expected error", i);
1481 }
1482 }
1483 }
1484
1485 /// Tests that the drift check measures the true distance between timestamps
1486 /// at opposite ends of the i64 range, for embedded and detached signatures.
1487 #[test]
1488 fn test_drift_range() {
1489 let signer = xdsa::SecretKey::generate();
1490 let tests: [(&str, i64, i64, u64, bool); 5] = [
1491 (
1492 "wrapping distance",
1493 -9223372036854775740,
1494 9223372036854775683,
1495 209,
1496 false,
1497 ),
1498 ("full range within max", i64::MIN, i64::MAX, u64::MAX, true),
1499 (
1500 "full range over max",
1501 i64::MIN,
1502 i64::MAX,
1503 u64::MAX - 1,
1504 false,
1505 ),
1506 ("near the maximum", i64::MAX - 5, i64::MAX, 5, true),
1507 ("near the minimum", i64::MIN + 5, i64::MIN, 5, true),
1508 ];
1509 for (name, timestamp, now, max_drift, want_ok) in tests {
1510 let signed = sign_at(
1511 b"payload".as_slice(),
1512 b"".as_slice(),
1513 &signer,
1514 b"",
1515 timestamp,
1516 )
1517 .unwrap();
1518 let result = verify_at::<Vec<u8>, _>(
1519 &signed,
1520 b"".as_slice(),
1521 &signer.public_key(),
1522 b"",
1523 Some(max_drift),
1524 now,
1525 );
1526 assert_eq!(result.is_ok(), want_ok, "{name}");
1527 assert!(
1528 want_ok || matches!(result, Err(Error::StaleSignature(..))),
1529 "{name}"
1530 );
1531
1532 let signed = sign_detached_at(b"".as_slice(), &signer, b"", timestamp).unwrap();
1533 let result = verify_detached_at(
1534 &signed,
1535 b"".as_slice(),
1536 &signer.public_key(),
1537 b"",
1538 Some(max_drift),
1539 now,
1540 );
1541 assert_eq!(result.is_ok(), want_ok, "{name}");
1542 assert!(
1543 want_ok || matches!(result, Err(Error::StaleSignature(..))),
1544 "{name}"
1545 );
1546 }
1547 }
1548
1549 /// Tests various combinations of sealing and opening ops.
1550 #[test]
1551 fn test_seal_open() {
1552 struct TestCase {
1553 msg_to_seal: &'static [u8],
1554 msg_to_auth: &'static [u8],
1555 opener_msg_to_auth: &'static [u8],
1556 domain: &'static [u8],
1557 opener_domain: &'static [u8],
1558 timestamp: Option<i64>,
1559 max_drift: Option<u64>,
1560 wrong_signer: bool,
1561 want_ok: bool,
1562 }
1563 // Fetch the current time for drift tests
1564 let now = SystemTime::now()
1565 .duration_since(UNIX_EPOCH)
1566 .unwrap()
1567 .as_secs() as i64;
1568
1569 let tests = [
1570 // Valid seal/open with aad
1571 TestCase {
1572 msg_to_seal: b"foo",
1573 msg_to_auth: b"bar",
1574 opener_msg_to_auth: b"bar",
1575 domain: b"baz",
1576 opener_domain: b"baz",
1577 timestamp: None,
1578 max_drift: None,
1579 wrong_signer: false,
1580 want_ok: true,
1581 },
1582 // Valid seal/open, empty aad
1583 TestCase {
1584 msg_to_seal: b"foo",
1585 msg_to_auth: b"",
1586 opener_msg_to_auth: b"",
1587 domain: b"baz",
1588 opener_domain: b"baz",
1589 timestamp: None,
1590 max_drift: None,
1591 wrong_signer: false,
1592 want_ok: true,
1593 },
1594 // Valid seal/open, no drift check
1595 TestCase {
1596 msg_to_seal: b"foo",
1597 msg_to_auth: b"bar",
1598 opener_msg_to_auth: b"bar",
1599 domain: b"baz",
1600 opener_domain: b"baz",
1601 timestamp: Some(now),
1602 max_drift: None,
1603 wrong_signer: false,
1604 want_ok: true,
1605 },
1606 // Valid seal/open, valid drift
1607 TestCase {
1608 msg_to_seal: b"foo",
1609 msg_to_auth: b"bar",
1610 opener_msg_to_auth: b"bar",
1611 domain: b"baz",
1612 opener_domain: b"baz",
1613 timestamp: Some(now - 30),
1614 max_drift: Some(60),
1615 wrong_signer: false,
1616 want_ok: true,
1617 },
1618 // Wrong domain
1619 TestCase {
1620 msg_to_seal: b"foo",
1621 msg_to_auth: b"",
1622 opener_msg_to_auth: b"",
1623 domain: b"baz",
1624 opener_domain: b"baz2",
1625 timestamp: None,
1626 max_drift: None,
1627 wrong_signer: false,
1628 want_ok: false,
1629 },
1630 // Wrong aad
1631 TestCase {
1632 msg_to_seal: b"foo",
1633 msg_to_auth: b"bar",
1634 opener_msg_to_auth: b"bar2",
1635 domain: b"baz",
1636 opener_domain: b"baz",
1637 timestamp: None,
1638 max_drift: None,
1639 wrong_signer: false,
1640 want_ok: false,
1641 },
1642 // Wrong signer
1643 TestCase {
1644 msg_to_seal: b"foo",
1645 msg_to_auth: b"",
1646 opener_msg_to_auth: b"",
1647 domain: b"baz",
1648 opener_domain: b"baz",
1649 timestamp: None,
1650 max_drift: None,
1651 wrong_signer: true,
1652 want_ok: false,
1653 },
1654 // Timestamp too far in the past
1655 TestCase {
1656 msg_to_seal: b"foo",
1657 msg_to_auth: b"bar",
1658 opener_msg_to_auth: b"bar",
1659 domain: b"baz",
1660 opener_domain: b"baz",
1661 timestamp: Some(now - 120),
1662 max_drift: Some(60),
1663 wrong_signer: false,
1664 want_ok: false,
1665 },
1666 // Timestamp too far in the future
1667 TestCase {
1668 msg_to_seal: b"foo",
1669 msg_to_auth: b"bar",
1670 opener_msg_to_auth: b"bar",
1671 domain: b"baz",
1672 opener_domain: b"baz",
1673 timestamp: Some(now + 120),
1674 max_drift: Some(60),
1675 wrong_signer: false,
1676 want_ok: false,
1677 },
1678 ];
1679
1680 for (i, test) in tests.iter().enumerate() {
1681 let alice = xdsa::SecretKey::generate();
1682 let bobby = xdsa::SecretKey::generate();
1683 let carol = xhpke::SecretKey::generate();
1684
1685 let sealed = match test.timestamp {
1686 Some(ts) => seal_at(
1687 test.msg_to_seal.to_vec(),
1688 test.msg_to_auth.to_vec(),
1689 &alice,
1690 &carol.public_key(),
1691 test.domain,
1692 &Padding::None,
1693 ts,
1694 )
1695 .unwrap(),
1696 None => seal(
1697 test.msg_to_seal.to_vec(),
1698 test.msg_to_auth.to_vec(),
1699 &alice,
1700 &carol.public_key(),
1701 test.domain,
1702 &Padding::None,
1703 )
1704 .unwrap(),
1705 };
1706
1707 let verifier = if test.wrong_signer {
1708 bobby.public_key()
1709 } else {
1710 alice.public_key()
1711 };
1712 let result: Result<Vec<u8>, _> = open(
1713 &sealed,
1714 &test.opener_msg_to_auth.to_vec(),
1715 &carol,
1716 &verifier,
1717 test.opener_domain,
1718 test.max_drift,
1719 );
1720
1721 if test.want_ok {
1722 let recovered = result.unwrap_or_else(|_| panic!("test {}: expected success", i));
1723 assert_eq!(recovered, test.msg_to_seal, "test {}: payload mismatch", i);
1724 } else {
1725 assert!(result.is_err(), "test {}: expected error", i);
1726 }
1727 }
1728 }
1729
1730 /// Tests CBOR encoding/decoding for sign/verify.
1731 #[test]
1732 fn test_sign_verify_typed() {
1733 let alice = xdsa::SecretKey::generate();
1734
1735 let payload = (42u64, "foo".to_string());
1736 let aad = ("bar".to_string(),);
1737
1738 let signed = sign(&payload, &aad, &alice, b"baz").unwrap();
1739 let recovered: (u64, String) =
1740 verify(&signed, &aad, &alice.public_key(), b"baz", None).unwrap();
1741
1742 assert_eq!(recovered, payload);
1743 }
1744
1745 /// Tests CBOR encoding/decoding for seal/open.
1746 #[test]
1747 fn test_seal_open_typed() {
1748 let alice = xdsa::SecretKey::generate();
1749 let carol = xhpke::SecretKey::generate();
1750
1751 let payload = (123u64, "foo".to_string());
1752 let aad = ("bar".to_string(),);
1753
1754 let sealed = seal(
1755 &payload,
1756 &aad,
1757 &alice,
1758 &carol.public_key(),
1759 b"baz",
1760 &Padding::Buckets {
1761 floor: 8192,
1762 step: 20,
1763 },
1764 )
1765 .unwrap();
1766 assert_eq!(open_plaintext(&sealed, &aad, &carol, b"baz").len(), 8192);
1767 let recovered: (u64, String) =
1768 open(&sealed, &aad, &carol, &alice.public_key(), b"baz", None).unwrap();
1769
1770 assert_eq!(recovered, payload);
1771 }
1772
1773 /// Tests that signer and peek read an envelope without verifying it, and
1774 /// refuse one that is truncated or followed by trailing bytes.
1775 #[test]
1776 fn test_signer_peek() {
1777 let alice = xdsa::SecretKey::generate();
1778
1779 // Read the signer and the payload of an embedded signature
1780 let signed = sign_at(b"foo".as_slice(), b"bar".as_slice(), &alice, b"baz", 0).unwrap();
1781 assert_eq!(signer(&signed).unwrap(), alice.fingerprint());
1782 assert_eq!(peek::<Vec<u8>>(&signed).unwrap(), b"foo");
1783
1784 // Refuse the same envelope truncated or with a trailing byte
1785 let truncated = &signed[..signed.len() - 1];
1786 let trailing = [signed.as_slice(), &[0]].concat();
1787 for input in [truncated, &trailing] {
1788 assert!(signer(input).is_err());
1789 assert!(peek::<Vec<u8>>(input).is_err());
1790 }
1791 // A detached signature has a signer but no payload to peek at
1792 let signed = sign_detached_at(b"bar".as_slice(), &alice, b"baz", 0).unwrap();
1793 assert_eq!(signer(&signed).unwrap(), alice.fingerprint());
1794 assert!(matches!(
1795 peek::<Vec<u8>>(&signed),
1796 Err(Error::MissingPayload)
1797 ));
1798 }
1799
1800 /// Fixture corpus generated with v0.16.0 to pin the COSE wire format.
1801 const FIXTURES: &str = include_str!("testdata/v0.16.json");
1802
1803 /// Retrieves a hex encoded field from a fixture corpus.
1804 fn fixture(corpus: &serde_json::Value, key: &str) -> Vec<u8> {
1805 hex::decode(corpus[key].as_str().unwrap()).unwrap()
1806 }
1807
1808 /// Tests that the padded fixture opens to its payload, pinning the padded format.
1809 #[test]
1810 fn test_padded_fixture() {
1811 // This envelope was sealed once by crypto-rs with invented fixture data
1812 let corpus: serde_json::Value =
1813 serde_json::from_str(include_str!("testdata/padded.json")).unwrap();
1814 let signer =
1815 xdsa::SecretKey::from_bytes(&fixture(&corpus, "xdsa_seed").try_into().unwrap());
1816 let recipient =
1817 xhpke::SecretKey::from_bytes(&fixture(&corpus, "xhpke_seed").try_into().unwrap());
1818 let domain = fixture(&corpus, "domain");
1819 let aad = fixture(&corpus, "aad");
1820 let sign1 = Zeroizing::new(fixture(&corpus, "sign1"));
1821 let envelope = fixture(&corpus, "encrypt0");
1822
1823 // Pin the exact plaintext layout independently of the padding implementation
1824 assert_eq!(
1825 corpus["padding"],
1826 serde_json::json!({ "type": "buckets", "floor": 8192, "step": 20 }),
1827 );
1828 assert_eq!(corpus["plaintext_length"], 8192);
1829 let plaintext = open_plaintext(&envelope, aad.as_slice(), &recipient, &domain);
1830 assert_eq!(plaintext.len(), 8192);
1831 assert_eq!(&plaintext[..3470], sign1.as_slice());
1832 assert_eq!(&plaintext[3470..], [0; 4722]);
1833 let decrypted =
1834 Zeroizing::new(decrypt(&envelope, aad.as_slice(), &recipient, &domain).unwrap());
1835 assert_eq!(decrypted, sign1);
1836
1837 // Verify the signature and its fixed timestamp, then read the payload
1838 assert_eq!(corpus["timestamp"], 1700000000);
1839 let payload: Vec<u8> = open_at(
1840 &envelope,
1841 aad.as_slice(),
1842 &recipient,
1843 &signer.public_key(),
1844 &domain,
1845 Some(0),
1846 1700000000,
1847 )
1848 .unwrap();
1849 assert_eq!(payload, b"padded cose fixture payload");
1850 assert_eq!(payload, fixture(&corpus, "payload"));
1851 }
1852
1853 /// Tests that the v0.16 fixture corpus still validates, since that was in the
1854 /// first public release of the Ark, so we can't change the format anymore.
1855 #[test]
1856 fn test_v0_16_fixtures() {
1857 let corpus: serde_json::Value = serde_json::from_str(FIXTURES).unwrap();
1858
1859 let xdsa_seed: [u8; 64] = fixture(&corpus, "xdsa_seed").try_into().unwrap();
1860 let xhpke_seed: [u8; 32] = fixture(&corpus, "xhpke_seed").try_into().unwrap();
1861 let signer = xdsa::SecretKey::from_bytes(&xdsa_seed);
1862 let recipient = xhpke::SecretKey::from_bytes(&xhpke_seed);
1863
1864 let domain = fixture(&corpus, "domain");
1865 let payload = fixture(&corpus, "payload");
1866 let aad = fixture(&corpus, "aad");
1867 let sign1 = fixture(&corpus, "sign1");
1868 let encrypt0 = fixture(&corpus, "encrypt0");
1869
1870 // Verify the committed signature and check the embedded payload
1871 let got: Vec<u8> = verify_at(
1872 &sign1,
1873 aad.as_slice(),
1874 &signer.public_key(),
1875 &domain,
1876 None,
1877 0,
1878 )
1879 .unwrap();
1880 assert_eq!(got, payload);
1881
1882 // Read the committed signature's signer and payload without verifying
1883 assert_eq!(super::signer(&sign1).unwrap(), signer.fingerprint());
1884 assert_eq!(peek::<Vec<u8>>(&sign1).unwrap(), payload);
1885
1886 // Wrong domains and tampered structures must fail
1887 let bad = verify_at::<Vec<u8>, _>(
1888 &sign1,
1889 aad.as_slice(),
1890 &signer.public_key(),
1891 b"wrong",
1892 None,
1893 0,
1894 );
1895 assert!(bad.is_err());
1896
1897 let mut tampered = sign1.clone();
1898 *tampered.last_mut().unwrap() ^= 1;
1899 let bad = verify_at::<Vec<u8>, _>(
1900 &tampered,
1901 aad.as_slice(),
1902 &signer.public_key(),
1903 &domain,
1904 None,
1905 0,
1906 );
1907 assert!(bad.is_err());
1908
1909 // Open the committed encrypted message and check the payload
1910 let got: Vec<u8> = open_at(
1911 &encrypt0,
1912 aad.as_slice(),
1913 &recipient,
1914 &signer.public_key(),
1915 &domain,
1916 None,
1917 0,
1918 )
1919 .unwrap();
1920 assert_eq!(got, payload);
1921
1922 let mut tampered = encrypt0.clone();
1923 *tampered.last_mut().unwrap() ^= 1;
1924 let bad = open_at::<Vec<u8>, _>(
1925 &tampered,
1926 aad.as_slice(),
1927 &recipient,
1928 &signer.public_key(),
1929 &domain,
1930 None,
1931 0,
1932 );
1933 assert!(bad.is_err());
1934 }
1935}