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/rfc8152
10//! https://datatracker.ietf.org/doc/html/draft-ietf-cose-hpke
11
12mod types;
13
14pub use types::{
15    CoseEncrypt0, CoseSign1, CritHeader, EmptyHeader, EncProtectedHeader, EncStructure,
16    EncapKeyHeader, HEADER_TIMESTAMP, SigProtectedHeader, SigStructure,
17};
18
19// Use an indirect time package that mostly defers to sts::time on most platforms,
20// except on wasm, where it uses the JS engine's time subsystem.
21use web_time::{SystemTime, UNIX_EPOCH};
22
23use crate::cbor::{self, Decode, Encode, Raw};
24use crate::{xdsa, xhpke};
25
26// DOMAIN_PREFIX is the prefix of a public string known to both parties during
27// cryptographic operation, with the purpose of binding the keys used to some
28// application context.
29//
30// The final domain will be this prefix concatenated with another contextual one
31// from an app layer action.
32const DOMAIN_PREFIX: &[u8] = b"dark-bio-v1:";
33
34/// Error is the failures that can occur during COSE operations.
35#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
36pub enum Error {
37    #[error("cbor: {0}")]
38    CborError(#[from] cbor::Error),
39    #[error("unexpected algorithm: have {0}, want {1}")]
40    UnexpectedAlgorithm(i64, i64),
41    #[error("unexpected signing key: have {0:x?}, want {1:x?}")]
42    UnexpectedSigningKey(xdsa::Fingerprint, xdsa::Fingerprint),
43    #[error("signature verification failed: {0}")]
44    InvalidSignature(String),
45    #[error("signature stale: time drift {0}s exceeds max {1}s")]
46    StaleSignature(u64, u64),
47    #[error("unexpected payload in detached signature")]
48    UnexpectedPayload,
49    #[error("missing payload in embedded signature")]
50    MissingPayload,
51    #[error("unexpected encryption key: have {0:x?}, want {1:x?}")]
52    UnexpectedEncryptionKey(xhpke::Fingerprint, xhpke::Fingerprint),
53    #[error("invalid encapsulated key size: {0}, expected {1}")]
54    InvalidEncapKeySize(usize, usize),
55    #[error("decryption failed: {0}")]
56    DecryptionFailed(String),
57}
58
59/// Private COSE algorithm identifier for composite ML-DSA-65 + Ed25519 signatures.
60pub const ALGORITHM_ID_XDSA: i64 = -70000;
61
62/// Private COSE algorithm identifier for X-Wing (ML-KEM-768 + X25519).
63pub const ALGORITHM_ID_XHPKE: i64 = -70001;
64
65/// sign_detached creates a COSE_Sign1 digital signature without an embedded
66/// payload (i.e. payload is empty).
67///
68/// Uses the current system time as the signature timestamp. For testing or custom
69/// timestamps, use [`sign_detached_at`].
70///
71/// - `msg_to_auth`: The message to sign (not embedded in COSE_Sign1)
72/// - `signer`: The xDSA secret key to sign with
73/// - `domain`: Application domain for replay protection
74///
75/// Returns the serialized COSE_Sign1 structure.
76pub fn sign_detached<A: Encode>(
77    msg_to_auth: A,
78    signer: &xdsa::SecretKey,
79    domain: &[u8],
80) -> Vec<u8> {
81    let timestamp = SystemTime::now()
82        .duration_since(UNIX_EPOCH)
83        .expect("system time before Unix epoch")
84        .as_secs() as i64;
85    sign_detached_at(msg_to_auth, signer, domain, timestamp)
86}
87
88/// sign creates a COSE_Sign1 digital signature with an embedded payload.
89///
90/// Uses the current system time as the signature timestamp. For testing or custom
91/// timestamps, use [`sign_at`].
92///
93/// - `msg_to_embed`: The message to sign (embedded in COSE_Sign1)
94/// - `msg_to_auth`: Additional authenticated data (not embedded, but signed)
95/// - `signer`: The xDSA secret key to sign with
96/// - `domain`: Application domain for replay protection
97///
98/// Returns the serialized COSE_Sign1 structure.
99pub fn sign<E: Encode, A: Encode>(
100    msg_to_embed: E,
101    msg_to_auth: A,
102    signer: &xdsa::SecretKey,
103    domain: &[u8],
104) -> Vec<u8> {
105    let timestamp = SystemTime::now()
106        .duration_since(UNIX_EPOCH)
107        .expect("system time before Unix epoch")
108        .as_secs() as i64;
109    sign_at(msg_to_embed, msg_to_auth, signer, domain, timestamp)
110}
111
112/// sign_detached_at creates a COSE_Sign1 digital signature without an embedded
113/// payload and with an explicit timestamp.
114///
115/// - `msg_to_auth`: The message to sign (not embedded in COSE_Sign1)
116/// - `signer`: The xDSA secret key to sign with
117/// - `domain`: Application domain for replay protection
118/// - `timestamp`: Unix timestamp in seconds to embed in the protected header
119///
120/// Returns the serialized COSE_Sign1 structure.
121pub fn sign_detached_at<A: Encode>(
122    msg_to_auth: A,
123    signer: &xdsa::SecretKey,
124    domain: &[u8],
125    timestamp: i64,
126) -> Vec<u8> {
127    // Restrict the user's domain to the context of this library
128    let info = [DOMAIN_PREFIX, domain].concat();
129    let aad = cbor::encode(&(&info, msg_to_auth));
130
131    let protected = cbor::encode(&SigProtectedHeader {
132        algorithm: ALGORITHM_ID_XDSA,
133        crit: CritHeader {
134            timestamp: HEADER_TIMESTAMP,
135        },
136        kid: signer.fingerprint(),
137        timestamp,
138    });
139    // Build and sign Sig_structure with empty payload for detached mode
140    let signature = signer.sign(
141        &SigStructure {
142            context: "Signature1",
143            protected: &protected,
144            external_aad: &aad,
145            payload: &[],
146        }
147        .encode_cbor(),
148    );
149    // Build and encode COSE_Sign1 with null payload
150    cbor::encode(&CoseSign1 {
151        protected,
152        unprotected: EmptyHeader {},
153        payload: None,
154        signature,
155    })
156}
157
158/// sign_at creates a COSE_Sign1 digital signature with an embedded payload
159/// and an explicit timestamp.
160///
161/// - `msg_to_embed`: The message to sign (embedded in COSE_Sign1)
162/// - `msg_to_auth`: Additional authenticated data (not embedded, but signed)
163/// - `signer`: The xDSA secret key to sign with
164/// - `domain`: Application domain for replay protection
165/// - `timestamp`: Unix timestamp in seconds to embed in the protected header
166///
167/// Returns the serialized COSE_Sign1 structure.
168pub fn sign_at<E: Encode, A: Encode>(
169    msg_to_embed: E,
170    msg_to_auth: A,
171    signer: &xdsa::SecretKey,
172    domain: &[u8],
173    timestamp: i64,
174) -> Vec<u8> {
175    let msg_to_embed = cbor::encode(msg_to_embed);
176
177    // Restrict the user's domain to the context of this library
178    let info = [DOMAIN_PREFIX, domain].concat();
179    let aad = cbor::encode(&(&info, msg_to_auth));
180
181    let protected = cbor::encode(&SigProtectedHeader {
182        algorithm: ALGORITHM_ID_XDSA,
183        crit: CritHeader {
184            timestamp: HEADER_TIMESTAMP,
185        },
186        kid: signer.fingerprint(),
187        timestamp,
188    });
189    // Build and sign Sig_structure
190    let signature = signer.sign(
191        &SigStructure {
192            context: "Signature1",
193            protected: &protected,
194            external_aad: &aad,
195            payload: &msg_to_embed,
196        }
197        .encode_cbor(),
198    );
199    // Build and encode COSE_Sign1
200    cbor::encode(&CoseSign1 {
201        protected,
202        unprotected: EmptyHeader {},
203        payload: Some(msg_to_embed),
204        signature,
205    })
206}
207
208/// verify_detached validates a COSE_Sign1 digital signature with a detached payload.
209///
210/// Uses the current system time for drift checking. For testing or custom
211/// timestamps, use [`verify_detached_at`].
212///
213/// - `msg_to_check`: The serialized COSE_Sign1 structure (with null payload)
214/// - `msg_to_auth`: The same message used during signing (verified but not embedded)
215/// - `verifier`: The xDSA public key to verify against
216/// - `domain`: Application domain for replay protection
217/// - `max_drift`: Signatures more in the past or future are rejected
218pub fn verify_detached<A: Encode>(
219    msg_to_check: &[u8],
220    msg_to_auth: A,
221    verifier: &xdsa::PublicKey,
222    domain: &[u8],
223    max_drift: Option<u64>,
224) -> Result<(), Error> {
225    let now = SystemTime::now()
226        .duration_since(UNIX_EPOCH)
227        .expect("system time before Unix epoch")
228        .as_secs() as i64;
229    verify_detached_at(msg_to_check, msg_to_auth, verifier, domain, max_drift, now)
230}
231
232/// verify_detached_at validates a COSE_Sign1 digital signature with a detached payload
233/// and an explicit current time for drift checking.
234///
235/// - `msg_to_check`: The serialized COSE_Sign1 structure (with null payload)
236/// - `msg_to_auth`: The same message used during signing (verified but not embedded)
237/// - `verifier`: The xDSA public key to verify against
238/// - `domain`: Application domain for replay protection
239/// - `max_drift`: Signatures more in the past or future are rejected
240/// - `now`: Unix timestamp in seconds to use for drift checking
241pub fn verify_detached_at<A: Encode>(
242    msg_to_check: &[u8],
243    msg_to_auth: A,
244    verifier: &xdsa::PublicKey,
245    domain: &[u8],
246    max_drift: Option<u64>,
247    now: i64,
248) -> Result<(), Error> {
249    // Restrict the user's domain to the context of this library
250    let info = [DOMAIN_PREFIX, domain].concat();
251    let aad = cbor::encode(&(&info, msg_to_auth));
252
253    // Parse COSE_Sign1
254    let sign1: CoseSign1 = cbor::decode(msg_to_check)?;
255
256    // Verify payload is null (detached)
257    if sign1.payload.is_some() {
258        return Err(Error::UnexpectedPayload);
259    }
260    // Verify the protected header
261    let header = verify_sig_protected_header(&sign1.protected, ALGORITHM_ID_XDSA, verifier)?;
262
263    // Check signature timestamp drift if max_drift is specified
264    if let Some(max) = max_drift {
265        let drift = (now - header.timestamp).unsigned_abs();
266        if drift > max {
267            return Err(Error::StaleSignature(drift, max));
268        }
269    }
270    // Reconstruct Sig_structure to verify (empty payload for detached mode)
271    let blob = SigStructure {
272        context: "Signature1",
273        protected: &sign1.protected,
274        external_aad: &aad,
275        payload: &[],
276    }
277    .encode_cbor();
278
279    // Verify signature
280    verifier
281        .verify(&blob, &sign1.signature)
282        .map_err(|e| Error::InvalidSignature(e.to_string()))?;
283
284    Ok(())
285}
286
287/// verify validates a COSE_Sign1 digital signature and returns the embedded payload.
288///
289/// Uses the current system time for drift checking. For testing or custom
290/// timestamps, use [`verify_at`].
291///
292/// - `msg_to_check`: The serialized COSE_Sign1 structure
293/// - `msg_to_auth`: The same additional authenticated data used during signing
294/// - `verifier`: The xDSA public key to verify against
295/// - `domain`: Application domain for replay protection
296/// - `max_drift`: Signatures more in the past or future are rejected
297///
298/// Returns the CBOR-decoded embedded payload if verification succeeds.
299pub fn verify<E: Decode, A: Encode>(
300    msg_to_check: &[u8],
301    msg_to_auth: A,
302    verifier: &xdsa::PublicKey,
303    domain: &[u8],
304    max_drift: Option<u64>,
305) -> Result<E, Error> {
306    let now = SystemTime::now()
307        .duration_since(UNIX_EPOCH)
308        .expect("system time before Unix epoch")
309        .as_secs() as i64;
310    verify_at(msg_to_check, msg_to_auth, verifier, domain, max_drift, now)
311}
312
313/// verify_at validates a COSE_Sign1 digital signature and returns the embedded payload,
314/// using an explicit current time for drift checking.
315///
316/// - `msg_to_check`: The serialized COSE_Sign1 structure
317/// - `msg_to_auth`: The same additional authenticated data used during signing
318/// - `verifier`: The xDSA public key to verify against
319/// - `domain`: Application domain for replay protection
320/// - `max_drift`: Signatures more in the past or future are rejected
321/// - `now`: Unix timestamp in seconds to use for drift checking
322///
323/// Returns the CBOR-decoded embedded payload if verification succeeds.
324pub fn verify_at<E: Decode, A: Encode>(
325    msg_to_check: &[u8],
326    msg_to_auth: A,
327    verifier: &xdsa::PublicKey,
328    domain: &[u8],
329    max_drift: Option<u64>,
330    now: i64,
331) -> Result<E, Error> {
332    // Restrict the user's domain to the context of this library
333    let info = [DOMAIN_PREFIX, domain].concat();
334    let aad = cbor::encode(&(&info, msg_to_auth));
335
336    // Parse COSE_Sign1
337    let sign1: CoseSign1 = cbor::decode(msg_to_check)?;
338
339    // Verify payload is present (embedded)
340    let payload = sign1.payload.ok_or(Error::MissingPayload)?;
341
342    // Verify the protected header
343    let header = verify_sig_protected_header(&sign1.protected, ALGORITHM_ID_XDSA, verifier)?;
344
345    // Check signature timestamp drift if max_drift is specified
346    if let Some(max) = max_drift {
347        let drift = (now - header.timestamp).unsigned_abs();
348        if drift > max {
349            return Err(Error::StaleSignature(drift, max));
350        }
351    }
352    // Reconstruct Sig_structure to verify
353    let blob = SigStructure {
354        context: "Signature1",
355        protected: &sign1.protected,
356        external_aad: &aad,
357        payload: &payload,
358    }
359    .encode_cbor();
360
361    // Verify signature
362    verifier
363        .verify(&blob, &sign1.signature)
364        .map_err(|e| Error::InvalidSignature(e.to_string()))?;
365
366    Ok(cbor::decode(&payload)?)
367}
368
369/// signer extracts the signer's fingerprint from a COSE_Sign1 signature without
370/// verifying it.
371///
372/// This allows looking up the appropriate verification key before attempting
373/// full signature verification.
374///
375/// - `signature`: The serialized COSE_Sign1 structure
376///
377/// Returns the signer's fingerprint from the protected header's `kid` field.
378pub fn signer(signature: &[u8]) -> Result<xdsa::Fingerprint, Error> {
379    let sign1: CoseSign1 = cbor::decode(signature)?;
380    let header: SigProtectedHeader = cbor::decode(&sign1.protected)?;
381    Ok(header.kid)
382}
383
384/// peek extracts the embedded payload from a COSE_Sign1 signature without
385/// verifying it.
386///
387/// **Warning**: This function does NOT verify the signature. The returned payload
388/// is unauthenticated and should not be trusted until verified with [`verify`].
389/// Use [`signer`] to extract the signer's fingerprint for key lookup.
390///
391/// - `signature`: The serialized COSE_Sign1 structure
392///
393/// Returns the CBOR-decoded payload.
394pub fn peek<E: Decode>(signature: &[u8]) -> Result<E, Error> {
395    let sign1: CoseSign1 = cbor::decode(signature)?;
396    let payload = sign1.payload.ok_or(Error::MissingPayload)?;
397    Ok(cbor::decode(&payload)?)
398}
399
400/// seal signs a message then encrypts it to a recipient.
401///
402/// Uses the current system time as the signature timestamp. For testing or custom
403/// timestamps, use [`seal_at`].
404///
405/// - `msg_to_seal`: The message to sign and encrypt
406/// - `msg_to_auth`: Additional authenticated data (signed and bound to encryption, but not embedded)
407/// - `signer`: The xDSA secret key to sign with
408/// - `recipient`: The xHPKE public key to encrypt to
409/// - `domain`: Application domain for HPKE key derivation
410///
411/// Returns the serialized COSE_Encrypt0 structure containing the encrypted COSE_Sign1.
412pub fn seal<E: Encode, A: Encode>(
413    msg_to_seal: E,
414    msg_to_auth: A,
415    signer: &xdsa::SecretKey,
416    recipient: &xhpke::PublicKey,
417    domain: &[u8],
418) -> Result<Vec<u8>, Error> {
419    let timestamp = SystemTime::now()
420        .duration_since(UNIX_EPOCH)
421        .expect("system time before Unix epoch")
422        .as_secs() as i64;
423    seal_at(
424        msg_to_seal,
425        msg_to_auth,
426        signer,
427        recipient,
428        domain,
429        timestamp,
430    )
431}
432
433/// seal_at signs a message then encrypts it to a recipient with an explicit
434/// timestamp.
435///
436/// - `msg_to_seal`: The message to sign and encrypt
437/// - `msg_to_auth`: Additional authenticated data (signed and bound to encryption, but not embedded)
438/// - `signer`: The xDSA secret key to sign with
439/// - `recipient`: The xHPKE public key to encrypt to
440/// - `domain`: Application domain for HPKE key derivation
441/// - `timestamp`: Unix timestamp in seconds to embed in the signature's protected header
442///
443/// Returns the serialized COSE_Encrypt0 structure containing the encrypted COSE_Sign1.
444pub fn seal_at<E: Encode, A: Encode>(
445    msg_to_seal: E,
446    msg_to_auth: A,
447    signer: &xdsa::SecretKey,
448    recipient: &xhpke::PublicKey,
449    domain: &[u8],
450    timestamp: i64,
451) -> Result<Vec<u8>, Error> {
452    // Pre-encode for EncStructure (which needs raw bytes for external_aad)
453    let msg_to_seal = cbor::encode(msg_to_seal);
454    let msg_to_auth = cbor::encode(msg_to_auth);
455
456    // Create a COSE_Sign1 with the payload, binding the AAD (use Raw to avoid re-encoding)
457    let signed = sign_at(
458        Raw(msg_to_seal),
459        Raw(msg_to_auth.clone()),
460        signer,
461        domain,
462        timestamp,
463    );
464    // Encrypt the signed message to the recipient
465    encrypt(&signed, Raw(msg_to_auth), recipient, domain)
466}
467
468/// encrypt encrypts an already-signed COSE_Sign1 to a recipient.
469///
470/// For most use cases, prefer [`seal`] which signs and encrypts in one step.
471/// Use this only when re-encrypting a message (from [`decrypt`]) to a different
472/// recipient without access to the original signer's key.
473///
474/// - `sign1`: The COSE_Sign1 structure (e.g., from [`decrypt`])
475/// - `msg_to_auth`: The same additional authenticated data used during sealing
476/// - `recipient`: The xHPKE public key to encrypt to
477/// - `domain`: Application domain for HPKE key derivation
478///
479/// Returns the serialized COSE_Encrypt0 structure.
480pub fn encrypt<A: Encode>(
481    sign1: &[u8],
482    msg_to_auth: A,
483    recipient: &xhpke::PublicKey,
484    domain: &[u8],
485) -> Result<Vec<u8>, Error> {
486    // Pre-encode for EncStructure (which needs raw bytes for external_aad)
487    let msg_to_auth = cbor::encode(msg_to_auth);
488
489    // Build protected header with recipient's fingerprint
490    let protected = cbor::encode(&EncProtectedHeader {
491        algorithm: ALGORITHM_ID_XHPKE,
492        kid: recipient.fingerprint(),
493    });
494    // Restrict the user's domain to the context of this library
495    let info = [DOMAIN_PREFIX, domain].concat();
496
497    // Build and seal Enc_structure
498    let (encap_key, ciphertext) = recipient
499        .seal(
500            sign1,
501            &EncStructure {
502                context: "Encrypt0",
503                protected: &protected,
504                external_aad: &msg_to_auth,
505            }
506            .encode_cbor(),
507            &info,
508        )
509        .map_err(|e| Error::DecryptionFailed(e.to_string()))?;
510
511    // Build and encode COSE_Encrypt0
512    Ok(cbor::encode(&CoseEncrypt0 {
513        protected,
514        unprotected: EncapKeyHeader {
515            encap_key: encap_key.to_vec(),
516        },
517        ciphertext,
518    }))
519}
520
521/// open decrypts and verifies a sealed message.
522///
523/// Uses the current system time for drift checking. For testing or custom
524/// timestamps, use [`open_at`].
525///
526/// - `msg_to_open`: The serialized COSE_Encrypt0 structure
527/// - `msg_to_auth`: The same additional authenticated data used during sealing
528/// - `recipient`: The xHPKE secret key to decrypt with
529/// - `sender`: The xDSA public key to verify the signature against
530/// - `domain`: Application domain for HPKE key derivation
531/// - `max_drift`: Signatures more in the past or future are rejected
532///
533/// Returns the CBOR-decoded payload if decryption and verification succeed.
534pub fn open<E: Decode, A: Encode + Clone>(
535    msg_to_open: &[u8],
536    msg_to_auth: A,
537    recipient: &xhpke::SecretKey,
538    sender: &xdsa::PublicKey,
539    domain: &[u8],
540    max_drift: Option<u64>,
541) -> Result<E, Error> {
542    let now = SystemTime::now()
543        .duration_since(UNIX_EPOCH)
544        .expect("system time before Unix epoch")
545        .as_secs() as i64;
546    open_at(
547        msg_to_open,
548        msg_to_auth,
549        recipient,
550        sender,
551        domain,
552        max_drift,
553        now,
554    )
555}
556
557/// open_at decrypts and verifies a sealed message with an explicit current time
558/// for drift checking.
559///
560/// - `msg_to_open`: The serialized COSE_Encrypt0 structure
561/// - `msg_to_auth`: The same additional authenticated data used during sealing
562/// - `recipient`: The xHPKE secret key to decrypt with
563/// - `sender`: The xDSA public key to verify the signature against
564/// - `domain`: Application domain for HPKE key derivation
565/// - `max_drift`: Signatures more in the past or future are rejected
566/// - `now`: Unix timestamp in seconds to use for drift checking
567///
568/// Returns the CBOR-decoded payload if decryption and verification succeed.
569pub fn open_at<E: Decode, A: Encode + Clone>(
570    msg_to_open: &[u8],
571    msg_to_auth: A,
572    recipient: &xhpke::SecretKey,
573    sender: &xdsa::PublicKey,
574    domain: &[u8],
575    max_drift: Option<u64>,
576    now: i64,
577) -> Result<E, Error> {
578    // Decrypt the COSE_Encrypt0 to get the COSE_Sign1
579    let sign1 = decrypt(msg_to_open, msg_to_auth.clone(), recipient, domain)?;
580
581    // Verify the signature and extract the payload
582    let raw: Raw = verify_at::<Raw, _>(&sign1, &msg_to_auth, sender, domain, max_drift, now)?;
583    Ok(cbor::decode(&raw.0)?)
584}
585
586/// decrypt decrypts a sealed message without verifying the signature.
587///
588/// This allows inspecting the signer before verification. Use [`signer`] to
589/// extract the signer's fingerprint, then [`verify`] or [`verify_at`] to verify.
590///
591/// - `msg_to_open`: The serialized COSE_Encrypt0 structure
592/// - `msg_to_auth`: The same additional authenticated data used during sealing
593/// - `recipient`: The xHPKE secret key to decrypt with
594/// - `domain`: Application domain for HPKE key derivation
595///
596/// Returns the decrypted COSE_Sign1 structure (not yet verified).
597pub fn decrypt<A: Encode>(
598    msg_to_open: &[u8],
599    msg_to_auth: A,
600    recipient: &xhpke::SecretKey,
601    domain: &[u8],
602) -> Result<Vec<u8>, Error> {
603    // Pre-encode for EncStructure (which needs raw bytes for external_aad)
604    let msg_to_auth = cbor::encode(msg_to_auth);
605
606    // Restrict the user's domain to the context of this library
607    let info = [DOMAIN_PREFIX, domain].concat();
608
609    // Parse COSE_Encrypt0
610    let encrypt0: CoseEncrypt0 = cbor::decode(msg_to_open)?;
611
612    // Verify protected header
613    verify_enc_protected_header(&encrypt0.protected, ALGORITHM_ID_XHPKE, recipient)?;
614
615    // Extract encapsulated key from the unprotected headers
616    let encap_key: &[u8; xhpke::ENCAP_KEY_SIZE] = encrypt0
617        .unprotected
618        .encap_key
619        .as_slice()
620        .try_into()
621        .map_err(|_| {
622            Error::InvalidEncapKeySize(encrypt0.unprotected.encap_key.len(), xhpke::ENCAP_KEY_SIZE)
623        })?;
624
625    // Rebuild and open Enc_structure
626    let decrypted = recipient
627        .open(
628            encap_key,
629            &encrypt0.ciphertext,
630            &EncStructure {
631                context: "Encrypt0",
632                protected: &encrypt0.protected,
633                external_aad: &msg_to_auth,
634            }
635            .encode_cbor(),
636            &info,
637        )
638        .map_err(|e| Error::DecryptionFailed(e.to_string()))?;
639
640    Ok(decrypted)
641}
642
643/// recipient extracts the recipient's fingerprint from a COSE_Encrypt0 message
644/// without decrypting it.
645///
646/// This allows looking up the appropriate decryption key before attempting
647/// full decryption.
648///
649/// - `ciphertext`: The serialized COSE_Encrypt0 structure
650///
651/// Returns the recipient's fingerprint from the protected header's `kid` field.
652pub fn recipient(ciphertext: &[u8]) -> Result<xhpke::Fingerprint, Error> {
653    let encrypt0: CoseEncrypt0 = cbor::decode(ciphertext)?;
654    let header: EncProtectedHeader = cbor::decode(&encrypt0.protected)?;
655    Ok(header.kid)
656}
657
658/// Verifies the signature protected header contains exactly the expected algorithm
659/// and that the key identifier matches the provided verifier.
660fn verify_sig_protected_header(
661    bytes: &[u8],
662    exp_algo: i64,
663    verifier: &xdsa::PublicKey,
664) -> Result<SigProtectedHeader, Error> {
665    let header: SigProtectedHeader = cbor::decode(bytes)?;
666    if header.algorithm != exp_algo {
667        return Err(Error::UnexpectedAlgorithm(header.algorithm, exp_algo));
668    }
669    if header.crit.timestamp != HEADER_TIMESTAMP {
670        return Err(Error::UnexpectedAlgorithm(
671            header.crit.timestamp,
672            HEADER_TIMESTAMP,
673        ));
674    }
675    if header.kid != verifier.fingerprint() {
676        return Err(Error::UnexpectedSigningKey(
677            header.kid,
678            verifier.fingerprint(),
679        ));
680    }
681    Ok(header)
682}
683
684/// Verifies the encryption protected header contains exactly the expected algorithm
685/// and that the key identifier matches the provided recipient.
686fn verify_enc_protected_header(
687    bytes: &[u8],
688    exp_algo: i64,
689    recipient: &xhpke::SecretKey,
690) -> Result<EncProtectedHeader, Error> {
691    let header: EncProtectedHeader = cbor::decode(bytes)?;
692    if header.algorithm != exp_algo {
693        return Err(Error::UnexpectedAlgorithm(header.algorithm, exp_algo));
694    }
695    if header.kid != recipient.fingerprint() {
696        return Err(Error::UnexpectedEncryptionKey(
697            header.kid,
698            recipient.fingerprint(),
699        ));
700    }
701    Ok(header)
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    // Tests various combinations of signing and verifying ops.
709    #[test]
710    fn test_sign_verify() {
711        struct TestCase {
712            msg_to_sign: &'static [u8],
713            msg_to_auth: &'static [u8],
714            verifier_msg_to_auth: &'static [u8],
715            domain: &'static [u8],
716            verifier_domain: &'static [u8],
717            timestamp: Option<i64>,
718            max_drift: Option<u64>,
719            wrong_key: bool,
720            want_ok: bool,
721        }
722        let now = SystemTime::now()
723            .duration_since(UNIX_EPOCH)
724            .unwrap()
725            .as_secs() as i64;
726
727        let tests = [
728            // Valid signature with aad
729            TestCase {
730                msg_to_sign: b"foo",
731                msg_to_auth: b"bar",
732                verifier_msg_to_auth: b"bar",
733                domain: b"baz",
734                verifier_domain: b"baz",
735                timestamp: None,
736                max_drift: None,
737                wrong_key: false,
738                want_ok: true,
739            },
740            // Valid signature, empty aad
741            TestCase {
742                msg_to_sign: b"foo",
743                msg_to_auth: b"",
744                verifier_msg_to_auth: b"",
745                domain: b"baz",
746                verifier_domain: b"baz",
747                timestamp: None,
748                max_drift: None,
749                wrong_key: false,
750                want_ok: true,
751            },
752            // Valid signature with explicit timestamp, no drift check
753            TestCase {
754                msg_to_sign: b"foo",
755                msg_to_auth: b"bar",
756                verifier_msg_to_auth: b"bar",
757                domain: b"baz",
758                verifier_domain: b"baz",
759                timestamp: Some(now),
760                max_drift: None,
761                wrong_key: false,
762                want_ok: true,
763            },
764            // Valid signature within drift tolerance
765            TestCase {
766                msg_to_sign: b"foo",
767                msg_to_auth: b"bar",
768                verifier_msg_to_auth: b"bar",
769                domain: b"baz",
770                verifier_domain: b"baz",
771                timestamp: Some(now - 30),
772                max_drift: Some(60),
773                wrong_key: false,
774                want_ok: true,
775            },
776            // Signature too old (exceeds max_drift)
777            TestCase {
778                msg_to_sign: b"foo",
779                msg_to_auth: b"bar",
780                verifier_msg_to_auth: b"bar",
781                domain: b"baz",
782                verifier_domain: b"baz",
783                timestamp: Some(now - 120),
784                max_drift: Some(60),
785                wrong_key: false,
786                want_ok: false,
787            },
788            // Signature too far in the future (exceeds max_drift)
789            TestCase {
790                msg_to_sign: b"foo",
791                msg_to_auth: b"bar",
792                verifier_msg_to_auth: b"bar",
793                domain: b"baz",
794                verifier_domain: b"baz",
795                timestamp: Some(now + 120),
796                max_drift: Some(60),
797                wrong_key: false,
798                want_ok: false,
799            },
800            // Wrong domain
801            TestCase {
802                msg_to_sign: b"foo",
803                msg_to_auth: b"bar",
804                verifier_msg_to_auth: b"bar",
805                domain: b"baz",
806                verifier_domain: b"baz2",
807                timestamp: Some(now + 120),
808                max_drift: Some(60),
809                wrong_key: false,
810                want_ok: false,
811            },
812            // Wrong aad
813            TestCase {
814                msg_to_sign: b"foo",
815                msg_to_auth: b"bar",
816                verifier_msg_to_auth: b"bar2",
817                domain: b"baz",
818                verifier_domain: b"baz",
819                timestamp: None,
820                max_drift: None,
821                wrong_key: false,
822                want_ok: false,
823            },
824            // Wrong key
825            TestCase {
826                msg_to_sign: b"foo",
827                msg_to_auth: b"",
828                verifier_msg_to_auth: b"",
829                domain: b"baz",
830                verifier_domain: b"baz",
831                timestamp: None,
832                max_drift: None,
833                wrong_key: true,
834                want_ok: false,
835            },
836        ];
837
838        for (i, test) in tests.iter().enumerate() {
839            let alice = xdsa::SecretKey::generate();
840            let bobby = xdsa::SecretKey::generate();
841
842            let signed = match test.timestamp {
843                Some(ts) => sign_at(
844                    &test.msg_to_sign.to_vec(),
845                    &test.msg_to_auth.to_vec(),
846                    &alice,
847                    test.domain,
848                    ts,
849                ),
850                None => sign(
851                    &test.msg_to_sign.to_vec(),
852                    &test.msg_to_auth.to_vec(),
853                    &alice,
854                    test.domain,
855                ),
856            };
857            let verifier = if test.wrong_key {
858                bobby.public_key()
859            } else {
860                alice.public_key()
861            };
862            let result: Result<Vec<u8>, _> = verify(
863                &signed,
864                &test.verifier_msg_to_auth.to_vec(),
865                &verifier,
866                test.verifier_domain,
867                test.max_drift,
868            );
869
870            if test.want_ok {
871                let recovered = result.expect(&format!("test {}: expected success", i));
872                assert_eq!(recovered, test.msg_to_sign, "test {}: payload mismatch", i);
873            } else {
874                assert!(result.is_err(), "test {}: expected error", i);
875            }
876        }
877    }
878
879    // Tests various combinations of sealing and opening ops.
880    #[test]
881    fn test_seal_open() {
882        struct TestCase {
883            msg_to_seal: &'static [u8],
884            msg_to_auth: &'static [u8],
885            opener_msg_to_auth: &'static [u8],
886            domain: &'static [u8],
887            opener_domain: &'static [u8],
888            timestamp: Option<i64>,
889            max_drift: Option<u64>,
890            wrong_signer: bool,
891            want_ok: bool,
892        }
893        // Fetch the current time for drift tests
894        let now = SystemTime::now()
895            .duration_since(UNIX_EPOCH)
896            .unwrap()
897            .as_secs() as i64;
898
899        let tests = [
900            // Valid seal/open with aad
901            TestCase {
902                msg_to_seal: b"foo",
903                msg_to_auth: b"bar",
904                opener_msg_to_auth: b"bar",
905                domain: b"baz",
906                opener_domain: b"baz",
907                timestamp: None,
908                max_drift: None,
909                wrong_signer: false,
910                want_ok: true,
911            },
912            // Valid seal/open, empty aad
913            TestCase {
914                msg_to_seal: b"foo",
915                msg_to_auth: b"",
916                opener_msg_to_auth: b"",
917                domain: b"baz",
918                opener_domain: b"baz",
919                timestamp: None,
920                max_drift: None,
921                wrong_signer: false,
922                want_ok: true,
923            },
924            // Valid seal/open, no drift check
925            TestCase {
926                msg_to_seal: b"foo",
927                msg_to_auth: b"bar",
928                opener_msg_to_auth: b"bar",
929                domain: b"baz",
930                opener_domain: b"baz",
931                timestamp: Some(now),
932                max_drift: None,
933                wrong_signer: false,
934                want_ok: true,
935            },
936            // Valid seal/open, valid drift
937            TestCase {
938                msg_to_seal: b"foo",
939                msg_to_auth: b"bar",
940                opener_msg_to_auth: b"bar",
941                domain: b"baz",
942                opener_domain: b"baz",
943                timestamp: Some(now - 30),
944                max_drift: Some(60),
945                wrong_signer: false,
946                want_ok: true,
947            },
948            // Wrong domain
949            TestCase {
950                msg_to_seal: b"foo",
951                msg_to_auth: b"",
952                opener_msg_to_auth: b"",
953                domain: b"baz",
954                opener_domain: b"baz2",
955                timestamp: None,
956                max_drift: None,
957                wrong_signer: false,
958                want_ok: false,
959            },
960            // Wrong aad
961            TestCase {
962                msg_to_seal: b"foo",
963                msg_to_auth: b"bar",
964                opener_msg_to_auth: b"bar2",
965                domain: b"baz",
966                opener_domain: b"baz",
967                timestamp: None,
968                max_drift: None,
969                wrong_signer: false,
970                want_ok: false,
971            },
972            // Wrong signer
973            TestCase {
974                msg_to_seal: b"foo",
975                msg_to_auth: b"",
976                opener_msg_to_auth: b"",
977                domain: b"baz",
978                opener_domain: b"baz",
979                timestamp: None,
980                max_drift: None,
981                wrong_signer: true,
982                want_ok: false,
983            },
984            // Timestamp too far in the past
985            TestCase {
986                msg_to_seal: b"foo",
987                msg_to_auth: b"bar",
988                opener_msg_to_auth: b"bar",
989                domain: b"baz",
990                opener_domain: b"baz",
991                timestamp: Some(now - 120),
992                max_drift: Some(60),
993                wrong_signer: false,
994                want_ok: false,
995            },
996            // Timestamp too far in the future
997            TestCase {
998                msg_to_seal: b"foo",
999                msg_to_auth: b"bar",
1000                opener_msg_to_auth: b"bar",
1001                domain: b"baz",
1002                opener_domain: b"baz",
1003                timestamp: Some(now + 120),
1004                max_drift: Some(60),
1005                wrong_signer: false,
1006                want_ok: false,
1007            },
1008        ];
1009
1010        for (i, test) in tests.iter().enumerate() {
1011            let alice = xdsa::SecretKey::generate();
1012            let bobby = xdsa::SecretKey::generate();
1013            let carol = xhpke::SecretKey::generate();
1014
1015            let sealed = match test.timestamp {
1016                Some(ts) => seal_at(
1017                    &test.msg_to_seal.to_vec(),
1018                    &test.msg_to_auth.to_vec(),
1019                    &alice,
1020                    &carol.public_key(),
1021                    test.domain,
1022                    ts,
1023                )
1024                .unwrap(),
1025                None => seal(
1026                    &test.msg_to_seal.to_vec(),
1027                    &test.msg_to_auth.to_vec(),
1028                    &alice,
1029                    &carol.public_key(),
1030                    test.domain,
1031                )
1032                .unwrap(),
1033            };
1034
1035            let verifier = if test.wrong_signer {
1036                bobby.public_key()
1037            } else {
1038                alice.public_key()
1039            };
1040            let result: Result<Vec<u8>, _> = open(
1041                &sealed,
1042                &test.opener_msg_to_auth.to_vec(),
1043                &carol,
1044                &verifier,
1045                test.opener_domain,
1046                test.max_drift,
1047            );
1048
1049            if test.want_ok {
1050                let recovered = result.expect(&format!("test {}: expected success", i));
1051                assert_eq!(recovered, test.msg_to_seal, "test {}: payload mismatch", i);
1052            } else {
1053                assert!(result.is_err(), "test {}: expected error", i);
1054            }
1055        }
1056    }
1057
1058    // Tests CBOR encoding/decoding for sign/verify.
1059    #[test]
1060    fn test_sign_verify_typed() {
1061        let alice = xdsa::SecretKey::generate();
1062
1063        let payload = (42u64, "foo".to_string());
1064        let aad = ("bar".to_string(),);
1065
1066        let signed = sign(&payload, &aad, &alice, b"baz");
1067        let recovered: (u64, String) =
1068            verify(&signed, &aad, &alice.public_key(), b"baz", None).unwrap();
1069
1070        assert_eq!(recovered, payload);
1071    }
1072
1073    // Tests CBOR encoding/decoding for seal/open.
1074    #[test]
1075    fn test_seal_open_typed() {
1076        let alice = xdsa::SecretKey::generate();
1077        let carol = xhpke::SecretKey::generate();
1078
1079        let payload = (123u64, "foo".to_string());
1080        let aad = ("bar".to_string(),);
1081
1082        let sealed = seal(&payload, &aad, &alice, &carol.public_key(), b"baz").unwrap();
1083        let recovered: (u64, String) =
1084            open(&sealed, &aad, &carol, &alice.public_key(), b"baz", None).unwrap();
1085
1086        assert_eq!(recovered, payload);
1087    }
1088}