Skip to main content

pask_wire/
envelope.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Wilder Management Inc. (d/b/a Wilder Robotics) <rob@wilder-robotics.com>
3// pask-wire is licensed Apache-2.0. No commercial agreement is required to use,
4// modify or redistribute it; see LICENSING.md in the workspace root.
5
6use alloc::{borrow::ToOwned, string::ToString, vec::Vec};
7use coset::{
8    Algorithm, AsCborValue, CborSerializable, ContentType, CoseSign1, CoseSign1Builder,
9    HeaderBuilder, Label, cbor::Value, iana,
10};
11use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
12
13use crate::{
14    Error, Payload, Result,
15    cwt::{CWT_CLAIMS_LABEL, CwtClaims},
16    payload::{SPEC_VERSION, SPEC_VERSION_06},
17};
18
19/// Required protected content type for the profile.
20pub const CONTENT_TYPE: &str = "application/pser+json; profile=wilder.pser/0.5";
21
22/// Content type for the 0.6 profile version.
23pub const CONTENT_TYPE_06: &str = "application/pser+json; profile=wilder.pser/0.6";
24
25/// Returns the required protected content type for the given profile version.
26///
27/// Returns `None` for unsupported versions. Used by both the producer and the
28/// verifier so that version selection is one mapping, not two independent
29/// checks that can disagree.
30fn content_type_for_spec(spec: &str) -> Option<&'static str> {
31    match spec {
32        SPEC_VERSION => Some(CONTENT_TYPE),
33        SPEC_VERSION_06 => Some(CONTENT_TYPE_06),
34        _ => None,
35    }
36}
37
38/// Produces an attached-payload `COSE_Sign1` statement using Ed25519.
39///
40/// # Errors
41///
42/// Returns an error if the issuer is empty or payload/envelope serialization fails.
43pub fn produce_ed25519(payload: &Payload, issuer: &str, key: &SigningKey) -> Result<Vec<u8>> {
44    produce(payload, issuer, iana::Algorithm::EdDSA, |data| {
45        key.sign(data).to_bytes().to_vec()
46    })
47}
48
49/// Parses and verifies an Ed25519 `COSE_Sign1` statement and its PSER payload.
50///
51/// # Errors
52///
53/// Returns an error for malformed envelopes, invalid headers or payloads, or a bad signature.
54pub fn verify_ed25519(statement: &[u8], key: &VerifyingKey) -> Result<Payload> {
55    verify(statement, iana::Algorithm::EdDSA, |signature, data| {
56        let signature = Signature::from_slice(signature).map_err(|_| Error::Signature)?;
57        key.verify(data, &signature).map_err(|_| Error::Signature)
58    })
59}
60
61/// Produces an attached-payload `COSE_Sign1` statement using ES256.
62///
63/// # Errors
64///
65/// Returns an error if the issuer is empty or payload/envelope serialization fails.
66#[cfg(feature = "es256")]
67pub fn produce_es256(
68    payload: &Payload,
69    issuer: &str,
70    key: &p256::ecdsa::SigningKey,
71) -> Result<Vec<u8>> {
72    use p256::ecdsa::signature::Signer as _;
73
74    produce(payload, issuer, iana::Algorithm::ES256, |data| {
75        let signature: p256::ecdsa::Signature = key.sign(data);
76        signature.to_bytes().to_vec()
77    })
78}
79
80/// Parses and verifies an ES256 `COSE_Sign1` statement and its PSER payload.
81///
82/// # Errors
83///
84/// Returns an error for malformed envelopes, invalid headers or payloads, or a bad signature.
85#[cfg(feature = "es256")]
86pub fn verify_es256(statement: &[u8], key: &p256::ecdsa::VerifyingKey) -> Result<Payload> {
87    use p256::ecdsa::signature::Verifier as _;
88
89    verify(statement, iana::Algorithm::ES256, |signature, data| {
90        let signature =
91            p256::ecdsa::Signature::from_slice(signature).map_err(|_| Error::Signature)?;
92        key.verify(data, &signature).map_err(|_| Error::Signature)
93    })
94}
95
96fn produce<F>(
97    payload: &Payload,
98    issuer: &str,
99    algorithm: iana::Algorithm,
100    signer: F,
101) -> Result<Vec<u8>>
102where
103    F: FnOnce(&[u8]) -> Vec<u8>,
104{
105    if issuer.is_empty() {
106        return Err(Error::Header("CWT iss must not be empty"));
107    }
108    let claims = CwtClaims {
109        issuer: issuer.to_owned(),
110        subject: payload.site_id().as_bytes().to_vec(),
111    };
112    // Select the protected content type from the payload's profile version.
113    // The producer and verifier share one mapping via content_type_for_spec,
114    // so a 0.6 payload always gets a 0.6 header and a 0.5 payload always gets
115    // a 0.5 header.
116    let ct = content_type_for_spec(payload.spec())
117        .ok_or(Error::Validation("unsupported spec version for production"))?;
118    let protected = HeaderBuilder::new()
119        .algorithm(algorithm)
120        .content_type(ct.to_owned())
121        .value(CWT_CLAIMS_LABEL, claims.to_value())
122        .build();
123    let statement = CoseSign1Builder::new()
124        .protected(protected)
125        .payload(payload.to_jcs()?)
126        .create_signature(&[], signer)
127        .build();
128    statement
129        .to_vec()
130        .map_err(|_| Error::Cose("failed to serialize COSE_Sign1"))
131}
132
133fn verify<F>(statement: &[u8], expected_algorithm: iana::Algorithm, verifier: F) -> Result<Payload>
134where
135    F: FnOnce(&[u8], &[u8]) -> Result<()>,
136{
137    let statement = parse_statement(statement)?;
138    let payload_bytes = statement
139        .payload
140        .as_deref()
141        .ok_or(Error::Cose("detached payloads are not permitted"))?;
142    let payload = Payload::parse_canonical(payload_bytes)?;
143    validate_headers(&statement, expected_algorithm, &payload)?;
144    statement
145        .verify_signature(&[], |signature, data| verifier(signature, data))
146        .map_err(|_| Error::Signature)?;
147    Ok(payload)
148}
149
150/// COSE header label for content type.
151const COSE_LABEL_CONTENT_TYPE: i64 = 3;
152
153/// Parses a `COSE_Sign1` from CBOR bytes.
154/// Accepts the transmitted tag-18 form and, separately, legacy untagged local
155/// producer output. Other tags and nested wrappers are rejected.
156///
157/// The content type is read structurally from COSE header label 3 in the
158/// protected header map, not from a raw byte search of the header bytes.
159///
160/// The `coset` crate (0.4.x) enforces that content type text strings contain
161/// exactly one '/' (the MIME type separator). The Pask content type includes
162/// a profile parameter with a second '/' (e.g., "wilder.pser/0.5"), which
163/// fails this check. To work around this, the protected header is parsed
164/// separately as a CBOR map to locate the content type at label 3. If the
165/// content type is found there and is a supported type, a temporary parsing
166/// copy is created where only the label-3 value has the second '/' replaced
167/// with '-' so `coset` can parse the structure. The original bytes are
168/// preserved for signature verification via `protected.original_data`, and
169/// the content type is normalized back to its canonical form after parsing.
170///
171/// A profile string appearing in another protected header field (not label 3)
172/// is not treated as a content type and is not modified by the compatibility
173/// adaptation. The patch is applied to the structurally identified label-3
174/// value only, never to a substring found by searching the whole header.
175#[allow(clippy::collapsible_if)]
176fn parse_statement(mut encoded: &[u8]) -> Result<CoseSign1> {
177    let mut value: Value = coset::cbor::de::from_reader(&mut encoded)
178        .map_err(|_| Error::Cose("failed to parse COSE_Sign1 CBOR"))?;
179    if !encoded.is_empty() {
180        return Err(Error::Cose("trailing bytes after COSE_Sign1"));
181    }
182    value = match value {
183        Value::Tag(18, inner) => *inner,
184        other => other,
185    };
186
187    let Value::Array(items) = &mut value else {
188        return Err(Error::Cose("COSE_Sign1 must be an array"));
189    };
190    let Some(Value::Bytes(protected_original)) = items.first() else {
191        return Err(Error::Cose("COSE_Sign1 protected header must be bytes"));
192    };
193    let protected_original = protected_original.clone();
194
195    // Structurally parse the protected header as a CBOR map to locate the
196    // content type at COSE label 3. This is not a raw byte search: we
197    // deserialize the protected header and look at the actual header field.
198    // Trailing data after the map is rejected.
199    let mut protected_reader = &protected_original[..];
200    let protected_map: Value = coset::cbor::de::from_reader(&mut protected_reader)
201        .map_err(|_| Error::Cose("protected header is not valid CBOR"))?;
202    if !protected_reader.is_empty() {
203        return Err(Error::Cose("trailing data in protected header"));
204    }
205
206    let mut ct_needs_compat = false;
207    let mut ct_normalized: Option<&'static str> = None;
208    let mut ct_label_count = 0;
209    let mut ct_wrong_type = false;
210
211    if let Value::Map(entries) = &protected_map {
212        for (label, val) in entries {
213            if let Value::Integer(label_int) = label {
214                if i128::from(*label_int) == COSE_LABEL_CONTENT_TYPE as i128 {
215                    ct_label_count += 1;
216                    match val {
217                        Value::Text(ct_str) => {
218                            if ct_str == CONTENT_TYPE {
219                                ct_needs_compat = true;
220                                ct_normalized = Some(CONTENT_TYPE);
221                            } else if ct_str == CONTENT_TYPE_06 {
222                                ct_needs_compat = true;
223                                ct_normalized = Some(CONTENT_TYPE_06);
224                            }
225                            // Other text values at label 3 are not patched.
226                            // coset may accept or reject them; validate_headers
227                            // will reject them as unsupported.
228                        }
229                        _ => {
230                            ct_wrong_type = true;
231                        }
232                    }
233                }
234            }
235        }
236    }
237
238    if ct_label_count > 1 {
239        return Err(Error::Header("duplicate content_type in protected header"));
240    }
241    if ct_wrong_type {
242        return Err(Error::Header("content_type must be a text string"));
243    }
244
245    // If the content type at label 3 is a supported type with two slashes,
246    // create a temporary parsing copy where only the label-3 value is patched
247    // (second '/' replaced with '-') so coset can parse the structure.
248    // The original bytes are preserved for signature verification.
249    // This modifies the structurally identified label-3 value only, never a
250    // substring found by searching the whole header.
251    if ct_needs_compat {
252        let ct = ct_normalized.expect("checked above");
253        let slash_pos = ct
254            .rfind('/')
255            .expect("content type contains a profile version separator");
256
257        // Clone the protected header map and patch only the label-3 value.
258        let mut patched_map = protected_map.clone();
259        if let Value::Map(entries) = &mut patched_map {
260            for (label, val) in entries.iter_mut() {
261                if let Value::Integer(label_int) = label {
262                    if i128::from(*label_int) == COSE_LABEL_CONTENT_TYPE as i128 {
263                        if let Value::Text(ct_str) = val {
264                            if ct_str == ct {
265                                ct_str.replace_range(slash_pos..=slash_pos, "-");
266                            }
267                        }
268                    }
269                }
270            }
271        }
272
273        // Serialize the patched map and replace the protected header bytes
274        // in the outer COSE_Sign1 array. Only the label-3 value was changed;
275        // all other fields preserve their original encoding.
276        let mut patched_bytes = Vec::new();
277        coset::cbor::ser::into_writer(&patched_map, &mut patched_bytes)
278            .map_err(|_| Error::Cose("failed to serialize patched protected header"))?;
279
280        if let Value::Array(items) = &mut value {
281            if let Some(Value::Bytes(protected)) = items.first_mut() {
282                *protected = patched_bytes;
283            }
284        }
285    }
286
287    let mut statement = CoseSign1::from_cbor_value(value)
288        .map_err(|_| Error::Cose("failed to parse COSE_Sign1 structure"))?;
289
290    // Restore the original protected header bytes for signature verification
291    // and normalize the content type to its canonical form (with '/').
292    if ct_needs_compat {
293        let ct = ct_normalized.expect("checked above");
294        statement.protected.original_data = Some(protected_original);
295        statement.protected.header.content_type = Some(ContentType::Text(ct.to_owned()));
296    }
297
298    Ok(statement)
299}
300
301fn validate_headers(
302    statement: &CoseSign1,
303    expected_algorithm: iana::Algorithm,
304    payload: &Payload,
305) -> Result<()> {
306    let header = &statement.protected.header;
307    if header.alg != Some(Algorithm::Assigned(expected_algorithm)) {
308        return Err(Error::Header("unexpected or missing signing algorithm"));
309    }
310    // Require exact agreement between the payload's profile version and the
311    // protected content type. A 0.6 payload must carry a 0.6 header, and a
312    // 0.5 payload must carry a 0.5 header. The verifier does not accept a
313    // mismatch even when both versions are individually supported.
314    let expected_ct = content_type_for_spec(payload.spec())
315        .ok_or(Error::Validation("unsupported spec version"))?;
316    if header.content_type != Some(ContentType::Text(expected_ct.to_string())) {
317        return Err(Error::Header(
318            "protected content_type does not match payload spec version",
319        ));
320    }
321    let mut claim_values = header
322        .rest
323        .iter()
324        .filter_map(|(label, value)| (label == &Label::Int(CWT_CLAIMS_LABEL)).then_some(value));
325    let claims = claim_values
326        .next()
327        .ok_or(Error::Header("CWT_Claims is missing"))?;
328    if claim_values.next().is_some() {
329        return Err(Error::Header("CWT_Claims is duplicated"));
330    }
331    let claims = CwtClaims::from_value(claims)?;
332    if claims.issuer.is_empty() {
333        return Err(Error::Header("CWT iss must not be empty"));
334    }
335    if claims.subject.as_slice() != payload.site_id().as_bytes() {
336        return Err(Error::Header("CWT sub does not match site.id"));
337    }
338    // Issue #66: Under wilder.pser/0.6, when bindingMode is DIRECT_WITNESS,
339    // attestation.witnessKey and the protected CWT `iss` value MUST be textually
340    // equal. This check does not apply to DELEGATED_WITNESS mode or to earlier
341    // profile versions. It establishes only a naming convention; it does not
342    // establish that the signature-verification key is authentically associated
343    // with either identifier or that genuine TEE hardware produced the
344    // signature.
345    if payload.spec() == crate::payload::SPEC_VERSION_06
346        && matches!(
347            payload.attestation_binding_mode(),
348            crate::BindingMode::DirectWitness
349        )
350        && claims.issuer != payload.witness_key()
351    {
352        return Err(Error::Header(
353            "DIRECT_WITNESS witnessKey and CWT iss must be textually equal under wilder.pser/0.6",
354        ));
355    }
356    if statement.unprotected.alg.is_some()
357        || statement.unprotected.content_type.is_some()
358        || statement
359            .unprotected
360            .rest
361            .iter()
362            .any(|(label, _)| label == &Label::Int(CWT_CLAIMS_LABEL))
363    {
364        return Err(Error::Header(
365            "profile headers must not appear in the unprotected map",
366        ));
367    }
368    Ok(())
369}