use alloc::{borrow::ToOwned, string::ToString, vec::Vec};
use coset::{
Algorithm, AsCborValue, CborSerializable, ContentType, CoseSign1, CoseSign1Builder,
HeaderBuilder, Label, cbor::Value, iana,
};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use crate::{
Error, Payload, Result,
cwt::{CWT_CLAIMS_LABEL, CwtClaims},
payload::{SPEC_VERSION, SPEC_VERSION_06},
};
pub const CONTENT_TYPE: &str = "application/pser+json; profile=wilder.pser/0.5";
pub const CONTENT_TYPE_06: &str = "application/pser+json; profile=wilder.pser/0.6";
fn content_type_for_spec(spec: &str) -> Option<&'static str> {
match spec {
SPEC_VERSION => Some(CONTENT_TYPE),
SPEC_VERSION_06 => Some(CONTENT_TYPE_06),
_ => None,
}
}
pub fn produce_ed25519(payload: &Payload, issuer: &str, key: &SigningKey) -> Result<Vec<u8>> {
produce(payload, issuer, iana::Algorithm::EdDSA, |data| {
key.sign(data).to_bytes().to_vec()
})
}
pub fn verify_ed25519(statement: &[u8], key: &VerifyingKey) -> Result<Payload> {
verify(statement, iana::Algorithm::EdDSA, |signature, data| {
let signature = Signature::from_slice(signature).map_err(|_| Error::Signature)?;
key.verify(data, &signature).map_err(|_| Error::Signature)
})
}
#[cfg(feature = "es256")]
pub fn produce_es256(
payload: &Payload,
issuer: &str,
key: &p256::ecdsa::SigningKey,
) -> Result<Vec<u8>> {
use p256::ecdsa::signature::Signer as _;
produce(payload, issuer, iana::Algorithm::ES256, |data| {
let signature: p256::ecdsa::Signature = key.sign(data);
signature.to_bytes().to_vec()
})
}
#[cfg(feature = "es256")]
pub fn verify_es256(statement: &[u8], key: &p256::ecdsa::VerifyingKey) -> Result<Payload> {
use p256::ecdsa::signature::Verifier as _;
verify(statement, iana::Algorithm::ES256, |signature, data| {
let signature =
p256::ecdsa::Signature::from_slice(signature).map_err(|_| Error::Signature)?;
key.verify(data, &signature).map_err(|_| Error::Signature)
})
}
fn produce<F>(
payload: &Payload,
issuer: &str,
algorithm: iana::Algorithm,
signer: F,
) -> Result<Vec<u8>>
where
F: FnOnce(&[u8]) -> Vec<u8>,
{
if issuer.is_empty() {
return Err(Error::Header("CWT iss must not be empty"));
}
let claims = CwtClaims {
issuer: issuer.to_owned(),
subject: payload.site_id().as_bytes().to_vec(),
};
let ct = content_type_for_spec(payload.spec())
.ok_or(Error::Validation("unsupported spec version for production"))?;
let protected = HeaderBuilder::new()
.algorithm(algorithm)
.content_type(ct.to_owned())
.value(CWT_CLAIMS_LABEL, claims.to_value())
.build();
let statement = CoseSign1Builder::new()
.protected(protected)
.payload(payload.to_jcs()?)
.create_signature(&[], signer)
.build();
statement
.to_vec()
.map_err(|_| Error::Cose("failed to serialize COSE_Sign1"))
}
fn verify<F>(statement: &[u8], expected_algorithm: iana::Algorithm, verifier: F) -> Result<Payload>
where
F: FnOnce(&[u8], &[u8]) -> Result<()>,
{
let statement = parse_statement(statement)?;
let payload_bytes = statement
.payload
.as_deref()
.ok_or(Error::Cose("detached payloads are not permitted"))?;
let payload = Payload::parse_canonical(payload_bytes)?;
validate_headers(&statement, expected_algorithm, &payload)?;
statement
.verify_signature(&[], |signature, data| verifier(signature, data))
.map_err(|_| Error::Signature)?;
Ok(payload)
}
const COSE_LABEL_CONTENT_TYPE: i64 = 3;
#[allow(clippy::collapsible_if)]
fn parse_statement(mut encoded: &[u8]) -> Result<CoseSign1> {
let mut value: Value = coset::cbor::de::from_reader(&mut encoded)
.map_err(|_| Error::Cose("failed to parse COSE_Sign1 CBOR"))?;
if !encoded.is_empty() {
return Err(Error::Cose("trailing bytes after COSE_Sign1"));
}
value = match value {
Value::Tag(18, inner) => *inner,
other => other,
};
let Value::Array(items) = &mut value else {
return Err(Error::Cose("COSE_Sign1 must be an array"));
};
let Some(Value::Bytes(protected_original)) = items.first() else {
return Err(Error::Cose("COSE_Sign1 protected header must be bytes"));
};
let protected_original = protected_original.clone();
let mut protected_reader = &protected_original[..];
let protected_map: Value = coset::cbor::de::from_reader(&mut protected_reader)
.map_err(|_| Error::Cose("protected header is not valid CBOR"))?;
if !protected_reader.is_empty() {
return Err(Error::Cose("trailing data in protected header"));
}
let mut ct_needs_compat = false;
let mut ct_normalized: Option<&'static str> = None;
let mut ct_label_count = 0;
let mut ct_wrong_type = false;
if let Value::Map(entries) = &protected_map {
for (label, val) in entries {
if let Value::Integer(label_int) = label {
if i128::from(*label_int) == COSE_LABEL_CONTENT_TYPE as i128 {
ct_label_count += 1;
match val {
Value::Text(ct_str) => {
if ct_str == CONTENT_TYPE {
ct_needs_compat = true;
ct_normalized = Some(CONTENT_TYPE);
} else if ct_str == CONTENT_TYPE_06 {
ct_needs_compat = true;
ct_normalized = Some(CONTENT_TYPE_06);
}
}
_ => {
ct_wrong_type = true;
}
}
}
}
}
}
if ct_label_count > 1 {
return Err(Error::Header("duplicate content_type in protected header"));
}
if ct_wrong_type {
return Err(Error::Header("content_type must be a text string"));
}
if ct_needs_compat {
let ct = ct_normalized.expect("checked above");
let slash_pos = ct
.rfind('/')
.expect("content type contains a profile version separator");
let mut patched_map = protected_map.clone();
if let Value::Map(entries) = &mut patched_map {
for (label, val) in entries.iter_mut() {
if let Value::Integer(label_int) = label {
if i128::from(*label_int) == COSE_LABEL_CONTENT_TYPE as i128 {
if let Value::Text(ct_str) = val {
if ct_str == ct {
ct_str.replace_range(slash_pos..=slash_pos, "-");
}
}
}
}
}
}
let mut patched_bytes = Vec::new();
coset::cbor::ser::into_writer(&patched_map, &mut patched_bytes)
.map_err(|_| Error::Cose("failed to serialize patched protected header"))?;
if let Value::Array(items) = &mut value {
if let Some(Value::Bytes(protected)) = items.first_mut() {
*protected = patched_bytes;
}
}
}
let mut statement = CoseSign1::from_cbor_value(value)
.map_err(|_| Error::Cose("failed to parse COSE_Sign1 structure"))?;
if ct_needs_compat {
let ct = ct_normalized.expect("checked above");
statement.protected.original_data = Some(protected_original);
statement.protected.header.content_type = Some(ContentType::Text(ct.to_owned()));
}
Ok(statement)
}
fn validate_headers(
statement: &CoseSign1,
expected_algorithm: iana::Algorithm,
payload: &Payload,
) -> Result<()> {
let header = &statement.protected.header;
if header.alg != Some(Algorithm::Assigned(expected_algorithm)) {
return Err(Error::Header("unexpected or missing signing algorithm"));
}
let expected_ct = content_type_for_spec(payload.spec())
.ok_or(Error::Validation("unsupported spec version"))?;
if header.content_type != Some(ContentType::Text(expected_ct.to_string())) {
return Err(Error::Header(
"protected content_type does not match payload spec version",
));
}
let mut claim_values = header
.rest
.iter()
.filter_map(|(label, value)| (label == &Label::Int(CWT_CLAIMS_LABEL)).then_some(value));
let claims = claim_values
.next()
.ok_or(Error::Header("CWT_Claims is missing"))?;
if claim_values.next().is_some() {
return Err(Error::Header("CWT_Claims is duplicated"));
}
let claims = CwtClaims::from_value(claims)?;
if claims.issuer.is_empty() {
return Err(Error::Header("CWT iss must not be empty"));
}
if claims.subject.as_slice() != payload.site_id().as_bytes() {
return Err(Error::Header("CWT sub does not match site.id"));
}
if payload.spec() == crate::payload::SPEC_VERSION_06
&& matches!(
payload.attestation_binding_mode(),
crate::BindingMode::DirectWitness
)
&& claims.issuer != payload.witness_key()
{
return Err(Error::Header(
"DIRECT_WITNESS witnessKey and CWT iss must be textually equal under wilder.pser/0.6",
));
}
if statement.unprotected.alg.is_some()
|| statement.unprotected.content_type.is_some()
|| statement
.unprotected
.rest
.iter()
.any(|(label, _)| label == &Label::Int(CWT_CLAIMS_LABEL))
{
return Err(Error::Header(
"profile headers must not appear in the unprotected map",
));
}
Ok(())
}