diff --git a/.github/workflows/spdm-validator.yml b/.github/workflows/spdm-validator.yml index db001b52b..ebd08f44f 100644 --- a/.github/workflows/spdm-validator.yml +++ b/.github/workflows/spdm-validator.yml @@ -135,6 +135,17 @@ jobs: exit 1 fi + + - name: Verify EAT from measurement block for SPDM-MCTP + working-directory: ocp-eat-verifier + env: + SPDM_VALIDATOR_DIR: ${{ github.workspace }}/spdm-emu/build/bin + run: | + cargo build --release -p ocptoken + ./target/release/ocptoken verify \ + -e $SPDM_VALIDATOR_DIR/measurement_block_fd.bin + + - name: Run SPDM validator tests on DOE transport env: SPDM_VALIDATOR_DIR: ${{ github.workspace }}/spdm-emu/build/bin diff --git a/.gitignore b/.gitignore index 2ce236dcf..da83e56d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -/target*/ +*target*/ +**/target/ test_key # By default, ignore Cargo.lock files in non-workspace directories. diff --git a/ocp-eat-verifier/Cargo.toml b/ocp-eat-verifier/Cargo.toml new file mode 100644 index 000000000..85a174219 --- /dev/null +++ b/ocp-eat-verifier/Cargo.toml @@ -0,0 +1,19 @@ +# Licensed under the Apache-2.0 license + +[workspace] +members = [ + "ocptoken-rs", +] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Caliptra contributors"] + +[workspace.dependencies] +coset = { git = "https://github.com/google/coset",rev = "3ebd2d7d0dafe2b6856934ea2b4fa28ea3d9a373"} +hex = "0.4" +thiserror = "2.0" +openssl = { version = "0.10", features = ["vendored"] } +clap = { version = "4", features = ["derive"] } \ No newline at end of file diff --git a/ocp-eat-verifier/ocptoken-rs/Cargo.toml b/ocp-eat-verifier/ocptoken-rs/Cargo.toml new file mode 100644 index 000000000..7ef1435a2 --- /dev/null +++ b/ocp-eat-verifier/ocptoken-rs/Cargo.toml @@ -0,0 +1,16 @@ +# Licensed under the Apache-2.0 license + +[package] +name = "ocptoken" +version = "0.1.0" +edition = "2021" +authors = ["Caliptra Contributors"] + +[dependencies] +coset.workspace = true +hex.workspace = true +thiserror.workspace = true +openssl.workspace = true +clap.workspace = true + + diff --git a/ocp-eat-verifier/ocptoken-rs/src/error.rs b/ocp-eat-verifier/ocptoken-rs/src/error.rs new file mode 100644 index 000000000..7d160db48 --- /dev/null +++ b/ocp-eat-verifier/ocptoken-rs/src/error.rs @@ -0,0 +1,34 @@ +// Licensed under the Apache-2.0 license + +use thiserror::Error; +/// Errors that can occur when working with OCP EAT tokens +#[derive(Error, Debug)] +pub enum OcpEatError { + /// COSE parsing or validation error + #[error("COSE error: {0:?}")] + CoseSign1(coset::CoseError), + + #[error("Invalid token: {0}")] + InvalidToken(&'static str), + + /// Certificate parsing error + #[error("Certificate error: {0}")] + Certificate(String), + + /// Signature verification failure + #[error("Signature verification failed")] + SignatureVerification, + + /// Crypto backend error + #[error("Crypto error: {0}")] + Crypto(String), +} + +impl From for OcpEatError { + fn from(err: coset::CoseError) -> Self { + OcpEatError::CoseSign1(err) + } +} + +/// Result type for OCP EAT operations +pub type OcpEatResult = std::result::Result; diff --git a/ocp-eat-verifier/ocptoken-rs/src/lib.rs b/ocp-eat-verifier/ocptoken-rs/src/lib.rs new file mode 100644 index 000000000..0fe6feae4 --- /dev/null +++ b/ocp-eat-verifier/ocptoken-rs/src/lib.rs @@ -0,0 +1,4 @@ +// Licensed under the Apache-2.0 license + +pub mod token; +pub mod error; \ No newline at end of file diff --git a/ocp-eat-verifier/ocptoken-rs/src/main.rs b/ocp-eat-verifier/ocptoken-rs/src/main.rs new file mode 100644 index 000000000..b0243f7fb --- /dev/null +++ b/ocp-eat-verifier/ocptoken-rs/src/main.rs @@ -0,0 +1,103 @@ +// Licensed under the Apache-2.0 license + +use clap::{Parser, Subcommand}; +use std::fs; +use std::path::PathBuf; + +use ocptoken::token::evidence::Evidence; + +#[derive(Parser, Debug)] +#[command( + name = "ocptoken", + author, + version, + about = "Verify an OCP TOKEN COSE_Sign1 token", + long_about = None +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Cryptographically verify the supplied OCP token using the EAT attestation key + Verify(VerifyArgs), +} + +#[derive(Parser, Debug)] +#[command( + author, + version, + about = "Cryptographically verify the supplied OCP token using the EAT attestation key" +)] +struct VerifyArgs { + #[arg( + short = 'e', + long = "evidence", + value_name = "EVIDENCE", + default_value = "ocp_eat.cbor" + )] + evidence: PathBuf, +} + +fn main() { + let cli = Cli::parse(); + + match cli.command { + Commands::Verify(args) => run_verify(&args), + } +} + +fn run_verify(args: &VerifyArgs) { + // 1. Load the binary file + let encoded = match fs::read(&args.evidence) { + Ok(b) => b, + Err(e) => { + eprintln!( + "Failed to read evidence file '{}': {}", + args.evidence.display(), + e + ); + std::process::exit(1); + } + }; + + println!( + "Loaded evidence file '{}' ({} bytes)", + args.evidence.display(), + encoded.len() + ); + + // 2. Decode the evidence + let ev = match Evidence::decode(&encoded) { + Ok(ev) => { + println!("Decode successful"); + ev + } + Err(e) => { + eprintln!("Evidence::decode failed: {:?}", e); + + // Optional debug dump + let prefix_len = encoded.len().min(32); + eprintln!( + "First {} bytes of input: {:02x?}", + prefix_len, + &encoded[..prefix_len] + ); + + std::process::exit(1); + } + }; + + // 3. Cryptographically verify + match ev.verify() { + Ok(()) => { + println!("Signature verification successful"); + } + Err(e) => { + eprintln!("Evidence::verify failed: {:?}", e); + std::process::exit(1); + } + } +} diff --git a/ocp-eat-verifier/ocptoken-rs/src/token/evidence.rs b/ocp-eat-verifier/ocptoken-rs/src/token/evidence.rs new file mode 100644 index 000000000..f9bebec00 --- /dev/null +++ b/ocp-eat-verifier/ocptoken-rs/src/token/evidence.rs @@ -0,0 +1,282 @@ +// Licensed under the Apache-2.0 license + +use crate::error::{OcpEatError, OcpEatResult}; +use coset::{cbor::value::Value, iana::Algorithm, CborSerializable, CoseSign1, Header}; + +use openssl::{ + bn::{BigNum, BigNumContext}, + ec::{EcGroup, EcKey, EcPoint}, + nid::Nid, + pkey::PKey, + x509::X509, +}; + +pub const OCP_EAT_CLAIMS_KEY_ID: &str = ""; + +/// COSE header parameter: x5chain (label 33) +const COSE_HDR_PARAM_X5CHAIN: i64 = 33; + +/// Parsed and verified EAT evidence +pub struct Evidence { + pub signed_eat: Option, +} + +impl Default for Evidence { + fn default() -> Self { + Evidence { signed_eat: None } + } +} + +impl Evidence { + pub fn new(signed_eat: CoseSign1) -> Self { + Evidence { + signed_eat: Some(signed_eat), + } + } + + /// Decode and structurally validate a COSE_Sign1 + /// (Steps 1–3) + pub fn decode(slice: &[u8]) -> OcpEatResult { + /* ========================================================== + * Skip CBOR tags / bstr + * ========================================================== */ + let value = skip_cbor_tags(slice)?; + let cose_bytes = value.to_vec().map_err(OcpEatError::CoseSign1)?; + + /* ========================================================== + * Strict COSE decode + * ========================================================== */ + let cose = CoseSign1::from_slice(&cose_bytes).map_err(OcpEatError::CoseSign1)?; + + /* ========================================================== + * Verify protected header + * ========================================================== */ + verify_protected_header(&cose.protected.header)?; + + Ok(Evidence { + signed_eat: Some(cose), + }) + } + + /// Cryptographically verify the decoded COSE_Sign1 + + pub fn verify(&self) -> OcpEatResult<()> { + let cose = self + .signed_eat + .as_ref() + .ok_or_else(|| OcpEatError::InvalidToken("Missing COSE_Sign1"))?; + + /* ---------------------------------------------------------- + * Extract leaf cert from unprotected header + * ---------------------------------------------------------- */ + let cert_der = extract_leaf_cert_der(&cose.unprotected)?; + let (pubkey_x, pubkey_y) = extract_pubkey_xy(&cert_der)?; + + /* ---------------------------------------------------------- + * Verify ES384 signature + * ---------------------------------------------------------- */ + cose.verify_signature(&[], |signature, to_be_signed| { + verify_signature_es384(signature, pubkey_x, pubkey_y, to_be_signed) + })?; + + Ok(()) + } +} + +/* -------------------------------------------------------------------------- */ +/* Helper functions */ +/* -------------------------------------------------------------------------- */ + +fn skip_cbor_tags(slice: &[u8]) -> OcpEatResult { + let mut value = Value::from_slice(slice).map_err(OcpEatError::CoseSign1)?; + + loop { + match value { + Value::Tag(_, boxed) => value = *boxed, + Value::Bytes(bytes) => { + value = Value::from_slice(&bytes).map_err(OcpEatError::CoseSign1)? + } + Value::Array(_) => break, + _ => { + return Err(OcpEatError::InvalidToken( + "Invalid COSE_Sign1 structure: expected CBOR tag, byte string, or array", + )); + } + } + } + + Ok(value) +} + +/// Extract leaf certificate DER from x5chain (label 33) +fn extract_leaf_cert_der(unprotected: &Header) -> OcpEatResult> { + let value = unprotected + .rest + .iter() + .find_map(|(label, value)| { + if *label == coset::Label::Int(COSE_HDR_PARAM_X5CHAIN) { + Some(value) + } else { + None + } + }) + .ok_or(OcpEatError::InvalidToken( + "Missing x5chain in COSE protected header", + ))?; + + match value { + Value::Array(arr) => arr.first(), + Value::Bytes(_) => Some(value), + _ => None, + } + .and_then(|v| match v { + Value::Bytes(bytes) => Some(bytes.clone()), // 👈 CLONE + _ => None, + }) + .ok_or(OcpEatError::InvalidToken( + "Missing or invalid x5chain: expected DER-encoded certificate bytes", + )) +} + +/// Extract raw P-384 public key coordinates (x, y) from DER X.509 cert +fn extract_pubkey_xy(cert_der: &[u8]) -> OcpEatResult<([u8; 48], [u8; 48])> { + // Parse X.509 certificate using OpenSSL + let cert = X509::from_der(cert_der) + .map_err(|e| OcpEatError::Certificate(format!("OpenSSL X509 parse failed: {}", e)))?; + + // Extract public key + let pubkey: PKey = cert + .public_key() + .map_err(|e| OcpEatError::Certificate(format!("Failed to extract public key: {}", e)))?; + + // Ensure EC key + let ec_key = pubkey + .ec_key() + .map_err(|_| OcpEatError::Certificate("Public key is not an EC key".into()))?; + + let group = ec_key.group(); + let point = ec_key.public_key(); + + let mut ctx = BigNumContext::new().map_err(|e| OcpEatError::Crypto(e.to_string()))?; + + let mut ctx_x = BigNum::new().map_err(|e| OcpEatError::Crypto(e.to_string()))?; + let mut ctx_y = BigNum::new().map_err(|e| OcpEatError::Crypto(e.to_string()))?; + + point + .affine_coordinates_gfp(group, &mut ctx_x, &mut ctx_y, &mut ctx) + .map_err(|e| OcpEatError::Crypto(e.to_string()))?; + + let x_bytes = ctx_x + .to_vec_padded(48) + .map_err(|_| OcpEatError::Certificate("Failed to pad X coordinate".into()))?; + + let y_bytes = ctx_y + .to_vec_padded(48) + .map_err(|_| OcpEatError::Certificate("Failed to pad Y coordinate".into()))?; + + let mut x = [0u8; 48]; + let mut y = [0u8; 48]; + + x.copy_from_slice(&x_bytes); + y.copy_from_slice(&y_bytes); + + Ok((x, y)) +} + +/// Verify ES384 COSE signature using raw EC public key +fn verify_signature_es384( + signature: &[u8], + pubkey_x: [u8; 48], + pubkey_y: [u8; 48], + message: &[u8], +) -> OcpEatResult<()> { + if signature.len() != 96 { + return Err(OcpEatError::SignatureVerification); + } + + let r = BigNum::from_slice(&signature[..48]).map_err(|_| OcpEatError::SignatureVerification)?; + let s = BigNum::from_slice(&signature[48..]).map_err(|_| OcpEatError::SignatureVerification)?; + + let sig = openssl::ecdsa::EcdsaSig::from_private_components(r, s) + .map_err(|_| OcpEatError::SignatureVerification)?; + + let group = + EcGroup::from_curve_name(Nid::SECP384R1).map_err(|e| OcpEatError::Crypto(e.to_string()))?; + + let mut ctx = BigNumContext::new().map_err(|e| OcpEatError::Crypto(e.to_string()))?; + + let px = BigNum::from_slice(&pubkey_x).unwrap(); + let py = BigNum::from_slice(&pubkey_y).unwrap(); + + let mut point = EcPoint::new(&group).map_err(|e| OcpEatError::Crypto(e.to_string()))?; + + point + .set_affine_coordinates_gfp(&group, &px, &py, &mut ctx) + .map_err(|e| OcpEatError::Crypto(e.to_string()))?; + + let ec_key = + EcKey::from_public_key(&group, &point).map_err(|e| OcpEatError::Crypto(e.to_string()))?; + + let digest = openssl::hash::hash(openssl::hash::MessageDigest::sha384(), message) + .map_err(|e| OcpEatError::Crypto(e.to_string()))?; + + let verified = sig + .verify(&digest, &ec_key) + .map_err(|e| OcpEatError::Crypto(e.to_string()))?; + + if verified { + Ok(()) + } else { + Err(OcpEatError::SignatureVerification) + } +} + +fn verify_protected_header(protected: &Header) -> OcpEatResult<()> { + /* ---------------------------------------------------------- + * Algorithm must be ES384 or ESP384 + * ---------------------------------------------------------- */ + + let alg_ok = matches!( + protected.alg, + Some(coset::RegisteredLabelWithPrivate::Assigned( + Algorithm::ES384 + )) | Some(coset::RegisteredLabelWithPrivate::Assigned( + Algorithm::ESP384 + )) + ); + + if !alg_ok { + return Err(OcpEatError::InvalidToken( + "Unexpected algorithm in protected header", + )); + } + + /* ---------------------------------------------------------- + * Content-Type + * ---------------------------------------------------------- */ + match &protected.content_type { + None => { + // Accept missing content-type + } + Some(coset::RegisteredLabel::Assigned(coset::iana::CoapContentFormat::EatCwt)) => { + // Accept EAT CWT + } + _other => { + return Err(OcpEatError::InvalidToken( + "Content format mismatch in protected header", + )); + } + } + + /* ---------------------------------------------------------- + * Key ID + * ---------------------------------------------------------- */ + + if protected.key_id != OCP_EAT_CLAIMS_KEY_ID.as_bytes().to_vec() { + return Err(OcpEatError::InvalidToken( + "Key ID mismatch in protected header", + )); + } + + Ok(()) +} diff --git a/ocp-eat-verifier/ocptoken-rs/src/token/mod.rs b/ocp-eat-verifier/ocptoken-rs/src/token/mod.rs new file mode 100644 index 000000000..a44f65dfd --- /dev/null +++ b/ocp-eat-verifier/ocptoken-rs/src/token/mod.rs @@ -0,0 +1,3 @@ +// Licensed under the Apache-2.0 license + +pub mod evidence; diff --git a/ocp-eat-verifier/ocptoken-rs/tests/test_evidence.rs b/ocp-eat-verifier/ocptoken-rs/tests/test_evidence.rs new file mode 100644 index 000000000..ba3923594 --- /dev/null +++ b/ocp-eat-verifier/ocptoken-rs/tests/test_evidence.rs @@ -0,0 +1,103 @@ +// Licensed under the Apache-2.0 license + +use coset::{ + cbor::value::Value, iana::Algorithm, CborSerializable, CoseSign1Builder, HeaderBuilder, +}; + +use openssl::{ + ec::{EcGroup, EcKey}, + ecdsa::EcdsaSig, + hash::MessageDigest, + nid::Nid, + pkey::PKey, + sign::Signer, + x509::{X509NameBuilder, X509}, +}; + +use ocptoken::token::evidence::Evidence; + +#[test] +fn decode_and_verify_ecc_p384_cose_sign1() { + // 1️ Generate ECC P-384 key pair + let group = EcGroup::from_curve_name(Nid::SECP384R1).unwrap(); + let ec_key = EcKey::generate(&group).unwrap(); + let pkey = PKey::from_ec_key(ec_key).unwrap(); + + // 2️ Create X.509 certificate from public key + use openssl::asn1::Asn1Time; + use openssl::bn::BigNum; + + // 3 Create X.509 certificate from public key + let mut name = X509NameBuilder::new().unwrap(); + name.append_entry_by_text("CN", "test-cert").unwrap(); + let name = name.build(); + + let mut cert_builder = X509::builder().unwrap(); + + cert_builder.set_version(2).unwrap(); + + // serial number + let mut serial = BigNum::new().unwrap(); + serial + .rand(64, openssl::bn::MsbOption::MAYBE_ZERO, false) + .unwrap(); + let serial = serial.to_asn1_integer().unwrap(); + cert_builder.set_serial_number(&serial).unwrap(); + + // Subject / issuer + cert_builder.set_subject_name(&name).unwrap(); + cert_builder.set_issuer_name(&name).unwrap(); + + // validity + let not_before = Asn1Time::days_from_now(0).unwrap(); + let not_after = Asn1Time::days_from_now(365).unwrap(); + cert_builder.set_not_before(¬_before).unwrap(); + cert_builder.set_not_after(¬_after).unwrap(); + + // Public key + cert_builder.set_pubkey(&pkey).unwrap(); + + // Sign certificate + cert_builder.sign(&pkey, MessageDigest::sha384()).unwrap(); + + let cert = cert_builder.build(); + let cert_der = cert.to_der().unwrap(); + + // Dummy payload + let payload = b"dummy payload for COSE signature"; + + // 4️ Create COSE_Sign1 structure + let cose = CoseSign1Builder::new() + .payload(payload.to_vec()) + .protected(HeaderBuilder::new().algorithm(Algorithm::ES384).build()) + .unprotected( + HeaderBuilder::new() + // x5chain = label 33 + .value(33, Value::Array(vec![Value::Bytes(cert_der.clone())])) + .build(), + ) + .create_signature(&[], |msg| { + let mut signer = Signer::new(MessageDigest::sha384(), &pkey).unwrap(); + signer.update(msg).unwrap(); + + let der_sig = signer.sign_to_vec().unwrap(); + + // DER → raw r||s (COSE format) + let sig = EcdsaSig::from_der(&der_sig).unwrap(); + let r = sig.r().to_vec_padded(48).unwrap(); + let s = sig.s().to_vec_padded(48).unwrap(); + [r, s].concat() + }) + .build(); + + // 5️ Encode to CBOR + let encoded = cose.to_vec().unwrap(); + + // 6️ Decode + let evidence = Evidence::decode(&encoded).expect("Evidence::decode should succeed"); + + // 7️ Verify + evidence + .verify() + .expect("COSE_Sign1 signature verification should succeed"); +}