1use 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
19pub const CONTENT_TYPE: &str = "application/pser+json; profile=wilder.pser/0.5";
21
22pub const CONTENT_TYPE_06: &str = "application/pser+json; profile=wilder.pser/0.6";
24
25fn 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
38pub 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
49pub 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#[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#[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 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
150const COSE_LABEL_CONTENT_TYPE: i64 = 3;
152
153#[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 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 }
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 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 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 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 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 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 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}