Skip to main content

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}