From ac0635f3228e3aa9fd4944bb2d087b08d0da0698 Mon Sep 17 00:00:00 2001 From: Kun Lai Date: Tue, 14 Jul 2026 16:00:43 +0800 Subject: [PATCH] feat: split cose and jwt into independent default-on features Make cose-rust, openssl, and jsonwebtoken optional behind two independent default-on features: cose = [dep:cose-rust, dep:openssl, dep:jsonwebtoken] jwt = [dep:jsonwebtoken] cose no longer implies the jwt feature -- it depends on the jsonwebtoken *crate* (used by from_cose_jwk to parse JWK keys), not the jwt feature's API surface. This lets JWT-only builds drop cose+openssl and COSE-only builds drop the jwt feature's code, with the two features genuinely orthogonal. The jsonwebtoken import in ear.rs is now gated on any(cose, jwt) so the shared JWK types are available to the cose path without forcing the jwt feature on. JWT-only doctests are wrapped in cfg(feature = "jwt") blocks so the feature matrix (cose-only / jwt-only / default) all pass cargo test cleanly. Signed-off-by: Kun Lai Assisted-by: Claude Signed-off-by: Kun Lai --- Cargo.toml | 14 +- src/base64.rs | 1 + src/ear.rs | 911 --------------------------------------------- src/ear/cose.rs | 286 ++++++++++++++ src/ear/jwt.rs | 184 +++++++++ src/ear/mod.rs | 490 ++++++++++++++++++++++++ src/lib.rs | 14 +- src/trust/claim.rs | 2 +- 8 files changed, 984 insertions(+), 918 deletions(-) delete mode 100644 src/ear.rs create mode 100644 src/ear/cose.rs create mode 100644 src/ear/jwt.rs create mode 100644 src/ear/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 4a55286..dddd5cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,15 +9,23 @@ license = "Apache-2.0" keywords = ["ear", "serde"] categories = ["data-structures", "encoding"] + [dependencies] base64 = "0.22.1" ciborium = "0.2.0" -cose-rust = "0.1.2" +cose-rust = {version = "0.1.2", optional = true} hex = "0.4.3" -jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } +jsonwebtoken = {version = "10", features = ["aws_lc_rs"], optional = true} lazy_static = "1.5.0" -openssl = "0.10.54" +openssl = {version = "0.10.54", optional = true} phf = {version = "0.13.1", features = ["macros", "serde"]} serde = {version = "1.0", features = ["derive"]} serde_json = {version = "1.0.93", features = ["raw_value"]} thiserror = "2.0.16" + + +[features] +default = ["cose", "jwt"] +cose = ["dep:cose-rust", "dep:openssl", "dep:jsonwebtoken"] +jwt = ["dep:jsonwebtoken"] + diff --git a/src/base64.rs b/src/base64.rs index 33e3f8c..21df549 100644 --- a/src/base64.rs +++ b/src/base64.rs @@ -11,6 +11,7 @@ use serde::{ use crate::error::Error; /// decodes bytes from a base64-encoded string +#[cfg(feature = "cose")] pub fn decode_str(v: &str) -> Result, Error> { general_purpose::URL_SAFE_NO_PAD .decode(v) diff --git a/src/ear.rs b/src/ear.rs deleted file mode 100644 index 8d71cd4..0000000 --- a/src/ear.rs +++ /dev/null @@ -1,911 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -use core::ops::DerefMut; - -use std::collections::BTreeMap; -use std::fmt; -use std::time::{SystemTime, UNIX_EPOCH}; - -use jsonwebtoken::{self as jwt, jwk}; -use openssl::{bn, ec, nid::Nid, pkey}; -use serde::{ - de::{self, Deserialize, Visitor}, - ser::{Error as _, Serialize, SerializeMap}, -}; - -use crate::algorithm::Algorithm; -use crate::appraisal::Appraisal; -use crate::base64::{self, Bytes}; -use crate::error::Error; -use crate::extension::{get_profile, Extensions}; -use crate::id::VerifierID; -use crate::nonce::Nonce; -use crate::trust::tier::TrustTier; -use cose::message::CoseMessage; - -#[allow(clippy::upper_case_acronyms)] -enum KeyFormat { - PEM, - DER, -} - -/// An EAT Attestation Result -/// -/// One or more appraisals associated with meta-data about the verifier and the attestation -/// request. -#[derive(Debug, PartialEq)] -pub struct Ear { - /// The EAT profile of the associated claim-set - /// - /// See - pub profile: String, - /// "Issued At" -- the time at which the EAR is issued - /// - /// See: - /// - - /// - - pub iat: i64, - /// Identifier of the verifier that created the EAR - pub vid: VerifierID, - /// The set of attested environment submodule names and associated Appraisals - /// - /// At least one submod must be present (e.g. representing the entire attested environment). - pub submods: BTreeMap, - /// A use-supplied nonce echoed by the verifier to provide freshness - pub nonce: Option, - /// Raw encoded evidence received by the verifier - pub raw_evidence: Option, - /// extension claims - pub extensions: Extensions, -} - -impl Ear { - /// Create an empty EAR - pub fn new() -> Ear { - Ear { - profile: "".to_string(), - iat: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64, - vid: VerifierID::new(), - submods: BTreeMap::new(), - nonce: None, - raw_evidence: None, - extensions: Extensions::new(), - } - } - - /// Create an empty EAR, registering extensions associated with the specified profile - pub fn new_with_profile(profile: &str) -> Result { - let mut ear = Ear { - profile: profile.to_string(), - iat: 0, - vid: VerifierID::new(), - submods: BTreeMap::new(), - nonce: None, - raw_evidence: None, - extensions: Extensions::new(), - }; - - match get_profile(&ear.profile) { - Some(profile) => { - profile.populate_ear_extensions(&mut ear)?; - Ok(ear) - } - None => Err(Error::ProfileError(format!("{profile} is not registered"))), - } - } - - /// Decode an EAR from a JWT token, verifying the signature using the specified JWK-encoded - /// key. - pub fn from_jwt_jwk(token: &str, alg: Algorithm, key: &[u8]) -> Result { - let jwk: jwk::Jwk = - serde_json::from_slice(key).map_err(|e| Error::KeyError(e.to_string()))?; - - let dk = jwt::DecodingKey::from_jwk(&jwk).map_err(|e| Error::KeyError(e.to_string()))?; - - let jwt_alg = match alg { - Algorithm::ES256 => jwt::Algorithm::ES256, - Algorithm::ES384 => jwt::Algorithm::ES384, - Algorithm::EdDSA => jwt::Algorithm::EdDSA, - Algorithm::PS256 => jwt::Algorithm::PS256, - Algorithm::PS384 => jwt::Algorithm::PS384, - Algorithm::PS512 => jwt::Algorithm::PS512, - _ => return Err(Error::SignError(format!("algorithm {alg:?} not supported"))), - }; - - Self::from_jwt(token, jwt_alg, &dk) - } - - pub fn from_jwt( - token: &str, - alg: jwt::Algorithm, - key: &jwt::DecodingKey, - ) -> Result { - let mut validation = jwt::Validation::new(alg); - // the default validation sets "exp" as a mandatory claim, which an EAR is not required to - // have. - validation.set_required_spec_claims::<&str>(&[]); - - let token_data = - jwt::decode(token, key, &validation).map_err(|e| Error::VerifyError(e.to_string()))?; - Ok(token_data.claims) - } - - /// Decode an EAR from a COSE token, verifying the signature using the specified JWK-encoded - /// key. - pub fn from_cose_jwk(token: &[u8], alg: Algorithm, key: &[u8]) -> Result { - let jwk: jwk::Jwk = - serde_json::from_slice(key).map_err(|e| Error::KeyError(e.to_string()))?; - - let cose_alg = alg_to_cose(&alg)?; - - let mut cose_key = cose::keys::CoseKey::new(); - cose_key.alg(match jwk.common.key_algorithm { - Some(jwt::jwk::KeyAlgorithm::ES256) => cose::algs::ES256, - Some(jwt::jwk::KeyAlgorithm::ES384) => cose::algs::ES384, - Some(jwt::jwk::KeyAlgorithm::EdDSA) => cose::algs::EDDSA, - Some(a) => return Err(Error::KeyError(format!("unsupported algorithm {a:?}"))), - None => cose_alg, - }); - cose_key.key_ops(vec![cose::keys::KEY_OPS_VERIFY]); - - // NOTE: there appears to be a bug in the cose-rust lib, which means CoseSign.key() expects - // the d param to be set, even if the key is only used for verification. - cose_key.d(hex::decode("deadbeef").unwrap()); - - match jwk.algorithm { - jwk::AlgorithmParameters::EllipticCurve(ec_params) => { - cose_key.kty(cose::keys::EC2); - cose_key.crv(match ec_params.curve { - jwk::EllipticCurve::P256 => cose::keys::P_256, - jwk::EllipticCurve::P384 => cose::keys::P_384, - jwk::EllipticCurve::P521 => cose::keys::P_521, - c => return Err(Error::KeyError(format!("invalid EC2 curve {c:?}"))), - }); - cose_key.x(base64::decode_str(ec_params.x.as_str())?); - cose_key.y(base64::decode_str(ec_params.y.as_str())?); - } - jwk::AlgorithmParameters::OctetKeyPair(okp_params) => { - cose_key.kty(cose::keys::OKP); - cose_key.crv(match okp_params.curve { - jwk::EllipticCurve::Ed25519 => cose::keys::ED25519, - c => return Err(Error::KeyError(format!("invalid OKP curve {c:?}"))), - }); - cose_key.x(base64::decode_str(okp_params.x.as_str())?); - } - a => { - return Err(Error::KeyError(format!( - "unsupported algorithm params {a:?}" - ))) - } - } - - Self::from_cose(token, &cose_key) - } - - fn from_cose(token: &[u8], key: &cose::keys::CoseKey) -> Result { - let mut sign1 = CoseMessage::new_sign(); - - sign1.bytes = token.to_vec(); - sign1.init_decoder(None).unwrap(); - sign1.key(key).unwrap(); - sign1.decode(None, None).unwrap(); - - ciborium::de::from_reader(sign1.payload.as_slice()) - .map_err(|e| Error::VerifyError(e.to_string())) - } - - /// Encode the EAR as a JWT token, signing it with the specified PEM-encoded key - #[allow(clippy::type_complexity)] - pub fn sign_jwt_pem(&self, alg: Algorithm, key: &[u8]) -> Result { - let header = &jwt::Header::new(alg_to_jwt_alg(&alg)?); - self.sign_jwt_pem_with_header(header, key) - } - - /// Encode the EAR as a JWT token, signing it with the specified PEM-encoded key, and including - /// the provided headers. - pub fn sign_jwt_pem_with_header( - &self, - header: &jwt::Header, - key: &[u8], - ) -> Result { - let keyfunc: fn(&[u8]) -> Result = match header.alg { - jwt::Algorithm::ES256 => jwt::EncodingKey::from_ec_pem, - jwt::Algorithm::ES384 => jwt::EncodingKey::from_ec_pem, - jwt::Algorithm::EdDSA => jwt::EncodingKey::from_ed_pem, - jwt::Algorithm::PS256 => jwt::EncodingKey::from_rsa_pem, - jwt::Algorithm::PS384 => jwt::EncodingKey::from_rsa_pem, - jwt::Algorithm::PS512 => jwt::EncodingKey::from_rsa_pem, - _ => { - return Err(Error::SignError(format!( - "algorithm {0:?} not supported", - header.alg - ))) - } - }; - - let ek = keyfunc(key).map_err(|e| Error::KeyError(e.to_string()))?; - - jwt::encode(header, self, &ek).map_err(|e| Error::SignError(e.to_string())) - } - - /// Encode the EAR as a JWT token, signing it with the specified DER-encoded key - pub fn sign_jwk_der(&self, alg: Algorithm, key: &[u8]) -> Result { - let header = &jwt::Header::new(alg_to_jwt_alg(&alg)?); - self.sign_jwk_der_with_header(header, key) - } - - /// Encode the EAR as a JWT token, signing it with the specified DER-encoded key, - /// including the specified header(s). - pub fn sign_jwk_der_with_header( - &self, - header: &jwt::Header, - key: &[u8], - ) -> Result { - let ek = match header.alg { - jwt::Algorithm::ES256 => jwt::EncodingKey::from_ec_der(key), - jwt::Algorithm::ES384 => jwt::EncodingKey::from_ec_der(key), - jwt::Algorithm::EdDSA => jwt::EncodingKey::from_ed_der(key), - jwt::Algorithm::PS256 => jwt::EncodingKey::from_rsa_der(key), - jwt::Algorithm::PS384 => jwt::EncodingKey::from_rsa_der(key), - jwt::Algorithm::PS512 => jwt::EncodingKey::from_rsa_der(key), - _ => { - return Err(Error::SignError(format!( - "algorithm {:?} not supported", - header.alg - ))) - } - }; - - jwt::encode(header, self, &ek).map_err(|e| Error::SignError(e.to_string())) - } - - /// Encode the EAR as a COSE token, signing it with the specified PEM-encoded key - pub fn sign_cose_pem(&self, alg: Algorithm, key: &[u8]) -> Result, Error> { - let header = new_cose_header(&alg)?; - self.sign_cose_bytes_with_header(header, key, KeyFormat::PEM) - } - - /// Encode the EAR as a COSE token, signing it with the specified DER-encoded key - pub fn sign_cose_der(&self, alg: Algorithm, key: &[u8]) -> Result, Error> { - let header = new_cose_header(&alg)?; - self.sign_cose_bytes_with_header(header, key, KeyFormat::DER) - } - - /// Encode the EAR as a COSE token with the specified header, signing it with the specified - /// PEM-encoded key - pub fn sign_cose_pem_with_header( - &self, - header: cose::headers::CoseHeader, - key: &[u8], - ) -> Result, Error> { - self.sign_cose_bytes_with_header(header, key, KeyFormat::PEM) - } - - /// Encode the EAR as a COSE token with the specified header, signing it with the specified - /// DER-encoded key - pub fn sign_cose_der_with_header( - &self, - header: cose::headers::CoseHeader, - key: &[u8], - ) -> Result, Error> { - self.sign_cose_bytes_with_header(header, key, KeyFormat::DER) - } - - fn sign_cose_bytes_with_header( - &self, - header: cose::headers::CoseHeader, - key: &[u8], - key_fmt: KeyFormat, - ) -> Result, Error> { - let cose_alg = header - .alg - .ok_or(Error::SignError("alg header must be set".to_string()))?; - - let mut cose_key = cose::keys::CoseKey::new(); - cose_key.alg(cose_alg); - cose_key.key_ops(vec![cose::keys::KEY_OPS_SIGN]); - - match cose_alg { - cose::algs::ES256 | cose::algs::ES384 | cose::algs::PS512 => { - let ec_key = match key_fmt { - KeyFormat::PEM => ec::EcKey::private_key_from_pem(key), - KeyFormat::DER => ec::EcKey::private_key_from_der(key), - } - .map_err(|e| Error::KeyError(e.to_string()))?; - - let ec_group = ec_key.group(); - - cose_key.kty(cose::keys::EC2); - cose_key.crv(match ec_group.curve_name() { - Some(Nid::X9_62_PRIME256V1) => cose::keys::P_256, - Some(Nid::SECP384R1) => cose::keys::P_384, - Some(Nid::SECP521R1) => cose::keys::P_521, - _ => return Err(Error::KeyError("unsupported EC group".to_string())), - }); - - let mut x = bn::BigNum::new().map_err(|e| Error::KeyError(e.to_string()))?; - let mut y = bn::BigNum::new().map_err(|e| Error::KeyError(e.to_string()))?; - - let mut ctx = - bn::BigNumContext::new_secure().map_err(|e| Error::KeyError(e.to_string()))?; - - let x_ref = x.deref_mut(); - let y_ref = y.deref_mut(); - let ctx_ref = ctx.deref_mut(); - - ec_key - .public_key() - .affine_coordinates(ec_group, x_ref, y_ref, ctx_ref) - .map_err(|e| Error::KeyError(e.to_string()))?; - - cose_key.x(x_ref.to_vec()); - cose_key.y(y_ref.to_vec()); - cose_key.d(ec_key.private_key().to_vec()); - } - cose::algs::EDDSA => { - cose_key.kty(cose::keys::OKP); - cose_key.crv(cose::keys::ED25519); - - let p_key = match key_fmt { - KeyFormat::PEM => pkey::PKey::private_key_from_pem(key), - KeyFormat::DER => pkey::PKey::private_key_from_der(key), - } - .map_err(|e| Error::KeyError(e.to_string()))?; - - let raw = p_key - .raw_private_key() - .map_err(|e| Error::KeyError(e.to_string()))?; - - cose_key.d(raw[..32].to_vec()); - cose_key.x(raw[32..].to_vec()); - } - _ => { - return Err(Error::SignError(format!( - "algorithm {cose_alg:?} not supported" - ))) - } - }; - - self.sign_cose_with_header(header, &cose_key) - } - - fn sign_cose_with_header( - &self, - header: cose::headers::CoseHeader, - key: &cose::keys::CoseKey, - ) -> Result, Error> { - let mut payload: Vec = Vec::new(); - ciborium::ser::into_writer(self, &mut payload) - .map_err(|e| Error::SignError(e.to_string()))?; - - let mut sign1 = CoseMessage::new_sign(); - sign1.payload(payload); - sign1.add_header(header); - - if let Some(a) = key.alg { - if a != sign1.header.alg.unwrap() { - return Err(Error::SignError( - "specified algorithm doesn't match key".to_string(), - )); - } - }; - - sign1 - .key(key) - .map_err(|e| Error::SignError(format!("{e:?}")))?; - - sign1 - .secure_content(None) - .map_err(|e| Error::SignError(format!("{e:?}")))?; - sign1 - .encode(true) - .map_err(|e| Error::SignError(format!("{e:?}")))?; - - Ok(sign1.bytes.to_vec()) - } - - /// Ensure that the EAR is valid - pub fn validate(&self) -> Result<(), Error> { - if self.profile.as_str() == "" { - return Err(Error::ValidationError("empty profile".to_string())); - } - - if self.submods.is_empty() { - return Err(Error::ValidationError("empty submods".to_string())); - } - - // do we want to have stronger validation here? e.g. checking that iat is not in the future - // or impossibly distant past. - if self.iat == 0 { - return Err(Error::ValidationError("iat unset".to_string())); - } - - self.vid.validate().map_err(|e| { - let msg = match e { - Error::ValidationError(s) => s, - _ => e.to_string(), - }; - Error::ValidationError(format!("verifier-id: {msg}")) - })?; - - Ok(()) - } - - pub fn update_status_from_trust_vector(&mut self) { - for submod in self.submods.values_mut() { - if submod.status == TrustTier::None { - submod.update_status_from_trust_vector(); - } - } - } -} - -impl Default for Ear { - fn default() -> Self { - Self::new() - } -} - -impl Serialize for Ear { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.validate().map_err(S::Error::custom)?; - - let is_human_readable = serializer.is_human_readable(); - let mut map = serializer.serialize_map(None)?; - - if is_human_readable { - map.serialize_entry("eat_profile", &self.profile)?; - map.serialize_entry("iat", &self.iat)?; - map.serialize_entry("ear.verifier-id", &self.vid)?; - map.serialize_entry("submods", &self.submods)?; - - if let Some(n) = &self.nonce { - map.serialize_entry("eat_nonce", &n)? - } - - if let Some(r) = &self.raw_evidence { - map.serialize_entry("ear.raw-evidence", &r)? - } - - self.extensions.serialize_to_map_by_name(&mut map)?; - } else { - // !is_human_readable - map.serialize_entry(&265, &self.profile)?; - map.serialize_entry(&6, &self.iat)?; - map.serialize_entry(&1004, &self.vid)?; - map.serialize_entry(&266, &self.submods)?; - - if let Some(n) = &self.nonce { - map.serialize_entry(&10, &n)? - } - - if let Some(r) = &self.raw_evidence { - map.serialize_entry(&1002, &r)? - } - - self.extensions.serialize_to_map_by_key(&mut map)?; - } - - map.end() - } -} - -impl<'de> Deserialize<'de> for Ear { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let is_hr = deserializer.is_human_readable(); - - deserializer.deserialize_map(EarVisitor { - is_human_readable: is_hr, - }) - } -} - -struct EarVisitor { - pub is_human_readable: bool, -} - -impl<'de> Visitor<'de> for EarVisitor { - type Value = Ear; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a CBOR map or JSON object") - } - - fn visit_map(self, mut map: A) -> Result - where - A: serde::de::MapAccess<'de>, - { - let mut ear = Ear::new(); - - loop { - if self.is_human_readable { - match map.next_key::<&str>()? { - Some("eat_profile") => ear.profile = map.next_value::()?, - Some("iat") => ear.iat = map.next_value::()?, - Some("ear.verifier-id") => ear.vid = map.next_value::()?, - Some("submods") => { - ear.submods = map.next_value::>()? - } - Some("eat_nonce") => ear.nonce = Some(map.next_value::()?), - Some("ear.raw-evidence") => ear.raw_evidence = Some(map.next_value::()?), - Some(name) => ear.extensions.visit_map_entry_by_name(name, &mut map)?, - None => break, - } - } else { - // !is_human_readable - match map.next_key::()? { - Some(265) => ear.profile = map.next_value::()?, - Some(6) => ear.iat = map.next_value::()?, - Some(1004) => ear.vid = map.next_value::()?, - Some(266) => ear.submods = map.next_value::>()?, - Some(10) => ear.nonce = Some(map.next_value::()?), - Some(1002) => ear.raw_evidence = Some(map.next_value::()?), - Some(key) => ear.extensions.visit_map_entry_by_key(key, &mut map)?, - None => break, - } - } - } - - if let Some(profile) = get_profile(&ear.profile) { - profile - .populate_ear_extensions(&mut ear) - .map_err(de::Error::custom)? - } - - ear.validate().map_err(de::Error::custom)?; - - Ok(ear) - } -} - -#[inline] -pub fn new_jwt_header(alg: &Algorithm) -> Result { - Ok(jwt::Header::new(alg_to_jwt_alg(alg)?)) -} - -#[inline] -pub fn new_cose_header(alg: &Algorithm) -> Result { - let cose_alg = alg_to_cose(alg)?; - let mut header = cose::headers::CoseHeader::new(); - header.alg(cose_alg, true, false); - - Ok(header) -} - -#[inline] -fn alg_to_jwt_alg(alg: &Algorithm) -> Result { - match alg { - Algorithm::ES256 => Ok(jwt::Algorithm::ES256), - Algorithm::ES384 => Ok(jwt::Algorithm::ES384), - Algorithm::EdDSA => Ok(jwt::Algorithm::EdDSA), - Algorithm::PS256 => Ok(jwt::Algorithm::PS256), - Algorithm::PS384 => Ok(jwt::Algorithm::PS384), - Algorithm::PS512 => Ok(jwt::Algorithm::PS512), - _ => Err(Error::SignError(format!("algorithm {alg:?} not supported"))), - } -} - -#[inline] -fn alg_to_cose(alg: &Algorithm) -> Result { - match alg { - Algorithm::ES256 => Ok(cose::algs::ES256), - Algorithm::ES384 => Ok(cose::algs::ES384), - Algorithm::ES512 => Ok(cose::algs::ES512), - Algorithm::EdDSA => Ok(cose::algs::EDDSA), - _ => Err(Error::SignError(format!("algorithm {alg:?} not supported"))), - } -} - -#[cfg(test)] -#[rustfmt::skip::macros(vec)] -mod test { - use super::*; - use crate::extension::*; - use crate::raw::{RawValue, RawValueKind}; - use ciborium::{de::from_reader, ser::into_writer}; - - const EAR_STRING: &str = r#" - { - "eat_profile":"tag:github.com,2023:veraison/ear", - "iat":1666529184, - "ear.verifier-id":{ - "build":"vsts 0.0.1", - "developer":"https://veraison-project.org" - }, - "submods":{ - "test": {"ear.status": "none"} - }, - "ear.raw-evidence":"NzQ3MjY5NzM2NTYzNzQK" - } - "#; - - const EAR_WITH_EXTENSIONS_STRING: &str = r#" - { - "eat_profile":"tag:github.com,2023:veraison/ear", - "iat":1666529184, - "ear.verifier-id":{ - "build":"vsts 0.0.1", - "developer":"https://veraison-project.org" - }, - "submods":{ - "test": { - "ear.status": "none", - "ext3": "3q2-7w" - } - }, - "ear.raw-evidence":"NzQ3MjY5NzM2NTYzNzQK", - "ext1": "foo", - "ext2": 42 - } - "#; - - const SIGNING_KEY: &str = "-----BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPp4XZRnRHSMhGg0t -6yjQCRV35J4TUY4idLgiCu6EyLqhRANCAAQbx8C533c2AKDwL/RtjVipVnnM2WRv -5w2wZNCJrubSK0StYKJ71CikDgkhw8M90ojfRIowqpl0uLA3kW3PEZy9 ------END PRIVATE KEY----- -"; - const VERIF_KEY: &str = r#" - { - "kty":"EC", - "crv":"P-256", - "x":"G8fAud93NgCg8C_0bY1YqVZ5zNlkb-cNsGTQia7m0is", - "y":"RK1gonvUKKQOCSHDwz3SiN9EijCqmXS4sDeRbc8RnL0" - } - "#; - - #[test] - fn sign_jwk() { - let ear = Ear { - profile: "test".to_string(), - iat: 1, - vid: VerifierID { - build: "vsts 0.0.1".to_string(), - developer: "https://veraison-project.org".to_string(), - }, - raw_evidence: None, - nonce: None, - submods: BTreeMap::from([("test".to_string(), Appraisal::new())]), - extensions: Extensions::new(), - }; - - let signed = ear - .sign_jwt_pem(Algorithm::ES256, SIGNING_KEY.as_bytes()) - .unwrap(); - - let ear2 = - Ear::from_jwt_jwk(signed.as_str(), Algorithm::ES256, VERIF_KEY.as_bytes()).unwrap(); - - assert_eq!(ear, ear2); - } - - #[test] - fn cose() { - let ear = Ear { - profile: "test".to_string(), - iat: 1, - vid: VerifierID { - build: "vsts 0.0.1".to_string(), - developer: "https://veraison-project.org".to_string(), - }, - raw_evidence: None, - nonce: None, - submods: BTreeMap::from([("test".to_string(), Appraisal::new())]), - extensions: Extensions::new(), - }; - - let signed = ear - .sign_cose_pem(Algorithm::ES256, SIGNING_KEY.as_bytes()) - .unwrap(); - - let ear2 = - Ear::from_cose_jwk(signed.as_slice(), Algorithm::ES256, VERIF_KEY.as_bytes()).unwrap(); - - assert_eq!(ear, ear2); - } - - #[test] - fn serde() { - let ear = Ear { - profile: "tag:github.com,2023:veraison/ear".to_string(), - iat: 1666529184, - vid: VerifierID { - build: "vsts 0.0.1".to_string(), - developer: "https://veraison-project.org".to_string(), - }, - raw_evidence: Some(Bytes::from( - vec![ - 0x37, 0x34, 0x37, 0x32, 0x36, 0x39, 0x37, 0x33, 0x36, 0x35, 0x36, 0x33, 0x37, - 0x34, 0x0a, - ] - .as_slice(), - )), - nonce: None, - submods: BTreeMap::from([("test".to_string(), Appraisal::new())]), - extensions: Extensions::new(), - }; - - let val = serde_json::to_string(&ear).unwrap(); - assert_eq!( - val.parse::().unwrap(), - EAR_STRING.parse::().unwrap(), - ); - - let mut buf: Vec = Vec::new(); - into_writer(&ear, &mut buf).unwrap(); - assert_eq!( - buf, - vec![ - 0xbf, // map (indefinite length) - 0x19, // unsigned int in the next 2 bytes - 0x01, 0x09, // 265 - 0x78, 0x20, // text string (32) - 0x74, 0x61, 0x67, 0x3a, 0x67, 0x69, 0x74, 0x68, // "tag:gith" - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2c, 0x32, // "ub.com,2" - 0x30, 0x32, 0x33, 0x3a, 0x76, 0x65, 0x72, 0x61, // "023:vera" - 0x69, 0x73, 0x6f, 0x6e, 0x2f, 0x65, 0x61, 0x72, // "ison/ear" - 0x06, // 6 - 0x1a, // unsigned int in the next 4 bytes - 0x63, 0x55, 0x37, 0xa0, // 1666529184 - 0x19, // unsigned int in the next 2 bytes - 0x3, 0xec, // 1004 - 0xa2, // map (2) - 0x00, // 0 - 0x78, 0x1c, // text string (28) - 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, // "https://" - 0x76, 0x65, 0x72, 0x61, 0x69, 0x73, 0x6f, 0x6e, // "veraison" - 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, // "-project" - 0x2e, 0x6f, 0x72, 0x67, // ".org" - 0x01, // 1 - 0x6a, // text string (10) - 0x76, 0x73, 0x74, 0x73, 0x20, 0x30, 0x2e, 0x30, // "vsts 0.0" - 0x2e, 0x31, // ".1" - 0x19, // unsigned int in the next 2 bytes - 0x01, 0x0a, // 266 - 0xa1, // map (1) - 0x64, // text string (4) - 0x74, 0x65, 0x73, 0x74, // "test" - 0xbf, // map (indefinite length) - 0x19, // unsigned int in the next 2 bytes - 0x03, 0xe8, // 1000 - 0x00, // 0 - 0xff, // break / end indefinite map - 0x19, // unsigned int in the next 2 bytes - 0x03, 0xea, // 1002 - 0x4f, // byte string (15) - 0x37, 0x34, 0x37, 0x32, 0x36, 0x39, 0x37, 0x33, - 0x36, 0x35, 0x36, 0x33, 0x37, 0x34, 0x0a, - 0xff, // break / end indefinite map - ] - ); - - let ear2: Ear = serde_json::from_str(EAR_STRING).unwrap(); - assert_eq!(ear.profile, ear2.profile); - assert_eq!(ear.iat, ear2.iat); - assert_eq!(ear.vid.build, ear2.vid.build); - assert_eq!(ear.vid.developer, ear2.vid.developer); - assert_eq!(ear.raw_evidence, ear2.raw_evidence); - - let ear2: Ear = from_reader(buf.as_slice()).unwrap(); - assert_eq!(ear.profile, ear2.profile); - assert_eq!(ear.iat, ear2.iat); - assert_eq!(ear.vid.build, ear2.vid.build); - assert_eq!(ear.vid.developer, ear2.vid.developer); - assert_eq!(ear.raw_evidence, ear2.raw_evidence); - } - - #[test] - fn serde_extensions() { - let mut profile = Profile::new("tag:github.com,2023:veraison/ear"); - profile - .register_ear_extension("ext1", -1, RawValueKind::String) - .unwrap(); - profile - .register_ear_extension("ext2", -2, RawValueKind::Integer) - .unwrap(); - profile - .register_appraisal_extension("ext3", -1, RawValueKind::Bytes) - .unwrap(); - register_profile(&profile).unwrap(); - - let ear = serde_json::from_str::(EAR_WITH_EXTENSIONS_STRING).unwrap(); - - let v1 = ear.extensions.get_by_name("ext1").unwrap(); - assert_eq!(v1, RawValue::String("foo".to_string())); - - let text = serde_json::to_string(&ear).unwrap(); - assert_eq!( - text.parse::().unwrap(), - EAR_WITH_EXTENSIONS_STRING - .parse::() - .unwrap(), - ); - - let mut buf: Vec = Vec::new(); - into_writer(&ear, &mut buf).unwrap(); - assert_eq!( - buf, - vec![ - 0xbf, // map (indefinite length) - 0x19, // unsigned int in the next 2 bytes - 0x01, 0x09, // 265 - 0x78, 0x20, // text string (32) - 0x74, 0x61, 0x67, 0x3a, 0x67, 0x69, 0x74, 0x68, // "tag:gith" - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2c, 0x32, // "ub.com,2" - 0x30, 0x32, 0x33, 0x3a, 0x76, 0x65, 0x72, 0x61, // "023:vera" - 0x69, 0x73, 0x6f, 0x6e, 0x2f, 0x65, 0x61, 0x72, // "ison/ear" - 0x06, // 6 - 0x1a, // unsigned int in the next 4 bytes - 0x63, 0x55, 0x37, 0xa0, // 1666529184 - 0x19, // unsigned int in the next 2 bytes - 0x3, 0xec, // 1004 - 0xa2, // map (2) - 0x00, // 0 - 0x78, 0x1c, // text string (28) - 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, // "https://" - 0x76, 0x65, 0x72, 0x61, 0x69, 0x73, 0x6f, 0x6e, // "veraison" - 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, // "-project" - 0x2e, 0x6f, 0x72, 0x67, // ".org" - 0x01, // 1 - 0x6a, // text string (10) - 0x76, 0x73, 0x74, 0x73, 0x20, 0x30, 0x2e, 0x30, // "vsts 0.0" - 0x2e, 0x31, // ".1" - 0x19, // unsigned int in the next 2 bytes - 0x01, 0x0a, // 266 - 0xa1, // map (1) - 0x64, // text string (4) - 0x74, 0x65, 0x73, 0x74, // "test" - 0xbf, // map (indefinite length) - 0x19, // unsigned int in the next 2 bytes - 0x03, 0xe8, // 1000 - 0x00, // 0 - 0x20, // -1 - 0x44, // byte string (3) - 0xde, 0xad, 0xbe, 0xef, - 0xff, // break / end indefinite map - 0x19, // unsigned int in the next 2 bytes - 0x03, 0xea, // 1002 - 0x4f, // byte string (15) - 0x37, 0x34, 0x37, 0x32, 0x36, 0x39, 0x37, 0x33, - 0x36, 0x35, 0x36, 0x33, 0x37, 0x34, 0x0a, - 0x21, // -2 - 0x18, // unsigned int next byte - 0x2a, // 42 - 0x20, // -1 - 0x63, // text string (3) - 0x66, 0x6f, 0x6f, // "foo" - 0xff, // break / end indefinite map - ] - ); - - let ear2: Ear = from_reader(buf.as_slice()).unwrap(); - assert_eq!(ear, ear2); - } - - #[test] - fn verify() { - const VERIF_KEY: &str = r#" - { - "crv": "P-256", - "kty": "EC", - "x": "usWxHK2PmfnHKwXPS54m0kTcGJ90UiglWiGahtagnv8", - "y": "IBOL-C3BttVivg-lSreASjpkttcsz-1rb7btKLv8EX4" - } - "#; - - let ear_jwt = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJlYXIudmVyaWZpZXItaWQiOnsiYnVpbGQiOiJOL0EiLCJkZXZlbG9wZXIiOiJWZXJhaXNvbiBQcm9qZWN0In0sImVhdF9ub25jZSI6IjNXSHlqbmRHT1RJPSIsImVhdF9wcm9maWxlIjoidGFnOmdpdGh1Yi5jb20sMjAyMzp2ZXJhaXNvbi9lYXIiLCJpYXQiOjE3MDQ5MDgxOTUsInN1Ym1vZHMiOnsiUEFSU0VDX1RQTSI6eyJlYXIuYXBwcmFpc2FsLXBvbGljeS1pZCI6InBvbGljeTpQQVJTRUNfVFBNIiwiZWFyLnN0YXR1cyI6ImFmZmlybWluZyIsImVhci50cnVzdHdvcnRoaW5lc3MtdmVjdG9yIjp7ImNvbmZpZ3VyYXRpb24iOjAsImV4ZWN1dGFibGVzIjoyLCJmaWxlLXN5c3RlbSI6MCwiaGFyZHdhcmUiOjIsImluc3RhbmNlLWlkZW50aXR5IjoyLCJydW50aW1lLW9wYXF1ZSI6MCwic291cmNlZC1kYXRhIjowLCJzdG9yYWdlLW9wYXF1ZSI6MH0sImVhci52ZXJhaXNvbi5hbm5vdGF0ZWQtZXZpZGVuY2UiOnsia2F0Ijp7ImNlcnRJbmZvIjoiLzFSRFI0QVhBQ0lBQzRPZnJLT0ZLSGxhM2pFelVQSzNNSkNTK1cydHdCVlRFREY4RTk2dzFWWlpBQWdBQVFJREJBVUdCd0FBQUFBYXZJOTFPSFRnOTNOdHliUUJETTZINVJSQTFjNEFJZ0FMM3p1UDlHSy96MXhBR3Fuc1Zxd0ZxU09BdkxVUExoQUkrTmErOFV3VmZWWUFJZ0FMNGhRWm1kbXJaN05vbEExdmRXbEJMeC96TXQ0RldhSWt1R3JoWEdHUkJpWT0iLCJraWQiOiJBYUZKUUNRSDNzT3RxSFdUVWs2WjUrZncvazE4dnl2SkVuWXcxTTdrVHZ0VCIsInB1YkFyZWEiOiJBQ01BQ3dBRUFISUFBQUFRQUJnQUN3QURBQkFBSUtFL0JCMjJySmFDbktRK3BxM05PeEQxcmJaNXp5ZituTThzMS9jbDlwd1RBQ0IyUDlCb2gwcDlEYmlqYUdpVVF1ZkRHWDNaL0ZYZFVqd3JCTUZEKzlPTW53PT0iLCJzaWciOiJBQmdBQ3dBZzA4SkVGY1lxRmsrUnpPVHZvaUp0K1JMOEZvd3oxNzVMakVmTW1KTHcyOU1BSUJLbDQ3eWJyYmdmOTltK21DblVDbkZtTFRNZDN5MUFLTWVoaFNiWEMvYzQiLCJ0cG1WZXIiOiIyLjAifSwicGF0Ijp7ImF0dGVzdEluZm8iOiIvMVJEUjRBWUFDSUFDNE9mcktPRktIbGEzakV6VVBLM01KQ1MrVzJ0d0JWVEVERjhFOTZ3MVZaWkFBZ0FBUUlEQkFVR0J3QUFBQUFhdkk5Mk9IVGc5M050eWJRQkRNNkg1UlJBMWM0QUFBQUJBQXNEQndBQUFDQXVxYXVSbU5GamdBZEFETkxEdnZITWRGdUdTM1lCR2c0YnhTR0FyR1JTMUE9PSIsImtpZCI6IkFhRkpRQ1FIM3NPdHFIV1RVazZaNStmdy9rMTh2eXZKRW5ZdzFNN2tUdnRUIiwic2lnIjoiQUJnQUN3QWdNcWN0TlRuZFh3VU5MZkNERW1lOC81c0hVM2diaGFPL05OdW4xY2tpT0xBQUlLVFkwU2VWUUJIWkpuaXNPRzNTb2VOQ1dHYTJnWlMrSUhuWkN2M3dUOTVJIiwidHBtVmVyIjoiMi4wIn19LCJlYXIudmVyYWlzb24ua2V5LWF0dGVzdGF0aW9uIjp7ImFrcHViIjoiTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFb1Q4RUhiYXNsb0tjcEQ2bXJjMDdFUFd0dG5uUEpfNmN6eXpYOXlYMm5CTjJQOUJvaDBwOURiaWphR2lVUXVmREdYM1pfRlhkVWp3ckJNRkQtOU9NbncifX19fQ.eRyCRmGEOt2GeMvi1-PiSaIVOuixBHwz8FYPSm7XuKnZd6XYe_8HQaCXEtarpOppvzoyHcZvU_4rV54iE7PQaw"; - - let ear = Ear::from_jwt_jwk(ear_jwt, Algorithm::ES256, VERIF_KEY.as_bytes()) - .expect("successfully verified"); - - assert_eq!("tag:github.com,2023:veraison/ear", ear.profile); - } -} diff --git a/src/ear/cose.rs b/src/ear/cose.rs new file mode 100644 index 0000000..707eb83 --- /dev/null +++ b/src/ear/cose.rs @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: Apache-2.0 + +use core::ops::DerefMut; + +use cose::message::CoseMessage; +use jsonwebtoken::{self as jwt, jwk}; +use openssl::{bn, ec, nid::Nid, pkey}; + +use crate::algorithm::Algorithm; +use crate::base64; +use crate::error::Error; +use crate::Ear; + +#[allow(clippy::upper_case_acronyms)] +enum KeyFormat { + PEM, + DER, +} + +impl Ear { + /// Decode an EAR from a COSE token, verifying the signature using the specified JWK-encoded + /// key. + pub fn from_cose_jwk(token: &[u8], alg: Algorithm, key: &[u8]) -> Result { + let jwk: jwk::Jwk = + serde_json::from_slice(key).map_err(|e| Error::KeyError(e.to_string()))?; + + let cose_alg = alg_to_cose(&alg)?; + + let mut cose_key = cose::keys::CoseKey::new(); + cose_key.alg(match jwk.common.key_algorithm { + Some(jwt::jwk::KeyAlgorithm::ES256) => cose::algs::ES256, + Some(jwt::jwk::KeyAlgorithm::ES384) => cose::algs::ES384, + Some(jwt::jwk::KeyAlgorithm::EdDSA) => cose::algs::EDDSA, + Some(a) => return Err(Error::KeyError(format!("unsupported algorithm {a:?}"))), + None => cose_alg, + }); + cose_key.key_ops(vec![cose::keys::KEY_OPS_VERIFY]); + + // NOTE: there appears to be a bug in the cose-rust lib, which means CoseSign.key() expects + // the d param to be set, even if the key is only used for verification. + cose_key.d(hex::decode("deadbeef").unwrap()); + + match jwk.algorithm { + jwk::AlgorithmParameters::EllipticCurve(ec_params) => { + cose_key.kty(cose::keys::EC2); + cose_key.crv(match ec_params.curve { + jwk::EllipticCurve::P256 => cose::keys::P_256, + jwk::EllipticCurve::P384 => cose::keys::P_384, + jwk::EllipticCurve::P521 => cose::keys::P_521, + c => return Err(Error::KeyError(format!("invalid EC2 curve {c:?}"))), + }); + cose_key.x(base64::decode_str(ec_params.x.as_str())?); + cose_key.y(base64::decode_str(ec_params.y.as_str())?); + } + jwk::AlgorithmParameters::OctetKeyPair(okp_params) => { + cose_key.kty(cose::keys::OKP); + cose_key.crv(match okp_params.curve { + jwk::EllipticCurve::Ed25519 => cose::keys::ED25519, + c => return Err(Error::KeyError(format!("invalid OKP curve {c:?}"))), + }); + cose_key.x(base64::decode_str(okp_params.x.as_str())?); + } + a => { + return Err(Error::KeyError(format!( + "unsupported algorithm params {a:?}" + ))) + } + } + + Self::from_cose(token, &cose_key) + } + + fn from_cose(token: &[u8], key: &cose::keys::CoseKey) -> Result { + let mut sign1 = CoseMessage::new_sign(); + + sign1.bytes = token.to_vec(); + sign1.init_decoder(None).unwrap(); + sign1.key(key).unwrap(); + sign1.decode(None, None).unwrap(); + + ciborium::de::from_reader(sign1.payload.as_slice()) + .map_err(|e| Error::VerifyError(e.to_string())) + } + + /// Encode the EAR as a COSE token, signing it with the specified PEM-encoded key + pub fn sign_cose_pem(&self, alg: Algorithm, key: &[u8]) -> Result, Error> { + let header = new_cose_header(&alg)?; + self.sign_cose_bytes_with_header(header, key, KeyFormat::PEM) + } + + /// Encode the EAR as a COSE token, signing it with the specified DER-encoded key + pub fn sign_cose_der(&self, alg: Algorithm, key: &[u8]) -> Result, Error> { + let header = new_cose_header(&alg)?; + self.sign_cose_bytes_with_header(header, key, KeyFormat::DER) + } + + /// Encode the EAR as a COSE token with the specified header, signing it with the specified + /// PEM-encoded key + pub fn sign_cose_pem_with_header( + &self, + header: cose::headers::CoseHeader, + key: &[u8], + ) -> Result, Error> { + self.sign_cose_bytes_with_header(header, key, KeyFormat::PEM) + } + + /// Encode the EAR as a COSE token with the specified header, signing it with the specified + /// DER-encoded key + pub fn sign_cose_der_with_header( + &self, + header: cose::headers::CoseHeader, + key: &[u8], + ) -> Result, Error> { + self.sign_cose_bytes_with_header(header, key, KeyFormat::DER) + } + + fn sign_cose_bytes_with_header( + &self, + header: cose::headers::CoseHeader, + key: &[u8], + key_fmt: KeyFormat, + ) -> Result, Error> { + let cose_alg = header + .alg + .ok_or(Error::SignError("alg header must be set".to_string()))?; + + let mut cose_key = cose::keys::CoseKey::new(); + cose_key.alg(cose_alg); + cose_key.key_ops(vec![cose::keys::KEY_OPS_SIGN]); + + match cose_alg { + cose::algs::ES256 | cose::algs::ES384 | cose::algs::PS512 => { + let ec_key = match key_fmt { + KeyFormat::PEM => ec::EcKey::private_key_from_pem(key), + KeyFormat::DER => ec::EcKey::private_key_from_der(key), + } + .map_err(|e| Error::KeyError(e.to_string()))?; + + let ec_group = ec_key.group(); + + cose_key.kty(cose::keys::EC2); + cose_key.crv(match ec_group.curve_name() { + Some(Nid::X9_62_PRIME256V1) => cose::keys::P_256, + Some(Nid::SECP384R1) => cose::keys::P_384, + Some(Nid::SECP521R1) => cose::keys::P_521, + _ => return Err(Error::KeyError("unsupported EC group".to_string())), + }); + + let mut x = bn::BigNum::new().map_err(|e| Error::KeyError(e.to_string()))?; + let mut y = bn::BigNum::new().map_err(|e| Error::KeyError(e.to_string()))?; + + let mut ctx = + bn::BigNumContext::new_secure().map_err(|e| Error::KeyError(e.to_string()))?; + + let x_ref = x.deref_mut(); + let y_ref = y.deref_mut(); + let ctx_ref = ctx.deref_mut(); + + ec_key + .public_key() + .affine_coordinates(ec_group, x_ref, y_ref, ctx_ref) + .map_err(|e| Error::KeyError(e.to_string()))?; + + cose_key.x(x_ref.to_vec()); + cose_key.y(y_ref.to_vec()); + cose_key.d(ec_key.private_key().to_vec()); + } + cose::algs::EDDSA => { + cose_key.kty(cose::keys::OKP); + cose_key.crv(cose::keys::ED25519); + + let p_key = match key_fmt { + KeyFormat::PEM => pkey::PKey::private_key_from_pem(key), + KeyFormat::DER => pkey::PKey::private_key_from_der(key), + } + .map_err(|e| Error::KeyError(e.to_string()))?; + + let raw = p_key + .raw_private_key() + .map_err(|e| Error::KeyError(e.to_string()))?; + + cose_key.d(raw[..32].to_vec()); + cose_key.x(raw[32..].to_vec()); + } + _ => { + return Err(Error::SignError(format!( + "algorithm {cose_alg:?} not supported" + ))) + } + }; + + self.sign_cose_with_header(header, &cose_key) + } + + fn sign_cose_with_header( + &self, + header: cose::headers::CoseHeader, + key: &cose::keys::CoseKey, + ) -> Result, Error> { + let mut payload: Vec = Vec::new(); + ciborium::ser::into_writer(self, &mut payload) + .map_err(|e| Error::SignError(e.to_string()))?; + + let mut sign1 = CoseMessage::new_sign(); + sign1.payload(payload); + sign1.add_header(header); + + if let Some(a) = key.alg { + if a != sign1.header.alg.unwrap() { + return Err(Error::SignError( + "specified algorithm doesn't match key".to_string(), + )); + } + }; + + sign1 + .key(key) + .map_err(|e| Error::SignError(format!("{e:?}")))?; + + sign1 + .secure_content(None) + .map_err(|e| Error::SignError(format!("{e:?}")))?; + sign1 + .encode(true) + .map_err(|e| Error::SignError(format!("{e:?}")))?; + + Ok(sign1.bytes.to_vec()) + } +} + +#[inline] +pub fn new_cose_header(alg: &Algorithm) -> Result { + let cose_alg = alg_to_cose(alg)?; + let mut header = cose::headers::CoseHeader::new(); + header.alg(cose_alg, true, false); + + Ok(header) +} + +#[inline] +fn alg_to_cose(alg: &Algorithm) -> Result { + match alg { + Algorithm::ES256 => Ok(cose::algs::ES256), + Algorithm::ES384 => Ok(cose::algs::ES384), + Algorithm::ES512 => Ok(cose::algs::ES512), + Algorithm::EdDSA => Ok(cose::algs::EDDSA), + _ => Err(Error::SignError(format!("algorithm {alg:?} not supported"))), + } +} + +#[cfg(test)] +#[rustfmt::skip::macros(vec)] +mod test { + use super::*; + use crate::{Appraisal, Extensions, VerifierID}; + use std::collections::BTreeMap; + + #[test] + fn cose() { + let ear = Ear { + profile: "test".to_string(), + iat: 1, + vid: VerifierID { + build: "vsts 0.0.1".to_string(), + developer: "https://veraison-project.org".to_string(), + }, + raw_evidence: None, + nonce: None, + submods: BTreeMap::from([("test".to_string(), Appraisal::new())]), + extensions: Extensions::new(), + }; + + let signed = ear + .sign_cose_pem(Algorithm::ES256, crate::ear::test::SIGNING_KEY.as_bytes()) + .unwrap(); + + let ear2 = Ear::from_cose_jwk( + signed.as_slice(), + Algorithm::ES256, + crate::ear::test::VERIF_KEY.as_bytes(), + ) + .unwrap(); + + assert_eq!(ear, ear2); + } +} diff --git a/src/ear/jwt.rs b/src/ear/jwt.rs new file mode 100644 index 0000000..4b44af6 --- /dev/null +++ b/src/ear/jwt.rs @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 + +use jsonwebtoken::{self as jwt, jwk}; + +use crate::algorithm::Algorithm; +use crate::error::Error; +use crate::Ear; + +impl Ear { + /// Decode an EAR from a JWT token, verifying the signature using the specified JWK-encoded + /// key. + pub fn from_jwt_jwk(token: &str, alg: Algorithm, key: &[u8]) -> Result { + let jwk: jwk::Jwk = + serde_json::from_slice(key).map_err(|e| Error::KeyError(e.to_string()))?; + + let dk = jwt::DecodingKey::from_jwk(&jwk).map_err(|e| Error::KeyError(e.to_string()))?; + + let jwt_alg = match alg { + Algorithm::ES256 => jwt::Algorithm::ES256, + Algorithm::ES384 => jwt::Algorithm::ES384, + Algorithm::EdDSA => jwt::Algorithm::EdDSA, + Algorithm::PS256 => jwt::Algorithm::PS256, + Algorithm::PS384 => jwt::Algorithm::PS384, + Algorithm::PS512 => jwt::Algorithm::PS512, + _ => return Err(Error::SignError(format!("algorithm {alg:?} not supported"))), + }; + + Self::from_jwt(token, jwt_alg, &dk) + } + + pub fn from_jwt( + token: &str, + alg: jwt::Algorithm, + key: &jwt::DecodingKey, + ) -> Result { + let mut validation = jwt::Validation::new(alg); + // the default validation sets "exp" as a mandatory claim, which an EAR is not required to + // have. + validation.set_required_spec_claims::<&str>(&[]); + + let token_data = + jwt::decode(token, key, &validation).map_err(|e| Error::VerifyError(e.to_string()))?; + Ok(token_data.claims) + } + + /// Encode the EAR as a JWT token, signing it with the specified PEM-encoded key + #[allow(clippy::type_complexity)] + pub fn sign_jwt_pem(&self, alg: Algorithm, key: &[u8]) -> Result { + let header = &jwt::Header::new(alg_to_jwt_alg(&alg)?); + self.sign_jwt_pem_with_header(header, key) + } + + /// Encode the EAR as a JWT token, signing it with the specified PEM-encoded key, and including + /// the provided headers. + pub fn sign_jwt_pem_with_header( + &self, + header: &jwt::Header, + key: &[u8], + ) -> Result { + let keyfunc: fn(&[u8]) -> Result = match header.alg { + jwt::Algorithm::ES256 => jwt::EncodingKey::from_ec_pem, + jwt::Algorithm::ES384 => jwt::EncodingKey::from_ec_pem, + jwt::Algorithm::EdDSA => jwt::EncodingKey::from_ed_pem, + jwt::Algorithm::PS256 => jwt::EncodingKey::from_rsa_pem, + jwt::Algorithm::PS384 => jwt::EncodingKey::from_rsa_pem, + jwt::Algorithm::PS512 => jwt::EncodingKey::from_rsa_pem, + _ => { + return Err(Error::SignError(format!( + "algorithm {0:?} not supported", + header.alg + ))) + } + }; + + let ek = keyfunc(key).map_err(|e| Error::KeyError(e.to_string()))?; + + jwt::encode(header, self, &ek).map_err(|e| Error::SignError(e.to_string())) + } + + /// Encode the EAR as a JWT token, signing it with the specified DER-encoded key + pub fn sign_jwk_der(&self, alg: Algorithm, key: &[u8]) -> Result { + let header = &jwt::Header::new(alg_to_jwt_alg(&alg)?); + self.sign_jwk_der_with_header(header, key) + } + + /// Encode the EAR as a JWT token, signing it with the specified DER-encoded key, + /// including the specified header(s). + pub fn sign_jwk_der_with_header( + &self, + header: &jwt::Header, + key: &[u8], + ) -> Result { + let ek = match header.alg { + jwt::Algorithm::ES256 => jwt::EncodingKey::from_ec_der(key), + jwt::Algorithm::ES384 => jwt::EncodingKey::from_ec_der(key), + jwt::Algorithm::EdDSA => jwt::EncodingKey::from_ed_der(key), + jwt::Algorithm::PS256 => jwt::EncodingKey::from_rsa_der(key), + jwt::Algorithm::PS384 => jwt::EncodingKey::from_rsa_der(key), + jwt::Algorithm::PS512 => jwt::EncodingKey::from_rsa_der(key), + _ => { + return Err(Error::SignError(format!( + "algorithm {:?} not supported", + header.alg + ))) + } + }; + + jwt::encode(header, self, &ek).map_err(|e| Error::SignError(e.to_string())) + } +} + +#[inline] +pub fn new_jwt_header(alg: &Algorithm) -> Result { + Ok(jwt::Header::new(alg_to_jwt_alg(alg)?)) +} + +#[inline] +fn alg_to_jwt_alg(alg: &Algorithm) -> Result { + match alg { + Algorithm::ES256 => Ok(jwt::Algorithm::ES256), + Algorithm::ES384 => Ok(jwt::Algorithm::ES384), + Algorithm::EdDSA => Ok(jwt::Algorithm::EdDSA), + Algorithm::PS256 => Ok(jwt::Algorithm::PS256), + Algorithm::PS384 => Ok(jwt::Algorithm::PS384), + Algorithm::PS512 => Ok(jwt::Algorithm::PS512), + _ => Err(Error::SignError(format!("algorithm {alg:?} not supported"))), + } +} + +#[cfg(test)] +#[rustfmt::skip::macros(vec)] +mod test { + use super::*; + use crate::{Appraisal, Extensions, VerifierID}; + use std::collections::BTreeMap; + + #[test] + fn sign_jwk() { + let ear = Ear { + profile: "test".to_string(), + iat: 1, + vid: VerifierID { + build: "vsts 0.0.1".to_string(), + developer: "https://veraison-project.org".to_string(), + }, + raw_evidence: None, + nonce: None, + submods: BTreeMap::from([("test".to_string(), Appraisal::new())]), + extensions: Extensions::new(), + }; + + let signed = ear + .sign_jwt_pem(Algorithm::ES256, crate::ear::test::SIGNING_KEY.as_bytes()) + .unwrap(); + + let ear2 = Ear::from_jwt_jwk( + signed.as_str(), + Algorithm::ES256, + crate::ear::test::VERIF_KEY.as_bytes(), + ) + .unwrap(); + + assert_eq!(ear, ear2); + } + + #[test] + fn verify() { + const VERIF_KEY: &str = r#" + { + "crv": "P-256", + "kty": "EC", + "x": "usWxHK2PmfnHKwXPS54m0kTcGJ90UiglWiGahtagnv8", + "y": "IBOL-C3BttVivg-lSreASjpkttcsz-1rb7btKLv8EX4" + } + "#; + + let ear_jwt = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJlYXIudmVyaWZpZXItaWQiOnsiYnVpbGQiOiJOL0EiLCJkZXZlbG9wZXIiOiJWZXJhaXNvbiBQcm9qZWN0In0sImVhdF9ub25jZSI6IjNXSHlqbmRHT1RJPSIsImVhdF9wcm9maWxlIjoidGFnOmdpdGh1Yi5jb20sMjAyMzp2ZXJhaXNvbi9lYXIiLCJpYXQiOjE3MDQ5MDgxOTUsInN1Ym1vZHMiOnsiUEFSU0VDX1RQTSI6eyJlYXIuYXBwcmFpc2FsLXBvbGljeS1pZCI6InBvbGljeTpQQVJTRUNfVFBNIiwiZWFyLnN0YXR1cyI6ImFmZmlybWluZyIsImVhci50cnVzdHdvcnRoaW5lc3MtdmVjdG9yIjp7ImNvbmZpZ3VyYXRpb24iOjAsImV4ZWN1dGFibGVzIjoyLCJmaWxlLXN5c3RlbSI6MCwiaGFyZHdhcmUiOjIsImluc3RhbmNlLWlkZW50aXR5IjoyLCJydW50aW1lLW9wYXF1ZSI6MCwic291cmNlZC1kYXRhIjowLCJzdG9yYWdlLW9wYXF1ZSI6MH0sImVhci52ZXJhaXNvbi5hbm5vdGF0ZWQtZXZpZGVuY2UiOnsia2F0Ijp7ImNlcnRJbmZvIjoiLzFSRFI0QVhBQ0lBQzRPZnJLT0ZLSGxhM2pFelVQSzNNSkNTK1cydHdCVlRFREY4RTk2dzFWWlpBQWdBQVFJREJBVUdCd0FBQUFBYXZJOTFPSFRnOTNOdHliUUJETTZINVJSQTFjNEFJZ0FMM3p1UDlHSy96MXhBR3Fuc1Zxd0ZxU09BdkxVUExoQUkrTmErOFV3VmZWWUFJZ0FMNGhRWm1kbXJaN05vbEExdmRXbEJMeC96TXQ0RldhSWt1R3JoWEdHUkJpWT0iLCJraWQiOiJBYUZKUUNRSDNzT3RxSFdUVWs2WjUrZncvazE4dnl2SkVuWXcxTTdrVHZ0VCIsInB1YkFyZWEiOiJBQ01BQ3dBRUFISUFBQUFRQUJnQUN3QURBQkFBSUtFL0JCMjJySmFDbktRK3BxM05PeEQxcmJaNXp5ZituTThzMS9jbDlwd1RBQ0IyUDlCb2gwcDlEYmlqYUdpVVF1ZkRHWDNaL0ZYZFVqd3JCTUZEKzlPTW53PT0iLCJzaWciOiJBQmdBQ3dBZzA4SkVGY1lxRmsrUnpPVHZvaUp0K1JMOEZvd3oxNzVMakVmTW1KTHcyOU1BSUJLbDQ3eWJyYmdmOTltK21DblVDbkZtTFRNZDN5MUFLTWVoaFNiWEMvYzQiLCJ0cG1WZXIiOiIyLjAifSwicGF0Ijp7ImF0dGVzdEluZm8iOiIvMVJEUjRBWUFDSUFDNE9mcktPRktIbGEzakV6VVBLM01KQ1MrVzJ0d0JWVEVERjhFOTZ3MVZaWkFBZ0FBUUlEQkFVR0J3QUFBQUFhdkk5Mk9IVGc5M050eWJRQkRNNkg1UlJBMWM0QUFBQUJBQXNEQndBQUFDQXVxYXVSbU5GamdBZEFETkxEdnZITWRGdUdTM1lCR2c0YnhTR0FyR1JTMUE9PSIsImtpZCI6IkFhRkpRQ1FIM3NPdHFIV1RVazZaNStmdy9rMTh2eXZKRW5ZdzFNN2tUdnRUIiwic2lnIjoiQUJnQUN3QWdNcWN0TlRuZFh3VU5MZkNERW1lOC81c0hVM2diaGFPL05OdW4xY2tpT0xBQUlLVFkwU2VWUUJIWkpuaXNPRzNTb2VOQ1dHYTJnWlMrSUhuWkN2M3dUOTVJIiwidHBtVmVyIjoiMi4wIn19LCJlYXIudmVyYWlzb24ua2V5LWF0dGVzdGF0aW9uIjp7ImFrcHViIjoiTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFb1Q4RUhiYXNsb0tjcEQ2bXJjMDdFUFd0dG5uUEpfNmN6eXpYOXlYMm5CTjJQOUJvaDBwOURiaWphR2lVUXVmREdYM1pfRlhkVWp3ckJNRkQtOU9NbncifX19fQ.eRyCRmGEOt2GeMvi1-PiSaIVOuixBHwz8FYPSm7XuKnZd6XYe_8HQaCXEtarpOppvzoyHcZvU_4rV54iE7PQaw"; + + let ear = Ear::from_jwt_jwk(ear_jwt, Algorithm::ES256, VERIF_KEY.as_bytes()) + .expect("successfully verified"); + + assert_eq!("tag:github.com,2023:veraison/ear", ear.profile); + } +} diff --git a/src/ear/mod.rs b/src/ear/mod.rs new file mode 100644 index 0000000..b9ed942 --- /dev/null +++ b/src/ear/mod.rs @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeMap; +use std::fmt; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{ + de::{self, Deserialize, Visitor}, + ser::{Error as _, Serialize, SerializeMap}, +}; + +use crate::appraisal::Appraisal; +use crate::base64::Bytes; +use crate::error::Error; +use crate::extension::{get_profile, Extensions}; +use crate::id::VerifierID; +use crate::nonce::Nonce; +use crate::trust::tier::TrustTier; + +#[cfg(feature = "cose")] +pub mod cose; +#[cfg(feature = "jwt")] +pub mod jwt; + +/// An EAT Attestation Result +/// +/// One or more appraisals associated with meta-data about the verifier and the attestation +/// request. +#[derive(Debug, PartialEq)] +pub struct Ear { + /// The EAT profile of the associated claim-set + /// + /// See + pub profile: String, + /// "Issued At" -- the time at which the EAR is issued + /// + /// See: + /// - + /// - + pub iat: i64, + /// Identifier of the verifier that created the EAR + pub vid: VerifierID, + /// The set of attested environment submodule names and associated Appraisals + /// + /// At least one submod must be present (e.g. representing the entire attested environment). + pub submods: BTreeMap, + /// A use-supplied nonce echoed by the verifier to provide freshness + pub nonce: Option, + /// Raw encoded evidence received by the verifier + pub raw_evidence: Option, + /// extension claims + pub extensions: Extensions, +} + +impl Ear { + /// Create an empty EAR + pub fn new() -> Ear { + Ear { + profile: "".to_string(), + iat: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + vid: VerifierID::new(), + submods: BTreeMap::new(), + nonce: None, + raw_evidence: None, + extensions: Extensions::new(), + } + } + + /// Create an empty EAR, registering extensions associated with the specified profile + pub fn new_with_profile(profile: &str) -> Result { + let mut ear = Ear { + profile: profile.to_string(), + iat: 0, + vid: VerifierID::new(), + submods: BTreeMap::new(), + nonce: None, + raw_evidence: None, + extensions: Extensions::new(), + }; + + match get_profile(&ear.profile) { + Some(profile) => { + profile.populate_ear_extensions(&mut ear)?; + Ok(ear) + } + None => Err(Error::ProfileError(format!("{profile} is not registered"))), + } + } + + /// Ensure that the EAR is valid + pub fn validate(&self) -> Result<(), Error> { + if self.profile.as_str() == "" { + return Err(Error::ValidationError("empty profile".to_string())); + } + + if self.submods.is_empty() { + return Err(Error::ValidationError("empty submods".to_string())); + } + + // do we want to have stronger validation here? e.g. checking that iat is not in the future + // or impossibly distant past. + if self.iat == 0 { + return Err(Error::ValidationError("iat unset".to_string())); + } + + self.vid.validate().map_err(|e| { + let msg = match e { + Error::ValidationError(s) => s, + _ => e.to_string(), + }; + Error::ValidationError(format!("verifier-id: {msg}")) + })?; + + Ok(()) + } + + pub fn update_status_from_trust_vector(&mut self) { + for submod in self.submods.values_mut() { + if submod.status == TrustTier::None { + submod.update_status_from_trust_vector(); + } + } + } +} + +impl Default for Ear { + fn default() -> Self { + Self::new() + } +} + +impl Serialize for Ear { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.validate().map_err(S::Error::custom)?; + + let is_human_readable = serializer.is_human_readable(); + let mut map = serializer.serialize_map(None)?; + + if is_human_readable { + map.serialize_entry("eat_profile", &self.profile)?; + map.serialize_entry("iat", &self.iat)?; + map.serialize_entry("ear.verifier-id", &self.vid)?; + map.serialize_entry("submods", &self.submods)?; + + if let Some(n) = &self.nonce { + map.serialize_entry("eat_nonce", &n)? + } + + if let Some(r) = &self.raw_evidence { + map.serialize_entry("ear.raw-evidence", &r)? + } + + self.extensions.serialize_to_map_by_name(&mut map)?; + } else { + // !is_human_readable + map.serialize_entry(&265, &self.profile)?; + map.serialize_entry(&6, &self.iat)?; + map.serialize_entry(&1004, &self.vid)?; + map.serialize_entry(&266, &self.submods)?; + + if let Some(n) = &self.nonce { + map.serialize_entry(&10, &n)? + } + + if let Some(r) = &self.raw_evidence { + map.serialize_entry(&1002, &r)? + } + + self.extensions.serialize_to_map_by_key(&mut map)?; + } + + map.end() + } +} + +impl<'de> Deserialize<'de> for Ear { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let is_hr = deserializer.is_human_readable(); + + deserializer.deserialize_map(EarVisitor { + is_human_readable: is_hr, + }) + } +} + +struct EarVisitor { + pub is_human_readable: bool, +} + +impl<'de> Visitor<'de> for EarVisitor { + type Value = Ear; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a CBOR map or JSON object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut ear = Ear::new(); + + loop { + if self.is_human_readable { + match map.next_key::<&str>()? { + Some("eat_profile") => ear.profile = map.next_value::()?, + Some("iat") => ear.iat = map.next_value::()?, + Some("ear.verifier-id") => ear.vid = map.next_value::()?, + Some("submods") => { + ear.submods = map.next_value::>()? + } + Some("eat_nonce") => ear.nonce = Some(map.next_value::()?), + Some("ear.raw-evidence") => ear.raw_evidence = Some(map.next_value::()?), + Some(name) => ear.extensions.visit_map_entry_by_name(name, &mut map)?, + None => break, + } + } else { + // !is_human_readable + match map.next_key::()? { + Some(265) => ear.profile = map.next_value::()?, + Some(6) => ear.iat = map.next_value::()?, + Some(1004) => ear.vid = map.next_value::()?, + Some(266) => ear.submods = map.next_value::>()?, + Some(10) => ear.nonce = Some(map.next_value::()?), + Some(1002) => ear.raw_evidence = Some(map.next_value::()?), + Some(key) => ear.extensions.visit_map_entry_by_key(key, &mut map)?, + None => break, + } + } + } + + if let Some(profile) = get_profile(&ear.profile) { + profile + .populate_ear_extensions(&mut ear) + .map_err(de::Error::custom)? + } + + ear.validate().map_err(de::Error::custom)?; + + Ok(ear) + } +} + +#[cfg(test)] +#[rustfmt::skip::macros(vec)] +pub mod test { + use super::*; + use crate::extension::*; + use crate::raw::{RawValue, RawValueKind}; + use ciborium::{de::from_reader, ser::into_writer}; + + const EAR_STRING: &str = r#" + { + "eat_profile":"tag:github.com,2023:veraison/ear", + "iat":1666529184, + "ear.verifier-id":{ + "build":"vsts 0.0.1", + "developer":"https://veraison-project.org" + }, + "submods":{ + "test": {"ear.status": "none"} + }, + "ear.raw-evidence":"NzQ3MjY5NzM2NTYzNzQK" + } + "#; + + const EAR_WITH_EXTENSIONS_STRING: &str = r#" + { + "eat_profile":"tag:github.com,2023:veraison/ear", + "iat":1666529184, + "ear.verifier-id":{ + "build":"vsts 0.0.1", + "developer":"https://veraison-project.org" + }, + "submods":{ + "test": { + "ear.status": "none", + "ext3": "3q2-7w" + } + }, + "ear.raw-evidence":"NzQ3MjY5NzM2NTYzNzQK", + "ext1": "foo", + "ext2": 42 + } + "#; + + #[cfg(any(feature = "cose", feature = "jwt"))] + pub const SIGNING_KEY: &str = "-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPp4XZRnRHSMhGg0t +6yjQCRV35J4TUY4idLgiCu6EyLqhRANCAAQbx8C533c2AKDwL/RtjVipVnnM2WRv +5w2wZNCJrubSK0StYKJ71CikDgkhw8M90ojfRIowqpl0uLA3kW3PEZy9 +-----END PRIVATE KEY----- +"; + #[cfg(any(feature = "cose", feature = "jwt"))] + pub const VERIF_KEY: &str = r#" + { + "kty":"EC", + "crv":"P-256", + "x":"G8fAud93NgCg8C_0bY1YqVZ5zNlkb-cNsGTQia7m0is", + "y":"RK1gonvUKKQOCSHDwz3SiN9EijCqmXS4sDeRbc8RnL0" + } + "#; + + #[test] + fn serde() { + let ear = Ear { + profile: "tag:github.com,2023:veraison/ear".to_string(), + iat: 1666529184, + vid: VerifierID { + build: "vsts 0.0.1".to_string(), + developer: "https://veraison-project.org".to_string(), + }, + raw_evidence: Some(Bytes::from( + vec![ + 0x37, 0x34, 0x37, 0x32, 0x36, 0x39, 0x37, 0x33, 0x36, 0x35, 0x36, 0x33, 0x37, + 0x34, 0x0a, + ] + .as_slice(), + )), + nonce: None, + submods: BTreeMap::from([("test".to_string(), Appraisal::new())]), + extensions: Extensions::new(), + }; + + let val = serde_json::to_string(&ear).unwrap(); + assert_eq!( + val.parse::().unwrap(), + EAR_STRING.parse::().unwrap(), + ); + + let mut buf: Vec = Vec::new(); + into_writer(&ear, &mut buf).unwrap(); + assert_eq!( + buf, + vec![ + 0xbf, // map (indefinite length) + 0x19, // unsigned int in the next 2 bytes + 0x01, 0x09, // 265 + 0x78, 0x20, // text string (32) + 0x74, 0x61, 0x67, 0x3a, 0x67, 0x69, 0x74, 0x68, // "tag:gith" + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2c, 0x32, // "ub.com,2" + 0x30, 0x32, 0x33, 0x3a, 0x76, 0x65, 0x72, 0x61, // "023:vera" + 0x69, 0x73, 0x6f, 0x6e, 0x2f, 0x65, 0x61, 0x72, // "ison/ear" + 0x06, // 6 + 0x1a, // unsigned int in the next 4 bytes + 0x63, 0x55, 0x37, 0xa0, // 1666529184 + 0x19, // unsigned int in the next 2 bytes + 0x3, 0xec, // 1004 + 0xa2, // map (2) + 0x00, // 0 + 0x78, 0x1c, // text string (28) + 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, // "https://" + 0x76, 0x65, 0x72, 0x61, 0x69, 0x73, 0x6f, 0x6e, // "veraison" + 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, // "-project" + 0x2e, 0x6f, 0x72, 0x67, // ".org" + 0x01, // 1 + 0x6a, // text string (10) + 0x76, 0x73, 0x74, 0x73, 0x20, 0x30, 0x2e, 0x30, // "vsts 0.0" + 0x2e, 0x31, // ".1" + 0x19, // unsigned int in the next 2 bytes + 0x01, 0x0a, // 266 + 0xa1, // map (1) + 0x64, // text string (4) + 0x74, 0x65, 0x73, 0x74, // "test" + 0xbf, // map (indefinite length) + 0x19, // unsigned int in the next 2 bytes + 0x03, 0xe8, // 1000 + 0x00, // 0 + 0xff, // break / end indefinite map + 0x19, // unsigned int in the next 2 bytes + 0x03, 0xea, // 1002 + 0x4f, // byte string (15) + 0x37, 0x34, 0x37, 0x32, 0x36, 0x39, 0x37, 0x33, + 0x36, 0x35, 0x36, 0x33, 0x37, 0x34, 0x0a, + 0xff, // break / end indefinite map + ] + ); + + let ear2: Ear = serde_json::from_str(EAR_STRING).unwrap(); + assert_eq!(ear.profile, ear2.profile); + assert_eq!(ear.iat, ear2.iat); + assert_eq!(ear.vid.build, ear2.vid.build); + assert_eq!(ear.vid.developer, ear2.vid.developer); + assert_eq!(ear.raw_evidence, ear2.raw_evidence); + + let ear2: Ear = from_reader(buf.as_slice()).unwrap(); + assert_eq!(ear.profile, ear2.profile); + assert_eq!(ear.iat, ear2.iat); + assert_eq!(ear.vid.build, ear2.vid.build); + assert_eq!(ear.vid.developer, ear2.vid.developer); + assert_eq!(ear.raw_evidence, ear2.raw_evidence); + } + + #[test] + fn serde_extensions() { + let mut profile = Profile::new("tag:github.com,2023:veraison/ear"); + profile + .register_ear_extension("ext1", -1, RawValueKind::String) + .unwrap(); + profile + .register_ear_extension("ext2", -2, RawValueKind::Integer) + .unwrap(); + profile + .register_appraisal_extension("ext3", -1, RawValueKind::Bytes) + .unwrap(); + register_profile(&profile).unwrap(); + + let ear = serde_json::from_str::(EAR_WITH_EXTENSIONS_STRING).unwrap(); + + let v1 = ear.extensions.get_by_name("ext1").unwrap(); + assert_eq!(v1, RawValue::String("foo".to_string())); + + let text = serde_json::to_string(&ear).unwrap(); + assert_eq!( + text.parse::().unwrap(), + EAR_WITH_EXTENSIONS_STRING + .parse::() + .unwrap(), + ); + + let mut buf: Vec = Vec::new(); + into_writer(&ear, &mut buf).unwrap(); + assert_eq!( + buf, + vec![ + 0xbf, // map (indefinite length) + 0x19, // unsigned int in the next 2 bytes + 0x01, 0x09, // 265 + 0x78, 0x20, // text string (32) + 0x74, 0x61, 0x67, 0x3a, 0x67, 0x69, 0x74, 0x68, // "tag:gith" + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2c, 0x32, // "ub.com,2" + 0x30, 0x32, 0x33, 0x3a, 0x76, 0x65, 0x72, 0x61, // "023:vera" + 0x69, 0x73, 0x6f, 0x6e, 0x2f, 0x65, 0x61, 0x72, // "ison/ear" + 0x06, // 6 + 0x1a, // unsigned int in the next 4 bytes + 0x63, 0x55, 0x37, 0xa0, // 1666529184 + 0x19, // unsigned int in the next 2 bytes + 0x3, 0xec, // 1004 + 0xa2, // map (2) + 0x00, // 0 + 0x78, 0x1c, // text string (28) + 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, // "https://" + 0x76, 0x65, 0x72, 0x61, 0x69, 0x73, 0x6f, 0x6e, // "veraison" + 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, // "-project" + 0x2e, 0x6f, 0x72, 0x67, // ".org" + 0x01, // 1 + 0x6a, // text string (10) + 0x76, 0x73, 0x74, 0x73, 0x20, 0x30, 0x2e, 0x30, // "vsts 0.0" + 0x2e, 0x31, // ".1" + 0x19, // unsigned int in the next 2 bytes + 0x01, 0x0a, // 266 + 0xa1, // map (1) + 0x64, // text string (4) + 0x74, 0x65, 0x73, 0x74, // "test" + 0xbf, // map (indefinite length) + 0x19, // unsigned int in the next 2 bytes + 0x03, 0xe8, // 1000 + 0x00, // 0 + 0x20, // -1 + 0x44, // byte string (3) + 0xde, 0xad, 0xbe, 0xef, + 0xff, // break / end indefinite map + 0x19, // unsigned int in the next 2 bytes + 0x03, 0xea, // 1002 + 0x4f, // byte string (15) + 0x37, 0x34, 0x37, 0x32, 0x36, 0x39, 0x37, 0x33, + 0x36, 0x35, 0x36, 0x33, 0x37, 0x34, 0x0a, + 0x21, // -2 + 0x18, // unsigned int next byte + 0x2a, // 42 + 0x20, // -1 + 0x63, // text string (3) + 0x66, 0x6f, 0x6f, // "foo" + 0xff, // break / end indefinite map + ] + ); + + let ear2: Ear = from_reader(buf.as_slice()).unwrap(); + assert_eq!(ear, ear2); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9389a90..dab2b98 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ //! ## Signing //! //! ``` +//! # #[cfg(feature = "jwt")] { //! use std::collections::BTreeMap; //! use ear::{Ear, VerifierID, Algorithm, Appraisal, Extensions}; //! @@ -45,11 +46,13 @@ //! //! let signed = token.sign_jwt_pem(Algorithm::ES256, SIGNING_KEY.as_bytes()).unwrap(); //! } +//! # } //! ``` //! //! ## Verification //! //! ``` +//! # #[cfg(feature = "jwt")] { //! use ear::{Ear, Algorithm}; //! //! const VERIF_KEY: &str = r#" @@ -67,6 +70,7 @@ //! let token = Ear::from_jwt_jwk(signed, Algorithm::ES256, VERIF_KEY.as_bytes()).unwrap(); //! println!("EAR profiles: {}", token.profile); //! } +//! # } //! ``` //! //! # Extensions and Profiles @@ -188,6 +192,7 @@ //! inside an EAR. //! //! ``` +//! # #[cfg(feature = "jwt")] { //! use ear::{Ear, Algorithm, Appraisal, RawValueKind, RawValue}; //! use std::time::{SystemTime, Duration, UNIX_EPOCH}; //! @@ -236,6 +241,7 @@ //! _ => panic!(), //! }; //! assert!(SystemTime::now().duration_since(UNIX_EPOCH).unwrap() < exp2); +//! # } //! ``` //! //! # JWT/CWT headers @@ -250,7 +256,7 @@ //! `_with_header` signing methods can be used to specify a custom `cose::headers::CoseHeader`, //! which can be reating from an algorithm using `new_cwt_header`. //! -//! ``` +//! ```ignore (requires the cose and jwt features) //! use std::collections::BTreeMap; //! use ear::{Ear, VerifierID, Algorithm, Appraisal, Extensions, new_jwt_header, new_cose_header}; //! @@ -316,8 +322,10 @@ mod trust; pub use self::algorithm::Algorithm; pub use self::appraisal::Appraisal; pub use self::base64::Bytes; -pub use self::ear::new_cose_header; -pub use self::ear::new_jwt_header; +#[cfg(feature = "cose")] +pub use self::ear::cose::new_cose_header; +#[cfg(feature = "jwt")] +pub use self::ear::jwt::new_jwt_header; pub use self::ear::Ear; pub use self::error::Error; pub use self::extension::get_profile; diff --git a/src/trust/claim.rs b/src/trust/claim.rs index 87802f2..406ff42 100644 --- a/src/trust/claim.rs +++ b/src/trust/claim.rs @@ -458,7 +458,7 @@ impl TrustClaim { } /// Return the `ValueDescription` for the current value of this claim. - fn value_desc(&self) -> Option<&ValueDescription> { + fn value_desc(&self) -> Option<&ValueDescription<'_>> { let val = self.value(); if (-1..=1).contains(&val) || val == 99 { return COMMON_CLAIM_MAP.get(&val);