Skip to main content

igvmfilegen/corim_signature/
envelope.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Operations on signed CoRIM envelopes (`#6.18(COSE_Sign1)` carrying a
5//! `tagged-unsigned-corim-map` payload).
6//!
7//! Two public entry points:
8//!
9//! - [`detach_payload`] splits a bundled signed CoRIM into its CoRIM
10//!   document and a detached COSE_Sign1 (nil-payload) signature.
11//! - [`verify_corim_signature`] cryptographically verifies a detached
12//!   signature against a document, using the issuer X.509 certificate
13//!   carried in the envelope's `x5chain` / `x5bag` protected header
14//!   (RFC 9360).
15//!
16//! # Design rationale
17//!
18//! Parsing and encoding both delegate to the `corim` crate's
19//! [`decode_signed_corim`] / [`encode_signed_corim`] entry points -- the
20//! same code path that the upstream `igvm` crate uses for its CoRIM
21//! support. This keeps a single source of truth for signed-CoRIM
22//! envelope handling in the workspace and ensures that any envelope we
23//! accept also satisfies draft-ietf-rats-corim section 4.2 (protected header
24//! must include `corim-meta` or `cwt-claims`).
25//!
26//! Cryptographic verification is performed via the workspace `crypto`
27//! crate's RSA-PSS primitives; only PS384 is currently supported
28//! (see [`verify_corim_signature`] for details).
29//!
30//! [`decode_signed_corim`]: corim::types::signed::decode_signed_corim
31//! [`encode_signed_corim`]: corim::types::signed::encode_signed_corim
32
33use anyhow::Context;
34use corim::types::signed::CORIM_CONTENT_TYPE;
35use corim::types::signed::CoseAlgorithm;
36use corim::types::signed::decode_signed_corim;
37use corim::types::signed::encode_signed_corim;
38use crypto::HashAlgorithm;
39use crypto::x509::X509Certificate;
40
41/// Output of [`detach_payload`]: the CoRIM document plus a detached
42/// COSE_Sign1 envelope (`payload` field set to nil).
43#[derive(Debug)]
44pub struct DetachedCorim {
45    /// CBOR-encoded CoRIM document extracted from the input envelope.
46    pub document: Vec<u8>,
47    /// CoRIM-spec `#6.18(COSE_Sign1)` envelope with the payload slot
48    /// nil, suitable for [`verify_corim_signature`] against `document`.
49    pub signature: Vec<u8>,
50}
51
52/// Split a bundled (payload-embedded) COSE_Sign1 into its CoRIM document
53/// payload and a detached COSE_Sign1 signature.
54///
55/// A signed CoRIM is `Tag(18) [ protected, unprotected, payload, signature ]`
56/// where `payload` is a `bstr` containing the CBOR-encoded CoRIM document.
57///
58/// This function:
59/// 1. Decodes the COSE_Sign1 with `corim::types::signed::decode_signed_corim`
60/// 2. Extracts the raw payload bytes -> returned as the document
61/// 3. Re-emits the envelope with the payload field set to nil
62///    -> returned as the detached signature
63///
64/// The protected-header bytes and signature bytes are preserved verbatim
65/// across the round-trip: `decode_signed_corim` retains the original
66/// `protected_header_bytes` as-is, and `encode_signed_corim` emits them
67/// unmodified. This is required because the COSE signature is computed
68/// over the exact protected-header bytes.
69///
70/// # Errors
71/// Returns an error if:
72/// - the input is not a valid CoRIM-spec-compliant `#6.18(COSE_Sign1)`, or
73/// - the input has a nil payload (i.e., is already detached) -- in that
74///   case pass the bytes straight to [`verify_corim_signature`] instead
75///   of splitting them.
76pub fn detach_payload(data: &[u8]) -> anyhow::Result<DetachedCorim> {
77    let mut signed = decode_signed_corim(data).context("Signed CoRIM: decode failed")?;
78
79    let document = signed.payload.take().ok_or_else(|| {
80        anyhow::anyhow!(
81            "Signed CoRIM: payload is nil (already detached); pass the detached \
82             signature directly instead of splitting it"
83        )
84    })?;
85
86    // `payload` is now `None` -> encode produces a detached envelope.
87    let signature = encode_signed_corim(&signed)
88        .context("Signed CoRIM: failed to encode detached signature")?;
89
90    tracing::debug!(
91        input_size = data.len(),
92        document_size = document.len(),
93        detached_signature_size = signature.len(),
94        "Split signed CoRIM into document payload and detached COSE_Sign1 signature"
95    );
96
97    Ok(DetachedCorim {
98        document,
99        signature,
100    })
101}
102
103/// Cryptographically verify a detached COSE_Sign1 CoRIM signature against
104/// the document it endorses.
105///
106/// The issuer X.509 certificate is taken from the envelope's protected
107/// header per RFC 9360: `x5chain` (key 33) is preferred, with `x5bag`
108/// (key 32) as a fallback. For a chain or bag, the end-entity (leaf)
109/// certificate is used.
110///
111/// Enforces:
112///
113/// 1. The envelope decodes as a CoRIM-spec-compliant `#6.18(COSE_Sign1)`
114///    via [`decode_signed_corim`].
115/// 2. The payload is nil (detached form).
116/// 3. The COSE signature bytes are non-empty.
117/// 4. If the protected header carries a `content-type` (key 3), it equals
118///    `"application/rim+cbor"`.
119/// 5. The protected header carries an `x5chain` or `x5bag` entry.
120/// 6. The protected-header algorithm is supported (see below).
121/// 7. The end-entity certificate parses as DER X.509 and exposes an RSA
122///    public key.
123/// 8. The signature math verifies via `pss_verify` over the COSE
124///    `Sig_structure1` TBS bytes built from the envelope's protected
125///    header, the supplied `document`, and empty external AAD.
126///
127/// # Supported algorithms
128///
129/// Only **PS384** is currently accepted: RSA-PSS with SHA-384,
130/// MGF1-SHA-384, and a salt length equal to the hash output size
131/// (48 bytes), per RFC 8230 section 2 (COSE alg ID `-38`).
132///
133/// All other algorithms (RSA PKCS#1 v1.5, ECDSA, EdDSA, other PSS
134/// variants) are rejected with a targeted error -- adding support
135/// would require extending the `crypto` crate with the corresponding
136/// primitives or COSE alg-ID mappings here.
137///
138/// # Arguments
139/// * `signature` - Detached COSE_Sign1 CoRIM envelope (nil payload).
140/// * `document` - The CoRIM document the signature should endorse.
141pub fn verify_corim_signature(signature: &[u8], document: &[u8]) -> anyhow::Result<()> {
142    let signed = decode_signed_corim(signature).context("CoRIM signature: decode failed")?;
143
144    if !signed.is_detached() {
145        anyhow::bail!(
146            "CoRIM signature: payload must be nil for a detached signature; \
147             embedded payloads must be split first"
148        );
149    }
150
151    if signed.signature.is_empty() {
152        anyhow::bail!("CoRIM signature: COSE signature bytes must be non-empty");
153    }
154
155    if let Some(ct) = &signed.protected.content_type
156        && ct != CORIM_CONTENT_TYPE
157    {
158        anyhow::bail!(
159            "CoRIM signature: protected content-type is {ct:?}, expected {CORIM_CONTENT_TYPE:?}"
160        );
161    }
162
163    // Extract the issuer cert from x5chain (key 33) -- falling back to
164    // x5bag (key 32). Per RFC 9360 the end-entity is the first cert
165    // (chain) or the only cert (single bstr); CoseX509::end_entity()
166    // hides that distinction.
167    let issuer_cert_der: &[u8] = signed
168        .protected
169        .x5chain
170        .as_ref()
171        .or(signed.protected.x5bag.as_ref())
172        .map(|x| x.end_entity())
173        .ok_or_else(|| {
174            anyhow::anyhow!(
175                "CoRIM signature: protected header carries neither x5chain (key 33) \
176                 nor x5bag (key 32); cannot identify the issuer certificate"
177            )
178        })?;
179
180    // Only PS384 is supported: RSA-PSS with SHA-384, MGF1-SHA-384, and
181    // a salt length equal to the hash output (48 bytes) per RFC 8230
182    // section 2.
183    let hash = match signed.protected.alg {
184        CoseAlgorithm::Ps384 => HashAlgorithm::Sha384,
185        other => anyhow::bail!(
186            "CoRIM signature: unsupported COSE algorithm {other} ({}). \
187             Only PS384 (-38) is supported.",
188            other.to_i64(),
189        ),
190    };
191
192    let cert = X509Certificate::from_der(issuer_cert_der)
193        .context("CoRIM signature: failed to parse issuer certificate (expected DER)")?;
194
195    let pubkey = cert
196        .public_key()
197        .context("CoRIM signature: failed to extract public key from issuer certificate")?
198        .rsa()
199        .context("CoRIM signature: issuer certificate public key is not an RSA key")?;
200
201    let tbs = signed
202        .to_be_signed_detached(document, &[])
203        .context("CoRIM signature: failed to construct Sig_structure1 TBS bytes")?;
204
205    let valid = pubkey
206        .pss_verify(&tbs, &signed.signature, hash)
207        .context("CoRIM signature: RSA-PSS verification primitive returned an error")?;
208
209    if !valid {
210        anyhow::bail!(
211            "CoRIM signature: cryptographic verification failed; signature \
212             does not match the supplied document under the issuer's public key"
213        );
214    }
215
216    tracing::debug!(
217        signature_size = signature.len(),
218        document_size = document.len(),
219        tbs_size = tbs.len(),
220        issuer_cert_size = issuer_cert_der.len(),
221        alg = %signed.protected.alg,
222        "CoRIM signature cryptographically verified against issuer certificate from x5chain/x5bag"
223    );
224
225    Ok(())
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use corim::cbor::value::Value;
232    use corim::types::signed::CwtClaims;
233    use corim::types::signed::SignedCorimBuilder;
234    use test_with_tracing::test;
235
236    const TEST_PAYLOAD: &[u8] = &[0xAA, 0xBB, 0xCC, 0xDD];
237
238    /// Build a CoRIM-spec-compliant bundled `#6.18(COSE_Sign1)` envelope
239    /// with the given inner payload and signature.
240    fn make_bundled(payload: &[u8], signature: Vec<u8>) -> Vec<u8> {
241        SignedCorimBuilder::new(-7_i64, payload.to_vec())
242            .set_cwt_claims(CwtClaims::new("test"))
243            .build_with_signature(signature)
244            .unwrap()
245    }
246
247    /// Build a CoRIM-spec-compliant detached `#6.18(COSE_Sign1)` envelope
248    /// (payload field is nil).
249    fn make_detached(payload: &[u8], signature: Vec<u8>) -> Vec<u8> {
250        SignedCorimBuilder::new(-7_i64, payload.to_vec())
251            .set_cwt_claims(CwtClaims::new("test"))
252            .build_detached_with_signature(signature)
253            .unwrap()
254    }
255
256    #[test]
257    fn split_basic_round_trip() {
258        let signature = vec![0xDE; 32];
259        let bundled = make_bundled(TEST_PAYLOAD, signature.clone());
260
261        let detached = detach_payload(&bundled).unwrap();
262        assert_eq!(detached.document, TEST_PAYLOAD);
263
264        // The detached envelope round-trips as a nil-payload COSE_Sign1
265        // with the original signature bytes preserved verbatim.
266        let decoded = decode_signed_corim(&detached.signature).unwrap();
267        assert_eq!(decoded.signature, signature);
268        assert!(decoded.payload.is_none());
269    }
270
271    #[test]
272    fn split_preserves_signed_bytes() {
273        // The detached envelope must keep the protected-header bytes
274        // and signature bytes verbatim -- otherwise external signature
275        // verification would fail.
276        let signature = vec![0x01; 64];
277        let bundled = make_bundled(&[0xCA, 0xFE, 0xBA, 0xBE], signature.clone());
278
279        let original = decode_signed_corim(&bundled).unwrap();
280        let detached = detach_payload(&bundled).unwrap();
281        let after = decode_signed_corim(&detached.signature).unwrap();
282
283        assert_eq!(
284            after.protected_header_bytes,
285            original.protected_header_bytes
286        );
287        assert_eq!(after.signature, original.signature);
288        assert!(after.payload.is_none());
289    }
290
291    #[test]
292    fn split_already_detached_errors() {
293        let detached = make_detached(TEST_PAYLOAD, vec![0xDE; 32]);
294        let err = detach_payload(&detached).unwrap_err();
295        assert!(
296            err.to_string().contains("already detached"),
297            "Error should mention already detached: {err}"
298        );
299    }
300
301    #[test]
302    fn split_empty_errors() {
303        assert!(detach_payload(&[]).is_err());
304    }
305
306    #[test]
307    fn split_large_payload_round_trip() {
308        let payload: Vec<u8> = (0..256).map(|i| (i & 0xFF) as u8).collect();
309        let signature = vec![0xAB; 64];
310        let bundled = make_bundled(&payload, signature.clone());
311
312        let detached = detach_payload(&bundled).unwrap();
313        assert_eq!(detached.document, payload);
314
315        let decoded = decode_signed_corim(&detached.signature).unwrap();
316        assert_eq!(decoded.signature, signature);
317        assert!(decoded.payload.is_none());
318    }
319
320    // ---------- verify_corim_signature ----------
321
322    use crate::corim_signature::test_helpers::SIGNER;
323    use crate::corim_signature::test_helpers::sign_envelope_for;
324    use crate::corim_signature::test_helpers::sign_envelope_no_cert;
325    use crate::corim_signature::test_helpers::sign_envelope_with;
326    use corim::types::signed::COSE_HEADER_ALG;
327    use corim::types::signed::COSE_HEADER_CONTENT_TYPE;
328    use corim::types::signed::COSE_HEADER_CWT_CLAIMS;
329    use corim::types::signed::COSE_HEADER_X5CHAIN;
330    use corim::types::signed::cwt::CWT_CLAIM_ISS;
331    use corim::types::tags::TAG_SIGNED_CORIM;
332    use crypto::rsa::RsaKeyPair;
333
334    #[test]
335    fn verify_ps384_round_trip() {
336        let document = b"corim-document-bytes";
337        let envelope = sign_envelope_for(document, "test");
338
339        verify_corim_signature(&envelope, document).expect("valid PS384 signature should verify");
340    }
341
342    #[test]
343    fn verify_rejects_tampered_document() {
344        let document = b"original-document";
345        let tampered = b"tampered-document";
346        let envelope = sign_envelope_for(document, "test");
347
348        let err = verify_corim_signature(&envelope, tampered).unwrap_err();
349        let msg = format!("{err:#}");
350        assert!(
351            msg.contains("cryptographic verification failed"),
352            "Error should report verification failure: {msg}"
353        );
354    }
355
356    #[test]
357    fn verify_rejects_wrong_issuer() {
358        // Sign with a fresh key but embed the shared SIGNER's cert in
359        // x5chain. The verifier extracts the (wrong) cert from the
360        // header and fails to verify the signature against it.
361        let document = b"corim-document";
362        let signer_key = RsaKeyPair::generate(2048).expect("signer keygen");
363        let envelope = sign_envelope_with(&signer_key, document, &SIGNER.cert_der, "test");
364
365        let err = verify_corim_signature(&envelope, document).unwrap_err();
366        let msg = format!("{err:#}");
367        assert!(
368            msg.contains("cryptographic verification failed"),
369            "Error should report verification failure: {msg}"
370        );
371    }
372
373    #[test]
374    fn verify_rejects_unsupported_algorithm() {
375        // ES256 is a modeled COSE alg but not accepted; only PS384 is.
376        let envelope = SignedCorimBuilder::new(CoseAlgorithm::Es256, b"corim-document".to_vec())
377            .set_cwt_claims(CwtClaims::new("test"))
378            .add_protected(COSE_HEADER_X5CHAIN, Value::Bytes(b"dummy".to_vec()))
379            .build_detached_with_signature(vec![0xDE; 64])
380            .unwrap();
381        let err = verify_corim_signature(&envelope, b"corim-document").unwrap_err();
382        let msg = format!("{err:#}");
383        assert!(
384            msg.contains("unsupported COSE algorithm"),
385            "Error should report unsupported algorithm: {msg}"
386        );
387    }
388
389    #[test]
390    fn verify_rejects_malformed_cert() {
391        // Embed garbage bytes in x5chain. The signature math will run
392        // only after cert parsing, so the from_der failure fires first.
393        let document = b"corim-document";
394        let envelope = sign_envelope_with(
395            &SIGNER.key,
396            document,
397            b"not-a-der-encoded-x509-cert",
398            "test",
399        );
400
401        let err = verify_corim_signature(&envelope, document).unwrap_err();
402        let msg = format!("{err:#}");
403        assert!(
404            msg.contains("issuer certificate"),
405            "Error should mention issuer certificate: {msg}"
406        );
407    }
408
409    #[test]
410    fn verify_rejects_missing_issuer_cert() {
411        // Envelope without x5chain or x5bag -> cert extraction fails
412        // before any signature math runs.
413        let envelope = sign_envelope_no_cert(&SIGNER.key, b"doc");
414        let err = verify_corim_signature(&envelope, b"doc").unwrap_err();
415        let msg = format!("{err:#}");
416        assert!(
417            msg.contains("x5chain") && msg.contains("x5bag"),
418            "Error should mention x5chain/x5bag: {msg}"
419        );
420    }
421
422    #[test]
423    fn verify_rejects_attached_payload() {
424        let bundled = make_bundled(b"doc", vec![0xDE; 32]);
425        let err = verify_corim_signature(&bundled, b"doc").unwrap_err();
426        let msg = format!("{err:#}");
427        assert!(
428            msg.contains("nil") || msg.contains("embedded"),
429            "Error should mention nil or embedded: {msg}"
430        );
431    }
432
433    #[test]
434    fn verify_rejects_empty_signature() {
435        let sig = make_detached(b"doc", vec![]);
436        let err = verify_corim_signature(&sig, b"doc").unwrap_err();
437        assert!(
438            err.to_string().contains("non-empty"),
439            "Error should mention non-empty: {err}"
440        );
441    }
442
443    #[test]
444    fn verify_rejects_empty_input() {
445        assert!(verify_corim_signature(&[], b"doc").is_err());
446    }
447
448    #[test]
449    fn verify_rejects_untagged_envelope() {
450        // CoRIM mandates `#6.18` wrapping; a raw 4-element array
451        // without Tag(18) must be rejected by decode_signed_corim.
452        let cose = Value::Array(vec![
453            Value::Bytes(vec![]),
454            Value::Map(vec![]),
455            Value::Null,
456            Value::Bytes(vec![0xFF; 32]),
457        ]);
458        let buf = corim::cbor::encode(&cose).unwrap();
459        assert!(verify_corim_signature(&buf, b"doc").is_err());
460    }
461
462    #[test]
463    fn verify_rejects_wrong_content_type() {
464        // Manually build a detached envelope whose protected header carries
465        // a non-CoRIM content-type. We do this via the raw CBOR codec
466        // because SignedCorimBuilder always emits "application/rim+cbor".
467        let protected_map = Value::Map(vec![
468            (
469                Value::Integer(COSE_HEADER_ALG.into()),
470                Value::Integer(CoseAlgorithm::Ps384.to_i64().into()),
471            ),
472            (
473                Value::Integer(COSE_HEADER_CONTENT_TYPE.into()),
474                Value::Text("application/x-other".into()),
475            ),
476            (
477                Value::Integer(COSE_HEADER_CWT_CLAIMS.into()),
478                Value::Map(vec![(
479                    Value::Integer(CWT_CLAIM_ISS.into()),
480                    Value::Text("test".into()),
481                )]),
482            ),
483        ]);
484        let protected_bytes = corim::cbor::encode(&protected_map).unwrap();
485        let cose = Value::Tag(
486            TAG_SIGNED_CORIM,
487            Box::new(Value::Array(vec![
488                Value::Bytes(protected_bytes),
489                Value::Map(vec![]),
490                Value::Null,
491                Value::Bytes(vec![0xAB; 16]),
492            ])),
493        );
494        let buf = corim::cbor::encode(&cose).unwrap();
495        let err = verify_corim_signature(&buf, b"doc").unwrap_err();
496        let msg = format!("{err:#}");
497        assert!(
498            msg.contains("content-type"),
499            "Error should mention content-type: {msg}"
500        );
501    }
502}