diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7eaa40a..4746c4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,20 +10,32 @@ env: CARGO_TERM_COLOR: always jobs: - build: - + lint: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v5 - name: Formatting checks run: cargo fmt --all -- --check - name: Clippy checks run: cargo clippy --all-targets -- -D clippy::all -D clippy::cargo -A clippy::multiple-crate-versions + + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + features: + - "" # default (cose + jwt) + - "--no-default-features --features cose" + - "--no-default-features --features jwt" + - "--no-default-features" + - "--all-features" + steps: + - uses: actions/checkout@v5 - name: Build - run: cargo build --verbose + run: cargo build --verbose ${{ matrix.features }} - name: Run tests - run: cargo test --verbose + run: cargo test --verbose ${{ matrix.features }} # Recommended pipeline if using advisories, to avoid sudden breakages # From: https://github.com/EmbarkStudios/cargo-deny-action diff --git a/Cargo.toml b/Cargo.toml index 4a55286..bfd74c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,15 +9,27 @@ license = "Apache-2.0" keywords = ["ear", "serde"] categories = ["data-structures", "encoding"] + +[package.metadata.docs.rs] +features = ["cose", "jwt"] + + [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);