diff --git a/.gitignore b/.gitignore index 855ecc1..7881bf3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/target +target Cargo.lock .ssh .rustup @@ -13,3 +13,8 @@ Cargo.lock .config .gitconfig .cargo +.bash_history +.risc0 +.bashrc +/go +old diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4d7c990 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "experiments/risc-zero/rustcrypto-elliptic-curves"] + path = experiments/risc-zero/rustcrypto-elliptic-curves + url = https://github.com/HarryR/RustCrypto-elliptic-curves.git + branch = risc0-p256-p384-unified diff --git a/Cargo.toml b/Cargo.toml index 59cb053..1543155 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ license = "MIT OR Apache-2.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_bytes = "0.11" +serde-big-array = "0.5" +zerocopy = { version = "0.8", features = ["derive"] } base64 = "0.22" sha1 = "0.10" sha2 = "0.10" diff --git a/Makefile b/Makefile index 43e56ad..47d829a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build release-x86 check test fmt fmt-check clippy doc clean ci +.PHONY: all build release-x86 check test fmt fmt-check clippy doc clean ci coverage setup-coverage all: build @@ -34,5 +34,16 @@ clean: ci: fmt-check check clippy test doc +setup-coverage: + rustup component add llvm-tools-preview + cargo install cargo-llvm-cov + +coverage-html: + cargo llvm-cov -p vaportpm-verify --html + @echo "Coverage report: target/llvm-cov/html/index.html" + +coverage-text: + cargo llvm-cov -p vaportpm-verify + rustup: rustup default stable \ No newline at end of file diff --git a/README.md b/README.md index 8f0327e..f29621d 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,11 @@ Physical TPM trust is vapor. It evaporates under scrutiny - supply chain attacks The verifier handles **cryptographic verification**: - Validates signatures and certificate chains -- Returns the SHA-256 hash of the trust anchor's public key +- Identifies the cloud provider via embedded root CA hashes You handle **policy decisions**: -- Is this trust root acceptable? - Do the PCR values match known-good measurements? +- Is the nonce fresh (replay protection)? ## Supported Platforms diff --git a/crates/vaportpm-attest/AWS-NITRO.md b/crates/vaportpm-attest/AWS-NITRO.md index 7ceb51e..754ae0c 100644 --- a/crates/vaportpm-attest/AWS-NITRO.md +++ b/crates/vaportpm-attest/AWS-NITRO.md @@ -159,7 +159,7 @@ COSE_Sign1 = [ ... } }, - "signing_key_public_keys": { + "ak_pubkeys": { "ecc_p256": { "x": "3678325466f129d8279056737fe48378...", "y": "9384bc5fafdc7938f9a51e09490a5555..." diff --git a/crates/vaportpm-attest/src/a9n.rs b/crates/vaportpm-attest/src/a9n.rs index c077dfb..18c7065 100644 --- a/crates/vaportpm-attest/src/a9n.rs +++ b/crates/vaportpm-attest/src/a9n.rs @@ -175,7 +175,9 @@ pub fn attest(nonce: &[u8]) -> Result { attest_gcp(&mut tpm, nonce, &pcr_values, pcr_alg)? } else if is_nitro { // Nitro path: create long-term AK, use TPM2_Quote - attest_nitro(&mut tpm, nonce, &pcr_values, pcr_alg)? + // SHA-384 is hardcoded — the Quote must attest the same PCR bank that + // the Nitro NSM document signs, so they can be cross-verified. + attest_nitro(&mut tpm, nonce, &pcr_values)? } else { return Err(anyhow!( "Unknown platform - only AWS Nitro and GCP Shielded VM are supported" @@ -236,12 +238,11 @@ pub fn attest(nonce: &[u8]) -> Result { /// /// Creates a TCG-compliant restricted AK in the endorsement hierarchy, then uses /// TPM2_Quote to sign the PCR values. The AK is bound to the Nitro NSM document. -fn attest_nitro( - tpm: &mut Tpm, - nonce: &[u8], - pcr_values: &[(u8, Vec)], - pcr_alg: crate::TpmAlg, -) -> Result { +/// +/// The Quote always uses SHA-384 PCR selection because the Nitro NSM document +/// signs SHA-384 PCR values. Using the same bank ensures the TPM Quote's PCR +/// digest can be cross-verified against the Nitro-signed values. +fn attest_nitro(tpm: &mut Tpm, nonce: &[u8], pcr_values: &[(u8, Vec)]) -> Result { // Create restricted AK in endorsement hierarchy (TCG-compliant AK profile) // Trust comes from Nitro NSM document binding the AK public key let signing_key = tpm.create_restricted_ak(TPM_RH_ENDORSEMENT)?; @@ -255,9 +256,11 @@ fn attest_nitro( }, ); - // Build PCR selection bitmap for Quote + // Build PCR selection bitmap for Quote — always SHA-384 for Nitro + // The TPM Quote must attest the same PCR bank that the Nitro NSM + // document signs, so the verification side can cross-check them. let pcr_bitmap = build_pcr_bitmap(pcr_values); - let pcr_selection = vec![(pcr_alg, pcr_bitmap.as_slice())]; + let pcr_selection = vec![(crate::TpmAlg::Sha384, pcr_bitmap.as_slice())]; // Perform TPM2_Quote - signs PCR values with AK let quote_result = tpm.quote(signing_key.handle, nonce, &pcr_selection)?; diff --git a/crates/vaportpm-verify/Cargo.toml b/crates/vaportpm-verify/Cargo.toml index 9748104..179638b 100644 --- a/crates/vaportpm-verify/Cargo.toml +++ b/crates/vaportpm-verify/Cargo.toml @@ -33,6 +33,12 @@ base64 = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +zerocopy = { workspace = true } + +[dev-dependencies] +rcgen = "0.13" +p256 = { workspace = true, features = ["pkcs8"] } +p384 = { workspace = true, features = ["pkcs8"] } [lib] name = "vaportpm_verify" @@ -41,4 +47,3 @@ path = "src/lib.rs" [[bin]] name = "vaportpm-verify" path = "src/bin/verify.rs" - diff --git a/crates/vaportpm-verify/README.md b/crates/vaportpm-verify/README.md index ac35899..f9bccb2 100644 --- a/crates/vaportpm-verify/README.md +++ b/crates/vaportpm-verify/README.md @@ -11,7 +11,7 @@ This crate verifies attestation documents without requiring TPM or internet acce - **Nitro Attestation Verification** - COSE Sign1 signature and certificate chain validation - **TPM Signature Verification** - ECDSA P-256 signature verification - **PCR Policy Verification** - Compute and verify PCR policy digests -- **X.509 Chain Validation** - Certificate chain validation using rustls-webpki +- **X.509 Chain Validation** - Certificate chain validation with ECDSA (P-256, P-384) and RSA signature support - **Zero TPM Dependencies** - Pure cryptographic verification ## Usage @@ -27,8 +27,8 @@ fn verify(json: &str) -> Result<(), Box> { let result = verify_attestation_output(&output, UnixTime::now())?; println!("Verified via: {:?}", result.provider); - println!("Nonce: {}", result.nonce); - println!("Root CA hash: {}", result.root_pubkey_hash); + println!("Nonce: {:?}", result.nonce); + println!("PCRs: {:?}", result.pcrs); Ok(()) } @@ -41,7 +41,7 @@ fn verify(json: &str) -> Result<(), Box> { | Check | Description | |-------|-------------| | COSE Signature | ECDSA P-384 signature over Nitro document | -| Certificate Chain | Validates chain, returns root pubkey hash | +| Certificate Chain | Validates chain to AWS Nitro Root CA | | Public Key Binding | AK public key matches signed `public_key` field | | TPM Quote Signature | AK's ECDSA P-256 signature over TPM2_Quote | | Nonce Binding | TPM Quote nonce matches Nitro nonce (freshness) | @@ -51,7 +51,7 @@ fn verify(json: &str) -> Result<(), Box> { | Check | Description | |-------|-------------| -| AK Certificate Chain | Validates chain to Google CA, returns root pubkey hash | +| AK Certificate Chain | Validates chain to Google EK/AK CA Root | | TPM Quote Signature | AK's ECDSA P-256 signature over TPM2_Quote | | Nonce Verification | Quote extraData matches expected nonce | | PCR Digest | Quote's pcrDigest matches hash of claimed PCRs | @@ -60,23 +60,23 @@ fn verify(json: &str) -> Result<(), Box> { ```rust pub struct VerificationResult { - /// The verified nonce (hex-encoded) - pub nonce: String, - /// Cloud provider (AWS, GCP) + /// The nonce that was verified (32 bytes) + pub nonce: [u8; 32], + /// Cloud provider that issued the attestation pub provider: CloudProvider, - /// PCR values from the attestation - pub pcrs: BTreeMap, - /// SHA-256 hash of the root CA's public key - pub root_pubkey_hash: String, + /// Validated PCR bank from the attestation + pub pcrs: PcrBank, + /// Timestamp when verification was performed (seconds since Unix epoch) + pub verified_at: u64, } ``` -The `root_pubkey_hash` identifies the trust anchor. For AWS Nitro, this is the hash of the Nitro Root CA's public key. For GCP, this is the hash of Google's AK Root CA. +The `provider` field identifies the trust anchor. The library embeds known root CA public key hashes and maps them to `CloudProvider::Aws` or `CloudProvider::Gcp` internally. Verification fails if the root CA is not recognized. ## API ```rust -/// Verify a complete AttestationOutput, returns the root of trust hash +/// Verify a complete AttestationOutput pub fn verify_attestation_output( output: &AttestationOutput, time: UnixTime, // Use UnixTime::now() for production @@ -89,9 +89,10 @@ pub fn verify_attestation_json(json: &str) -> Result>, +} + +impl From for VerificationResultJson { + fn from(result: VerificationResult) -> Self { + let mut pcr_map = BTreeMap::new(); + for (idx, value) in result.pcrs.values().enumerate() { + pcr_map.insert(idx as u8, hex::encode(value)); + } + + let mut pcrs = BTreeMap::new(); + pcrs.insert(result.pcrs.algorithm().to_string(), pcr_map); + + VerificationResultJson { + nonce: hex::encode(result.nonce), + provider: result.provider, + pcrs, + } + } +} fn main() -> ExitCode { let args: Vec = std::env::args().collect(); @@ -35,7 +64,8 @@ fn main() -> ExitCode { match verify_attestation_json(&json) { Ok(result) => { - println!("{}", serde_json::to_string_pretty(&result).unwrap()); + let json_result = VerificationResultJson::from(result); + println!("{}", serde_json::to_string_pretty(&json_result).unwrap()); ExitCode::SUCCESS } Err(e) => { diff --git a/crates/vaportpm-verify/src/ephemeral_gcp_tests.rs b/crates/vaportpm-verify/src/ephemeral_gcp_tests.rs new file mode 100644 index 0000000..216e4de --- /dev/null +++ b/crates/vaportpm-verify/src/ephemeral_gcp_tests.rs @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Ephemeral key tests for the GCP verification path. +//! +//! These tests build complete, cryptographically valid attestations from scratch +//! using ephemeral keys, then introduce specific inconsistencies to test error +//! paths through the public API (`verify_decoded_attestation_output`). + +use crate::error::{ + ChainValidationReason, InvalidAttestReason, SignatureInvalidReason, VerifyError, +}; +use crate::pcr::{P256PublicKey, PcrAlgorithm}; +use crate::roots::register_test_root; +use crate::test_support; +use crate::{ + verify_decoded_attestation_output, CloudProvider, DecodedAttestationOutput, + DecodedPlatformAttestation, +}; + +#[test] +fn test_ephemeral_gcp_happy_path() { + let nonce = [0xBB; 32]; + let pcrs = test_support::make_gcp_pcrs(); + let (decoded, time, _guard) = test_support::build_valid_gcp(&nonce, &pcrs); + + let result = verify_decoded_attestation_output(&decoded, time); + assert!(result.is_ok(), "Happy path should succeed: {:?}", result); + + let vr = result.unwrap(); + assert_eq!(vr.provider, CloudProvider::Gcp); + assert_eq!(vr.nonce, nonce); +} + +#[test] +fn test_ephemeral_gcp_reject_multiple_pcr_banks() { + let nonce = [0xBB; 32]; + let pcrs = test_support::make_gcp_pcrs(); + + let chain = test_support::generate_gcp_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Gcp); + + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + // TWO banks in pcr_select + let pcr_select = vec![ + (PcrAlgorithm::Sha256 as u16, vec![0xFF, 0xFF, 0xFF]), + (PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF]), + ]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &chain.ak_signing_key); + + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey: chain.ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Gcp { + cert_chain_der: vec![chain.leaf_der, chain.root_der], + }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::MultiplePcrBanks { .. } + )) + ), + "Expected multiple PCR bank error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_gcp_reject_wrong_pcr_algorithm() { + let nonce = [0xBB; 32]; + let pcrs = test_support::make_gcp_pcrs(); + + let chain = test_support::generate_gcp_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Gcp); + + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + // SHA-384 instead of SHA-256 + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &chain.ak_signing_key); + + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey: chain.ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Gcp { + cert_chain_der: vec![chain.leaf_der, chain.root_der], + }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::WrongPcrAlgorithm { + expected: PcrAlgorithm::Sha256, + got: 0x000C, + } + )) + ), + "Expected wrong algorithm error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_gcp_reject_nonce_mismatch() { + let nonce = [0xBB; 32]; + let pcrs = test_support::make_gcp_pcrs(); + let (mut decoded, time, _guard) = test_support::build_valid_gcp(&nonce, &pcrs); + + // Change the decoded nonce + decoded.nonce = [0xCC; 32]; + + let result = verify_decoded_attestation_output(&decoded, time); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::NonceMismatch + )) + ), + "Expected nonce mismatch error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_gcp_reject_ak_pubkey_mismatch() { + let nonce = [0xBB; 32]; + let pcrs = test_support::make_gcp_pcrs(); + let (mut decoded, time, _guard) = test_support::build_valid_gcp(&nonce, &pcrs); + + // Change the AK pubkey — doesn't match the leaf cert + decoded.ak_pubkey = P256PublicKey { + x: [0x04; 32], + y: [0x04; 32], + }; + + let result = verify_decoded_attestation_output(&decoded, time); + assert!( + matches!( + result, + Err(VerifyError::SignatureInvalid( + SignatureInvalidReason::AkPublicKeyMismatch + )) + ), + "Expected AK mismatch error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_gcp_reject_wrong_pcr_bank_algorithm() { + // Construct a valid Nitro (SHA-384) PcrBank and pass it to GCP verification + // This should be rejected by the algorithm check. + let nonce = [0xBB; 32]; + let wrong_pcrs = test_support::make_nitro_pcrs(); // SHA-384 + let gcp_pcrs = test_support::make_gcp_pcrs(); // SHA-256 + + let (mut decoded, time, _guard) = test_support::build_valid_gcp(&nonce, &gcp_pcrs); + decoded.pcrs = wrong_pcrs; + + let result = verify_decoded_attestation_output(&decoded, time); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::WrongPcrBankAlgorithm { + expected: PcrAlgorithm::Sha256, + got: PcrAlgorithm::Sha384, + } + )) + ), + "Expected WrongPcrBankAlgorithm error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_gcp_reject_partial_pcr_bitmap() { + let nonce = [0xBB; 32]; + let pcrs = test_support::make_gcp_pcrs(); + + let chain = test_support::generate_gcp_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Gcp); + + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + // Correct algorithm but partial bitmap — only first 16 PCRs selected + let pcr_select = vec![(PcrAlgorithm::Sha256 as u16, vec![0xFF, 0xFF, 0x00])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &chain.ak_signing_key); + + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey: chain.ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Gcp { + cert_chain_der: vec![chain.leaf_der, chain.root_der], + }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::PartialPcrBitmap + )) + ), + "Expected PartialPcrBitmap error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_gcp_reject_wrong_provider_root() { + // Build a valid GCP attestation but register the root as AWS. + // The cert chain will validate, but the provider check should + // reject it: "requires GCP root CA, got Aws". + let nonce = [0xBB; 32]; + let pcrs = test_support::make_gcp_pcrs(); + + let chain = test_support::generate_gcp_chain(); + // Register as AWS instead of GCP + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha256 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &chain.ak_signing_key); + + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey: chain.ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Gcp { + cert_chain_der: vec![chain.leaf_der, chain.root_der], + }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::WrongProvider { + expected: CloudProvider::Gcp, + got: CloudProvider::Aws, + } + )) + ), + "Expected wrong provider error, got: {:?}", + result + ); +} diff --git a/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs b/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs new file mode 100644 index 0000000..4be5c6e --- /dev/null +++ b/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs @@ -0,0 +1,774 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Ephemeral key tests for the Nitro verification path. +//! +//! These tests build complete, cryptographically valid attestations from scratch +//! using ephemeral keys, then introduce specific inconsistencies to test error +//! paths through the public API (`verify_decoded_attestation_output`). + +use std::collections::BTreeMap; + +use p256::pkcs8::DecodePrivateKey as _; + +use crate::error::{ + CborParseReason, ChainValidationReason, CoseVerifyReason, InvalidAttestReason, + SignatureInvalidReason, VerifyError, +}; +use crate::pcr::{P256PublicKey, PcrAlgorithm, PcrBank}; +use crate::roots::register_test_root; +use crate::test_support; +use crate::{ + verify_decoded_attestation_output, CloudProvider, DecodedAttestationOutput, + DecodedPlatformAttestation, +}; + +/// Helper: generate an ephemeral P-256 AK key pair, returning (P256PublicKey, pkcs8_der). +fn ephemeral_ak() -> (P256PublicKey, Vec) { + let ak_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let ak_pkcs8 = ak_key.serialize_der(); + let ak_sk = p256::ecdsa::SigningKey::from_pkcs8_der(&ak_pkcs8).unwrap(); + let ak_point = ak_sk.verifying_key().to_encoded_point(false); + let ak_pubkey = P256PublicKey::from_sec1_uncompressed(ak_point.as_bytes()).unwrap(); + (ak_pubkey, ak_pkcs8) +} + +/// Helper: convert PcrBank → idx-only map for COSE document. +fn to_nitro_pcr_map(pcrs: &PcrBank) -> BTreeMap> { + pcrs.values() + .enumerate() + .map(|(idx, val)| (idx as u8, val.to_vec())) + .collect() +} + +// ========================================================================= + +#[test] +fn test_ephemeral_nitro_happy_path() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let (decoded, time, _guard) = test_support::build_valid_nitro(&nonce, &pcrs); + + let result = verify_decoded_attestation_output(&decoded, time); + assert!(result.is_ok(), "Happy path should succeed: {:?}", result); + + let vr = result.unwrap(); + assert_eq!(vr.provider, CloudProvider::Aws); + assert_eq!(vr.nonce, nonce); +} + +#[test] +fn test_ephemeral_nitro_reject_multiple_pcr_banks() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let nitro_pcrs = to_nitro_pcr_map(&pcrs); + + let pcr_select = vec![ + (PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF]), + (PcrAlgorithm::Sha256 as u16, vec![0xFF, 0xFF, 0xFF]), + ]; + // Build with two PCR banks in the Quote: + let chain = test_support::generate_nitro_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_sec1), + Some(&nonce), + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::MultiplePcrBanks { .. } + )) + ), + "Expected multiple PCR bank error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_wrong_pcr_algorithm() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let nitro_pcrs = to_nitro_pcr_map(&pcrs); + + let chain = test_support::generate_nitro_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + // SHA-256 instead of SHA-384 + let pcr_select = vec![(PcrAlgorithm::Sha256 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_sec1), + Some(&nonce), + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::WrongPcrAlgorithm { + expected: PcrAlgorithm::Sha384, + got: 0x000B, + } + )) + ), + "Expected wrong algorithm error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_partial_pcr_bitmap() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let nitro_pcrs = to_nitro_pcr_map(&pcrs); + + let chain = test_support::generate_nitro_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + // PCR 23 deselected + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFE])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_sec1), + Some(&nonce), + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::PartialPcrBitmap + )) + ), + "Expected partial bitmap error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_nonce_mismatch() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let (mut decoded, time, _guard) = test_support::build_valid_nitro(&nonce, &pcrs); + + decoded.nonce = [0xBB; 32]; + + let result = verify_decoded_attestation_output(&decoded, time); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::NonceMismatch + )) + ), + "Expected nonce mismatch error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_missing_nitro_nonce() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let nitro_pcrs = to_nitro_pcr_map(&pcrs); + + let chain = test_support::generate_nitro_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_sec1), + None, // no nonce + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::CborParse(CborParseReason::MissingField { + field: "nonce" + })) + ), + "Expected missing nonce error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_nitro_nonce_mismatch() { + let nonce = [0xAA; 32]; + let wrong_nonce = [0xCC; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let nitro_pcrs = to_nitro_pcr_map(&pcrs); + + let chain = test_support::generate_nitro_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_sec1), + Some(&wrong_nonce), // different nonce + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::SignatureInvalid( + SignatureInvalidReason::NitroNonceMismatch + )) + ), + "Expected Nitro nonce mismatch error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_empty_signed_pcrs() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + + let chain = test_support::generate_nitro_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &BTreeMap::new(), // empty PCRs in COSE document + Some(&ak_sec1), + Some(&nonce), + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::EmptySignedPcrs + )) + ), + "Expected empty signed PCRs error, got: {:?}", + result + ); +} + +// Note: test_ephemeral_nitro_reject_pcr_missing_from_attestation was removed +// because PcrBank guarantees all 24 PCRs are present by construction. The +// missing-PCR invariant is tested in pcr.rs unit tests +// (test_reject_wrong_count, test_reject_index_out_of_range). + +#[test] +fn test_ephemeral_nitro_reject_pcr_not_signed() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + + // COSE has only 23 PCRs (missing PCR 0) — tests that the COSE document + // must contain all 24 PCR values that match the decoded PcrBank. + let mut nitro_pcrs = to_nitro_pcr_map(&pcrs); + nitro_pcrs.remove(&0); + + let chain = test_support::generate_nitro_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_sec1), + Some(&nonce), + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::PcrNotSigned { .. } + )) + ), + "Expected unsigned PCR error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_pcr_value_mismatch() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + + // COSE has different value for PCR 0 — tests that the COSE document's + // signed PCR values must match the decoded PcrBank values exactly. + let mut nitro_pcrs = to_nitro_pcr_map(&pcrs); + nitro_pcrs.insert(0, vec![0xFF; 48]); + + let chain = test_support::generate_nitro_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_sec1), + Some(&nonce), + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::SignatureInvalid( + SignatureInvalidReason::PcrValueMismatch { index: 0 } + )) + ), + "Expected PCR value mismatch error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_missing_public_key() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let nitro_pcrs = to_nitro_pcr_map(&pcrs); + + let chain = test_support::generate_nitro_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + None, // no public_key + Some(&nonce), + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::CborParse(CborParseReason::MissingField { + field: "public_key" + })) + ), + "Expected missing public_key error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_ak_mismatch() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let nitro_pcrs = to_nitro_pcr_map(&pcrs); + + let chain = test_support::generate_nitro_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + // Different public_key in COSE + let wrong_pubkey = [0x05; 65]; + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&wrong_pubkey), + Some(&nonce), + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::SignatureInvalid( + SignatureInvalidReason::AkPublicKeyMismatch + )) + ), + "Expected AK mismatch error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_wrong_provider_root() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let nitro_pcrs = to_nitro_pcr_map(&pcrs); + + let chain = test_support::generate_nitro_chain(); + // Register as GCP instead of AWS + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Gcp); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_sec1), + Some(&nonce), + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::WrongProvider { + expected: CloudProvider::Aws, + got: CloudProvider::Gcp, + } + )) + ), + "Expected wrong provider error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_unknown_root_ca() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let nitro_pcrs = to_nitro_pcr_map(&pcrs); + + let chain = test_support::generate_nitro_chain(); + // Deliberately NOT registering the root + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + let cose_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_sec1), + Some(&nonce), + &chain.cose_signing_key, + ); + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + assert!( + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::UnknownRootCa { .. } + )) + ), + "Expected unknown root CA error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_payload_not_map() { + use coset::{iana, CborSerializable, CoseSign1, HeaderBuilder}; + + let payload_array = ciborium::Value::Array(vec![ciborium::Value::Integer(42.into())]); + let mut payload_bytes = Vec::new(); + ciborium::into_writer(&payload_array, &mut payload_bytes).unwrap(); + + let cose = CoseSign1 { + protected: coset::ProtectedHeader { + original_data: None, + header: HeaderBuilder::new() + .algorithm(iana::Algorithm::ES384) + .build(), + }, + unprotected: Default::default(), + payload: Some(payload_bytes), + signature: vec![0u8; 96], + }; + let document = cose.to_vec().unwrap(); + + let nonce = [0xAA; 32]; + let decoded = DecodedAttestationOutput { + nonce, + pcrs: test_support::make_nitro_pcrs(), + ak_pubkey: P256PublicKey { + x: [0x04; 32], + y: [0x04; 32], + }, + quote_attest: vec![], + quote_signature: vec![], + platform: DecodedPlatformAttestation::Nitro { document }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + assert!( + matches!( + result, + Err(VerifyError::CborParse(CborParseReason::PayloadNotMap)) + ), + "Expected PayloadNotMap error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_invalid_signature_length() { + use coset::{CborSerializable, CoseSign1}; + + let nonce = [0xAA; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let nitro_pcrs = to_nitro_pcr_map(&pcrs); + + let chain = test_support::generate_nitro_chain(); + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + let (ak_pubkey, ak_pkcs8) = ephemeral_ak(); + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + + // Build a valid COSE doc first + let valid_doc = test_support::build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_sec1), + Some(&nonce), + &chain.cose_signing_key, + ); + + // Re-encode with a 64-byte signature instead of 96 + let mut cose = CoseSign1::from_slice(&valid_doc).unwrap(); + cose.signature = vec![0u8; 64]; + let bad_doc = cose.to_vec().unwrap(); + + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_sig = test_support::sign_tpm_quote("e_attest, &ak_pkcs8); + + let decoded = DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature: quote_sig, + platform: DecodedPlatformAttestation::Nitro { document: bad_doc }, + }; + + let result = verify_decoded_attestation_output(&decoded, test_support::ephemeral_time()); + drop(guard); + assert!( + matches!( + result, + Err(VerifyError::CoseVerify( + CoseVerifyReason::InvalidSignatureLength { + expected: 96, + got: 64, + } + )) + ), + "Expected InvalidSignatureLength error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_wrong_pcr_bank_algorithm() { + // Construct a valid GCP (SHA-256) PcrBank and pass it to Nitro verification. + // This should be rejected by the algorithm check in verify_nitro_bindings. + let nonce = [0xAA; 32]; + let wrong_pcrs = test_support::make_gcp_pcrs(); // SHA-256 + let nitro_pcrs = test_support::make_nitro_pcrs(); // SHA-384 + + let (mut decoded, time, _guard) = test_support::build_valid_nitro(&nonce, &nitro_pcrs); + decoded.pcrs = wrong_pcrs; + + let result = verify_decoded_attestation_output(&decoded, time); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::WrongPcrBankAlgorithm { + expected: PcrAlgorithm::Sha384, + got: PcrAlgorithm::Sha256, + } + )) + ), + "Expected WrongPcrBankAlgorithm error, got: {:?}", + result + ); +} diff --git a/crates/vaportpm-verify/src/error.rs b/crates/vaportpm-verify/src/error.rs index 52001f0..3ad03bb 100644 --- a/crates/vaportpm-verify/src/error.rs +++ b/crates/vaportpm-verify/src/error.rs @@ -4,6 +4,8 @@ use thiserror::Error; +use crate::CloudProvider; + /// Errors that can occur during verification #[derive(Debug, Error)] pub enum VerifyError { @@ -11,26 +13,338 @@ pub enum VerifyError { HexDecode(#[from] hex::FromHexError), #[error("Invalid attestation structure: {0}")] - InvalidAttest(String), + InvalidAttest(#[from] InvalidAttestReason), #[error("Signature verification failed: {0}")] - SignatureInvalid(String), + SignatureInvalid(#[from] SignatureInvalidReason), #[error("Certificate parsing failed: {0}")] - CertificateParse(String), + CertificateParse(#[from] CertificateParseReason), #[error("Certificate chain validation failed: {0}")] - ChainValidation(String), + ChainValidation(#[from] ChainValidationReason), #[error("CBOR parsing failed: {0}")] - CborParse(String), + CborParse(#[from] CborParseReason), #[error("COSE signature verification failed: {0}")] - CoseVerify(String), + CoseVerify(#[from] CoseVerifyReason), #[error("PCR index out of bounds: {0}")] - PcrIndexOutOfBounds(String), + PcrIndexOutOfBounds(#[from] PcrIndexOutOfBoundsReason), #[error("No attestations could be verified: {0}")] - NoValidAttestation(String), + NoValidAttestation(#[from] NoValidAttestationReason), +} + +// ============================================================================= +// InvalidAttestReason +// ============================================================================= + +#[derive(Debug, Error)] +pub enum InvalidAttestReason { + // TPM binary structure (tpm.rs SafeCursor) + #[error("Invalid TPM magic: expected 0x{expected:08x}, got 0x{got:08x}")] + TpmMagicInvalid { expected: u32, got: u32 }, + + #[error("Invalid attest type: expected 0x{expected:04x} (QUOTE), got 0x{got:04x}")] + TpmTypeInvalid { expected: u16, got: u16 }, + + #[error("Truncated TPM structure at offset {offset}")] + TpmTruncated { offset: usize }, + + #[error("Integer overflow at offset {offset}")] + TpmOverflow { offset: usize }, + + #[error("PCR selection count {count} exceeds reasonable maximum")] + PcrSelectionCountExceeded { count: u32 }, + + #[error("PCR bitmap size {size} exceeds maximum")] + PcrBitmapSizeExceeded { size: u8 }, + + // PCR validation (shared gcp.rs + nitro.rs) + #[error("Requires exactly one PCR bank selection, got {count}")] + MultiplePcrBanks { count: usize }, + + #[error("Requires TPM Quote to select {expected}, got algorithm 0x{got:04X}")] + WrongPcrAlgorithm { + expected: crate::pcr::PcrAlgorithm, + got: u16, + }, + + #[error("Requires all 24 PCRs selected in Quote bitmap")] + PartialPcrBitmap, + + #[error("Nonce does not match Quote")] + NonceMismatch, + + #[error("Nonce is not 32 bytes")] + NonceLengthInvalid, + + #[error("Missing PCR {index} - all 24 PCRs (0-23) are required")] + MissingPcr { index: u8 }, + + // PCR bank validation (pcr.rs) + #[error("PCR bank is empty")] + PcrBankEmpty, + + #[error("PCR bank contains mixed algorithms")] + PcrBankMixedAlgorithms, + + #[error("PCR bank has {got} entries, expected {expected}")] + PcrBankWrongCount { expected: usize, got: usize }, + + #[error("PCR {index} value has wrong length: expected {expected}, got {got}")] + PcrValueWrongLength { + index: u8, + expected: usize, + got: usize, + }, + + #[error("Unknown PCR algorithm: 0x{alg_id:04X}")] + UnknownPcrAlgorithm { alg_id: u16 }, + + #[error("Wrong PCR bank algorithm: expected {expected}, got {got}")] + WrongPcrBankAlgorithm { + expected: crate::pcr::PcrAlgorithm, + got: crate::pcr::PcrAlgorithm, + }, + + #[error("Invalid AK public key format")] + InvalidAkPubkeyFormat, + + #[error("Nitro document contains no signed PCRs")] + EmptySignedPcrs, + + #[error("PCR {pcr_index} in attestation but not signed by Nitro document")] + PcrNotSigned { pcr_index: u8 }, + + #[error("PCR digest mismatch")] + PcrDigestMismatch, + + #[error("Unexpected Nitro digest algorithm: expected SHA384, got {got}")] + WrongDigestAlgorithm { got: String }, + + // Flat binary format (flat.rs) + #[error("Input too short: {actual} < {minimum}")] + InputTooShort { actual: usize, minimum: usize }, + + #[error("Failed to parse flat header")] + FlatHeaderInvalid, + + #[error("Truncated flat field: {field}")] + FlatTruncated { field: &'static str }, + + #[error("Unknown platform type: {platform_type}")] + UnknownPlatformType { platform_type: u8 }, + + // JSON + #[error("JSON parse error: {0}")] + JsonParse(#[from] serde_json::Error), +} + +// ============================================================================= +// SignatureInvalidReason +// ============================================================================= + +#[derive(Debug, Error)] +pub enum SignatureInvalidReason { + #[error("Invalid public key: {0}")] + InvalidPublicKey(String), + + #[error("Invalid signature DER: {0}")] + InvalidSignatureEncoding(String), + + #[error("Signature verification failed: {0}")] + EcdsaVerificationFailed(String), + + #[error("AK public key mismatch between certificate and decoded input")] + AkPublicKeyMismatch, + + #[error("TPM nonce does not match Nitro nonce")] + NitroNonceMismatch, + + #[error("PCR {index} SHA-384 mismatch between claimed and signed value")] + PcrValueMismatch { index: u8 }, +} + +// ============================================================================= +// CertificateParseReason +// ============================================================================= + +#[derive(Debug, Error)] +pub enum CertificateParseReason { + #[error("Line {line}: Unexpected BEGIN marker inside certificate block")] + NestedBeginMarker { line: usize }, + + #[error("Line {line}: END marker without matching BEGIN")] + EndWithoutBegin { line: usize }, + + #[error("Line {line}: Empty certificate content")] + EmptyCertContent { line: usize }, + + #[error("Line {line}: Invalid base64 character in certificate")] + InvalidBase64 { line: usize }, + + #[error("Line {line}: Unexpected content outside certificate block")] + UnexpectedContent { line: usize }, + + #[error("Unclosed certificate block (missing END marker)")] + UnclosedBlock, + + #[error("No certificates found in PEM")] + NoCertificates, + + #[error("Invalid DER: {0}")] + InvalidDer(String), + + #[error("Public key has unused bits")] + PublicKeyUnusedBits, + + #[error("Failed to encode cert as DER: {0}")] + DerEncodeFailed(String), +} + +// ============================================================================= +// ChainValidationReason +// ============================================================================= + +#[derive(Debug, Error)] +pub enum ChainValidationReason { + #[error("Empty certificate chain")] + EmptyChain, + + #[error("Certificate chain too deep: {depth} certificates (max {max})")] + ChainTooDeep { depth: usize, max: usize }, + + #[error("Leaf certificate has CA:TRUE - must be CA:FALSE")] + LeafIsCa, + + #[error("Certificate {index} (intermediate/root) must have CA:TRUE")] + CaMissingCaFlag { index: usize }, + + #[error("Certificate {index} pathLenConstraint violated: allows {allowed} CAs below, but {actual} exist")] + PathLenViolated { + index: usize, + allowed: u8, + actual: usize, + }, + + #[error("Certificate {index} (intermediate/root) missing Basic Constraints extension")] + MissingBasicConstraints { index: usize }, + + #[error("Leaf certificate missing digitalSignature key usage")] + LeafMissingDigitalSignature, + + #[error("Certificate {index} (CA) missing keyCertSign key usage")] + CaMissingKeyCertSign { index: usize }, + + #[error("Leaf certificate missing Key Usage extension")] + LeafMissingKeyUsage, + + #[error("Certificate {index} issuer does not match parent subject")] + IssuerMismatch { index: usize }, + + #[error("Certificate {index} signature verification failed")] + SignatureVerificationFailed { index: usize }, + + #[error("Unsupported signature algorithm: {oid}")] + UnsupportedAlgorithm { oid: String }, + + #[error("Certificate {index} is not yet valid")] + CertNotYetValid { index: usize }, + + #[error("Certificate {index} has expired")] + CertExpired { index: usize }, + + #[error("Unknown root CA: {hash}")] + UnknownRootCa { hash: String }, + + #[error("Verification path requires {expected:?} root CA, got {got:?}")] + WrongProvider { + expected: CloudProvider, + got: CloudProvider, + }, + + #[error("{0}")] + CryptoError(String), +} + +// ============================================================================= +// CborParseReason +// ============================================================================= + +#[derive(Debug, Error)] +pub enum CborParseReason { + #[error("Failed to parse payload: {0}")] + DeserializeFailed(String), + + #[error("Payload is not a map")] + PayloadNotMap, + + #[error("Missing field: {field}")] + MissingField { field: &'static str }, + + #[error("Missing pcrs or nitrotpm_pcrs field")] + MissingPcrs, +} + +// ============================================================================= +// CoseVerifyReason +// ============================================================================= + +#[derive(Debug, Error)] +pub enum CoseVerifyReason { + #[error("Failed to parse COSE Sign1: {0}")] + CoseSign1ParseFailed(String), + + #[error("Missing payload")] + MissingPayload, + + #[error("Failed to serialize protected header: {0}")] + ProtectedHeaderSerializeFailed(String), + + #[error("Failed to encode Sig_structure: {0}")] + SigStructureEncodeFailed(String), + + #[error("Invalid P-384 key: {0}")] + InvalidP384Key(String), + + #[error("Invalid ES384 signature length: expected {expected}, got {got}")] + InvalidSignatureLength { expected: usize, got: usize }, + + #[error("Invalid signature: {0}")] + InvalidSignature(String), + + #[error("COSE signature verification failed: {0}")] + SignatureVerificationFailed(String), +} + +// ============================================================================= +// PcrIndexOutOfBoundsReason +// ============================================================================= + +#[derive(Debug, Error)] +pub enum PcrIndexOutOfBoundsReason { + #[error("Negative PCR index: {index}")] + Negative { index: i128 }, + + #[error("PCR index {index} exceeds maximum {maximum}")] + ExceedsMaximum { index: i128, maximum: u8 }, +} + +// ============================================================================= +// NoValidAttestationReason +// ============================================================================= + +#[derive(Debug, Error)] +pub enum NoValidAttestationReason { + #[error("Missing ecc_p256 AK public key")] + MissingAkPublicKey, + + #[error("Missing ecc_p256 TPM attestation")] + MissingTpmAttestation, + + #[error("No platform attestation")] + NoPlatform, } diff --git a/crates/vaportpm-verify/src/flat.rs b/crates/vaportpm-verify/src/flat.rs new file mode 100644 index 0000000..b0b8dbe --- /dev/null +++ b/crates/vaportpm-verify/src/flat.rs @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Flat binary format for zkVM input - uses zerocopy for zero-copy parsing +//! +//! Use `flat::to_bytes()` on host, `flat::from_bytes()` in guest with `env::read_slice()`. +//! +//! PCR values are stored as contiguous bytes in index order (0-23), +//! with the algorithm stored in the header. No per-entry headers. + +use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout}; + +use crate::error::InvalidAttestReason; +use crate::pcr::{P256PublicKey, PcrAlgorithm, PcrBank, PCR_COUNT}; +use crate::{DecodedAttestationOutput, DecodedPlatformAttestation, VerifyError}; + +/// Platform type constants +pub const PLATFORM_GCP: u8 = 0; +pub const PLATFORM_NITRO: u8 = 1; + +/// Fixed-size header - zerocopy will map this directly from bytes +#[derive(Debug, Clone, Copy, FromBytes, IntoBytes, Immutable, KnownLayout)] +#[repr(C, packed)] +pub struct FlatHeader { + pub nonce: [u8; 32], + pub ak_pubkey: [u8; 65], + pub platform_type: u8, + pub quote_attest_len: u16, + pub quote_signature_len: u16, + /// TPM algorithm ID (0x000B = SHA-256, 0x000C = SHA-384) + pub pcr_algorithm: u16, + pub platform_data_len: u16, +} + +/// Size of the fixed header +pub const HEADER_SIZE: usize = core::mem::size_of::(); + +/// Serialize DecodedAttestationOutput to flat binary format +pub fn to_bytes(decoded: &DecodedAttestationOutput) -> Vec { + let platform_type = match &decoded.platform { + DecodedPlatformAttestation::Gcp { .. } => PLATFORM_GCP, + DecodedPlatformAttestation::Nitro { .. } => PLATFORM_NITRO, + }; + + // Build platform data + let platform_data = match &decoded.platform { + DecodedPlatformAttestation::Gcp { cert_chain_der } => { + let mut data = Vec::new(); + data.push(cert_chain_der.len() as u8); + for cert in cert_chain_der { + data.extend_from_slice(&(cert.len() as u16).to_le_bytes()); + } + for cert in cert_chain_der { + data.extend_from_slice(cert); + } + data + } + DecodedPlatformAttestation::Nitro { document } => document.clone(), + }; + + let algorithm = decoded.pcrs.algorithm(); + let digest_len = algorithm.digest_len(); + let pcr_data_len = PCR_COUNT * digest_len; + + let header = FlatHeader { + nonce: decoded.nonce, + ak_pubkey: decoded.ak_pubkey.to_sec1_uncompressed(), + platform_type, + quote_attest_len: decoded.quote_attest.len() as u16, + quote_signature_len: decoded.quote_signature.len() as u16, + pcr_algorithm: algorithm as u16, + platform_data_len: platform_data.len() as u16, + }; + + let mut buf = Vec::with_capacity(HEADER_SIZE + pcr_data_len + 2048 + platform_data.len()); + + // Write header as bytes (zerocopy ensures correct layout) + buf.extend_from_slice(header.as_bytes()); + + // Write PCRs: 24 contiguous values in index order, no per-entry headers + for value in decoded.pcrs.values() { + buf.extend_from_slice(value); + } + + // Write quote data + buf.extend_from_slice(&decoded.quote_attest); + buf.extend_from_slice(&decoded.quote_signature); + + // Write platform data + buf.extend_from_slice(&platform_data); + + buf +} + +/// Parse flat binary format using zerocopy for header +pub fn from_bytes(data: &[u8]) -> Result { + if data.len() < HEADER_SIZE { + return Err(InvalidAttestReason::InputTooShort { + actual: data.len(), + minimum: HEADER_SIZE, + } + .into()); + } + + // Zero-copy header parsing + let (header, _suffix) = + FlatHeader::ref_from_prefix(data).map_err(|_| InvalidAttestReason::FlatHeaderInvalid)?; + + let quote_attest_len = header.quote_attest_len as usize; + let quote_signature_len = header.quote_signature_len as usize; + let platform_data_len = header.platform_data_len as usize; + + let algorithm = PcrAlgorithm::try_from(header.pcr_algorithm) + .map_err(|alg_id| InvalidAttestReason::UnknownPcrAlgorithm { alg_id })?; + let pcr_data_len = PCR_COUNT * algorithm.digest_len(); + + let mut offset = HEADER_SIZE; + + // Parse PCRs: contiguous block of 24 * digest_len bytes + if offset + pcr_data_len > data.len() { + return Err(InvalidAttestReason::FlatTruncated { field: "PCR data" }.into()); + } + let pcr_bank = PcrBank::from_contiguous(algorithm, &data[offset..offset + pcr_data_len])?; + offset += pcr_data_len; + + // Parse quote data + if offset + quote_attest_len > data.len() { + return Err(InvalidAttestReason::FlatTruncated { + field: "quote_attest", + } + .into()); + } + let quote_attest = data[offset..offset + quote_attest_len].to_vec(); + offset += quote_attest_len; + + if offset + quote_signature_len > data.len() { + return Err(InvalidAttestReason::FlatTruncated { + field: "quote_signature", + } + .into()); + } + let quote_signature = data[offset..offset + quote_signature_len].to_vec(); + offset += quote_signature_len; + + // Parse platform data + if offset + platform_data_len > data.len() { + return Err(InvalidAttestReason::FlatTruncated { + field: "platform data", + } + .into()); + } + let platform_bytes = &data[offset..offset + platform_data_len]; + + let platform = match header.platform_type { + PLATFORM_GCP => { + if platform_bytes.is_empty() { + return Err(InvalidAttestReason::FlatTruncated { + field: "GCP platform data", + } + .into()); + } + let cert_count = platform_bytes[0] as usize; + let mut poffset = 1; + + let mut cert_lens = Vec::with_capacity(cert_count); + for _ in 0..cert_count { + if poffset + 2 > platform_bytes.len() { + return Err(InvalidAttestReason::FlatTruncated { + field: "cert length", + } + .into()); + } + let len = + u16::from_le_bytes(platform_bytes[poffset..poffset + 2].try_into().unwrap()) + as usize; + cert_lens.push(len); + poffset += 2; + } + + let mut cert_chain_der = Vec::with_capacity(cert_count); + for len in cert_lens { + if poffset + len > platform_bytes.len() { + return Err(InvalidAttestReason::FlatTruncated { field: "cert data" }.into()); + } + cert_chain_der.push(platform_bytes[poffset..poffset + len].to_vec()); + poffset += len; + } + + DecodedPlatformAttestation::Gcp { cert_chain_der } + } + PLATFORM_NITRO => DecodedPlatformAttestation::Nitro { + document: platform_bytes.to_vec(), + }, + _ => { + return Err(InvalidAttestReason::UnknownPlatformType { + platform_type: header.platform_type, + } + .into()) + } + }; + + let ak_pubkey = P256PublicKey::from_sec1_uncompressed(&header.ak_pubkey)?; + + Ok(DecodedAttestationOutput { + nonce: header.nonce, + pcrs: pcr_bank, + ak_pubkey, + quote_attest, + quote_signature, + platform, + }) +} diff --git a/crates/vaportpm-verify/src/flat_tests.rs b/crates/vaportpm-verify/src/flat_tests.rs new file mode 100644 index 0000000..b880e9a --- /dev/null +++ b/crates/vaportpm-verify/src/flat_tests.rs @@ -0,0 +1,510 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Tests for `flat.rs` — the flat binary serialization format used for zkVM input. +//! +//! Strategy: build valid `DecodedAttestationOutput` via `test_support`, serialize +//! with `flat::to_bytes()`, then either verify roundtrip or mutate bytes to trigger +//! specific `from_bytes` error paths. + +use crate::error::InvalidAttestReason; +use crate::flat::{self, FlatHeader, HEADER_SIZE}; +use crate::pcr::{PcrAlgorithm, PCR_COUNT}; +use crate::test_support; +use crate::{DecodedPlatformAttestation, VerifyError}; + +// ============================================================================ +// Field offsets in the repr(C, packed) FlatHeader +// ============================================================================ + +/// ak_pubkey: [u8; 65] at offset 32 +const OFF_AK_PUBKEY: usize = 32; +/// platform_type: u8 at offset 97 +const OFF_PLATFORM_TYPE: usize = 97; +/// quote_attest_len: u16 at offset 98 +const OFF_QUOTE_ATTEST_LEN: usize = 98; +/// quote_signature_len: u16 at offset 100 +const OFF_QUOTE_SIGNATURE_LEN: usize = 100; +/// pcr_algorithm: u16 at offset 102 +const OFF_PCR_ALGORITHM: usize = 102; +/// platform_data_len: u16 at offset 104 +const OFF_PLATFORM_DATA_LEN: usize = 104; + +// ============================================================================ +// Helpers +// ============================================================================ + +fn valid_gcp_bytes() -> Vec { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_gcp_pcrs(); + let (decoded, _, _guard) = test_support::build_valid_gcp(&nonce, &pcrs); + flat::to_bytes(&decoded) +} + +/// Patch a little-endian u16 at the given offset in `data`. +fn patch_u16_le(data: &mut [u8], offset: usize, value: u16) { + data[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); +} + +// ============================================================================ +// Roundtrip tests (2) +// ============================================================================ + +#[test] +fn test_roundtrip_gcp() { + let nonce = [0xAA; 32]; + let pcrs = test_support::make_gcp_pcrs(); + let (original, _, _guard) = test_support::build_valid_gcp(&nonce, &pcrs); + + let bytes = flat::to_bytes(&original); + let restored = flat::from_bytes(&bytes).expect("roundtrip should succeed"); + + // Nonce + assert_eq!(restored.nonce, original.nonce); + + // AK pubkey + assert_eq!( + restored.ak_pubkey.to_sec1_uncompressed(), + original.ak_pubkey.to_sec1_uncompressed() + ); + + // PCRs — algorithm and all 24 values + assert_eq!(restored.pcrs.algorithm(), PcrAlgorithm::Sha256); + for i in 0..PCR_COUNT { + assert_eq!( + restored.pcrs.get(i), + original.pcrs.get(i), + "PCR {i} mismatch" + ); + } + + // Quote attest & signature + assert_eq!(restored.quote_attest, original.quote_attest); + assert_eq!(restored.quote_signature, original.quote_signature); + + // Platform — GCP cert chain + match (&restored.platform, &original.platform) { + ( + DecodedPlatformAttestation::Gcp { + cert_chain_der: restored_certs, + }, + DecodedPlatformAttestation::Gcp { + cert_chain_der: original_certs, + }, + ) => { + assert_eq!(restored_certs.len(), original_certs.len()); + for (i, (r, o)) in restored_certs.iter().zip(original_certs.iter()).enumerate() { + assert_eq!(r, o, "cert {i} mismatch"); + } + } + _ => panic!("expected GCP platform"), + } +} + +#[test] +fn test_roundtrip_nitro() { + let nonce = [0xBB; 32]; + let pcrs = test_support::make_nitro_pcrs(); + let (original, _, _guard) = test_support::build_valid_nitro(&nonce, &pcrs); + + let bytes = flat::to_bytes(&original); + let restored = flat::from_bytes(&bytes).expect("roundtrip should succeed"); + + // Nonce + assert_eq!(restored.nonce, original.nonce); + + // AK pubkey + assert_eq!( + restored.ak_pubkey.to_sec1_uncompressed(), + original.ak_pubkey.to_sec1_uncompressed() + ); + + // PCRs — algorithm and all 24 values + assert_eq!(restored.pcrs.algorithm(), PcrAlgorithm::Sha384); + for i in 0..PCR_COUNT { + assert_eq!( + restored.pcrs.get(i), + original.pcrs.get(i), + "PCR {i} mismatch" + ); + } + + // Quote attest & signature + assert_eq!(restored.quote_attest, original.quote_attest); + assert_eq!(restored.quote_signature, original.quote_signature); + + // Platform — Nitro COSE document + match (&restored.platform, &original.platform) { + ( + DecodedPlatformAttestation::Nitro { + document: restored_doc, + }, + DecodedPlatformAttestation::Nitro { + document: original_doc, + }, + ) => { + assert_eq!(restored_doc, original_doc); + } + _ => panic!("expected Nitro platform"), + } +} + +// ============================================================================ +// Header size (1) +// ============================================================================ + +#[test] +fn test_header_size() { + assert_eq!( + HEADER_SIZE, 106, + "FlatHeader size changed — update flat format and tests" + ); + assert_eq!(HEADER_SIZE, std::mem::size_of::()); +} + +// ============================================================================ +// Input too short (2) +// ============================================================================ + +#[test] +fn test_reject_empty_input() { + let err = flat::from_bytes(&[]).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::InputTooShort { + actual: 0, + minimum: 106, + }) + ), + "unexpected error: {err:?}" + ); +} + +#[test] +fn test_reject_truncated_header() { + let err = flat::from_bytes(&[0u8; 105]).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::InputTooShort { + actual: 105, + minimum: 106, + }) + ), + "unexpected error: {err:?}" + ); +} + +// ============================================================================ +// Invalid header fields (2) +// ============================================================================ + +#[test] +fn test_reject_unknown_pcr_algorithm() { + let mut data = valid_gcp_bytes(); + patch_u16_le(&mut data, OFF_PCR_ALGORITHM, 0x9999); + let err = flat::from_bytes(&data).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::UnknownPcrAlgorithm { alg_id: 0x9999 }) + ), + "unexpected error: {err:?}" + ); +} + +#[test] +fn test_reject_unknown_platform_type() { + let mut data = valid_gcp_bytes(); + data[OFF_PLATFORM_TYPE] = 0xFF; + let err = flat::from_bytes(&data).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::UnknownPlatformType { + platform_type: 0xFF + }) + ), + "unexpected error: {err:?}" + ); +} + +// ============================================================================ +// Truncation at each field boundary (4) +// ============================================================================ + +#[test] +fn test_reject_truncated_pcr_data() { + let data = valid_gcp_bytes(); + // Truncate 1 byte into the PCR region + let truncated = &data[..HEADER_SIZE + 1]; + let err = flat::from_bytes(truncated).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::FlatTruncated { field: "PCR data" }) + ), + "unexpected error: {err:?}" + ); +} + +#[test] +fn test_reject_truncated_quote_attest() { + let data = valid_gcp_bytes(); + // PCR data for SHA-256: 24 * 32 = 768 bytes. Truncate 1 byte into quote_attest. + let pcr_end = HEADER_SIZE + PCR_COUNT * PcrAlgorithm::Sha256.digest_len(); + let truncated = &data[..pcr_end + 1]; + let err = flat::from_bytes(truncated).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::FlatTruncated { + field: "quote_attest" + }) + ), + "unexpected error: {err:?}" + ); +} + +#[test] +fn test_reject_truncated_quote_signature() { + let data = valid_gcp_bytes(); + // Read quote_attest_len from the header to find where quote_signature starts + let quote_attest_len = u16::from_le_bytes( + data[OFF_QUOTE_ATTEST_LEN..OFF_QUOTE_ATTEST_LEN + 2] + .try_into() + .unwrap(), + ) as usize; + let pcr_end = HEADER_SIZE + PCR_COUNT * PcrAlgorithm::Sha256.digest_len(); + let sig_start = pcr_end + quote_attest_len; + // Truncate 1 byte into quote_signature + let truncated = &data[..sig_start + 1]; + let err = flat::from_bytes(truncated).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::FlatTruncated { + field: "quote_signature" + }) + ), + "unexpected error: {err:?}" + ); +} + +#[test] +fn test_reject_truncated_platform_data() { + let data = valid_gcp_bytes(); + let quote_attest_len = u16::from_le_bytes( + data[OFF_QUOTE_ATTEST_LEN..OFF_QUOTE_ATTEST_LEN + 2] + .try_into() + .unwrap(), + ) as usize; + let quote_sig_len = u16::from_le_bytes( + data[OFF_QUOTE_SIGNATURE_LEN..OFF_QUOTE_SIGNATURE_LEN + 2] + .try_into() + .unwrap(), + ) as usize; + let pcr_end = HEADER_SIZE + PCR_COUNT * PcrAlgorithm::Sha256.digest_len(); + let platform_start = pcr_end + quote_attest_len + quote_sig_len; + // Truncate 1 byte into platform data + let truncated = &data[..platform_start + 1]; + let err = flat::from_bytes(truncated).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::FlatTruncated { + field: "platform data" + }) + ), + "unexpected error: {err:?}" + ); +} + +// ============================================================================ +// GCP platform data parsing (3) +// ============================================================================ + +#[test] +fn test_reject_gcp_empty_platform_data() { + let mut data = valid_gcp_bytes(); + // Find where platform data starts and set its length to 0 + let quote_attest_len = u16::from_le_bytes( + data[OFF_QUOTE_ATTEST_LEN..OFF_QUOTE_ATTEST_LEN + 2] + .try_into() + .unwrap(), + ) as usize; + let quote_sig_len = u16::from_le_bytes( + data[OFF_QUOTE_SIGNATURE_LEN..OFF_QUOTE_SIGNATURE_LEN + 2] + .try_into() + .unwrap(), + ) as usize; + let pcr_end = HEADER_SIZE + PCR_COUNT * PcrAlgorithm::Sha256.digest_len(); + let platform_start = pcr_end + quote_attest_len + quote_sig_len; + + // Set platform_data_len = 0 and truncate + patch_u16_le(&mut data, OFF_PLATFORM_DATA_LEN, 0); + data.truncate(platform_start); + + let err = flat::from_bytes(&data).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::FlatTruncated { + field: "GCP platform data" + }) + ), + "unexpected error: {err:?}" + ); +} + +#[test] +fn test_reject_gcp_truncated_cert_lengths() { + let mut data = valid_gcp_bytes(); + let quote_attest_len = u16::from_le_bytes( + data[OFF_QUOTE_ATTEST_LEN..OFF_QUOTE_ATTEST_LEN + 2] + .try_into() + .unwrap(), + ) as usize; + let quote_sig_len = u16::from_le_bytes( + data[OFF_QUOTE_SIGNATURE_LEN..OFF_QUOTE_SIGNATURE_LEN + 2] + .try_into() + .unwrap(), + ) as usize; + let pcr_end = HEADER_SIZE + PCR_COUNT * PcrAlgorithm::Sha256.digest_len(); + let platform_start = pcr_end + quote_attest_len + quote_sig_len; + + // Build minimal platform data: cert_count=2 but only 2 bytes (space for 1 length) + // Format: [cert_count: u8, len0: u16_le] — missing len1 + let platform_data = vec![ + 2u8, // cert_count = 2 + 0x10, 0x00, // len[0] = 16 + // len[1] is missing + ]; + let platform_len = platform_data.len() as u16; + patch_u16_le(&mut data, OFF_PLATFORM_DATA_LEN, platform_len); + data.truncate(platform_start); + data.extend_from_slice(&platform_data); + + let err = flat::from_bytes(&data).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::FlatTruncated { + field: "cert length" + }) + ), + "unexpected error: {err:?}" + ); +} + +#[test] +fn test_reject_gcp_truncated_cert_data() { + let mut data = valid_gcp_bytes(); + let quote_attest_len = u16::from_le_bytes( + data[OFF_QUOTE_ATTEST_LEN..OFF_QUOTE_ATTEST_LEN + 2] + .try_into() + .unwrap(), + ) as usize; + let quote_sig_len = u16::from_le_bytes( + data[OFF_QUOTE_SIGNATURE_LEN..OFF_QUOTE_SIGNATURE_LEN + 2] + .try_into() + .unwrap(), + ) as usize; + let pcr_end = HEADER_SIZE + PCR_COUNT * PcrAlgorithm::Sha256.digest_len(); + let platform_start = pcr_end + quote_attest_len + quote_sig_len; + + // Build platform data: 1 cert with claimed length 100, but only 10 bytes of data + let mut platform_data = Vec::new(); + platform_data.push(1u8); // cert_count = 1 + platform_data.extend_from_slice(&100u16.to_le_bytes()); // cert len = 100 + platform_data.extend_from_slice(&[0xDE; 10]); // only 10 bytes of cert data + + let platform_len = platform_data.len() as u16; + patch_u16_le(&mut data, OFF_PLATFORM_DATA_LEN, platform_len); + data.truncate(platform_start); + data.extend_from_slice(&platform_data); + + let err = flat::from_bytes(&data).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::FlatTruncated { field: "cert data" }) + ), + "unexpected error: {err:?}" + ); +} + +// ============================================================================ +// AK pubkey validation (1) +// ============================================================================ + +#[test] +fn test_reject_invalid_ak_pubkey() { + let mut data = valid_gcp_bytes(); + // Patch first byte of ak_pubkey from 0x04 (uncompressed) to 0x02 (compressed) + data[OFF_AK_PUBKEY] = 0x02; + let err = flat::from_bytes(&data).unwrap_err(); + assert!( + matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::InvalidAkPubkeyFormat) + ), + "unexpected error: {err:?}" + ); +} + +// ============================================================================ +// Multi-cert roundtrip (1) +// ============================================================================ + +#[test] +fn test_roundtrip_gcp_multiple_certs() { + let nonce = [0xCC; 32]; + let pcrs = test_support::make_gcp_pcrs(); + let chain = test_support::generate_gcp_chain(); + + // Build a DecodedAttestationOutput with 3 certs (leaf + 2 fake intermediates) + let pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha256 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = test_support::build_tpm_quote_attest(&nonce, &pcr_select, &pcr_digest); + let quote_signature = test_support::sign_tpm_quote("e_attest, &chain.ak_signing_key); + + let fake_intermediate_1: Vec = [0x30, 0x82, 0x01, 0x00] + .iter() + .copied() + .chain(std::iter::repeat_n(0xAA, 256)) + .collect(); + let fake_intermediate_2: Vec = [0x30, 0x82, 0x02, 0x00] + .iter() + .copied() + .chain(std::iter::repeat_n(0xBB, 512)) + .collect(); + + let original = crate::DecodedAttestationOutput { + nonce, + pcrs: pcrs.clone(), + ak_pubkey: chain.ak_pubkey, + quote_attest, + quote_signature, + platform: DecodedPlatformAttestation::Gcp { + cert_chain_der: vec![ + chain.leaf_der.clone(), + fake_intermediate_1.clone(), + fake_intermediate_2.clone(), + ], + }, + }; + + let bytes = flat::to_bytes(&original); + let restored = flat::from_bytes(&bytes).expect("multi-cert roundtrip should succeed"); + + match &restored.platform { + DecodedPlatformAttestation::Gcp { cert_chain_der } => { + assert_eq!(cert_chain_der.len(), 3, "expected 3 certs"); + assert_eq!(cert_chain_der[0], chain.leaf_der); + assert_eq!(cert_chain_der[1], fake_intermediate_1); + assert_eq!(cert_chain_der[2], fake_intermediate_2); + } + _ => panic!("expected GCP platform"), + } +} diff --git a/crates/vaportpm-verify/src/gcp.rs b/crates/vaportpm-verify/src/gcp.rs index 3d21a23..cde3778 100644 --- a/crates/vaportpm-verify/src/gcp.rs +++ b/crates/vaportpm-verify/src/gcp.rs @@ -2,142 +2,96 @@ //! GCP Shielded VM attestation verification -use std::collections::BTreeMap; - +use der::Decode; use pki_types::UnixTime; -use sha2::{Digest, Sha256}; +use x509_cert::Certificate; + +use crate::error::{ + CertificateParseReason, ChainValidationReason, InvalidAttestReason, SignatureInvalidReason, + VerifyError, +}; +use crate::pcr::PcrAlgorithm; +use crate::tpm::verify_quote; +use crate::x509::{extract_public_key, validate_tpm_cert_chain}; +use crate::CloudProvider; +use crate::{roots, DecodedAttestationOutput, VerificationResult}; + +fn verify_gcp_certs( + cert_chain_der: &[Vec], + time: UnixTime, +) -> Result<(Vec, CloudProvider), VerifyError> { + // Parse DER → Certificate (still needed for chain validation) + let certs: Vec = cert_chain_der + .iter() + .map(|der| { + Certificate::from_der(der) + .map_err(|e| CertificateParseReason::InvalidDer(e.to_string())) + }) + .collect::>()?; + + if certs.is_empty() { + return Err(ChainValidationReason::EmptyChain.into()); + } -use crate::error::VerifyError; -use crate::tpm::{parse_quote_attest, verify_ecdsa_p256, TpmQuoteInfo}; -use crate::x509::{extract_public_key, parse_cert_chain_pem, validate_tpm_cert_chain}; -use crate::{roots, VerificationResult}; + // Validate AK certificate chain to GCP root + let chain_result = validate_tpm_cert_chain(&certs, time)?; -use vaportpm_attest::a9n::{AttestationOutput, GcpAttestationData}; + // Verify root is a known GCP root + let provider = roots::provider_from_hash(&chain_result.root_pubkey_hash).ok_or_else(|| { + VerifyError::ChainValidation(ChainValidationReason::UnknownRootCa { + hash: hex::encode(chain_result.root_pubkey_hash), + }) + })?; + + // Defence in depth: ensure the GCP verification path only accepts GCP roots + if provider != CloudProvider::Gcp { + return Err(ChainValidationReason::WrongProvider { + expected: CloudProvider::Gcp, + got: provider, + } + .into()); + } + + Ok((certs, provider)) +} /// Verify GCP Shielded VM attestation /// /// This verification path: -/// 1. Parses TPM2_Quote attestation to extract PCR digest and nonce -/// 2. Validates AK certificate chain to Google's root CA -/// 3. Verifies Quote signature with AK public key from certificate +/// 1. Validates AK certificate chain to Google's root CA +/// 2. Verifies Quote ECDSA signature with AK public key (authenticates quote) +/// 3. Verifies nonce matches the authenticated Quote extraData /// 4. Verifies PCR digest matches claimed PCR values -pub fn verify_gcp_attestation( - output: &AttestationOutput, - gcp: &GcpAttestationData, +/// +/// The signature is verified before trusting any data parsed from the quote. +/// All inputs should be pre-decoded binary data (DER certs, raw bytes). +pub fn verify_gcp_decoded( + decoded: &DecodedAttestationOutput, + cert_chain_der: &[Vec], time: UnixTime, ) -> Result { - // Get TPM attestation (contains Quote data and signature) - let (_, tpm_attestation) = output - .attestation - .tpm - .iter() - .next() - .ok_or_else(|| VerifyError::NoValidAttestation("Missing TPM attestation".into()))?; - - // Parse TPM2_Quote attestation (type = QUOTE, not CERTIFY) - let quote_data = hex::decode(&tpm_attestation.attest_data)?; - let quote_info = parse_quote_attest("e_data)?; + let (certs, provider) = verify_gcp_certs(cert_chain_der, time)?; - // Verify top-level nonce matches nonce in Quote (prevents tampering) - let nonce_from_field = hex::decode(&output.nonce)?; - if nonce_from_field != quote_info.nonce { - return Err(VerifyError::InvalidAttest(format!( - "Nonce field does not match nonce in Quote. \ - Field: {}, Quote: {}", - output.nonce, - hex::encode("e_info.nonce) - ))); + // Verify AK public key from cert chain matches the decoded input + let ak_pubkey_from_cert = extract_public_key(&certs[0])?; + let ak_sec1 = decoded.ak_pubkey.to_sec1_uncompressed(); + if ak_pubkey_from_cert != ak_sec1 { + return Err(SignatureInvalidReason::AkPublicKeyMismatch.into()); } - // Validate AK certificate chain to GCP root - let chain_result = validate_tpm_cert_chain(&parse_cert_chain_pem(&gcp.ak_cert_chain)?, time)?; - - // Extract AK public key from leaf certificate - let certs = parse_cert_chain_pem(&gcp.ak_cert_chain)?; - let ak_pubkey = extract_public_key(&certs[0])?; + // Verify TPM Quote: signature, PCR bank (SHA-256), nonce, PCR digest + let quote_info = verify_quote(decoded, PcrAlgorithm::Sha256)?; - // Verify Quote signature with AK public key from certificate - let signature = hex::decode(&tpm_attestation.signature)?; - verify_ecdsa_p256("e_data, &signature, &ak_pubkey)?; - - // Verify PCR digest matches claimed PCR values - // The Quote contains a digest of the selected PCRs - this MUST be verified - let pcrs = output.pcrs.get("sha256").ok_or_else(|| { - VerifyError::InvalidAttest("Missing SHA-256 PCRs - required for GCP attestation".into()) - })?; - if pcrs.is_empty() { - return Err(VerifyError::InvalidAttest( - "SHA-256 PCRs map is empty - at least one PCR required".into(), - )); - } - verify_pcr_digest_matches("e_info, pcrs)?; - - // Verify root is a known GCP root - fail if not recognized - let provider = roots::provider_from_hash(&chain_result.root_pubkey_hash).ok_or_else(|| { - VerifyError::ChainValidation(format!( - "Unknown root CA: {}. Only known cloud provider roots are trusted.", - chain_result.root_pubkey_hash - )) - })?; + let nonce: [u8; 32] = quote_info + .nonce + .as_slice() + .try_into() + .map_err(|_| InvalidAttestReason::NonceLengthInvalid)?; Ok(VerificationResult { - nonce: hex::encode("e_info.nonce), + nonce, provider, - pcrs: pcrs.clone(), - root_pubkey_hash: chain_result.root_pubkey_hash, + pcrs: decoded.pcrs.clone(), + verified_at: time.as_secs(), }) } - -/// Verify that the PCR digest in a Quote matches the claimed PCR values -fn verify_pcr_digest_matches( - quote_info: &TpmQuoteInfo, - pcrs: &BTreeMap, -) -> Result<(), VerifyError> { - // The PCR digest is SHA-256(concatenation of selected PCR values in order) - // The selection order is determined by the pcr_select field - - // Build the expected digest by concatenating PCR values in selection order - let mut hasher = Sha256::new(); - - for (alg, bitmap) in "e_info.pcr_select { - // Only handle SHA-256 PCRs for now - if *alg != 0x000B { - // TPM_ALG_SHA256 - continue; - } - - // Iterate through bitmap to find selected PCRs - for (byte_idx, byte_val) in bitmap.iter().enumerate() { - for bit_idx in 0..8 { - if byte_val & (1 << bit_idx) != 0 { - let pcr_idx = (byte_idx * 8 + bit_idx) as u8; - if let Some(pcr_value_hex) = pcrs.get(&pcr_idx) { - let pcr_value = hex::decode(pcr_value_hex).map_err(|e| { - VerifyError::InvalidAttest(format!( - "Invalid PCR {} hex value: {}", - pcr_idx, e - )) - })?; - hasher.update(&pcr_value); - } else { - return Err(VerifyError::InvalidAttest(format!( - "PCR {} selected in Quote but not present in attestation", - pcr_idx - ))); - } - } - } - } - } - - let computed_digest = hasher.finalize(); - if computed_digest.as_ref() != quote_info.pcr_digest { - return Err(VerifyError::InvalidAttest(format!( - "PCR digest mismatch. Quote digest: {}, Computed from PCRs: {}", - hex::encode("e_info.pcr_digest), - hex::encode(computed_digest) - ))); - } - - Ok(()) -} diff --git a/crates/vaportpm-verify/src/lib.rs b/crates/vaportpm-verify/src/lib.rs index 1849e9b..1094a33 100644 --- a/crates/vaportpm-verify/src/lib.rs +++ b/crates/vaportpm-verify/src/lib.rs @@ -12,31 +12,23 @@ mod error; mod gcp; mod nitro; +pub mod pcr; mod tpm; mod x509; -use std::collections::BTreeMap; - use serde::Serialize; +use x509::parse_cert_chain_pem; -// Re-export error type -pub use error::VerifyError; - -// Re-export TPM types and functions (only those used by verification paths) -pub use tpm::{parse_quote_attest, verify_ecdsa_p256, TpmQuoteInfo}; - -// Re-export from vaportpm_attest -pub use vaportpm_attest::TpmAlg; - -// Re-export Nitro types and functions -pub use nitro::{verify_nitro_attestation, NitroDocument, NitroVerifyResult}; - -// Re-export X.509 utility functions -pub use x509::{ - extract_public_key, hash_public_key, parse_and_validate_tpm_cert_chain, parse_cert_chain_pem, - validate_tpm_cert_chain, ChainValidationResult, MAX_CHAIN_DEPTH, +// Re-export error types +pub use error::{ + CborParseReason, CertificateParseReason, ChainValidationReason, CoseVerifyReason, + InvalidAttestReason, NoValidAttestationReason, PcrIndexOutOfBoundsReason, + SignatureInvalidReason, VerifyError, }; +// Re-export PCR types +pub use pcr::{P256PublicKey, PcrAlgorithm, PcrBank, PCR_COUNT}; + // Re-export time type for testing pub use pki_types::UnixTime; @@ -51,81 +43,88 @@ pub enum CloudProvider { /// Known root CA certificates and public key hashes for cloud providers /// -/// Certificates are embedded from vaportpm_attest. Hashes are derived from -/// the certificate data at runtime, not hardcoded separately. +/// Hashes are pre-computed constants to avoid PEM parsing in zkVM guests. pub mod roots { use super::CloudProvider; - use crate::x509::{extract_public_key, hash_public_key, parse_cert_chain_pem}; - use std::sync::OnceLock; // Re-export embedded root certificate PEMs from vaportpm_attest - // Note: Intermediates are project-specific and must be fetched by the attestor pub use vaportpm_attest::roots::{AWS_NITRO_ROOT_PEM, GCP_EKAK_ROOT_PEM}; // Re-export SKI/AKI lookup pub use vaportpm_attest::roots::find_issuer_by_aki; - /// Cached root public key hashes (computed on first access) - static ROOT_HASHES: OnceLock = OnceLock::new(); - - struct RootHashes { - aws_nitro: String, - gcp_ekak_amd: String, - } + /// AWS Nitro Enclave Root CA public key hash (SHA-256, pre-computed) + pub const AWS_NITRO_ROOT_HASH: [u8; 32] = [ + 0xfb, 0x70, 0x59, 0x38, 0x0c, 0x01, 0xce, 0x83, 0x78, 0x53, 0x58, 0x08, 0x97, 0x1f, 0x48, + 0xad, 0xb2, 0x61, 0x1f, 0x2d, 0x33, 0x2c, 0x9e, 0x18, 0xbb, 0xfa, 0x1b, 0x84, 0xcf, 0x7c, + 0xad, 0xe2, + ]; - fn compute_root_hashes() -> RootHashes { - let aws_hash = compute_pubkey_hash(AWS_NITRO_ROOT_PEM).unwrap_or_default(); - let gcp_amd_hash = compute_pubkey_hash(GCP_EKAK_ROOT_PEM).unwrap_or_default(); + /// GCP Shielded VM EK/AK Root CA public key hash (SHA-256, pre-computed) + pub const GCP_EKAK_ROOT_HASH: [u8; 32] = [ + 0x9a, 0xb8, 0x45, 0xee, 0x46, 0x63, 0x63, 0x8e, 0x86, 0x81, 0x29, 0xc7, 0xe8, 0xdd, 0x4b, + 0x2a, 0x63, 0xa5, 0x12, 0x3f, 0xd8, 0x5d, 0x7b, 0x60, 0x28, 0x15, 0xa7, 0xc6, 0xc4, 0xad, + 0xe7, 0x69, + ]; - RootHashes { - aws_nitro: aws_hash, - gcp_ekak_amd: gcp_amd_hash, + /// Look up cloud provider from root public key hash + #[inline] + pub fn provider_from_hash(hash: &[u8; 32]) -> Option { + if hash == &AWS_NITRO_ROOT_HASH { + Some(CloudProvider::Aws) + } else if hash == &GCP_EKAK_ROOT_HASH { + Some(CloudProvider::Gcp) + } else { + #[cfg(test)] + { + if let Some(provider) = test_registry::lookup_test_root(hash) { + return Some(provider); + } + } + None } } - fn compute_pubkey_hash(pem: &str) -> Option { - let certs = parse_cert_chain_pem(pem).ok()?; - let cert = certs.first()?; - let pubkey = extract_public_key(cert).ok()?; - Some(hash_public_key(&pubkey)) - } + #[cfg(test)] + pub use test_registry::{register_test_root, TestRootGuard}; - fn get_hashes() -> &'static RootHashes { - ROOT_HASHES.get_or_init(compute_root_hashes) - } + #[cfg(test)] + mod test_registry { + use super::CloudProvider; + use std::cell::RefCell; + use std::collections::HashMap; - /// AWS Nitro Enclave Root CA public key hash (SHA-256) - /// - /// Derived from the embedded AWS Nitro root certificate. - pub fn aws_nitro_root_hash() -> &'static str { - &get_hashes().aws_nitro - } + thread_local! { + static TEST_ROOTS: RefCell> = + RefCell::new(HashMap::new()); + } - /// GCP Shielded VM EK/AK Root CA public key hash (SHA-256) - AMD/SEV - /// - /// Derived from the embedded GCP EK/AK root certificate for AMD instances. - pub fn gcp_ekak_root_amd_hash() -> &'static str { - &get_hashes().gcp_ekak_amd - } + /// RAII guard that removes a test root on drop (even on panic). + pub struct TestRootGuard { + hash: [u8; 32], + } - /// Look up cloud provider from root public key hash - /// - /// Returns `Some(CloudProvider)` if the hash matches a known root CA, - /// or `None` if the root is not recognized. - pub fn provider_from_hash(hash: &str) -> Option { - let hashes = get_hashes(); - if hash == hashes.aws_nitro { - Some(CloudProvider::Aws) - } else if hash == hashes.gcp_ekak_amd { - Some(CloudProvider::Gcp) - } else { - None + impl Drop for TestRootGuard { + fn drop(&mut self) { + TEST_ROOTS.with(|roots| { + roots.borrow_mut().remove(&self.hash); + }); + } + } + + /// Register an ephemeral root CA hash for testing. + /// Returns an RAII guard that removes the entry on drop. + pub fn register_test_root(hash: [u8; 32], provider: CloudProvider) -> TestRootGuard { + TEST_ROOTS.with(|roots| { + roots.borrow_mut().insert(hash, provider); + }); + TestRootGuard { hash } } - } - /// Check if a public key hash matches a known trust anchor - pub fn is_known_root_hash(hash: &str) -> bool { - provider_from_hash(hash).is_some() + /// Look up a test root by hash (called from `provider_from_hash`). + pub fn lookup_test_root(hash: &[u8; 32]) -> Option { + TEST_ROOTS.with(|roots| roots.borrow().get(hash).copied()) + } } } @@ -135,17 +134,148 @@ pub use vaportpm_attest::a9n::{ GcpAttestationData, NitroAttestationData, }; +/// Binary attestation data - all fields already decoded from hex/PEM +/// +/// This struct holds pre-decoded attestation data for efficient verification +/// in constrained environments (e.g., zkVM guests) where text parsing is expensive. +#[derive(Debug, Clone)] +pub struct DecodedAttestationOutput { + /// Nonce (32 bytes, already decoded from hex) + pub nonce: [u8; 32], + + /// Validated PCR bank (single algorithm, exactly 24 values) + pub pcrs: PcrBank, + + /// AK public key (P-256) + pub ak_pubkey: P256PublicKey, + + /// TPM Quote attest_data (raw bytes) + pub quote_attest: Vec, + + /// TPM Quote signature (raw DER bytes) + pub quote_signature: Vec, + + /// Platform-specific attestation + pub platform: DecodedPlatformAttestation, +} + +/// Platform-specific attestation data in decoded binary format +#[derive(Debug, Clone)] +pub enum DecodedPlatformAttestation { + /// GCP: certificate chain as DER bytes (leaf first) + Gcp { cert_chain_der: Vec> }, + + /// Nitro: COSE document (raw bytes, already hex-decoded) + Nitro { document: Vec }, +} + +pub mod flat; + +#[cfg(test)] +mod test_support; + +impl DecodedAttestationOutput { + /// Decode an AttestationOutput to binary format + pub fn decode(output: &AttestationOutput) -> Result { + use der::Encode; + + let nonce_bytes = hex::decode(&output.nonce)?; + let nonce: [u8; 32] = nonce_bytes + .try_into() + .map_err(|_| InvalidAttestReason::NonceLengthInvalid)?; + + let ak_pk = output + .ak_pubkeys + .get("ecc_p256") + .ok_or(NoValidAttestationReason::MissingAkPublicKey)?; + let ak_x = hex::decode(&ak_pk.x)?; + let ak_y = hex::decode(&ak_pk.y)?; + let ak_pubkey = P256PublicKey::from_coords(&ak_x, &ak_y)?; + + let tpm = output + .attestation + .tpm + .get("ecc_p256") + .ok_or(NoValidAttestationReason::MissingTpmAttestation)?; + let quote_attest = hex::decode(&tpm.attest_data)?; + let quote_signature = hex::decode(&tpm.signature)?; + + let mut pcr_entries = Vec::new(); + let mut algorithm = None; + for (alg_name, pcr_map) in &output.pcrs { + let alg = match alg_name.as_str() { + "sha256" => PcrAlgorithm::Sha256, + "sha384" => PcrAlgorithm::Sha384, + _ => continue, + }; + if algorithm.is_some() && algorithm != Some(alg) { + return Err(InvalidAttestReason::PcrBankMixedAlgorithms.into()); + } + algorithm = Some(alg); + for (idx, hex_value) in pcr_map { + pcr_entries.push((*idx, hex::decode(hex_value)?)); + } + } + let algorithm = algorithm.ok_or(InvalidAttestReason::PcrBankEmpty)?; + let pcrs = PcrBank::from_values(algorithm, pcr_entries)?; + + let platform = if let Some(ref gcp) = output.attestation.gcp { + let certs = parse_cert_chain_pem(&gcp.ak_cert_chain)?; + let cert_chain_der: Vec> = certs + .iter() + .map(|c| c.to_der()) + .collect::>() + .map_err(|e| CertificateParseReason::DerEncodeFailed(e.to_string()))?; + DecodedPlatformAttestation::Gcp { cert_chain_der } + } else if let Some(ref nitro) = output.attestation.nitro { + let document = hex::decode(&nitro.document)?; + DecodedPlatformAttestation::Nitro { document } + } else { + return Err(NoValidAttestationReason::NoPlatform.into()); + }; + + Ok(DecodedAttestationOutput { + nonce, + pcrs, + ak_pubkey, + quote_attest, + quote_signature, + platform, + }) + } +} + +/// Verify pre-decoded attestation (for zkVM) +/// Verify pre-decoded attestation +/// +/// # Arguments +/// * `decoded` - Pre-decoded attestation data +/// * `time` - Verification timestamp for certificate validation +pub fn verify_decoded_attestation_output( + decoded: &DecodedAttestationOutput, + time: UnixTime, +) -> Result { + match &decoded.platform { + DecodedPlatformAttestation::Gcp { cert_chain_der } => { + gcp::verify_gcp_decoded(decoded, cert_chain_der, time) + } + DecodedPlatformAttestation::Nitro { document } => { + nitro::verify_nitro_decoded(decoded, document, time) + } + } +} + /// Result of successful attestation verification -#[derive(Debug, Serialize)] +#[derive(Debug)] pub struct VerificationResult { - /// The nonce that was verified (hex-encoded) - pub nonce: String, + /// The nonce that was verified (32 bytes) + pub nonce: [u8; 32], /// Cloud provider that issued the attestation pub provider: CloudProvider, - /// PCR values from the attestation (index -> hex-encoded digest) - pub pcrs: BTreeMap, - /// SHA-256 hash of the root CA's public key - pub root_pubkey_hash: String, + /// Validated PCR bank from the attestation + pub pcrs: PcrBank, + /// Timestamp when verification was performed (seconds since Unix epoch) + pub verified_at: u64, } /// Verify an entire AttestationOutput @@ -167,10 +297,9 @@ pub struct VerificationResult { /// # Returns /// A unified `VerificationResult` containing: /// - `nonce`: The verified challenge (from TPM2_Quote.extraData) -/// - `provider`: Cloud provider (AWS/GCP) if root CA is recognized -/// - `pcrs`: PCR values from the attestation -/// - `root_pubkey_hash`: SHA-256 of the trust anchor's public key -/// - `method`: How verification was performed +/// - `provider`: Cloud provider (AWS/GCP) identified via root CA hash +/// - `pcrs`: Validated PCR bank from the attestation +/// - `verified_at`: Verification timestamp (Unix seconds) /// /// # Errors /// Returns `NoValidAttestation` if no supported verification path is available. @@ -178,167 +307,11 @@ pub fn verify_attestation_output( output: &AttestationOutput, time: UnixTime, ) -> Result { - // Must have at least one TPM attestation - if output.attestation.tpm.is_empty() { - return Err(VerifyError::NoValidAttestation( - "no TPM attestations present".into(), - )); - } - - // Try GCP verification path (certificate-based trust) - if let Some(ref gcp_data) = output.attestation.gcp { - return gcp::verify_gcp_attestation(output, gcp_data, time); - } + // Decode to binary format (all hex/PEM parsing happens here) + let decoded = DecodedAttestationOutput::decode(output)?; - // Try Nitro verification path (Nitro NSM document-based trust) - if let Some(ref nitro) = output.attestation.nitro { - return verify_nitro_quote_attestation(output, nitro, time); - } - - // No supported attestation method found - Err(VerifyError::NoValidAttestation( - "No GCP or Nitro attestation present. \ - Supported verification paths: AWS Nitro, GCP Shielded VM." - .into(), - )) -} - -/// Verify Nitro attestation path using TPM2_Quote -/// -/// This verification path: -/// 1. Parses TPM2_Quote attestation to extract PCR digest and nonce -/// 2. Verifies Quote signature with AK public key -/// 3. Verifies Nitro NSM document binds the AK public key -/// 4. Verifies PCRs match signed values in Nitro document -fn verify_nitro_quote_attestation( - output: &AttestationOutput, - nitro: &NitroAttestationData, - time: UnixTime, -) -> Result { - // Get the first (and typically only) TPM attestation - let (key_type, attestation) = output.attestation.tpm.iter().next().unwrap(); - - // Get the corresponding signing key (AK) public key - let ak_pk = output.ak_pubkeys.get(key_type).ok_or_else(|| { - VerifyError::NoValidAttestation(format!("{}: missing AK public key", key_type)) - })?; - - // Decode AK public key - let ak_x = hex::decode(&ak_pk.x)?; - let ak_y = hex::decode(&ak_pk.y)?; - - // Parse TPM2_Quote attestation - let attest_data = hex::decode(&attestation.attest_data)?; - let quote_info = parse_quote_attest(&attest_data)?; - - // Verify top-level nonce matches nonce in Quote (prevents tampering) - let nonce_from_field = hex::decode(&output.nonce)?; - if nonce_from_field != quote_info.nonce { - return Err(VerifyError::InvalidAttest(format!( - "Nonce field does not match nonce in Quote. \ - Field: {}, Quote: {}", - output.nonce, - hex::encode("e_info.nonce) - ))); - } - - // Verify AK signature over TPM2_Quote - let signature = hex::decode(&attestation.signature)?; - let mut ak_pubkey = vec![0x04]; - ak_pubkey.extend(&ak_x); - ak_pubkey.extend(&ak_y); - verify_ecdsa_p256(&attest_data, &signature, &ak_pubkey)?; - - // Verify Nitro attestation (COSE signature, cert chain) - let nitro_result = verify_nitro_attestation( - &nitro.document, - None, // Nonce validation happens via TPM binding below - None, // Pubkey validation happens below - time, - )?; - - // Extract signed values from Nitro document - let signed_pubkey = nitro_result.document.public_key.as_ref().ok_or_else(|| { - VerifyError::NoValidAttestation( - "Nitro document missing public_key field - cannot bind TPM signing key".into(), - ) - })?; - let signed_nonce = nitro_result.document.nonce.as_ref().ok_or_else(|| { - VerifyError::NoValidAttestation( - "Nitro document missing nonce field - cannot verify freshness".into(), - ) - })?; - - // Verify the AK public key matches the signed public_key in NSM document - let ak_secg = format!("04{}{}", ak_pk.x, ak_pk.y); - if ak_secg != *signed_pubkey { - return Err(VerifyError::SignatureInvalid(format!( - "TPM signing key does not match Nitro public_key binding: {} != {}", - ak_secg, signed_pubkey - ))); - } - - // Verify TPM nonce matches Nitro nonce (proves attestations generated together) - let tpm_nonce_hex = hex::encode("e_info.nonce); - if tpm_nonce_hex != *signed_nonce { - return Err(VerifyError::SignatureInvalid(format!( - "TPM nonce does not match Nitro nonce - attestations not generated together: {} != {}", - tpm_nonce_hex, signed_nonce - ))); - } - - // Verify SHA-384 PCRs match signed values in Nitro document - // The Nitro document contains nitrotpm_pcrs which are signed by AWS hardware - let sha384_pcrs = output.pcrs.get("sha384").ok_or_else(|| { - VerifyError::InvalidAttest("Missing SHA-384 PCRs - required for Nitro attestation".into()) - })?; - - let signed_pcrs = &nitro_result.document.pcrs; - if signed_pcrs.is_empty() { - return Err(VerifyError::InvalidAttest( - "Nitro document contains no signed PCRs".into(), - )); - } - - // All signed PCRs must be present and match - for (idx, signed_value) in signed_pcrs.iter() { - match sha384_pcrs.get(idx) { - Some(claimed_value) if claimed_value == signed_value => { - // Match - good - } - Some(claimed_value) => { - return Err(VerifyError::SignatureInvalid(format!( - "PCR {} SHA-384 mismatch: claimed {} != signed {}", - idx, claimed_value, signed_value - ))); - } - None => { - return Err(VerifyError::SignatureInvalid(format!( - "PCR {} in signed Nitro document but missing from attestation", - idx - ))); - } - } - } - - // Verify root is the known AWS Nitro root - fail if not recognized - let provider = roots::provider_from_hash(&nitro_result.root_pubkey_hash).ok_or_else(|| { - VerifyError::ChainValidation(format!( - "Unknown root CA: {}. Only known cloud provider roots are trusted.", - nitro_result.root_pubkey_hash - )) - })?; - - // Collect PCRs from the attestation (use SHA-384 for Nitro) - let pcrs = output.pcrs.get("sha384").cloned().unwrap_or_default(); - - // Nonce is from TPM2_Quote.extraData - Ok(VerificationResult { - nonce: hex::encode("e_info.nonce), - provider, - pcrs, - root_pubkey_hash: nitro_result.root_pubkey_hash, - }) + // Delegate to single verification path + verify_decoded_attestation_output(&decoded, time) } /// Convenience function to verify attestation from JSON string @@ -347,80 +320,26 @@ fn verify_nitro_quote_attestation( /// For testing with fixtures that have expired certificates, use /// `verify_attestation_output` directly with a specific time. pub fn verify_attestation_json(json: &str) -> Result { - let output: AttestationOutput = serde_json::from_str(json) - .map_err(|e| VerifyError::InvalidAttest(format!("JSON parse error: {}", e)))?; + let output: AttestationOutput = + serde_json::from_str(json).map_err(InvalidAttestReason::JsonParse)?; verify_attestation_output(&output, UnixTime::now()) } +#[cfg(test)] +mod ephemeral_gcp_tests; +#[cfg(test)] +mod ephemeral_nitro_tests; +#[cfg(test)] +mod flat_tests; + #[cfg(test)] mod tests { use super::*; - // Timestamp for Nitro test fixture (Feb 3, 2026 11:00:00 UTC - within cert validity window) - const NITRO_FIXTURE_TIMESTAMP_SECS: u64 = 1770116400; - - // Timestamp for GCP test fixture (Feb 2, 2026 when certificates are valid) - const GCP_FIXTURE_TIMESTAMP_SECS: u64 = 1770019200; // Feb 2, 2026 08:00:00 UTC - - fn nitro_fixture_time() -> UnixTime { - UnixTime::since_unix_epoch(std::time::Duration::from_secs(NITRO_FIXTURE_TIMESTAMP_SECS)) - } - - fn gcp_fixture_time() -> UnixTime { - UnixTime::since_unix_epoch(std::time::Duration::from_secs(GCP_FIXTURE_TIMESTAMP_SECS)) - } - - #[test] - fn test_verify_nitro_fixture() { - let fixture = include_str!("../test-nitro-fixture.json"); - let output: AttestationOutput = - serde_json::from_str(fixture).expect("Failed to parse test-nitro-fixture.json"); - - let result = verify_attestation_output(&output, nitro_fixture_time()) - .expect("Verification should succeed"); - - // Should be AWS (Nitro) - assert_eq!(result.provider, CloudProvider::Aws); - - // Nonce is now from TPM2B_ATTEST.extraData, not the raw attest_data field - assert!(!result.nonce.is_empty()); - - // Should have a root pubkey hash (AWS Nitro root) - assert!(!result.root_pubkey_hash.is_empty()); - assert_eq!(result.root_pubkey_hash.len(), 64); // SHA-256 = 32 bytes = 64 hex chars - } - - #[test] - fn test_verify_gcp_amd_fixture() { - let fixture = include_str!("../test-gcp-amd-fixture.json"); - let output: AttestationOutput = - serde_json::from_str(fixture).expect("Failed to parse test-gcp-fixture.json"); - - let result = verify_attestation_output(&output, gcp_fixture_time()) - .expect("Verification should succeed"); - - // Should be GCP - assert_eq!(result.provider, CloudProvider::Gcp); - - // Should have the nonce from the Quote - assert!(!result.nonce.is_empty()); - assert_eq!( - result.nonce, - "8a543108a653b4a1162232744cc9b945017a449dea4fbb0ca62f42d3ef145562" - ); - - // Should have PCR values - assert!(!result.pcrs.is_empty()); - - // Should have GCP root pubkey hash - assert!(!result.root_pubkey_hash.is_empty()); - assert_eq!(result.root_pubkey_hash.len(), 64); - } - #[test] fn test_reject_empty_attestation() { let output = AttestationOutput { - nonce: "deadbeef".to_string(), + nonce: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), pcrs: std::collections::HashMap::new(), ak_pubkeys: std::collections::HashMap::new(), attestation: AttestationContainer { @@ -430,113 +349,11 @@ mod tests { }, }; + // With the new architecture, decode() fails first when there's no AK pubkey let result = verify_attestation_output(&output, UnixTime::now()); - assert!(matches!(result, Err(VerifyError::NoValidAttestation(_)))); - } - - /// Test that tampering with the AK public key field is detected - #[test] - fn test_reject_tampered_nitro_public_key() { - let fixture = include_str!("../test-nitro-fixture.json"); - let mut output: AttestationOutput = - serde_json::from_str(fixture).expect("Failed to parse test-nitro-fixture.json"); - - // Tamper with the AK public key (attacker tries to substitute their own key) - // This should fail because it won't match the signed value in Nitro document - output.ak_pubkeys.insert( - "ecc_p256".to_string(), - EccPublicKeyCoords { - x: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - y: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), - }, - ); - - let result = verify_attestation_output(&output, nitro_fixture_time()); - assert!( - matches!(result, Err(VerifyError::SignatureInvalid(_))), - "Should reject tampered public_key field, got: {:?}", - result - ); - } - - /// Test that tampering with the top-level nonce field is detected - #[test] - fn test_reject_tampered_nitro_nonce() { - let fixture = include_str!("../test-nitro-fixture.json"); - let mut output: AttestationOutput = - serde_json::from_str(fixture).expect("Failed to parse test-nitro-fixture.json"); - - // Tamper with the top-level nonce - output.nonce = "deadbeef".to_string(); - - let result = verify_attestation_output(&output, nitro_fixture_time()); - assert!( - matches!(result, Err(VerifyError::InvalidAttest(_))), - "Should reject tampered nonce field, got: {:?}", - result - ); - } - - /// Test that tampering with SHA-384 PCR values is detected - #[test] - fn test_reject_tampered_pcr_values() { - let fixture = include_str!("../test-nitro-fixture.json"); - let mut output: AttestationOutput = - serde_json::from_str(fixture).expect("Failed to parse test-nitro-fixture.json"); - - // Tamper with a SHA-384 PCR value - if let Some(sha384_pcrs) = output.pcrs.get_mut("sha384") { - sha384_pcrs.insert( - 0, - "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string() - ); - } - - let result = verify_attestation_output(&output, nitro_fixture_time()); - // Tampering is detected: claimed PCR values don't match signed values in Nitro document assert!( - matches!(result, Err(VerifyError::SignatureInvalid(_))), - "Should reject tampered PCR values, got: {:?}", - result - ); - } -} - -#[cfg(test)] -mod tdx_tests { - use super::*; - - // Timestamp for GCP TDX test fixture (Feb 3, 2026 when certificates are valid) - const GCP_TDX_FIXTURE_TIMESTAMP_SECS: u64 = 1770091200; // Feb 3, 2026 08:00:00 UTC - - fn gcp_tdx_fixture_time() -> UnixTime { - UnixTime::since_unix_epoch(std::time::Duration::from_secs( - GCP_TDX_FIXTURE_TIMESTAMP_SECS, - )) - } - - #[test] - fn test_verify_gcp_tdx_fixture() { - let fixture = include_str!("../test-gcp-tdx-fixture.json"); - let output: AttestationOutput = - serde_json::from_str(fixture).expect("Failed to parse test-gcp-tdx-fixture.json"); - - let result = verify_attestation_output(&output, gcp_tdx_fixture_time()) - .expect("Verification should succeed"); - - // Should be GCP - assert_eq!(result.provider, CloudProvider::Gcp); - - // Should have the nonce from the Quote - assert_eq!( - result.nonce, - "6424632e79ec068f2189adf46d121b9a10f758c45a18c52f630da14600d4317b" + result.is_err(), + "Should reject attestation with missing components" ); - - // Should have PCR values - assert!(!result.pcrs.is_empty()); - - // Root pubkey hash should match the known GCP root (same as AMD) - assert_eq!(result.root_pubkey_hash.len(), 64); } } diff --git a/crates/vaportpm-verify/src/nitro.rs b/crates/vaportpm-verify/src/nitro.rs index 3c8de1b..d800518 100644 --- a/crates/vaportpm-verify/src/nitro.rs +++ b/crates/vaportpm-verify/src/nitro.rs @@ -9,167 +9,223 @@ use coset::{CborSerializable, CoseSign1}; use der::Decode; use ecdsa::signature::hazmat::PrehashVerifier; use p384::ecdsa::{Signature as P384Signature, VerifyingKey as P384VerifyingKey}; -use serde::Serialize; use sha2::{Digest, Sha384}; use x509_cert::Certificate; use pki_types::UnixTime; -use crate::error::VerifyError; +use crate::error::{ + CborParseReason, CertificateParseReason, ChainValidationReason, CoseVerifyReason, + InvalidAttestReason, PcrIndexOutOfBoundsReason, SignatureInvalidReason, VerifyError, +}; +use crate::pcr::PcrAlgorithm; +use crate::tpm::{verify_quote, TpmQuoteInfo}; use crate::x509::{extract_public_key, validate_tpm_cert_chain}; +use crate::CloudProvider; +use crate::{roots, DecodedAttestationOutput, VerificationResult}; -/// Result of successful Nitro attestation verification -/// -/// This struct is only returned when verification succeeds. -/// If signature or chain validation fails, an error is returned instead. -#[derive(Debug, Serialize)] -pub struct NitroVerifyResult { - /// Parsed attestation document fields - pub document: NitroDocument, - /// SHA-256 hash of the root CA's public key (hex string) - pub root_pubkey_hash: String, -} - -/// Parsed Nitro attestation document -#[derive(Debug, Serialize, Clone)] -pub struct NitroDocument { - /// Module ID - pub module_id: String, - /// Timestamp (milliseconds since epoch) - pub timestamp: u64, - /// TPM PCR values from Nitro document's `nitrotpm_pcrs` field (index -> hex SHA-384 digest) +/// Parsed Nitro attestation document (internal) +#[derive(Debug, Clone)] +struct NitroDocument { + /// TPM PCR values from Nitro document's `nitrotpm_pcrs` field (index -> SHA-384 digest) /// These are the PCR values signed by AWS hardware. - pub pcrs: BTreeMap, - /// Public key (hex-encoded, if provided) - pub public_key: Option, - /// User data (hex-encoded, if provided) - pub user_data: Option, - /// Nonce (hex-encoded, if provided) - pub nonce: Option, - /// Digest algorithm used - pub digest: String, + pub pcrs: BTreeMap>, + /// AK public key bound by the Nitro document (raw SEC1 bytes) + pub public_key: Vec, + /// Nonce signed by the Nitro document (raw bytes) + pub nonce: Vec, } -/// Verify Nitro attestation document +/// Verify Nitro TPM attestation with pre-decoded data +/// +/// Verification order (trust chain first, then cross-verify): +/// +/// 1. **COSE trust chain**: verify COSE signature → cert chain → AWS root +/// Establishes that the Nitro document came from AWS hardware before +/// we parse any of its semantic content. /// -/// # Arguments -/// * `document_hex` - CBOR-encoded COSE Sign1 attestation document as hex string -/// * `expected_nonce` - Expected nonce value (optional validation) -/// * `expected_pubkey_hex` - Expected public key in SECG format (optional validation) -/// * `time` - Time to use for certificate validation (use `UnixTime::now()` for production) +/// 2. **Parse authenticated Nitro document**: extract PCRs, public_key, nonce +/// from the now-authenticated COSE payload. /// -/// # Returns -/// Verification result with parsed document and root public key hash -pub fn verify_nitro_attestation( - document_hex: &str, - expected_nonce: Option<&[u8]>, - expected_pubkey_hex: Option<&str>, +/// 3. **TPM Quote signature**: verify ECDSA signature over Quote with the AK +/// that the Nitro document binds. +/// +/// 4. **Cross-verification**: nonce, AK binding, PCR values, PCR digest. +/// +/// All inputs should be pre-decoded binary data (raw COSE document bytes). +pub fn verify_nitro_decoded( + decoded: &DecodedAttestationOutput, + document_bytes: &[u8], time: UnixTime, -) -> Result { - // Decode hex input - let document_bytes = hex::decode(document_hex)?; +) -> Result { + // === Phase 1: Verify COSE trust chain === + // Establish that this document came from AWS before parsing its contents. + let (nitro_doc, provider) = verify_nitro_cose_chain(document_bytes, time)?; + + // === Phase 2: AK binding — Nitro document must agree on which key signed the Quote === + let ak_sec1 = decoded.ak_pubkey.to_sec1_uncompressed(); + if ak_sec1.as_slice() != nitro_doc.public_key.as_slice() { + return Err(SignatureInvalidReason::AkPublicKeyMismatch.into()); + } + + // === Phase 3: Verify TPM Quote (signature, PCR bank, nonce, PCR digest) === + let quote_info = verify_quote(decoded, PcrAlgorithm::Sha384)?; - // Parse the COSE Sign1 structure (NSM returns untagged COSE) - let cose_sign1 = CoseSign1::from_slice(&document_bytes) - .map_err(|e| VerifyError::CoseVerify(format!("Failed to parse COSE Sign1: {}", e)))?; + // === Phase 4: Cross-verify Nitro-specific bindings === + verify_nitro_bindings(decoded, "e_info, &nitro_doc)?; + + // Convert nonce to fixed-size array + let nonce: [u8; 32] = quote_info + .nonce + .as_slice() + .try_into() + .map_err(|_| InvalidAttestReason::NonceLengthInvalid)?; + + Ok(VerificationResult { + nonce, + provider, + pcrs: decoded.pcrs.clone(), + verified_at: time.as_secs(), + }) +} + +/// Phase 1: Verify COSE signature, certificate chain, and AWS root. +/// +/// Returns the authenticated Nitro document and cloud provider. +/// No semantic content from the document is trusted before this passes. +fn verify_nitro_cose_chain( + document_bytes: &[u8], + time: UnixTime, +) -> Result<(NitroDocument, CloudProvider), VerifyError> { + // Parse COSE Sign1 envelope + let cose_sign1 = CoseSign1::from_slice(document_bytes) + .map_err(|e| CoseVerifyReason::CoseSign1ParseFailed(e.to_string()))?; - // Extract the payload let payload = cose_sign1 .payload .as_ref() - .ok_or_else(|| VerifyError::CoseVerify("Missing payload".into()))?; + .ok_or(CoseVerifyReason::MissingPayload)?; - // Parse payload as CBOR - let doc_value: CborValue = ciborium::from_reader(payload.as_slice()) - .map_err(|e| VerifyError::CborParse(format!("Failed to parse payload: {}", e)))?; + // Minimal parse: extract only the certificate and CA bundle needed + // to verify the COSE signature. We don't touch semantic fields yet. + let payload_cbor: CborValue = ciborium::from_reader(payload.as_slice()) + .map_err(|e| CborParseReason::DeserializeFailed(e.to_string()))?; - // Extract document fields - let doc_map = match &doc_value { + let payload_map = match &payload_cbor { CborValue::Map(m) => m, - _ => return Err(VerifyError::CborParse("Payload is not a map".into())), + _ => return Err(CborParseReason::PayloadNotMap.into()), }; - let nitro_doc = parse_nitro_document(doc_map)?; - - // Validate nonce if provided - if let Some(expected) = expected_nonce { - if let Some(ref nonce_hex) = nitro_doc.nonce { - let nonce_bytes = hex::decode(nonce_hex)?; - if nonce_bytes != expected { - return Err(VerifyError::CoseVerify("Nonce mismatch".into())); - } - } - } - - // Validate public key if provided - if let Some(expected_pk) = expected_pubkey_hex { - if let Some(ref pk) = nitro_doc.public_key { - if pk != expected_pk { - return Err(VerifyError::CoseVerify("Public key mismatch".into())); - } - } - } - - // Extract certificate and CA bundle - let cert_der = extract_cbor_bytes(doc_map, "certificate")?; - let cabundle = extract_cbor_byte_array(doc_map, "cabundle")?; + let cert_der = extract_cbor_bytes(payload_map, "certificate")?; + let cabundle = extract_cbor_byte_array(payload_map, "cabundle")?; - // Parse certificates + // Build certificate chain (leaf first) let leaf_cert = Certificate::from_der(&cert_der) - .map_err(|e| VerifyError::CertificateParse(format!("Invalid leaf cert: {}", e)))?; + .map_err(|e| CertificateParseReason::InvalidDer(e.to_string()))?; - // Build chain in leaf-to-root order - // AWS cabundle is ordered [root, ..., issuer], so we reverse it let mut chain = vec![leaf_cert]; for ca_der in cabundle.into_iter().rev() { let ca_cert = Certificate::from_der(&ca_der) - .map_err(|e| VerifyError::CertificateParse(format!("Invalid CA cert: {}", e)))?; + .map_err(|e| CertificateParseReason::InvalidDer(e.to_string()))?; chain.push(ca_cert); } - // Verify COSE signature using leaf certificate (fails on error) - // Do this before chain validation to fail fast on signature issues + // Verify COSE signature let leaf_pubkey = extract_public_key(&chain[0])?; verify_cose_signature(&cose_sign1, &leaf_pubkey, payload)?; - // Validate certificate chain - // This validates signatures, dates, extensions, and returns root's public key hash + // Validate certificate chain and time validity let chain_result = validate_tpm_cert_chain(&chain, time)?; - let root_pubkey_hash = chain_result.root_pubkey_hash; - Ok(NitroVerifyResult { - document: nitro_doc, - root_pubkey_hash, - }) + // Verify root is a known AWS root + let provider = roots::provider_from_hash(&chain_result.root_pubkey_hash).ok_or_else(|| { + VerifyError::ChainValidation(ChainValidationReason::UnknownRootCa { + hash: hex::encode(chain_result.root_pubkey_hash), + }) + })?; + + if provider != CloudProvider::Aws { + return Err(ChainValidationReason::WrongProvider { + expected: CloudProvider::Aws, + got: provider, + } + .into()); + } + + // --- COSE document is now authenticated; safe to parse its contents --- + let nitro_doc = parse_nitro_document(payload_map)?; + + Ok((nitro_doc, provider)) +} + +/// Cross-verify Nitro-specific bindings after the TPM Quote has been authenticated. +/// +/// At this point the COSE document (AWS-signed) and TPM Quote (AK-signed) are +/// both authenticated. This verifies Nitro-specific consistency: +/// - COSE nonce matches Quote nonce +/// - PCR bank is SHA-384 (Nitro requirement) +/// - COSE-signed PCR values match decoded PCR values (bidirectional) +fn verify_nitro_bindings( + decoded: &DecodedAttestationOutput, + quote_info: &TpmQuoteInfo, + nitro_doc: &NitroDocument, +) -> Result<(), VerifyError> { + // --- Nonce: COSE document must agree with authenticated Quote --- + if quote_info.nonce.as_slice() != nitro_doc.nonce.as_slice() { + return Err(SignatureInvalidReason::NitroNonceMismatch.into()); + } + + // --- Bidirectional PCR match against COSE-signed values --- + let signed_pcrs = &nitro_doc.pcrs; + if signed_pcrs.is_empty() { + return Err(InvalidAttestReason::EmptySignedPcrs.into()); + } + + // Forward: every COSE-signed PCR must match the decoded value + for (idx, signed_value) in signed_pcrs.iter() { + let claimed_value = decoded.pcrs.get(*idx as usize); + if claimed_value != signed_value.as_slice() { + return Err(SignatureInvalidReason::PcrValueMismatch { index: *idx }.into()); + } + } + + // Reverse: every decoded PCR index must be present in COSE-signed PCRs + for pcr_idx in 0..24u8 { + if !signed_pcrs.contains_key(&pcr_idx) { + return Err(InvalidAttestReason::PcrNotSigned { pcr_index: pcr_idx }.into()); + } + } + + Ok(()) } /// Parse Nitro document fields from CBOR map fn parse_nitro_document(map: &[(CborValue, CborValue)]) -> Result { - let module_id = extract_cbor_text(map, "module_id")?; - let timestamp = extract_cbor_integer(map, "timestamp")?; + // Verify digest algorithm is SHA384 as expected let digest = extract_cbor_text(map, "digest")?; + if digest != "SHA384" { + return Err(InvalidAttestReason::WrongDigestAlgorithm { got: digest }.into()); + } - // Parse PCRs + // Parse PCRs (binary) let pcrs = extract_cbor_pcrs(map)?; - // Optional fields - let public_key = extract_cbor_bytes_optional(map, "public_key").map(|b| hex::encode(&b)); - let user_data = extract_cbor_bytes_optional(map, "user_data").map(|b| hex::encode(&b)); - let nonce = extract_cbor_bytes_optional(map, "nonce").map(|b| hex::encode(&b)); + // Required fields (binary) + let public_key = extract_cbor_bytes(map, "public_key")?; + let nonce = extract_cbor_bytes(map, "nonce")?; Ok(NitroDocument { - module_id, - timestamp, pcrs, public_key, - user_data, nonce, - digest, }) } /// Extract text field from CBOR map -fn extract_cbor_text(map: &[(CborValue, CborValue)], key: &str) -> Result { +fn extract_cbor_text( + map: &[(CborValue, CborValue)], + key: &'static str, +) -> Result { for (k, v) in map { if let CborValue::Text(k_text) = k { if k_text == key { @@ -179,39 +235,14 @@ fn extract_cbor_text(map: &[(CborValue, CborValue)], key: &str) -> Result Result { - for (k, v) in map { - if let CborValue::Text(k_text) = k { - if k_text == key { - if let CborValue::Integer(val) = v { - let val_i128: i128 = (*val).into(); - // Validate range before casting - if val_i128 < 0 { - return Err(VerifyError::CborParse(format!( - "Field {} has negative value: {}", - key, val_i128 - ))); - } - if val_i128 > u64::MAX as i128 { - return Err(VerifyError::CborParse(format!( - "Field {} exceeds u64 range: {}", - key, val_i128 - ))); - } - return Ok(val_i128 as u64); - } - } - } - } - Err(VerifyError::CborParse(format!("Missing field: {}", key))) + Err(CborParseReason::MissingField { field: key }.into()) } /// Extract bytes field from CBOR map -fn extract_cbor_bytes(map: &[(CborValue, CborValue)], key: &str) -> Result, VerifyError> { +fn extract_cbor_bytes( + map: &[(CborValue, CborValue)], + key: &'static str, +) -> Result, VerifyError> { for (k, v) in map { if let CborValue::Text(k_text) = k { if k_text == key { @@ -221,30 +252,13 @@ fn extract_cbor_bytes(map: &[(CborValue, CborValue)], key: &str) -> Result Option> { - for (k, v) in map { - if let CborValue::Text(k_text) = k { - if k_text == key { - if let CborValue::Bytes(val) = v { - return Some(val.clone()); - } - if let CborValue::Null = v { - return None; - } - } - } - } - None + Err(CborParseReason::MissingField { field: key }.into()) } /// Extract byte array field from CBOR map fn extract_cbor_byte_array( map: &[(CborValue, CborValue)], - key: &str, + key: &'static str, ) -> Result>, VerifyError> { for (k, v) in map { if let CborValue::Text(k_text) = k { @@ -261,7 +275,7 @@ fn extract_cbor_byte_array( } } } - Err(VerifyError::CborParse(format!("Missing field: {}", key))) + Err(CborParseReason::MissingField { field: key }.into()) } /// Maximum valid PCR index for AWS Nitro Enclaves (0-15) @@ -272,7 +286,7 @@ const MAX_TPM_PCR_INDEX: u8 = 23; /// Extract PCRs from CBOR map /// Handles both "pcrs" (Nitro Enclave) and "nitrotpm_pcrs" (Nitro TPM) field names -fn extract_cbor_pcrs(map: &[(CborValue, CborValue)]) -> Result, VerifyError> { +fn extract_cbor_pcrs(map: &[(CborValue, CborValue)]) -> Result>, VerifyError> { for (k, v) in map { if let CborValue::Text(k_text) = k { // Check for both field names: "pcrs" (enclave) and "nitrotpm_pcrs" (TPM) @@ -294,19 +308,20 @@ fn extract_cbor_pcrs(map: &[(CborValue, CborValue)]) -> Result max_index as i128 { - return Err(VerifyError::PcrIndexOutOfBounds(format!( - "PCR index {} exceeds maximum {}", - idx_i128, max_index - ))); + return Err(PcrIndexOutOfBoundsReason::ExceedsMaximum { + index: idx_i128, + maximum: max_index, + } + .into()); } - pcrs.insert(idx_i128 as u8, hex::encode(val)); + pcrs.insert(idx_i128 as u8, val.clone()); } } } @@ -315,9 +330,7 @@ fn extract_cbor_pcrs(map: &[(CborValue, CborValue)]) -> Result = vec![]; - let result = extract_cbor_bytes_optional(&map, "data"); - assert_eq!(result, None); + assert!(matches!( + result, + Err(VerifyError::CborParse(CborParseReason::MissingField { .. })) + )); } #[test] @@ -515,103 +503,10 @@ mod tests { fn test_extract_cbor_pcrs_missing() { let map: Vec<(CborValue, CborValue)> = vec![]; let result = extract_cbor_pcrs(&map); - assert!(matches!(result, Err(VerifyError::CborParse(_)))); - } - - // === verify_nitro_attestation Input Validation Tests === - - // These tests fail before certificate validation, so time doesn't matter - fn dummy_time() -> UnixTime { - UnixTime::since_unix_epoch(std::time::Duration::from_secs(0)) - } - - #[test] - fn test_reject_invalid_hex() { - let result = verify_nitro_attestation("not valid hex!!!", None, None, dummy_time()); - assert!(matches!(result, Err(VerifyError::HexDecode(_)))); - } - - #[test] - fn test_reject_empty_document() { - let result = verify_nitro_attestation("", None, None, dummy_time()); - // Empty string decodes to empty bytes, which fails COSE parsing - assert!(result.is_err()); - } - - #[test] - fn test_reject_truncated_cbor() { - // Valid hex but truncated CBOR - let result = verify_nitro_attestation("d28443", None, None, dummy_time()); - assert!(matches!(result, Err(VerifyError::CoseVerify(_)))); - } - - #[test] - fn test_reject_non_cose_cbor() { - // Valid CBOR but not a COSE Sign1 (just an integer) - let mut buf = Vec::new(); - ciborium::into_writer(&CborValue::Integer(42.into()), &mut buf).unwrap(); - let hex_str = hex::encode(&buf); - - let result = verify_nitro_attestation(&hex_str, None, None, dummy_time()); - assert!(matches!(result, Err(VerifyError::CoseVerify(_)))); - } - - #[test] - fn test_reject_wrong_cose_tag() { - // CBOR with a different tag (not COSE Sign1's 18) - let buf = vec![ - 0xd8, 0x63, // Tag 99 (not 18) - 0x80, // Empty array - ]; - let hex_str = hex::encode(&buf); - - let result = verify_nitro_attestation(&hex_str, None, None, dummy_time()); - assert!(matches!(result, Err(VerifyError::CoseVerify(_)))); - } - - // === Signature Length Validation === - - #[test] - fn test_signature_length_check() { - // Directly test the signature length check in verify_cose_signature - // by checking that wrong-length signatures are rejected - - // Test that signatures with wrong length are rejected - // Expected length for ES384 is 96 bytes (48 for R + 48 for S) - let wrong_lengths = [0, 48, 64, 95, 97, 128]; - - for len in wrong_lengths { - let sig = vec![0u8; len]; - // Simulate what verify_cose_signature checks - assert_ne!(sig.len(), 96, "Length {} should be invalid", len); - } - - // Correct length should pass the check (but would fail signature verification) - let sig = vec![0u8; 96]; - assert_eq!(sig.len(), 96); - } - - // === Nonce Validation === - - #[test] - fn test_nonce_validation_matches() { - // When nonce is present and matches, no error from nonce check - // This tests the nonce comparison logic - let expected = b"test-nonce"; - let actual = hex::encode(expected); - - // Simulate the check in verify_nitro_attestation - let nonce_bytes = hex::decode(&actual).unwrap(); - assert_eq!(nonce_bytes, expected); - } - - #[test] - fn test_nonce_validation_mismatch() { - let expected = b"expected-nonce"; - let actual = b"different-nonce"; - - // These should not match - assert_ne!(expected.as_slice(), actual.as_slice()); + assert!(matches!( + result, + Err(VerifyError::CborParse(CborParseReason::MissingPcrs)) + )); } // === PCR Index Bounds Tests === @@ -650,7 +545,12 @@ mod tests { )]), )]; let result = extract_cbor_pcrs(&map); - assert!(matches!(result, Err(VerifyError::PcrIndexOutOfBounds(_)))); + assert!(matches!( + result, + Err(VerifyError::PcrIndexOutOfBounds( + PcrIndexOutOfBoundsReason::ExceedsMaximum { .. } + )) + )); } #[test] @@ -664,7 +564,12 @@ mod tests { )]), )]; let result = extract_cbor_pcrs(&map); - assert!(matches!(result, Err(VerifyError::PcrIndexOutOfBounds(_)))); + assert!(matches!( + result, + Err(VerifyError::PcrIndexOutOfBounds( + PcrIndexOutOfBoundsReason::ExceedsMaximum { .. } + )) + )); } #[test] @@ -678,43 +583,66 @@ mod tests { )]), )]; let result = extract_cbor_pcrs(&map); - assert!(matches!(result, Err(VerifyError::PcrIndexOutOfBounds(_)))); + assert!(matches!( + result, + Err(VerifyError::PcrIndexOutOfBounds( + PcrIndexOutOfBoundsReason::Negative { .. } + )) + )); } - // === Malicious Integer Tests === + // === parse_nitro_document Tests === #[test] - fn test_reject_negative_timestamp() { - let map = vec![( - CborValue::Text("timestamp".to_string()), - CborValue::Integer((-1i64).into()), - )]; - let result = extract_cbor_integer(&map, "timestamp"); + fn test_parse_nitro_document_wrong_digest_algorithm() { + let map = vec![ + ( + CborValue::Text("digest".to_string()), + CborValue::Text("SHA256".to_string()), + ), + ( + CborValue::Text("pcrs".to_string()), + CborValue::Map(vec![( + CborValue::Integer(0.into()), + CborValue::Bytes(vec![0x00; 48]), + )]), + ), + ]; + let result = parse_nitro_document(&map); assert!( - matches!(result, Err(VerifyError::CborParse(_))), - "Should reject negative timestamp, got: {:?}", + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::WrongDigestAlgorithm { .. } + )) + ), + "Should reject SHA256 digest, got: {:?}", result ); } - #[test] - fn test_accept_valid_timestamp() { - let map = vec![( - CborValue::Text("timestamp".to_string()), - CborValue::Integer(1234567890i64.into()), - )]; - let result = extract_cbor_integer(&map, "timestamp"); - assert_eq!(result.unwrap(), 1234567890); - } + // === extract_cbor_byte_array edge cases === #[test] - fn test_accept_max_i64_timestamp() { - // i64::MAX is valid and fits in u64 + fn test_extract_cbor_byte_array_mixed_items_skips_non_bytes() { + // Mixed array with bytes and non-bytes items — only bytes should be returned let map = vec![( - CborValue::Text("timestamp".to_string()), - CborValue::Integer(i64::MAX.into()), + CborValue::Text("mixed".to_string()), + CborValue::Array(vec![ + CborValue::Bytes(vec![1, 2, 3]), + CborValue::Integer(42.into()), + CborValue::Bytes(vec![4, 5, 6]), + CborValue::Text("not bytes".to_string()), + CborValue::Bytes(vec![7, 8, 9]), + ]), )]; - let result = extract_cbor_integer(&map, "timestamp"); - assert_eq!(result.unwrap(), i64::MAX as u64); + let result = extract_cbor_byte_array(&map, "mixed").unwrap(); + assert_eq!(result.len(), 3); + assert_eq!(result[0], vec![1, 2, 3]); + assert_eq!(result[1], vec![4, 5, 6]); + assert_eq!(result[2], vec![7, 8, 9]); } + + // verify_nitro_bindings and verify_tpm_quote_signature error paths are + // tested through the public API in ephemeral_nitro_tests.rs. } diff --git a/crates/vaportpm-verify/src/pcr.rs b/crates/vaportpm-verify/src/pcr.rs new file mode 100644 index 0000000..ded46f0 --- /dev/null +++ b/crates/vaportpm-verify/src/pcr.rs @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Validated PCR bank type and supporting types. + +use crate::error::{InvalidAttestReason, VerifyError}; + +/// Number of PCRs in a complete bank. +pub const PCR_COUNT: usize = 24; + +/// PCR hash algorithm. +/// +/// Discriminant values are the TPM algorithm IDs (TPMI_ALG_HASH). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +pub enum PcrAlgorithm { + Sha256 = 0x000B, + Sha384 = 0x000C, +} + +impl PcrAlgorithm { + /// Digest length in bytes. + pub fn digest_len(self) -> usize { + match self { + PcrAlgorithm::Sha256 => 32, + PcrAlgorithm::Sha384 => 48, + } + } +} + +impl TryFrom for PcrAlgorithm { + type Error = u16; + fn try_from(value: u16) -> Result { + match value { + 0x000B => Ok(PcrAlgorithm::Sha256), + 0x000C => Ok(PcrAlgorithm::Sha384), + other => Err(other), + } + } +} + +impl std::fmt::Display for PcrAlgorithm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PcrAlgorithm::Sha256 => write!(f, "sha256"), + PcrAlgorithm::Sha384 => write!(f, "sha384"), + } + } +} + +/// A complete, validated PCR bank. +/// +/// Invariants guaranteed by construction: +/// - Single algorithm (SHA-256 or SHA-384) +/// - Exactly 24 PCR values, indexed 0-23 +/// - Each value has the correct length for the algorithm +#[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] // Intentional: avoid heap allocation in zkVM +pub enum PcrBank { + Sha256([[u8; 32]; PCR_COUNT]), + Sha384([[u8; 48]; PCR_COUNT]), +} + +impl PcrBank { + /// Construct from an algorithm and indexed PCR values. + /// + /// Validates: all 24 indices (0-23) present, correct value lengths for the algorithm. + pub fn from_values>( + algorithm: PcrAlgorithm, + values: impl IntoIterator, + ) -> Result { + let digest_len = algorithm.digest_len(); + + // Validate and collect into a flat buffer + let mut flat = vec![0u8; PCR_COUNT * digest_len]; + let mut seen = [false; PCR_COUNT]; + for (idx, value) in values { + let value = value.as_ref(); + if (idx as usize) < PCR_COUNT { + if value.len() != digest_len { + return Err(InvalidAttestReason::PcrValueWrongLength { + index: idx, + expected: digest_len, + got: value.len(), + } + .into()); + } + let offset = idx as usize * digest_len; + flat[offset..offset + digest_len].copy_from_slice(value); + seen[idx as usize] = true; + } + } + for idx in 0..PCR_COUNT as u8 { + if !seen[idx as usize] { + return Err(InvalidAttestReason::MissingPcr { index: idx }.into()); + } + } + + // Copy into correctly-typed arrays + match algorithm { + PcrAlgorithm::Sha256 => { + let mut bank = [[0u8; 32]; PCR_COUNT]; + for i in 0..PCR_COUNT { + bank[i].copy_from_slice(&flat[i * 32..(i + 1) * 32]); + } + Ok(PcrBank::Sha256(bank)) + } + PcrAlgorithm::Sha384 => { + let mut bank = [[0u8; 48]; PCR_COUNT]; + for i in 0..PCR_COUNT { + bank[i].copy_from_slice(&flat[i * 48..(i + 1) * 48]); + } + Ok(PcrBank::Sha384(bank)) + } + } + } + + /// Construct from a contiguous buffer of PCR values in index order. + /// + /// `data` must be exactly `PCR_COUNT * algorithm.digest_len()` bytes, + /// containing values for PCRs 0-23 packed sequentially. + pub fn from_contiguous(algorithm: PcrAlgorithm, data: &[u8]) -> Result { + let digest_len = algorithm.digest_len(); + let expected_len = PCR_COUNT * digest_len; + if data.len() != expected_len { + return Err(InvalidAttestReason::InputTooShort { + actual: data.len(), + minimum: expected_len, + } + .into()); + } + match algorithm { + PcrAlgorithm::Sha256 => { + let mut bank = [[0u8; 32]; PCR_COUNT]; + for i in 0..PCR_COUNT { + bank[i].copy_from_slice(&data[i * 32..(i + 1) * 32]); + } + Ok(PcrBank::Sha256(bank)) + } + PcrAlgorithm::Sha384 => { + let mut bank = [[0u8; 48]; PCR_COUNT]; + for i in 0..PCR_COUNT { + bank[i].copy_from_slice(&data[i * 48..(i + 1) * 48]); + } + Ok(PcrBank::Sha384(bank)) + } + } + } + + /// Which algorithm this bank uses. + pub fn algorithm(&self) -> PcrAlgorithm { + match self { + PcrBank::Sha256(_) => PcrAlgorithm::Sha256, + PcrBank::Sha384(_) => PcrAlgorithm::Sha384, + } + } + + /// Get a single PCR value by index. Panics if `index >= 24`. + pub fn get(&self, index: usize) -> &[u8] { + match self { + PcrBank::Sha256(v) => &v[index], + PcrBank::Sha384(v) => &v[index], + } + } + + /// Iterate all 24 PCR values in index order as `&[u8]` slices. + pub fn values(&self) -> PcrIter<'_> { + PcrIter { bank: self, idx: 0 } + } +} + +/// Iterator over PCR values as `&[u8]` slices. +pub struct PcrIter<'a> { + bank: &'a PcrBank, + idx: usize, +} + +impl<'a> Iterator for PcrIter<'a> { + type Item = &'a [u8]; + + fn next(&mut self) -> Option { + if self.idx >= PCR_COUNT { + return None; + } + let val = self.bank.get(self.idx); + self.idx += 1; + Some(val) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = PCR_COUNT - self.idx; + (remaining, Some(remaining)) + } +} + +impl<'a> ExactSizeIterator for PcrIter<'a> {} + +/// ECDSA P-256 public key as raw x/y coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct P256PublicKey { + pub x: [u8; 32], + pub y: [u8; 32], +} + +impl P256PublicKey { + /// Parse from SEC1 uncompressed format (`0x04 || x || y`, 65 bytes). + pub fn from_sec1_uncompressed(bytes: &[u8]) -> Result { + if bytes.len() != 65 || bytes[0] != 0x04 { + return Err(InvalidAttestReason::InvalidAkPubkeyFormat.into()); + } + let mut x = [0u8; 32]; + let mut y = [0u8; 32]; + x.copy_from_slice(&bytes[1..33]); + y.copy_from_slice(&bytes[33..65]); + Ok(P256PublicKey { x, y }) + } + + /// Construct from raw x and y coordinates. + pub fn from_coords(x: &[u8], y: &[u8]) -> Result { + if x.len() != 32 || y.len() != 32 { + return Err(InvalidAttestReason::InvalidAkPubkeyFormat.into()); + } + let mut xb = [0u8; 32]; + let mut yb = [0u8; 32]; + xb.copy_from_slice(x); + yb.copy_from_slice(y); + Ok(P256PublicKey { x: xb, y: yb }) + } + + /// Reconstruct SEC1 uncompressed format (`0x04 || x || y`, 65 bytes). + pub fn to_sec1_uncompressed(&self) -> [u8; 65] { + let mut out = [0u8; 65]; + out[0] = 0x04; + out[1..33].copy_from_slice(&self.x); + out[33..65].copy_from_slice(&self.y); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_values_sha256() { + let bank = PcrBank::from_values(PcrAlgorithm::Sha256, (0u8..24).map(|i| (i, vec![i; 32]))) + .unwrap(); + assert_eq!(bank.algorithm(), PcrAlgorithm::Sha256); + assert_eq!(bank.get(0), &[0u8; 32]); + assert_eq!(bank.get(23), &[23u8; 32]); + assert_eq!(bank.values().count(), 24); + } + + #[test] + fn test_from_values_sha384() { + let bank = PcrBank::from_values(PcrAlgorithm::Sha384, (0u8..24).map(|i| (i, vec![i; 48]))) + .unwrap(); + assert_eq!(bank.algorithm(), PcrAlgorithm::Sha384); + assert_eq!(bank.get(0), &[0u8; 48]); + assert_eq!(bank.get(23), &[23u8; 48]); + } + + #[test] + fn test_reject_missing_pcr() { + // Only 23 values — PCR 23 is missing + let err = PcrBank::from_values(PcrAlgorithm::Sha256, (0u8..23).map(|i| (i, vec![i; 32]))) + .unwrap_err(); + assert!(matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::MissingPcr { index: 23 }) + )); + } + + #[test] + fn test_reject_wrong_value_length() { + let values: Vec<(u8, Vec)> = (0u8..24) + .map(|i| { + if i == 5 { + (i, vec![i; 48]) // wrong length for SHA-256 + } else { + (i, vec![i; 32]) + } + }) + .collect(); + let err = PcrBank::from_values(PcrAlgorithm::Sha256, values).unwrap_err(); + assert!(matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::PcrValueWrongLength { + index: 5, + expected: 32, + got: 48 + }) + )); + } + + #[test] + fn test_reject_index_out_of_range() { + // Indices 0-22 plus 24 (out of range) — PCR 23 is missing + let values: Vec<(u8, Vec)> = (0u8..23) + .chain(std::iter::once(24u8)) + .map(|i| (i, vec![0u8; 32])) + .collect(); + let err = PcrBank::from_values(PcrAlgorithm::Sha256, values).unwrap_err(); + assert!(matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::MissingPcr { index: 23 }) + )); + } + + #[test] + fn test_pcr_algorithm_properties() { + assert_eq!(PcrAlgorithm::Sha256 as u16, 0x000B); + assert_eq!(PcrAlgorithm::Sha384 as u16, 0x000C); + assert_eq!(PcrAlgorithm::Sha256.digest_len(), 32); + assert_eq!(PcrAlgorithm::Sha384.digest_len(), 48); + assert_eq!(PcrAlgorithm::try_from(0x000Bu16), Ok(PcrAlgorithm::Sha256)); + assert_eq!(PcrAlgorithm::try_from(0x000Cu16), Ok(PcrAlgorithm::Sha384)); + assert_eq!(PcrAlgorithm::try_from(0x9999u16), Err(0x9999)); + assert_eq!(PcrAlgorithm::Sha256.to_string(), "sha256"); + assert_eq!(PcrAlgorithm::Sha384.to_string(), "sha384"); + } + + #[test] + fn test_p256_public_key_from_sec1() { + let mut bytes = [0u8; 65]; + bytes[0] = 0x04; + for i in 0..32 { + bytes[1 + i] = i as u8; + bytes[33 + i] = (32 + i) as u8; + } + let pk = P256PublicKey::from_sec1_uncompressed(&bytes).unwrap(); + assert_eq!(pk.to_sec1_uncompressed(), bytes); + } + + #[test] + fn test_p256_public_key_reject_wrong_prefix() { + let mut bytes = [0u8; 65]; + bytes[0] = 0x02; // compressed, not uncompressed + assert!(P256PublicKey::from_sec1_uncompressed(&bytes).is_err()); + } + + #[test] + fn test_p256_public_key_reject_wrong_length() { + let bytes = [0x04; 33]; // too short + assert!(P256PublicKey::from_sec1_uncompressed(&bytes).is_err()); + } + + #[test] + fn test_p256_from_coords() { + let x = [1u8; 32]; + let y = [2u8; 32]; + let pk = P256PublicKey::from_coords(&x, &y).unwrap(); + assert_eq!(pk.x, x); + assert_eq!(pk.y, y); + + let sec1 = pk.to_sec1_uncompressed(); + assert_eq!(sec1[0], 0x04); + assert_eq!(&sec1[1..33], &x); + assert_eq!(&sec1[33..65], &y); + } + + #[test] + fn test_p256_from_coords_reject_wrong_length() { + let x = [1u8; 31]; // too short + let y = [2u8; 32]; + assert!(P256PublicKey::from_coords(&x, &y).is_err()); + } +} diff --git a/crates/vaportpm-verify/src/test_support.rs b/crates/vaportpm-verify/src/test_support.rs new file mode 100644 index 0000000..f38bdfd --- /dev/null +++ b/crates/vaportpm-verify/src/test_support.rs @@ -0,0 +1,464 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Test support: ephemeral key generation and attestation builders. +//! +//! Gated behind `#[cfg(any(test, feature = "test-support"))]` — stripped +//! from production builds unless explicitly opted in. + +use std::collections::BTreeMap; +use std::time::Duration; + +use ciborium::Value as CborValue; +use coset::{iana, CborSerializable, CoseSign1, HeaderBuilder}; +use der::Decode; +use ecdsa::signature::hazmat::PrehashSigner; +use p256::pkcs8::DecodePrivateKey as _; +use sha2::{Digest, Sha256, Sha384}; + +use crate::pcr::{P256PublicKey, PcrAlgorithm, PcrBank}; +use crate::roots::{register_test_root, TestRootGuard}; +use crate::x509::hash_public_key; +use crate::{CloudProvider, DecodedAttestationOutput, DecodedPlatformAttestation}; +use pki_types::UnixTime; + +// ============================================================================ +// TPM Quote builder +// ============================================================================ + +/// TPM_GENERATED magic +const TPM_GENERATED_VALUE: u32 = 0xff544347; +/// TPM_ST_ATTEST_QUOTE +const TPM_ST_ATTEST_QUOTE: u16 = 0x8018; + +/// Build a raw TPM2B_ATTEST (Quote) structure. +/// +/// `pcr_select`: list of `(alg_u16, bitmap_bytes)` — e.g. `(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])`. +/// `pcr_digest`: the SHA-256 of concatenated selected PCR values. +pub fn build_tpm_quote_attest( + nonce: &[u8; 32], + pcr_select: &[(u16, Vec)], + pcr_digest: &[u8], +) -> Vec { + let mut buf = Vec::with_capacity(256); + + // magic + buf.extend_from_slice(&TPM_GENERATED_VALUE.to_be_bytes()); + // type + buf.extend_from_slice(&TPM_ST_ATTEST_QUOTE.to_be_bytes()); + + // qualifiedSigner (TPM2B_NAME) — minimal: 34 bytes of zeros + let signer_name = [0u8; 34]; + buf.extend_from_slice(&(signer_name.len() as u16).to_be_bytes()); + buf.extend_from_slice(&signer_name); + + // extraData (TPM2B_DATA) — our nonce + buf.extend_from_slice(&(nonce.len() as u16).to_be_bytes()); + buf.extend_from_slice(nonce); + + // clockInfo (TPMS_CLOCK_INFO) — 17 bytes of zeros + buf.extend_from_slice(&[0u8; 17]); + + // firmwareVersion — 8 bytes + buf.extend_from_slice(&[0u8; 8]); + + // attested.quote.pcrSelect (TPML_PCR_SELECTION) + buf.extend_from_slice(&(pcr_select.len() as u32).to_be_bytes()); + for (alg, bitmap) in pcr_select { + buf.extend_from_slice(&alg.to_be_bytes()); + buf.push(bitmap.len() as u8); + buf.extend_from_slice(bitmap); + } + + // attested.quote.pcrDigest (TPM2B_DIGEST) + buf.extend_from_slice(&(pcr_digest.len() as u16).to_be_bytes()); + buf.extend_from_slice(pcr_digest); + + buf +} + +/// Compute the PCR digest (SHA-256 of concatenated PCR values in index order). +pub fn compute_pcr_digest(pcrs: &PcrBank) -> Vec { + let mut hasher = Sha256::new(); + for value in pcrs.values() { + hasher.update(value); + } + hasher.finalize().to_vec() +} + +/// Sign a TPM Quote with a P-256 key (PKCS8 DER). +/// +/// The TPM signs SHA-256(attest_data) using prehash ECDSA. +/// Returns the DER-encoded ECDSA signature. +pub fn sign_tpm_quote(attest_data: &[u8], ak_signing_key_pkcs8: &[u8]) -> Vec { + let signing_key = p256::ecdsa::SigningKey::from_pkcs8_der(ak_signing_key_pkcs8).unwrap(); + let digest = Sha256::digest(attest_data); + let signature: p256::ecdsa::Signature = signing_key.sign_prehash(&digest).unwrap(); + signature.to_der().as_bytes().to_vec() +} + +// ============================================================================ +// COSE Sign1 builder (Nitro) +// ============================================================================ + +/// Build a COSE Sign1 document for Nitro attestation. +/// +/// `leaf_der`: DER-encoded leaf certificate +/// `cabundle`: list of DER-encoded CA certificates (root last, will be reversed in CBOR) +/// `pcrs`: PCR index → SHA-384 value +/// `public_key`: optional AK public key bytes +/// `nonce`: optional nonce bytes +/// `signing_key_pkcs8`: P-384 private key in PKCS8 DER (signs the COSE envelope) +pub fn build_nitro_cose_doc( + leaf_der: &[u8], + cabundle: &[Vec], + pcrs: &BTreeMap>, + public_key: Option<&[u8]>, + nonce: Option<&[u8]>, + signing_key_pkcs8: &[u8], +) -> Vec { + // Build CBOR payload map + let pcr_map: Vec<(CborValue, CborValue)> = pcrs + .iter() + .map(|(idx, val)| { + ( + CborValue::Integer((*idx as i64).into()), + CborValue::Bytes(val.clone()), + ) + }) + .collect(); + + let cabundle_cbor: Vec = cabundle + .iter() + .rev() // Nitro stores root-first in cabundle array, reversed from chain order + .map(|der| CborValue::Bytes(der.clone())) + .collect(); + + let mut payload_map: Vec<(CborValue, CborValue)> = vec![ + ( + CborValue::Text("module_id".to_string()), + CborValue::Text("test-module".to_string()), + ), + ( + CborValue::Text("timestamp".to_string()), + CborValue::Integer(1770116400i64.into()), + ), + ( + CborValue::Text("digest".to_string()), + CborValue::Text("SHA384".to_string()), + ), + ( + CborValue::Text("nitrotpm_pcrs".to_string()), + CborValue::Map(pcr_map), + ), + ( + CborValue::Text("certificate".to_string()), + CborValue::Bytes(leaf_der.to_vec()), + ), + ( + CborValue::Text("cabundle".to_string()), + CborValue::Array(cabundle_cbor), + ), + ]; + + // public_key + match public_key { + Some(pk) => payload_map.push(( + CborValue::Text("public_key".to_string()), + CborValue::Bytes(pk.to_vec()), + )), + None => payload_map.push((CborValue::Text("public_key".to_string()), CborValue::Null)), + } + + // nonce + match nonce { + Some(n) => payload_map.push(( + CborValue::Text("nonce".to_string()), + CborValue::Bytes(n.to_vec()), + )), + None => payload_map.push((CborValue::Text("nonce".to_string()), CborValue::Null)), + } + + let payload_cbor = CborValue::Map(payload_map); + let mut payload_bytes = Vec::new(); + ciborium::into_writer(&payload_cbor, &mut payload_bytes).unwrap(); + + // Build protected header: alg = ES384 (-35) + let protected = HeaderBuilder::new() + .algorithm(iana::Algorithm::ES384) + .build(); + + let protected_bytes = coset::ProtectedHeader { + original_data: None, + header: protected, + } + .to_vec() + .unwrap(); + + // Build Sig_structure + let sig_structure = CborValue::Array(vec![ + CborValue::Text("Signature1".to_string()), + CborValue::Bytes(protected_bytes.clone()), + CborValue::Bytes(vec![]), // external_aad + CborValue::Bytes(payload_bytes.clone()), + ]); + + let mut sig_structure_bytes = Vec::new(); + ciborium::into_writer(&sig_structure, &mut sig_structure_bytes).unwrap(); + + // Hash and sign + let digest = Sha384::digest(&sig_structure_bytes); + let signing_key = p384::ecdsa::SigningKey::from_pkcs8_der(signing_key_pkcs8).unwrap(); + let signature: p384::ecdsa::Signature = signing_key.sign_prehash(&digest).unwrap(); + + // COSE uses raw r||s (96 bytes for P-384) + let sig_raw = signature.to_bytes(); + + // Construct CoseSign1 + let cose = CoseSign1 { + protected: coset::ProtectedHeader { + original_data: None, + header: HeaderBuilder::new() + .algorithm(iana::Algorithm::ES384) + .build(), + }, + unprotected: Default::default(), + payload: Some(payload_bytes), + signature: sig_raw.to_vec(), + }; + + cose.to_vec().unwrap() +} + +// ============================================================================ +// Certificate chain generation +// ============================================================================ + +/// Generated key material for a P-384 Nitro-style cert chain. +pub struct NitroChainKeys { + /// Root CA cert DER + pub root_der: Vec, + /// Leaf cert DER + pub leaf_der: Vec, + /// COSE signing key (P-384, PKCS8 DER) — from the leaf cert + pub cose_signing_key: Vec, + /// Root public key hash (SHA-256) + pub root_pubkey_hash: [u8; 32], +} + +/// Generate a P-384 cert chain for Nitro tests (leaf + root). +/// +/// Returns key material needed to build COSE documents and register the test root. +pub fn generate_nitro_chain() -> NitroChainKeys { + // Root CA (self-signed, P-384) + let mut ca_params = + rcgen::CertificateParams::new(vec!["AWS Nitro Test Root".to_string()]).unwrap(); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params.key_usages = vec![ + rcgen::KeyUsagePurpose::KeyCertSign, + rcgen::KeyUsagePurpose::DigitalSignature, + ]; + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, "AWS Nitro Test Root"); + let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P384_SHA384).unwrap(); + let ca_cert = ca_params.self_signed(&ca_key).unwrap(); + + // Leaf cert (signed by root, P-384) + let mut leaf_params = + rcgen::CertificateParams::new(vec!["Nitro Test Leaf".to_string()]).unwrap(); + leaf_params.is_ca = rcgen::IsCa::NoCa; + leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "Nitro Test Leaf"); + let leaf_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P384_SHA384).unwrap(); + let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap(); + + // Compute root pubkey hash + let root_x509 = + x509_cert::Certificate::from_der(ca_cert.der()).expect("root cert should parse"); + let root_pubkey = crate::x509::extract_public_key(&root_x509).unwrap(); + let root_pubkey_hash = hash_public_key(&root_pubkey); + + NitroChainKeys { + root_der: ca_cert.der().to_vec(), + leaf_der: leaf_cert.der().to_vec(), + cose_signing_key: leaf_key.serialize_der(), + root_pubkey_hash, + } +} + +/// Generated key material for a P-256 GCP-style cert chain. +pub struct GcpChainKeys { + /// Root CA cert DER + pub root_der: Vec, + /// Leaf cert DER + pub leaf_der: Vec, + /// AK signing key (P-256, PKCS8 DER) — from the leaf cert + pub ak_signing_key: Vec, + /// AK public key (P-256) + pub ak_pubkey: P256PublicKey, + /// Root public key hash (SHA-256) + pub root_pubkey_hash: [u8; 32], +} + +/// Generate a P-256 cert chain for GCP tests (leaf + root). +pub fn generate_gcp_chain() -> GcpChainKeys { + // Root CA (self-signed, P-256) + let mut ca_params = + rcgen::CertificateParams::new(vec!["GCP EK/AK Test Root".to_string()]).unwrap(); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params.key_usages = vec![ + rcgen::KeyUsagePurpose::KeyCertSign, + rcgen::KeyUsagePurpose::DigitalSignature, + ]; + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, "GCP EK/AK Test Root"); + let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let ca_cert = ca_params.self_signed(&ca_key).unwrap(); + + // Leaf cert (signed by root, P-256) + let mut leaf_params = + rcgen::CertificateParams::new(vec!["GCP AK Test Leaf".to_string()]).unwrap(); + leaf_params.is_ca = rcgen::IsCa::NoCa; + leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "GCP AK Test Leaf"); + let leaf_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap(); + + // Extract AK pubkey from leaf cert + let leaf_x509 = + x509_cert::Certificate::from_der(leaf_cert.der()).expect("leaf cert should parse"); + let ak_pubkey_vec = crate::x509::extract_public_key(&leaf_x509).unwrap(); + let ak_pubkey = P256PublicKey::from_sec1_uncompressed(&ak_pubkey_vec).unwrap(); + + // Compute root pubkey hash + let root_x509 = + x509_cert::Certificate::from_der(ca_cert.der()).expect("root cert should parse"); + let root_pubkey = crate::x509::extract_public_key(&root_x509).unwrap(); + let root_pubkey_hash = hash_public_key(&root_pubkey); + + GcpChainKeys { + root_der: ca_cert.der().to_vec(), + leaf_der: leaf_cert.der().to_vec(), + ak_signing_key: leaf_key.serialize_der(), + ak_pubkey, + root_pubkey_hash, + } +} + +// ============================================================================ +// High-level convenience builders +// ============================================================================ + +/// Default verification timestamp for ephemeral tests. +/// Feb 3, 2026 11:00:00 UTC — same as Nitro fixture. +pub const EPHEMERAL_TIMESTAMP_SECS: u64 = 1770116400; + +pub fn ephemeral_time() -> UnixTime { + UnixTime::since_unix_epoch(Duration::from_secs(EPHEMERAL_TIMESTAMP_SECS)) +} + +/// Build 24 SHA-384 PCR values (for Nitro). +pub fn make_nitro_pcrs() -> PcrBank { + PcrBank::from_values(PcrAlgorithm::Sha384, (0u8..24).map(|i| (i, vec![i; 48]))).unwrap() +} + +/// Build 24 SHA-256 PCR values (for GCP). +pub fn make_gcp_pcrs() -> PcrBank { + PcrBank::from_values(PcrAlgorithm::Sha256, (0u8..24).map(|i| (i, vec![i; 32]))).unwrap() +} + +/// Build a complete, cryptographically valid Nitro attestation. +/// +/// Returns `(DecodedAttestationOutput, UnixTime, TestRootGuard)`. +/// The guard must be held alive for the duration of the test. +pub fn build_valid_nitro( + nonce: &[u8; 32], + pcrs: &PcrBank, +) -> (DecodedAttestationOutput, UnixTime, TestRootGuard) { + let chain = generate_nitro_chain(); + + // Register test root + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); + + // Generate AK key pair (P-256 for TPM Quote signing) + let ak_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let ak_signing_key_pkcs8 = ak_key.serialize_der(); + + // Extract AK public key (SEC1 uncompressed) + let ak_signing_key = p256::ecdsa::SigningKey::from_pkcs8_der(&ak_signing_key_pkcs8).unwrap(); + let ak_verifying_key = ak_signing_key.verifying_key(); + let ak_point = ak_verifying_key.to_encoded_point(false); + let ak_pubkey = P256PublicKey::from_sec1_uncompressed(ak_point.as_bytes()).unwrap(); + + // Build TPM Quote + let pcr_digest = compute_pcr_digest(pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha384 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = build_tpm_quote_attest(nonce, &pcr_select, &pcr_digest); + let quote_signature = sign_tpm_quote("e_attest, &ak_signing_key_pkcs8); + + // Build Nitro PCR map (index → value) for COSE document + let mut nitro_pcrs = BTreeMap::new(); + for (idx, val) in pcrs.values().enumerate() { + nitro_pcrs.insert(idx as u8, val.to_vec()); + } + + let ak_sec1 = ak_pubkey.to_sec1_uncompressed(); + + // Build COSE document + let cose_doc = build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_sec1), + Some(nonce), + &chain.cose_signing_key, + ); + + let decoded = DecodedAttestationOutput { + nonce: *nonce, + pcrs: pcrs.clone(), + ak_pubkey, + quote_attest, + quote_signature, + platform: DecodedPlatformAttestation::Nitro { document: cose_doc }, + }; + + (decoded, ephemeral_time(), guard) +} + +/// Build a complete, cryptographically valid GCP attestation. +/// +/// Returns `(DecodedAttestationOutput, UnixTime, TestRootGuard)`. +pub fn build_valid_gcp( + nonce: &[u8; 32], + pcrs: &PcrBank, +) -> (DecodedAttestationOutput, UnixTime, TestRootGuard) { + let chain = generate_gcp_chain(); + + // Register test root + let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Gcp); + + // Build TPM Quote + let pcr_digest = compute_pcr_digest(pcrs); + let pcr_select = vec![(PcrAlgorithm::Sha256 as u16, vec![0xFF, 0xFF, 0xFF])]; + let quote_attest = build_tpm_quote_attest(nonce, &pcr_select, &pcr_digest); + let quote_signature = sign_tpm_quote("e_attest, &chain.ak_signing_key); + + let decoded = DecodedAttestationOutput { + nonce: *nonce, + pcrs: pcrs.clone(), + ak_pubkey: chain.ak_pubkey, + quote_attest, + quote_signature, + platform: DecodedPlatformAttestation::Gcp { + cert_chain_der: vec![chain.leaf_der, chain.root_der], + }, + }; + + (decoded, ephemeral_time(), guard) +} diff --git a/crates/vaportpm-verify/src/tpm.rs b/crates/vaportpm-verify/src/tpm.rs index 6a4d888..fdec8d7 100644 --- a/crates/vaportpm-verify/src/tpm.rs +++ b/crates/vaportpm-verify/src/tpm.rs @@ -6,28 +6,31 @@ use ecdsa::signature::hazmat::PrehashVerifier; use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256VerifyingKey}; use sha2::{Digest, Sha256}; -use crate::error::VerifyError; +use crate::error::{InvalidAttestReason, SignatureInvalidReason, VerifyError}; +use crate::pcr::PcrBank; /// Verify ECDSA-SHA256 signature over a message -pub fn verify_ecdsa_p256( +pub(crate) fn verify_ecdsa_p256( message: &[u8], signature_der: &[u8], public_key: &[u8], ) -> Result<(), VerifyError> { // Parse the public key (SEC1/SECG format: 0x04 || X || Y for uncompressed) let verifying_key = P256VerifyingKey::from_sec1_bytes(public_key) - .map_err(|e| VerifyError::SignatureInvalid(format!("Invalid public key: {}", e)))?; + .map_err(|e| SignatureInvalidReason::InvalidPublicKey(e.to_string()))?; // Parse the DER-encoded signature let signature = P256Signature::from_der(signature_der) - .map_err(|e| VerifyError::SignatureInvalid(format!("Invalid signature DER: {}", e)))?; + .map_err(|e| SignatureInvalidReason::InvalidSignatureEncoding(e.to_string()))?; // TPM signs the SHA-256 hash of the message let digest = Sha256::digest(message); verifying_key .verify_prehash(&digest, &signature) - .map_err(|e| VerifyError::SignatureInvalid(format!("Signature verification failed: {}", e))) + .map_err(|e| SignatureInvalidReason::EcdsaVerificationFailed(e.to_string()))?; + + Ok(()) } // ============================================================================= @@ -45,10 +48,11 @@ const TPMS_CLOCK_INFO_SIZE: usize = 17; /// Parsed TPMS_ATTEST structure (from TPM2_Quote) #[derive(Debug)] -pub struct TpmQuoteInfo { +pub(crate) struct TpmQuoteInfo { /// Nonce/qualifying data from extraData field (raw bytes) pub nonce: Vec, - /// Name of the signing key + /// Name of the signing key (parsed to advance cursor; not used for verification) + #[allow(dead_code)] pub signer_name: Vec, /// PCR selection (algorithm, PCR indices as bitmap) pub pcr_select: Vec<(u16, Vec)>, @@ -67,47 +71,49 @@ pub struct TpmQuoteInfo { /// - firmwareVersion: u64 /// - attested.quote.pcrSelect: TPML_PCR_SELECTION (PCRs that were quoted) /// - attested.quote.pcrDigest: TPM2B_DIGEST (hash of PCR values) -pub fn parse_quote_attest(data: &[u8]) -> Result { +pub(crate) fn parse_quote_attest(data: &[u8]) -> Result { let mut cursor = SafeCursor::new(data); // magic (4 bytes) - let magic_bytes = cursor.read_bytes(4, "magic")?; + let magic_bytes = cursor.read_bytes(4)?; let magic = u32::from_be_bytes(magic_bytes.try_into().unwrap()); if magic != TPM_GENERATED_VALUE { - return Err(VerifyError::InvalidAttest(format!( - "Invalid TPM magic: expected 0x{:08x}, got 0x{:08x}", - TPM_GENERATED_VALUE, magic - ))); + return Err(InvalidAttestReason::TpmMagicInvalid { + expected: TPM_GENERATED_VALUE, + got: magic, + } + .into()); } // type (2 bytes) - let type_bytes = cursor.read_bytes(2, "type")?; + let type_bytes = cursor.read_bytes(2)?; let attest_type = u16::from_be_bytes(type_bytes.try_into().unwrap()); if attest_type != TPM_ST_ATTEST_QUOTE { - return Err(VerifyError::InvalidAttest(format!( - "Invalid attest type: expected 0x{:04x} (QUOTE), got 0x{:04x}", - TPM_ST_ATTEST_QUOTE, attest_type - ))); + return Err(InvalidAttestReason::TpmTypeInvalid { + expected: TPM_ST_ATTEST_QUOTE, + got: attest_type, + } + .into()); } // qualifiedSigner (TPM2B_NAME) - let signer_name = cursor.read_tpm2b("qualifiedSigner")?; + let signer_name = cursor.read_tpm2b()?; // extraData (TPM2B_DATA) - this is our nonce - let nonce = cursor.read_tpm2b("extraData")?; + let nonce = cursor.read_tpm2b()?; // clockInfo (TPMS_CLOCK_INFO) - skip it - cursor.skip(TPMS_CLOCK_INFO_SIZE, "clockInfo")?; + cursor.skip(TPMS_CLOCK_INFO_SIZE)?; // firmwareVersion (8 bytes) - skip it - cursor.skip(8, "firmwareVersion")?; + cursor.skip(8)?; // attested (TPMS_QUOTE_INFO) // - pcrSelect (TPML_PCR_SELECTION) - let pcr_select = cursor.read_pcr_selection("pcrSelect")?; + let pcr_select = cursor.read_pcr_selection()?; // - pcrDigest (TPM2B_DIGEST) - let pcr_digest = cursor.read_tpm2b("pcrDigest")?; + let pcr_digest = cursor.read_tpm2b()?; Ok(TpmQuoteInfo { nonce, @@ -129,12 +135,20 @@ impl<'a> SafeCursor<'a> { } /// Read exactly `len` bytes, returning error on overflow or truncation - fn read_bytes(&mut self, len: usize, field: &str) -> Result<&'a [u8], VerifyError> { - let end = self.offset.checked_add(len).ok_or_else(|| { - VerifyError::InvalidAttest(format!("Integer overflow reading {}", field)) - })?; + fn read_bytes(&mut self, len: usize) -> Result<&'a [u8], VerifyError> { + let end = self + .offset + .checked_add(len) + .ok_or(VerifyError::InvalidAttest( + InvalidAttestReason::TpmOverflow { + offset: self.offset, + }, + ))?; if end > self.data.len() { - return Err(VerifyError::InvalidAttest(format!("Truncated {}", field))); + return Err(InvalidAttestReason::TpmTruncated { + offset: self.offset, + } + .into()); } let bytes = &self.data[self.offset..end]; self.offset = end; @@ -142,79 +156,80 @@ impl<'a> SafeCursor<'a> { } /// Skip exactly `len` bytes - fn skip(&mut self, len: usize, field: &str) -> Result<(), VerifyError> { - let end = self.offset.checked_add(len).ok_or_else(|| { - VerifyError::InvalidAttest(format!("Integer overflow skipping {}", field)) - })?; + fn skip(&mut self, len: usize) -> Result<(), VerifyError> { + let end = self + .offset + .checked_add(len) + .ok_or(VerifyError::InvalidAttest( + InvalidAttestReason::TpmOverflow { + offset: self.offset, + }, + ))?; if end > self.data.len() { - return Err(VerifyError::InvalidAttest(format!("Truncated {}", field))); + return Err(InvalidAttestReason::TpmTruncated { + offset: self.offset, + } + .into()); } self.offset = end; Ok(()) } /// Read a TPM2B structure (2-byte size prefix + data) - fn read_tpm2b(&mut self, field: &str) -> Result, VerifyError> { - let size_bytes = self.read_bytes(2, field)?; + fn read_tpm2b(&mut self) -> Result, VerifyError> { + let size_bytes = self.read_bytes(2)?; let size = u16::from_be_bytes(size_bytes.try_into().unwrap()) as usize; - let data = self.read_bytes(size, field)?; + let data = self.read_bytes(size)?; Ok(data.to_vec()) } /// Read a u16 value (big-endian) - fn read_u16(&mut self, field: &str) -> Result { - let bytes = self.read_bytes(2, field)?; + fn read_u16(&mut self) -> Result { + let bytes = self.read_bytes(2)?; Ok(u16::from_be_bytes(bytes.try_into().unwrap())) } /// Read a u32 value (big-endian) - fn read_u32(&mut self, field: &str) -> Result { - let bytes = self.read_bytes(4, field)?; + fn read_u32(&mut self) -> Result { + let bytes = self.read_bytes(4)?; Ok(u32::from_be_bytes(bytes.try_into().unwrap())) } /// Read a u8 value - fn read_u8(&mut self, field: &str) -> Result { - let bytes = self.read_bytes(1, field)?; + fn read_u8(&mut self) -> Result { + let bytes = self.read_bytes(1)?; Ok(bytes[0]) } /// Read TPML_PCR_SELECTION structure /// /// Returns a list of (algorithm, PCR bitmap) pairs - fn read_pcr_selection(&mut self, field: &str) -> Result)>, VerifyError> { - let count = self.read_u32(&format!("{}.count", field))?; + fn read_pcr_selection(&mut self) -> Result)>, VerifyError> { + let count = self.read_u32()?; // Sanity check: count should be reasonable (max ~16 different algorithms) if count > 16 { - return Err(VerifyError::InvalidAttest(format!( - "{}: count {} exceeds reasonable maximum", - field, count - ))); + return Err(InvalidAttestReason::PcrSelectionCountExceeded { count }.into()); } let mut selections = Vec::with_capacity(count as usize); - for i in 0..count { + for _ in 0..count { // TPMS_PCR_SELECTION: // - hash (2 bytes) - algorithm - let hash_alg = self.read_u16(&format!("{}.selection[{}].hash", field, i))?; + let hash_alg = self.read_u16()?; // - sizeofSelect (1 byte) - bitmap size (typically 3 for 24 PCRs) - let bitmap_size = self.read_u8(&format!("{}.selection[{}].sizeofSelect", field, i))?; + let bitmap_size = self.read_u8()?; // Sanity check: bitmap size should be reasonable (max 32 for 256 PCRs) if bitmap_size > 32 { - return Err(VerifyError::InvalidAttest(format!( - "{}.selection[{}].sizeofSelect {} exceeds maximum", - field, i, bitmap_size - ))); + return Err( + InvalidAttestReason::PcrBitmapSizeExceeded { size: bitmap_size }.into(), + ); } // - pcrSelect (variable) - bitmap - let bitmap = self.read_bytes( - bitmap_size as usize, - &format!("{}.selection[{}].pcrSelect", field, i), - )?; + let bitmap = self.read_bytes(bitmap_size as usize)?; selections.push((hash_alg, bitmap.to_vec())); } @@ -223,6 +238,88 @@ impl<'a> SafeCursor<'a> { } } +/// Verify that the PCR digest in a Quote matches the claimed PCR values +/// +/// The TPM Quote contains a PCR digest (SHA-256 of concatenated PCR values). +/// PcrBank guarantees all 24 values in order, so we just hash them sequentially. +pub(crate) fn verify_pcr_digest_matches( + quote_info: &TpmQuoteInfo, + pcrs: &PcrBank, +) -> Result<(), VerifyError> { + let mut hasher = Sha256::new(); + for value in pcrs.values() { + hasher.update(value); + } + + let computed_digest = hasher.finalize(); + if computed_digest.as_ref() != quote_info.pcr_digest { + return Err(InvalidAttestReason::PcrDigestMismatch.into()); + } + + Ok(()) +} + +/// Verify a TPM2_Quote: signature, PCR bank selection, nonce, and PCR digest. +/// +/// This is the shared core of both GCP and Nitro verification. The only +/// platform-specific input is `expected_alg` (Sha256 for GCP, Sha384 for Nitro). +/// +/// After this returns successfully, the quote is fully authenticated and the +/// PCR values in `decoded.pcrs` are proven to match the signed digest. +pub(crate) fn verify_quote( + decoded: &crate::DecodedAttestationOutput, + expected_alg: crate::pcr::PcrAlgorithm, +) -> Result { + let quote_info = parse_quote_attest(&decoded.quote_attest)?; + + let ak_sec1 = decoded.ak_pubkey.to_sec1_uncompressed(); + verify_ecdsa_p256(&decoded.quote_attest, &decoded.quote_signature, &ak_sec1)?; + + // --- Quote is now authenticated; safe to trust its contents --- + + // Enforce exactly one PCR bank with the expected algorithm, all 24 PCRs selected. + if quote_info.pcr_select.len() != 1 { + return Err(InvalidAttestReason::MultiplePcrBanks { + count: quote_info.pcr_select.len(), + } + .into()); + } + let (quote_alg, quote_bitmap) = "e_info.pcr_select[0]; + if *quote_alg != expected_alg as u16 { + return Err(InvalidAttestReason::WrongPcrAlgorithm { + expected: expected_alg, + got: *quote_alg, + } + .into()); + } + if quote_bitmap.len() < 3 + || quote_bitmap[0] != 0xFF + || quote_bitmap[1] != 0xFF + || quote_bitmap[2] != 0xFF + { + return Err(InvalidAttestReason::PartialPcrBitmap.into()); + } + + // Verify PcrBank algorithm matches expected + if decoded.pcrs.algorithm() != expected_alg { + return Err(InvalidAttestReason::WrongPcrBankAlgorithm { + expected: expected_alg, + got: decoded.pcrs.algorithm(), + } + .into()); + } + + // Verify nonce matches Quote + if decoded.nonce != quote_info.nonce.as_slice() { + return Err(InvalidAttestReason::NonceMismatch.into()); + } + + // Verify PCR digest matches claimed PCR values + verify_pcr_digest_matches("e_info, &decoded.pcrs)?; + + Ok(quote_info) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/vaportpm-verify/src/x509.rs b/crates/vaportpm-verify/src/x509.rs index 928b6d8..96a5dbd 100644 --- a/crates/vaportpm-verify/src/x509.rs +++ b/crates/vaportpm-verify/src/x509.rs @@ -14,7 +14,7 @@ use rsa::RsaPublicKey; use sha2::{Digest, Sha256}; use x509_cert::Certificate; -use crate::error::VerifyError; +use crate::error::{CertificateParseReason, ChainValidationReason, VerifyError}; // X.509 extension OIDs const OID_KEY_USAGE: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.29.15"); @@ -23,7 +23,7 @@ const OID_BASIC_CONSTRAINTS: ObjectIdentifier = ObjectIdentifier::new_unwrap("2. /// Key Usage extension flags (OID 2.5.29.15) /// Only includes bits used for TPM certificate chain validation. #[derive(Debug, Clone, Default)] -pub struct KeyUsageFlags { +pub(crate) struct KeyUsageFlags { /// digitalSignature (bit 0) - key can be used to verify digital signatures pub digital_signature: bool, /// keyCertSign (bit 5) - key can be used to verify certificate signatures @@ -32,7 +32,7 @@ pub struct KeyUsageFlags { /// Basic Constraints extension (OID 2.5.29.19) #[derive(Debug, Clone, Default)] -pub struct BasicConstraints { +pub(crate) struct BasicConstraints { /// Whether this certificate is a CA pub ca: bool, /// Maximum number of intermediate certificates allowed below this CA @@ -42,7 +42,7 @@ pub struct BasicConstraints { /// Extract Key Usage extension from a certificate (OID 2.5.29.15) /// /// Returns None if the extension is not present. -pub fn extract_key_usage(cert: &Certificate) -> Option { +pub(crate) fn extract_key_usage(cert: &Certificate) -> Option { let extensions = cert.tbs_certificate.extensions.as_ref()?; for ext in extensions.iter() { @@ -74,7 +74,7 @@ pub fn extract_key_usage(cert: &Certificate) -> Option { /// Extract Basic Constraints extension from a certificate (OID 2.5.29.19) /// /// Returns None if the extension is not present. -pub fn extract_basic_constraints(cert: &Certificate) -> Option { +pub(crate) fn extract_basic_constraints(cert: &Certificate) -> Option { let extensions = cert.tbs_certificate.extensions.as_ref()?; for ext in extensions.iter() { @@ -135,7 +135,7 @@ pub fn extract_basic_constraints(cert: &Certificate) -> Option } /// Maximum allowed certificate chain depth (to prevent DoS) -pub const MAX_CHAIN_DEPTH: usize = 10; +pub(crate) const MAX_CHAIN_DEPTH: usize = 10; /// PEM certificate begin marker const PEM_CERT_BEGIN: &str = "-----BEGIN CERTIFICATE-----"; @@ -150,23 +150,18 @@ const PEM_CERT_END: &str = "-----END CERTIFICATE-----"; /// - Exact BEGIN/END markers (not just "contains") /// - No non-whitespace data between certificates /// - Valid base64 content within certificate blocks -pub fn parse_cert_chain_pem(pem: &str) -> Result, VerifyError> { +pub(crate) fn parse_cert_chain_pem(pem: &str) -> Result, VerifyError> { let mut certs = Vec::new(); let mut current_cert = String::new(); let mut in_cert = false; - let mut line_number = 0; - - for line in pem.lines() { - line_number += 1; + for (idx, line) in pem.lines().enumerate() { + let line_number = idx + 1; let trimmed = line.trim(); // Check for BEGIN marker if trimmed == PEM_CERT_BEGIN { if in_cert { - return Err(VerifyError::CertificateParse(format!( - "Line {}: Unexpected BEGIN marker inside certificate block", - line_number - ))); + return Err(CertificateParseReason::NestedBeginMarker { line: line_number }.into()); } in_cert = true; current_cert.clear(); @@ -176,25 +171,18 @@ pub fn parse_cert_chain_pem(pem: &str) -> Result, VerifyError> // Check for END marker if trimmed == PEM_CERT_END { if !in_cert { - return Err(VerifyError::CertificateParse(format!( - "Line {}: END marker without matching BEGIN", - line_number - ))); + return Err(CertificateParseReason::EndWithoutBegin { line: line_number }.into()); } in_cert = false; // Decode the certificate if current_cert.is_empty() { - return Err(VerifyError::CertificateParse(format!( - "Line {}: Empty certificate content", - line_number - ))); + return Err(CertificateParseReason::EmptyCertContent { line: line_number }.into()); } let der_bytes = base64_decode(¤t_cert)?; - let cert = Certificate::from_der(&der_bytes).map_err(|e| { - VerifyError::CertificateParse(format!("Line {}: Invalid DER: {}", line_number, e)) - })?; + let cert = Certificate::from_der(&der_bytes) + .map_err(|e| CertificateParseReason::InvalidDer(e.to_string()))?; certs.push(cert); continue; } @@ -203,10 +191,7 @@ pub fn parse_cert_chain_pem(pem: &str) -> Result, VerifyError> if in_cert { // Validate that line contains only base64 characters if !trimmed.is_empty() && !is_valid_base64_line(trimmed) { - return Err(VerifyError::CertificateParse(format!( - "Line {}: Invalid base64 character in certificate", - line_number - ))); + return Err(CertificateParseReason::InvalidBase64 { line: line_number }.into()); } current_cert.push_str(trimmed); continue; @@ -214,29 +199,17 @@ pub fn parse_cert_chain_pem(pem: &str) -> Result, VerifyError> // Outside certificate blocks: only whitespace is allowed if !trimmed.is_empty() { - return Err(VerifyError::CertificateParse(format!( - "Line {}: Unexpected content outside certificate block: '{}'", - line_number, - if trimmed.len() > 20 { - &trimmed[..20] - } else { - trimmed - } - ))); + return Err(CertificateParseReason::UnexpectedContent { line: line_number }.into()); } } // Check for unclosed certificate block if in_cert { - return Err(VerifyError::CertificateParse( - "Unclosed certificate block (missing END marker)".into(), - )); + return Err(CertificateParseReason::UnclosedBlock.into()); } if certs.is_empty() { - return Err(VerifyError::CertificateParse( - "No certificates found in PEM".into(), - )); + return Err(CertificateParseReason::NoCertificates.into()); } Ok(certs) @@ -250,34 +223,33 @@ fn is_valid_base64_line(s: &str) -> bool { /// Decode base64 string fn base64_decode(input: &str) -> Result, VerifyError> { - STANDARD - .decode(input) - .map_err(|e| VerifyError::CertificateParse(format!("Invalid base64: {}", e))) + STANDARD.decode(input).map_err(|e| { + VerifyError::CertificateParse(CertificateParseReason::InvalidDer(e.to_string())) + }) } /// Extract raw public key bytes from an X.509 certificate /// /// Returns the SubjectPublicKeyInfo's bit string contents -pub fn extract_public_key(cert: &Certificate) -> Result, VerifyError> { +pub(crate) fn extract_public_key(cert: &Certificate) -> Result, VerifyError> { let spki = &cert.tbs_certificate.subject_public_key_info; let pubkey_bits = spki .subject_public_key .as_bytes() - .ok_or_else(|| VerifyError::CertificateParse("Public key has unused bits".into()))?; + .ok_or(CertificateParseReason::PublicKeyUnusedBits)?; Ok(pubkey_bits.to_vec()) } -/// Compute SHA-256 hash of public key and return as hex string -pub fn hash_public_key(pubkey_bytes: &[u8]) -> String { - let digest = Sha256::digest(pubkey_bytes); - hex::encode(digest) +/// Compute SHA-256 hash of public key +pub(crate) fn hash_public_key(pubkey_bytes: &[u8]) -> [u8; 32] { + Sha256::digest(pubkey_bytes).into() } /// Result of certificate chain validation #[derive(Debug)] -pub struct ChainValidationResult { - /// SHA-256 hash of the root CA's public key (hex string) - pub root_pubkey_hash: String, +pub(crate) struct ChainValidationResult { + /// SHA-256 hash of the root CA's public key + pub root_pubkey_hash: [u8; 32], } /// Validate certificate chain with rigid X.509 validation @@ -309,21 +281,19 @@ pub struct ChainValidationResult { /// - Each certificate's Issuer must match its parent's Subject /// /// Chain should be leaf-first, root-last. -pub fn validate_tpm_cert_chain( +pub(crate) fn validate_tpm_cert_chain( chain: &[Certificate], time: UnixTime, ) -> Result { if chain.is_empty() { - return Err(VerifyError::ChainValidation( - "Empty certificate chain".into(), - )); + return Err(ChainValidationReason::EmptyChain.into()); } if chain.len() > MAX_CHAIN_DEPTH { - return Err(VerifyError::ChainValidation(format!( - "Certificate chain too deep: {} certificates (max {})", - chain.len(), - MAX_CHAIN_DEPTH - ))); + return Err(ChainValidationReason::ChainTooDeep { + depth: chain.len(), + max: MAX_CHAIN_DEPTH, + } + .into()); } // === X.509 Extension Validation === @@ -336,15 +306,10 @@ pub fn validate_tpm_cert_chain( // 1. Basic Constraints validation if let Some(bc) = extract_basic_constraints(cert) { if is_leaf && bc.ca { - return Err(VerifyError::ChainValidation( - "Leaf certificate has CA:TRUE - must be CA:FALSE".into(), - )); + return Err(ChainValidationReason::LeafIsCa.into()); } if !is_leaf && !bc.ca { - return Err(VerifyError::ChainValidation(format!( - "Certificate {} (intermediate/root) must have CA:TRUE", - i - ))); + return Err(ChainValidationReason::CaMissingCaFlag { index: i }.into()); } // Check pathLenConstraint for CA certificates @@ -358,50 +323,39 @@ pub fn validate_tpm_cert_chain( // (position 0 is leaf, positions 1..i-1 are intermediates below) let cas_below = if i > 0 { i - 1 } else { 0 }; if cas_below > path_len as usize { - return Err(VerifyError::ChainValidation(format!( - "Certificate {} pathLenConstraint violated: allows {} CAs below, but {} exist", - i, path_len, cas_below - ))); + return Err(ChainValidationReason::PathLenViolated { + index: i, + allowed: path_len, + actual: cas_below, + } + .into()); } } } } else if !is_leaf { // CA certificates SHOULD have Basic Constraints // This is a SHOULD per RFC 5280, but we enforce it for security - return Err(VerifyError::ChainValidation(format!( - "Certificate {} (intermediate/root) missing Basic Constraints extension", - i - ))); + return Err(ChainValidationReason::MissingBasicConstraints { index: i }.into()); } // 2. Key Usage validation if let Some(ku) = extract_key_usage(cert) { if is_leaf && !ku.digital_signature { - return Err(VerifyError::ChainValidation( - "Leaf certificate missing digitalSignature key usage".into(), - )); + return Err(ChainValidationReason::LeafMissingDigitalSignature.into()); } if !is_leaf && !ku.key_cert_sign { - return Err(VerifyError::ChainValidation(format!( - "Certificate {} (CA) missing keyCertSign key usage", - i - ))); + return Err(ChainValidationReason::CaMissingKeyCertSign { index: i }.into()); } } else if is_leaf { // Leaf certificate MUST have Key Usage for signing - return Err(VerifyError::ChainValidation( - "Leaf certificate missing Key Usage extension".into(), - )); + return Err(ChainValidationReason::LeafMissingKeyUsage.into()); } // 3. Subject/Issuer name chaining if !is_root { let parent = &chain[i + 1]; if cert.tbs_certificate.issuer != parent.tbs_certificate.subject { - return Err(VerifyError::ChainValidation(format!( - "Certificate {} issuer does not match parent subject", - i - ))); + return Err(ChainValidationReason::IssuerMismatch { index: i }.into()); } } } @@ -420,7 +374,7 @@ pub fn validate_tpm_cert_chain( let tbs_der = cert .tbs_certificate .to_der() - .map_err(|e| VerifyError::ChainValidation(format!("Failed to encode TBS: {}", e)))?; + .map_err(|e| ChainValidationReason::CryptoError(e.to_string()))?; // Get the signature let sig_bytes = cert.signature.raw_bytes(); @@ -440,81 +394,63 @@ pub fn validate_tpm_cert_chain( // RSA PKCS#1 v1.5 with SHA-256 verification // For RSA, we need the full SPKI structure, not just raw key bytes let issuer_spki = &issuer.tbs_certificate.subject_public_key_info; - let issuer_spki_der = issuer_spki.to_der().map_err(|e| { - VerifyError::ChainValidation(format!("Failed to encode issuer SPKI: {}", e)) - })?; + let issuer_spki_der = issuer_spki + .to_der() + .map_err(|e| ChainValidationReason::CryptoError(e.to_string()))?; let rsa_pubkey = RsaPublicKey::try_from( - spki::SubjectPublicKeyInfoRef::try_from(issuer_spki_der.as_slice()).map_err( - |e| VerifyError::ChainValidation(format!("Invalid RSA SPKI: {}", e)), - )?, + spki::SubjectPublicKeyInfoRef::try_from(issuer_spki_der.as_slice()) + .map_err(|e| ChainValidationReason::CryptoError(e.to_string()))?, ) - .map_err(|e| VerifyError::ChainValidation(format!("Invalid RSA key: {}", e)))?; + .map_err(|e| ChainValidationReason::CryptoError(e.to_string()))?; let verifying_key = RsaVerifyingKey::::new(rsa_pubkey); - let signature = RsaSignature::try_from(sig_bytes).map_err(|e| { - VerifyError::ChainValidation(format!("Invalid RSA signature: {}", e)) - })?; + let signature = RsaSignature::try_from(sig_bytes) + .map_err(|e| ChainValidationReason::CryptoError(e.to_string()))?; - verifying_key.verify(&tbs_der, &signature).map_err(|_| { - VerifyError::ChainValidation(format!( - "Certificate {} RSA signature verification failed", - i - )) - })?; + verifying_key + .verify(&tbs_der, &signature) + .map_err(|_| ChainValidationReason::SignatureVerificationFailed { index: i })?; } ECDSA_SHA256_OID => { // P-256 verification if issuer_pubkey.len() != 65 || issuer_pubkey[0] != 0x04 { - return Err(VerifyError::ChainValidation( + return Err(ChainValidationReason::CryptoError( "Invalid issuer public key format for P-256".into(), - )); + ) + .into()); } - let verifying_key = - P256VerifyingKey::from_sec1_bytes(&issuer_pubkey).map_err(|e| { - VerifyError::ChainValidation(format!("Invalid P-256 key: {}", e)) - })?; - - let signature = P256Signature::from_der(sig_bytes).map_err(|e| { - VerifyError::ChainValidation(format!("Invalid P-256 signature: {}", e)) - })?; - - verifying_key.verify(&tbs_der, &signature).map_err(|_| { - VerifyError::ChainValidation(format!( - "Certificate {} signature verification failed", - i - )) - })?; + let verifying_key = P256VerifyingKey::from_sec1_bytes(&issuer_pubkey) + .map_err(|e| ChainValidationReason::CryptoError(e.to_string()))?; + + let signature = P256Signature::from_der(sig_bytes) + .map_err(|e| ChainValidationReason::CryptoError(e.to_string()))?; + + verifying_key + .verify(&tbs_der, &signature) + .map_err(|_| ChainValidationReason::SignatureVerificationFailed { index: i })?; } ECDSA_SHA384_OID => { // P-384 verification if issuer_pubkey.len() != 97 || issuer_pubkey[0] != 0x04 { - return Err(VerifyError::ChainValidation( + return Err(ChainValidationReason::CryptoError( "Invalid issuer public key format for P-384".into(), - )); + ) + .into()); } - let verifying_key = - P384VerifyingKey::from_sec1_bytes(&issuer_pubkey).map_err(|e| { - VerifyError::ChainValidation(format!("Invalid P-384 key: {}", e)) - })?; - - let signature = P384Signature::from_der(sig_bytes).map_err(|e| { - VerifyError::ChainValidation(format!("Invalid P-384 signature: {}", e)) - })?; - - verifying_key.verify(&tbs_der, &signature).map_err(|_| { - VerifyError::ChainValidation(format!( - "Certificate {} signature verification failed", - i - )) - })?; + let verifying_key = P384VerifyingKey::from_sec1_bytes(&issuer_pubkey) + .map_err(|e| ChainValidationReason::CryptoError(e.to_string()))?; + + let signature = P384Signature::from_der(sig_bytes) + .map_err(|e| ChainValidationReason::CryptoError(e.to_string()))?; + + verifying_key + .verify(&tbs_der, &signature) + .map_err(|_| ChainValidationReason::SignatureVerificationFailed { index: i })?; } _ => { - return Err(VerifyError::ChainValidation(format!( - "Unsupported signature algorithm: {}", - alg_str - ))); + return Err(ChainValidationReason::UnsupportedAlgorithm { oid: alg_str }.into()); } } } @@ -532,16 +468,10 @@ pub fn validate_tpm_cert_chain( let not_after = validity.not_after.to_unix_duration().as_secs(); if unix_secs < not_before { - return Err(VerifyError::ChainValidation(format!( - "Certificate {} is not yet valid", - i - ))); + return Err(ChainValidationReason::CertNotYetValid { index: i }.into()); } if unix_secs > not_after { - return Err(VerifyError::ChainValidation(format!( - "Certificate {} has expired", - i - ))); + return Err(ChainValidationReason::CertExpired { index: i }.into()); } } @@ -554,18 +484,6 @@ pub fn validate_tpm_cert_chain( }) } -/// Parse PEM and validate TPM certificate chain -/// -/// Convenience wrapper that parses PEM then validates without EKU checking. -/// Chain should be leaf-first, root-last in the PEM. -pub fn parse_and_validate_tpm_cert_chain( - chain_pem: &str, - time: UnixTime, -) -> Result { - let certs = parse_cert_chain_pem(chain_pem)?; - validate_tpm_cert_chain(&certs, time) -} - #[cfg(test)] mod tests { use super::*; @@ -574,8 +492,8 @@ mod tests { fn test_hash_public_key() { let pubkey = [0x04, 0x01, 0x02, 0x03]; let hash = hash_public_key(&pubkey); - // SHA-256 of [0x04, 0x01, 0x02, 0x03] - assert_eq!(hash.len(), 64); // 32 bytes = 64 hex chars + // SHA-256 returns 32 bytes + assert_eq!(hash.len(), 32); } #[test] @@ -658,9 +576,12 @@ mod tests { SGVsbG8=\n\ -----END CERTIFICATE-----"; let result = parse_cert_chain_pem(pem); - assert!( - matches!(result, Err(VerifyError::CertificateParse(ref msg)) if msg.contains("Unexpected content")) - ); + assert!(matches!( + result, + Err(VerifyError::CertificateParse( + CertificateParseReason::UnexpectedContent { .. } + )) + ),); } #[test] @@ -676,9 +597,12 @@ mod tests { -----END CERTIFICATE-----"; let result = parse_cert_chain_pem(pem); // Will fail on invalid DER, not on parsing - assert!( - matches!(result, Err(VerifyError::CertificateParse(ref msg)) if msg.contains("DER")) - ); + assert!(matches!( + result, + Err(VerifyError::CertificateParse( + CertificateParseReason::InvalidDer(_) + )) + ),); } #[test] @@ -690,9 +614,12 @@ mod tests { V29ybGQ=\n\ -----END CERTIFICATE-----"; let result = parse_cert_chain_pem(pem); - assert!( - matches!(result, Err(VerifyError::CertificateParse(ref msg)) if msg.contains("Unexpected BEGIN")) - ); + assert!(matches!( + result, + Err(VerifyError::CertificateParse( + CertificateParseReason::NestedBeginMarker { .. } + )) + ),); } #[test] @@ -700,9 +627,12 @@ mod tests { // END marker without BEGIN should be rejected let pem = "-----END CERTIFICATE-----"; let result = parse_cert_chain_pem(pem); - assert!( - matches!(result, Err(VerifyError::CertificateParse(ref msg)) if msg.contains("without matching BEGIN")) - ); + assert!(matches!( + result, + Err(VerifyError::CertificateParse( + CertificateParseReason::EndWithoutBegin { .. } + )) + ),); } #[test] @@ -711,9 +641,12 @@ mod tests { let pem = "-----BEGIN CERTIFICATE-----\n\ SGVsbG8=\n"; let result = parse_cert_chain_pem(pem); - assert!( - matches!(result, Err(VerifyError::CertificateParse(ref msg)) if msg.contains("missing END")) - ); + assert!(matches!( + result, + Err(VerifyError::CertificateParse( + CertificateParseReason::UnclosedBlock + )) + ),); } #[test] diff --git a/crates/vaportpm-verify/tests/gcp.rs b/crates/vaportpm-verify/tests/gcp.rs new file mode 100644 index 0000000..ff41d61 --- /dev/null +++ b/crates/vaportpm-verify/tests/gcp.rs @@ -0,0 +1,530 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! GCP Shielded VM attestation verification — tampering and edge case tests +//! +//! These tests verify that the GCP verification path correctly rejects +//! attestations where any component has been tampered with. Each test +//! modifies exactly one field in a known-good fixture and asserts the +//! expected error. + +use std::collections::BTreeMap; +use std::time::Duration; + +use vaportpm_verify::{ + verify_attestation_output, verify_decoded_attestation_output, CertificateParseReason, + ChainValidationReason, CloudProvider, DecodedAttestationOutput, DecodedPlatformAttestation, + EccPublicKeyCoords, InvalidAttestReason, P256PublicKey, PcrAlgorithm, SignatureInvalidReason, + UnixTime, VerifyError, +}; + +use vaportpm_verify::AttestationOutput; + +/// Timestamp when GCP AMD fixture certificates are valid (Feb 2, 2026 08:00:00 UTC) +const GCP_AMD_FIXTURE_TIMESTAMP_SECS: u64 = 1770019200; + +/// Timestamp when GCP TDX fixture certificates are valid (Feb 3, 2026 08:00:00 UTC) +const GCP_TDX_FIXTURE_TIMESTAMP_SECS: u64 = 1770091200; + +fn gcp_amd_fixture_time() -> UnixTime { + UnixTime::since_unix_epoch(Duration::from_secs(GCP_AMD_FIXTURE_TIMESTAMP_SECS)) +} + +fn gcp_tdx_fixture_time() -> UnixTime { + UnixTime::since_unix_epoch(Duration::from_secs(GCP_TDX_FIXTURE_TIMESTAMP_SECS)) +} + +fn load_gcp_amd_fixture() -> AttestationOutput { + let fixture = include_str!("../test-gcp-amd-fixture.json"); + serde_json::from_str(fixture).expect("Failed to parse GCP AMD fixture") +} + +fn load_gcp_tdx_fixture() -> AttestationOutput { + let fixture = include_str!("../test-gcp-tdx-fixture.json"); + serde_json::from_str(fixture).expect("Failed to parse GCP TDX fixture") +} + +fn decode_gcp_amd_fixture() -> DecodedAttestationOutput { + let output = load_gcp_amd_fixture(); + DecodedAttestationOutput::decode(&output).expect("Failed to decode GCP AMD fixture") +} + +// ============================================================================= +// Sanity: unmodified fixtures +// ============================================================================= + +#[test] +fn test_gcp_amd_fixture_verifies() { + let output = load_gcp_amd_fixture(); + let result = verify_attestation_output(&output, gcp_amd_fixture_time()) + .expect("Verification should succeed"); + + assert_eq!(result.provider, CloudProvider::Gcp); + + let expected_nonce = + hex::decode("8a543108a653b4a1162232744cc9b945017a449dea4fbb0ca62f42d3ef145562").unwrap(); + assert_eq!(result.nonce.as_slice(), expected_nonce.as_slice()); + + assert_eq!(result.pcrs.algorithm(), PcrAlgorithm::Sha256); +} + +#[test] +fn test_gcp_tdx_fixture_verifies() { + let output = load_gcp_tdx_fixture(); + let result = verify_attestation_output(&output, gcp_tdx_fixture_time()) + .expect("Verification should succeed"); + + assert_eq!(result.provider, CloudProvider::Gcp); + + let expected_nonce = + hex::decode("6424632e79ec068f2189adf46d121b9a10f758c45a18c52f630da14600d4317b").unwrap(); + assert_eq!(result.nonce.as_slice(), expected_nonce.as_slice()); + + assert_eq!(result.pcrs.algorithm(), PcrAlgorithm::Sha256); +} + +// ============================================================================= +// Critical: AK public key tampering +// ============================================================================= + +/// Attacker substitutes their own ECC P-256 public key coordinates. +/// +/// Detected at: gcp.rs — AK pubkey from leaf certificate won't match +/// the tampered `decoded.ak_pubkey`. +#[test] +fn test_gcp_reject_tampered_ak_public_key() { + let mut output = load_gcp_amd_fixture(); + + output.ak_pubkeys.insert( + "ecc_p256".to_string(), + EccPublicKeyCoords { + x: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + y: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + }, + ); + + let result = verify_attestation_output(&output, gcp_amd_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::SignatureInvalid( + SignatureInvalidReason::AkPublicKeyMismatch + )) + ), + "Should reject tampered AK public key, got: {:?}", + result + ); +} + +// ============================================================================= +// Critical: PCR value tampering +// ============================================================================= + +/// Attacker modifies a single SHA-256 PCR value. +/// +/// Detected at: gcp.rs `verify_pcr_digest_matches` — the recomputed SHA-256 +/// digest of concatenated PCR values won't match the signed digest in the +/// TPM quote. +#[test] +fn test_gcp_reject_tampered_pcr_value() { + let mut output = load_gcp_amd_fixture(); + + if let Some(sha256_pcrs) = output.pcrs.get_mut("sha256") { + sha256_pcrs.insert( + 0, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string(), + ); + } + + let result = verify_attestation_output(&output, gcp_amd_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::PcrDigestMismatch + )) + ), + "Should reject tampered PCR value, got: {:?}", + result + ); +} + +// ============================================================================= +// Critical: TPM quote signature tampering +// ============================================================================= + +/// Attacker corrupts the ECDSA signature over the TPM quote. +/// +/// Detected at: gcp.rs `verify_ecdsa_p256` — the corrupted DER signature +/// won't verify against the AK public key. +#[test] +fn test_gcp_reject_tampered_quote_signature() { + let mut output = load_gcp_amd_fixture(); + + if let Some(tpm) = output.attestation.tpm.get_mut("ecc_p256") { + let mut sig_bytes = hex::decode(&tpm.signature).unwrap(); + // Flip a byte in the middle of the DER-encoded signature + sig_bytes[10] ^= 0xff; + tpm.signature = hex::encode(sig_bytes); + } + + let result = verify_attestation_output(&output, gcp_amd_fixture_time()); + assert!( + matches!(result, Err(VerifyError::SignatureInvalid(_))), + "Should reject tampered quote signature, got: {:?}", + result + ); +} + +// ============================================================================= +// Critical: TPM quote attest_data tampering +// ============================================================================= + +/// Attacker corrupts the TPM quote attest_data body (outside the nonce region). +/// +/// Detected at: gcp.rs `verify_ecdsa_p256` — SHA-256(modified attest_data) +/// won't match the existing signature. +#[test] +fn test_gcp_reject_tampered_quote_attest_data() { + let mut output = load_gcp_amd_fixture(); + + if let Some(tpm) = output.attestation.tpm.get_mut("ecc_p256") { + let mut attest_bytes = hex::decode(&tpm.attest_data).unwrap(); + // Flip the last byte (in the PCR digest area, not the nonce) + let last = attest_bytes.len() - 1; + attest_bytes[last] ^= 0xff; + tpm.attest_data = hex::encode(attest_bytes); + } + + let result = verify_attestation_output(&output, gcp_amd_fixture_time()); + assert!( + matches!(result, Err(VerifyError::SignatureInvalid(_))), + "Should reject tampered attest_data, got: {:?}", + result + ); +} + +// ============================================================================= +// Critical: Nonce tampering (correct length) +// ============================================================================= + +/// Attacker replaces the nonce with a different 32-byte value. +/// +/// This test uses a valid-length nonce (64 hex chars = 32 bytes) to exercise +/// the actual nonce comparison logic in verify_gcp_decoded, unlike the +/// existing test in lib.rs which uses a 4-byte nonce that fails at decode +/// time before reaching the comparison. +/// +/// Detected at: gcp.rs — `decoded.nonce != quote_info.nonce` +#[test] +fn test_gcp_reject_tampered_nonce_correct_length() { + let mut output = load_gcp_amd_fixture(); + + // Different 32-byte nonce (64 hex chars) + output.nonce = "0000000000000000000000000000000000000000000000000000000000000001".to_string(); + + let result = verify_attestation_output(&output, gcp_amd_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::NonceMismatch + )) + ), + "Should reject nonce that doesn't match Quote extraData, got: {:?}", + result + ); +} + +// ============================================================================= +// High: Missing SHA-256 PCRs +// ============================================================================= + +/// Attestation has PCR values but none for SHA-256 (only SHA-384). +/// GCP verification explicitly requires SHA-256 PCRs and rejects +/// any non-SHA-256 bank. +/// +/// Detected at: gcp.rs — WrongPcrBankAlgorithm check +#[test] +fn test_gcp_reject_non_sha256_pcrs() { + let mut output = load_gcp_amd_fixture(); + + // Remove the SHA-256 bank entirely, substitute 24 SHA-384 entries + // so that PcrBank::from_values succeeds. GCP then rejects it + // with WrongPcrBankAlgorithm. + output.pcrs.remove("sha256"); + let mut sha384_pcrs = BTreeMap::new(); + let sha384_zero = "0".repeat(96); // 48 bytes = 96 hex chars + for idx in 0u8..24 { + sha384_pcrs.insert(idx, sha384_zero.clone()); + } + output.pcrs.insert("sha384".to_string(), sha384_pcrs); + + let result = verify_attestation_output(&output, gcp_amd_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::WrongPcrBankAlgorithm { + expected: PcrAlgorithm::Sha256, + got: PcrAlgorithm::Sha384, + } + )) + ), + "Should reject attestation with non-SHA-256 PCRs, got: {:?}", + result + ); +} + +// ============================================================================= +// High: PCR selected in quote but missing from attestation +// ============================================================================= + +/// Removing a PCR from the attestation when all 24 are required. +/// +/// Detected at: PcrBank::from_values — rejects incomplete PCR sets +#[test] +fn test_gcp_reject_missing_pcr() { + let mut output = load_gcp_amd_fixture(); + + // Remove PCR 0 — hits MissingPcr at decode time + if let Some(sha256_pcrs) = output.pcrs.get_mut("sha256") { + sha256_pcrs.remove(&0u8); + } + + let result = verify_attestation_output(&output, gcp_amd_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::MissingPcr { index: 0 } + )) + ), + "Should reject when a PCR is missing, got: {:?}", + result + ); +} + +// ============================================================================= +// High: Certificate chain tampering +// ============================================================================= + +/// Attacker provides an empty certificate chain. +/// +/// Detected at: gcp.rs — "Empty certificate chain" check on parsed DER certs +#[test] +fn test_gcp_reject_empty_cert_chain() { + let mut output = load_gcp_amd_fixture(); + + if let Some(ref mut gcp) = output.attestation.gcp { + gcp.ak_cert_chain = String::new(); + } + + let result = verify_attestation_output(&output, gcp_amd_fixture_time()); + assert!( + result.is_err(), + "Should reject empty certificate chain, got: {:?}", + result + ); +} + +/// Attacker provides a cert chain with corrupted PEM content. +/// +/// Detected at: x509.rs PEM parser or DER decoder +#[test] +fn test_gcp_reject_corrupted_cert_chain() { + let mut output = load_gcp_amd_fixture(); + + if let Some(ref mut gcp) = output.attestation.gcp { + gcp.ak_cert_chain = + "-----BEGIN CERTIFICATE-----\nTm90QVJlYWxDZXJ0\n-----END CERTIFICATE-----\n" + .to_string(); + } + + let result = verify_attestation_output(&output, gcp_amd_fixture_time()); + assert!( + matches!(result, Err(VerifyError::CertificateParse(_))), + "Should reject corrupted cert chain, got: {:?}", + result + ); +} + +// ============================================================================= +// Medium: Time validity +// ============================================================================= + +/// Verification at a time before the leaf certificate's notBefore. +/// +/// Detected at: x509.rs `validate_tpm_cert_chain` — "not yet valid" +#[test] +fn test_gcp_reject_cert_not_yet_valid() { + let output = load_gcp_amd_fixture(); + + // Use a timestamp far in the past (year 2020) + let past_time = UnixTime::since_unix_epoch(Duration::from_secs(1577836800)); + + let result = verify_attestation_output(&output, past_time); + assert!( + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::CertNotYetValid { .. } + )) + ), + "Should reject cert not yet valid, got: {:?}", + result + ); +} + +/// Verification at a time after the leaf certificate's notAfter. +/// +/// Detected at: x509.rs `validate_tpm_cert_chain` — "has expired" +#[test] +fn test_gcp_reject_cert_expired() { + let output = load_gcp_amd_fixture(); + + // Use a timestamp far in the future (year 2100). + // The GCP leaf cert expires 2056-01-26, root expires 2122-07-08. + // The intermediate also expires 2122-07-08. So 2060 should trigger + // leaf expiry. + let future_time = UnixTime::since_unix_epoch(Duration::from_secs(2840140800)); + + let result = verify_attestation_output(&output, future_time); + assert!( + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::CertExpired { .. } + )) + ), + "Should reject expired cert, got: {:?}", + result + ); +} + +// ============================================================================= +// Coverage: decoded-level edge cases (via verify_decoded_attestation_output) +// +// These tests bypass the JSON→decode path to inject data that can't occur +// through normal deserialization but could arrive via the flat binary format +// or a buggy caller. +// +// These are crucial for testing the ZK verification path which takes data directly +// from the ZK host program, where essentially the host coul be malicious or adversarial +// ============================================================================= + +/// Empty cert_chain_der — no certificates at all. +/// +/// Covers: gcp.rs:40-44 (certs.is_empty() branch) +#[test] +fn test_gcp_decoded_reject_empty_cert_chain() { + let mut decoded = decode_gcp_amd_fixture(); + decoded.platform = DecodedPlatformAttestation::Gcp { + cert_chain_der: vec![], + }; + + let result = verify_decoded_attestation_output(&decoded, gcp_amd_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::EmptyChain + )) + ), + "Should reject empty cert chain in decoded path, got: {:?}", + result + ); +} + +/// Invalid DER bytes in cert_chain_der. +/// +/// Covers: gcp.rs:35-37 (Certificate::from_der error path) +#[test] +fn test_gcp_decoded_reject_invalid_der_cert() { + let mut decoded = decode_gcp_amd_fixture(); + decoded.platform = DecodedPlatformAttestation::Gcp { + cert_chain_der: vec![vec![0x30, 0x00, 0xFF, 0xFF]], + }; + + let result = verify_decoded_attestation_output(&decoded, gcp_amd_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::CertificateParse( + CertificateParseReason::InvalidDer(_) + )) + ), + "Should reject invalid DER cert, got: {:?}", + result + ); +} + +/// Certificate chain that is structurally valid but not rooted at a known +/// cloud provider CA. The chain validation passes but provider lookup fails. +/// +/// Covers: gcp.rs — provider_from_hash returns None → "Unknown root CA" +#[test] +fn test_gcp_decoded_reject_unknown_root_ca() { + use ecdsa::signature::hazmat::PrehashSigner; + use p256::pkcs8::DecodePrivateKey; + use sha2::Digest; + + // Generate a self-signed CA + let mut ca_params = rcgen::CertificateParams::new(vec!["Fake Root CA".to_string()]).unwrap(); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params.key_usages = vec![ + rcgen::KeyUsagePurpose::KeyCertSign, + rcgen::KeyUsagePurpose::DigitalSignature, + ]; + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, "Fake Root CA"); + let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let ca_cert = ca_params.self_signed(&ca_key).unwrap(); + + // Generate a leaf cert signed by our fake CA + let mut leaf_params = rcgen::CertificateParams::new(vec!["Fake Leaf".to_string()]).unwrap(); + leaf_params.is_ca = rcgen::IsCa::NoCa; + leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "Fake Leaf"); + let leaf_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap(); + + // Start from the real fixture (has valid quote_attest, nonce, PCRs) + let mut decoded = decode_gcp_amd_fixture(); + + // Extract AK public key from the leaf signing key + let leaf_signing_key_for_pk = + p256::ecdsa::SigningKey::from_pkcs8_der(&leaf_key.serialize_der()).unwrap(); + let ak_point = leaf_signing_key_for_pk + .verifying_key() + .to_encoded_point(false); + decoded.ak_pubkey = P256PublicKey::from_sec1_uncompressed(ak_point.as_bytes()).unwrap(); + + // Re-sign the quote_attest with the fake leaf's private key so the + // signature verification passes. verify_ecdsa_p256 does + // verify_prehash(SHA-256(message)), so we sign_prehash the same digest. + let leaf_signing_key = + p256::ecdsa::SigningKey::from_pkcs8_der(&leaf_key.serialize_der()).unwrap(); + let digest = sha2::Sha256::digest(&decoded.quote_attest); + let signature: p256::ecdsa::Signature = leaf_signing_key.sign_prehash(&digest).unwrap(); + decoded.quote_signature = signature.to_der().as_bytes().to_vec(); + + // Swap in our fake cert chain + decoded.platform = DecodedPlatformAttestation::Gcp { + cert_chain_der: vec![leaf_cert.der().to_vec(), ca_cert.der().to_vec()], + }; + + let result = verify_decoded_attestation_output(&decoded, gcp_amd_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::UnknownRootCa { .. } + )) + ), + "Should reject unknown root CA, got: {:?}", + result + ); +} diff --git a/crates/vaportpm-verify/tests/nitro.rs b/crates/vaportpm-verify/tests/nitro.rs new file mode 100644 index 0000000..b8dae1f --- /dev/null +++ b/crates/vaportpm-verify/tests/nitro.rs @@ -0,0 +1,395 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! AWS Nitro attestation verification — happy path and tampering tests +//! +//! These tests verify that the Nitro verification path correctly accepts +//! valid attestations and rejects any where a component has been tampered with. + +use std::collections::BTreeMap; +use std::time::Duration; + +use vaportpm_verify::{ + verify_attestation_output, verify_decoded_attestation_output, ChainValidationReason, + CloudProvider, DecodedAttestationOutput, DecodedPlatformAttestation, EccPublicKeyCoords, + InvalidAttestReason, PcrAlgorithm, UnixTime, VerifyError, +}; + +use vaportpm_verify::AttestationOutput; + +/// Timestamp when Nitro fixture certificates are valid (Feb 3, 2026 11:00:00 UTC) +const NITRO_FIXTURE_TIMESTAMP_SECS: u64 = 1770116400; + +fn nitro_fixture_time() -> UnixTime { + UnixTime::since_unix_epoch(Duration::from_secs(NITRO_FIXTURE_TIMESTAMP_SECS)) +} + +fn load_nitro_fixture() -> AttestationOutput { + let fixture = include_str!("../test-nitro-fixture.json"); + serde_json::from_str(fixture).expect("Failed to parse Nitro fixture") +} + +fn decode_nitro_fixture() -> DecodedAttestationOutput { + let output = load_nitro_fixture(); + DecodedAttestationOutput::decode(&output).expect("Failed to decode Nitro fixture") +} + +// ============================================================================= +// Sanity: unmodified fixture +// ============================================================================= + +#[test] +fn test_nitro_fixture_verifies() { + let output = load_nitro_fixture(); + let result = verify_attestation_output(&output, nitro_fixture_time()) + .expect("Verification should succeed"); + + assert_eq!(result.provider, CloudProvider::Aws); + + let expected_nonce = + hex::decode("230af3f7c0ec43ccf99a4cab47ac61469a36ea74b1e79740fdf8ccfc8f56161a").unwrap(); + assert_eq!(result.nonce.as_slice(), expected_nonce.as_slice()); + + assert_eq!(result.pcrs.algorithm(), PcrAlgorithm::Sha384); +} + +// ============================================================================= +// Tampering: AK public key +// ============================================================================= + +/// Attacker substitutes their own AK public key coordinates. +/// +/// Detected at: nitro.rs — the AK pubkey won't match the signed +/// `public_key` binding in the Nitro NSM document. +#[test] +fn test_nitro_reject_tampered_ak_public_key() { + let mut output = load_nitro_fixture(); + + output.ak_pubkeys.insert( + "ecc_p256".to_string(), + EccPublicKeyCoords { + x: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + y: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + }, + ); + + let result = verify_attestation_output(&output, nitro_fixture_time()); + assert!( + matches!(result, Err(VerifyError::SignatureInvalid(_))), + "Should reject tampered AK public key, got: {:?}", + result + ); +} + +// ============================================================================= +// Tampering: nonce +// ============================================================================= + +/// Attacker replaces the nonce with a short value (wrong length). +/// +/// Detected at: DecodedAttestationOutput::decode() — "nonce must be 32 bytes" +#[test] +fn test_nitro_reject_tampered_nonce_wrong_length() { + let mut output = load_nitro_fixture(); + + output.nonce = "deadbeef".to_string(); + + let result = verify_attestation_output(&output, nitro_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::NonceLengthInvalid + )) + ), + "Should reject wrong-length nonce, got: {:?}", + result + ); +} + +/// Attacker replaces the nonce with a different 32-byte value. +/// +/// This exercises the actual nonce comparison in verify_nitro_decoded, +/// not the decode-time length check. +/// +/// Detected at: nitro.rs — "Nonce does not match Quote" +#[test] +fn test_nitro_reject_tampered_nonce_correct_length() { + let mut output = load_nitro_fixture(); + + // Different 32-byte nonce (64 hex chars) + output.nonce = "0000000000000000000000000000000000000000000000000000000000000001".to_string(); + + let result = verify_attestation_output(&output, nitro_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::NonceMismatch + )) + ), + "Should reject nonce that doesn't match Quote extraData, got: {:?}", + result + ); +} + +// ============================================================================= +// Tampering: PCR values +// ============================================================================= + +/// Attacker modifies a SHA-384 PCR value. +/// +/// Detected at: tpm.rs verify_quote — PCR digest computed from tampered values +/// doesn't match the authenticated digest in the TPM Quote. +#[test] +fn test_nitro_reject_tampered_pcr_values() { + let mut output = load_nitro_fixture(); + + if let Some(sha384_pcrs) = output.pcrs.get_mut("sha384") { + sha384_pcrs.insert( + 0, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string() + ); + } + + let result = verify_attestation_output(&output, nitro_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::PcrDigestMismatch + )) + ), + "Should reject tampered PCR values, got: {:?}", + result + ); +} + +// ============================================================================= +// Critical: Tampered quote signature +// ============================================================================= + +/// Attacker corrupts the ECDSA signature over the TPM quote. +/// +/// Detected at: nitro.rs `verify_ecdsa_p256` — the corrupted DER signature +/// won't verify against the AK public key. +#[test] +fn test_nitro_reject_tampered_quote_signature() { + let mut output = load_nitro_fixture(); + + if let Some(tpm) = output.attestation.tpm.get_mut("ecc_p256") { + let mut sig_bytes = hex::decode(&tpm.signature).unwrap(); + // Flip a byte in the middle of the DER-encoded signature + sig_bytes[10] ^= 0xff; + tpm.signature = hex::encode(sig_bytes); + } + + let result = verify_attestation_output(&output, nitro_fixture_time()); + assert!( + matches!(result, Err(VerifyError::SignatureInvalid(_))), + "Should reject tampered quote signature, got: {:?}", + result + ); +} + +// ============================================================================= +// Critical: Tampered quote attest_data +// ============================================================================= + +/// Attacker corrupts the TPM quote attest_data body. +/// +/// With the verification order fix, the ECDSA signature is verified first, +/// so corrupting attest_data should produce SignatureInvalid (not a PCR +/// digest mismatch or nonce error). +/// +/// Detected at: nitro.rs `verify_ecdsa_p256` — SHA-256(modified attest_data) +/// won't match the existing signature. +#[test] +fn test_nitro_reject_tampered_quote_attest_data() { + let mut output = load_nitro_fixture(); + + if let Some(tpm) = output.attestation.tpm.get_mut("ecc_p256") { + let mut attest_bytes = hex::decode(&tpm.attest_data).unwrap(); + // Flip the last byte (in the PCR digest area, not the nonce) + let last = attest_bytes.len() - 1; + attest_bytes[last] ^= 0xff; + tpm.attest_data = hex::encode(attest_bytes); + } + + let result = verify_attestation_output(&output, nitro_fixture_time()); + assert!( + matches!(result, Err(VerifyError::SignatureInvalid(_))), + "Should reject tampered attest_data with SignatureInvalid (not PCR mismatch), got: {:?}", + result + ); +} + +// ============================================================================= +// Medium: Certificate time validity +// ============================================================================= + +/// Verification at a time before the leaf certificate's notBefore. +/// +/// Detected at: x509.rs `validate_tpm_cert_chain` — "not yet valid" +#[test] +fn test_nitro_reject_cert_not_yet_valid() { + let output = load_nitro_fixture(); + + // Use a timestamp far in the past (year 2020) + let past_time = UnixTime::since_unix_epoch(Duration::from_secs(1577836800)); + + let result = verify_attestation_output(&output, past_time); + assert!( + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::CertNotYetValid { .. } + )) + ), + "Should reject cert not yet valid, got: {:?}", + result + ); +} + +/// Verification at a time after the leaf certificate's notAfter. +/// +/// Detected at: x509.rs `validate_tpm_cert_chain` — "has expired" +#[test] +fn test_nitro_reject_cert_expired() { + let output = load_nitro_fixture(); + + // Use a timestamp far in the future (year 2100) + let future_time = UnixTime::since_unix_epoch(Duration::from_secs(4102444800)); + + let result = verify_attestation_output(&output, future_time); + assert!( + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::CertExpired { .. } + )) + ), + "Should reject expired cert, got: {:?}", + result + ); +} + +// ============================================================================= +// High: Missing SHA-384 PCRs +// ============================================================================= + +/// Attestation has PCR values but none for SHA-384 (only SHA-256). +/// Nitro verification explicitly requires SHA-384 PCRs and rejects +/// any non-SHA-384 bank. +/// +/// Detected at: nitro.rs — WrongPcrBankAlgorithm check +#[test] +fn test_nitro_reject_non_sha384_pcrs() { + let mut output = load_nitro_fixture(); + + // Remove the SHA-384 bank entirely, substitute 24 SHA-256 entries + // so that PcrBank::from_values succeeds. Nitro then rejects it + // with WrongPcrBankAlgorithm. + output.pcrs.remove("sha384"); + let mut sha256_pcrs = BTreeMap::new(); + let sha256_zero = "0".repeat(64); // 32 bytes = 64 hex chars + for idx in 0u8..24 { + sha256_pcrs.insert(idx, sha256_zero.clone()); + } + output.pcrs.insert("sha256".to_string(), sha256_pcrs); + + let result = verify_attestation_output(&output, nitro_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::WrongPcrBankAlgorithm { + expected: PcrAlgorithm::Sha384, + got: PcrAlgorithm::Sha256, + } + )) + ), + "Should reject attestation with non-SHA-384 PCRs, got: {:?}", + result + ); +} + +/// Attestation has SHA-384 PCRs but also includes SHA-256 PCRs. +/// PcrBank rejects mixed algorithms at decode time. +/// +/// Detected at: PcrBank::from_values — PcrBankMixedAlgorithms +#[test] +fn test_nitro_reject_extra_sha256_pcrs() { + let mut output = load_nitro_fixture(); + + // Add a SHA-256 bank alongside the existing SHA-384 bank. + // PcrBank::from_values rejects mixed algorithms. + let mut sha256_pcrs = BTreeMap::new(); + sha256_pcrs.insert( + 0u8, + "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + ); + output.pcrs.insert("sha256".to_string(), sha256_pcrs); + + let result = verify_attestation_output(&output, nitro_fixture_time()); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::PcrBankMixedAlgorithms + )) + ), + "Should reject attestation with extra SHA-256 PCRs alongside SHA-384, got: {:?}", + result + ); +} + +// ============================================================================= +// Coverage: decoded-level edge cases (via verify_decoded_attestation_output) +// +// These tests bypass the JSON→decode path to inject data that can't occur +// through normal deserialization but could arrive via the flat binary format +// or a buggy/malicious caller. +// ============================================================================= + +/// Empty COSE document bytes. +/// +/// Covers: nitro.rs — CoseSign1::from_slice error path +#[test] +fn test_nitro_decoded_reject_empty_cose_document() { + let mut decoded = decode_nitro_fixture(); + decoded.platform = DecodedPlatformAttestation::Nitro { document: vec![] }; + + let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); + assert!( + matches!(result, Err(VerifyError::CoseVerify(_))), + "Should reject empty COSE document, got: {:?}", + result + ); +} + +/// Corrupted COSE document bytes. +/// +/// Covers: nitro.rs — CoseSign1::from_slice error path +#[test] +fn test_nitro_decoded_reject_corrupted_cose_document() { + let mut decoded = decode_nitro_fixture(); + decoded.platform = DecodedPlatformAttestation::Nitro { + document: vec![0xFF, 0xFF, 0xFF, 0xFF], + }; + + let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); + assert!( + matches!(result, Err(VerifyError::CoseVerify(_))), + "Should reject corrupted COSE document, got: {:?}", + result + ); +} + +// Note: The following decoded-level PCR mutation tests were removed because +// PcrBank makes invalid PCR states unrepresentable at the type level: +// - test_nitro_decoded_reject_empty_pcrs (PcrBank always has 24 values) +// - test_nitro_decoded_reject_non_sha384_pcr (PcrBank has single algorithm) +// - test_nitro_decoded_reject_pcr_index_out_of_range (PcrBank indices are 0-23) +// - test_nitro_decoded_reject_missing_pcr (PcrBank has all 24) +// +// These invariants are now tested in pcr.rs unit tests via PcrBank::from_values. diff --git a/experiments/risc-zero/.gitignore b/experiments/risc-zero/.gitignore new file mode 100644 index 0000000..9bf95ea --- /dev/null +++ b/experiments/risc-zero/.gitignore @@ -0,0 +1 @@ +*.pb \ No newline at end of file diff --git a/experiments/risc-zero/Cargo.toml b/experiments/risc-zero/Cargo.toml new file mode 100644 index 0000000..8fdab5d --- /dev/null +++ b/experiments/risc-zero/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "vaportpm-zk-experiment" +version = "0.1.0" +edition = "2021" + +# Standalone workspace - isolated from main project +[workspace] + +[dependencies] +# Reference existing crates +vaportpm-verify = { path = "../../crates/vaportpm-verify" } +vaportpm-attest = { path = "../../crates/vaportpm-attest" } + +# RISC Zero +risc0-zkvm = "3.0" +vaportpm-zk-methods = { path = "methods" } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +hex = "0.4" +serde-big-array = "0.5" + +# Time +pki-types = { package = "rustls-pki-types", version = "1.13" } + +[dev-dependencies] +# For integration tests +hex-literal = "0.4" +bincode = "1.3" diff --git a/experiments/risc-zero/Makefile b/experiments/risc-zero/Makefile new file mode 100644 index 0000000..5afa3e5 --- /dev/null +++ b/experiments/risc-zero/Makefile @@ -0,0 +1,28 @@ +.PHONY: build test clean cycles setup + +# Dev mode - fast execution, no real proofs +export RISC0_DEV_MODE=1 + +build: + cargo build + +test: + cargo test -- --nocapture + +# Cycle count specifically +cycles: + rm -f profile.pb && RISC0_PPROF_OUT=./profile.pb cargo test --release test_p256_ecdsa_cycles -- --nocapture && ~/go/bin/go tool pprof -text profile.pb + #rm -f profile.pb && RISC0_PPROF_OUT=./profile.pb cargo test --release test_p384_ecdsa_cycles -- --nocapture && ~/go/bin/go tool pprof -text profile.pb + #rm -f profile.pb && RISC0_PPROF_OUT=./profile.pb cargo test --release test_gcp_attestation_cycle_count -- --nocapture && ~/go/bin/go tool pprof -text profile.pb + #rm -f profile.pb && RISC0_PPROF_OUT=./profile.pb cargo test --release test_nitro_attestation_cycle_count -- --nocapture && ~/go/bin/go tool pprof -text profile.pb + +pprof-ui: + ~/go/bin/go tool pprof -http 0.0.0.0:8090 profile.pb + +clean: + cargo clean + +# Install prerequisites +setup: + curl -L https://risczero.com/install | bash + rzup install diff --git a/experiments/risc-zero/README.md b/experiments/risc-zero/README.md new file mode 100644 index 0000000..ddf10d3 --- /dev/null +++ b/experiments/risc-zero/README.md @@ -0,0 +1,154 @@ +# RISC Zero ZK Verification Experiment + +This experiment runs `verify_decoded_attestation_output` inside RISC Zero zkVM to measure cycle counts and understand complexity. + +## Prerequisites + +1. Install the RISC Zero toolchain: + ```bash + # Install rzup (RISC Zero's toolchain manager) + curl -L https://risczero.com/install | bash + + # Restart shell or source profile, then install toolchain + rzup install + ``` + +## Usage + +### Run Cycle Count Tests + +```bash +cd experiments/risc-zero + +# Enable dev mode (fast execution, no real proofs) +export RISC0_DEV_MODE=1 + +# Run all cycle count tests +make cycles +``` + +Or run tests directly: +```bash +RISC0_DEV_MODE=1 cargo test -- --nocapture +``` + +### Expected Output + +``` +=== GCP Attestation Verification (Optimized + zerocopy) === +Flat input size: 5792 bytes +Total cycles: 882866 +Segments: 2 + +=== Nitro Attestation Verification (Optimized + zerocopy) === +Flat input size: 6446 bytes +Total cycles: 4177180 +Segments: 5 +``` + +## Host-to-Guest Communication + +Attestation data is passed from host to guest using an internal flat binary format (`vaportpm_verify::flat`). This avoids JSON parsing and hex decoding inside the zkVM, which would waste cycles on string manipulation rather than cryptographic verification. + +The host performs all text parsing (JSON, hex, PEM) upfront, converts to `DecodedAttestationOutput`, then serializes via `flat::to_bytes()`. The guest deserializes with `flat::from_bytes()` and calls `verify_decoded_attestation_output()` — the same verification function used by the native path. The flat format uses a zerocopy header for zero-allocation parsing of fixed fields. + +## Public Inputs + +The ZK circuit commits the following public inputs to the journal: + +| Field | Type | Description | +|-------|------|-------------| +| `pcr_hash` | `[u8; 32]` | SHA-256 of canonically-serialized PCR bank | +| `ak_pubkey` | `P256PublicKey` | AK public key (P-256 x/y coordinates) | +| `nonce` | `[u8; 32]` | Freshness nonce from TPM Quote | +| `provider` | `u8` | 0 = AWS, 1 = GCP | +| `verified_at` | `u64` | Verification timestamp (Unix seconds) | + +The `pcr_hash` is computed inside the guest as `SHA256(alg_u16_le || count || idx0 || value0 || idx1 || value1 || ...)` over the validated PCR bank, providing a compact commitment to all 24 PCR values. + +## Structure + +``` +experiments/risc-zero/ +├── Cargo.toml # Host crate (standalone workspace) +├── Makefile # Build/test commands +├── rustcrypto-elliptic-curves/ # Git submodule (P-384 fork) +├── src/ +│ ├── lib.rs # Library root +│ ├── host.rs # Host utilities +│ └── inputs.rs # ZkPublicInputs type +├── tests/ +│ ├── cycle_count.rs # Integration tests (cycle measurement) +│ └── ec_benchmarks.rs # EC operation benchmarks +└── methods/ + ├── Cargo.toml # Methods crate + ├── build.rs # Embeds guest ELF + ├── src/lib.rs # Re-exports generated constants + └── guest/ + ├── Cargo.toml # Guest deps + crypto patches + └── src/main.rs # Guest circuit +``` + +## How It Works + +1. The **host** (`tests/cycle_count.rs`) prepares inputs and measures cycles: + - Loads test fixtures (GCP AMD and Nitro attestations) + - Parses JSON and decodes hex/PEM on the host side + - Serializes to flat binary format via `flat::to_bytes()` with appended timestamp + - Runs the guest in dev mode (no real proofs) and reports cycle counts + +2. The **guest program** (`methods/guest/src/main.rs`) runs inside the zkVM: + - Reads flat binary input via `env::stdin()` + - Parses with `flat::from_bytes()` (zerocopy header, no allocations for fixed fields) + - Calls `verify_decoded_attestation_output()` (identical verification to native) + - Computes canonical PCR hash over the validated bank + - Commits public inputs to the journal + +## Accelerated Cryptography + +The guest uses RISC Zero's patched crypto crates for hardware-accelerated precompiles: + +| Crate | Precompile | Notes | +|-------|------------|-------| +| `sha2` | SHA-256/SHA-384 | Used extensively in cert validation | +| `p256` | P-256 ECDSA | GCP uses P-256 for AK signatures | +| `p384` | P-384 ECDSA | Nitro uses P-384 for all signatures (via fork) | +| `rsa` | RSA | GCP uses RSA-4096 certificates | +| `crypto-bigint` | Modular arithmetic | Accelerates bigint operations | + +These patches are applied via `[patch.crates-io]` in the guest Cargo.toml. The P-384 acceleration requires the `elliptic-curves` submodule. + +### P-384 Support (Nitro) + +AWS Nitro uses **P-384 ECDSA** exclusively for its certificate chain. This experiment uses a patched version of `elliptic-curves` with P-384 acceleration via `risc0-bigint2`. + +**Upstream PR:** https://github.com/risc0/RustCrypto-elliptic-curves/pull/15 + +The P-384 patch is included as a git submodule at `rustcrypto-elliptic-curves/`, tracking the `risc0-p256-p384-unified` branch from the fork. + +### Why Nitro is ~4.7x slower than GCP + +Both P-256 and P-384 have precompile acceleration, but the cost difference comes from volume. Nitro requires **5 P-384 ECDSA verifications**: +- 1 COSE signature verification (~1M cycles: ~400k P-384 verify + ~570k SHA-512 over COSE document) +- 4 certificate chain verifications (~400k cycles each) + +GCP uses RSA-4096 (cheap with dedicated precompile) and a single P-256 ECDSA verification (~250k cycles), keeping the total well under 1M cycles. + +Batch multi-scalar multiplication could theoretically help, but ECDSA verify requires independent scalar muls per signature (different messages and keys), and the RISC Zero precompile interface doesn't expose batching. This is effectively the floor for Nitro's chain structure. + +## Notes + +- This is a **research experiment** to evaluate ZK attestation verification feasibility +- Uses dev mode for fast iteration (no real proofs generated) +- The main project is completely unchanged +- Cycle counts give rough indication of proving cost +- GCP verification is production-viable at ~883K cycles (2 segments) +- Nitro verification is viable at ~4.2M cycles with P-384 acceleration (pending upstream merge) + +## Dependencies + +This experiment requires the P-384 accelerated elliptic-curves fork which hasn't yet been upstreamed to RISC-Zero. After cloning, initialize the submodule: + +```bash +git submodule update --init --recursive +``` diff --git a/experiments/risc-zero/methods/Cargo.toml b/experiments/risc-zero/methods/Cargo.toml new file mode 100644 index 0000000..4564bb4 --- /dev/null +++ b/experiments/risc-zero/methods/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "vaportpm-zk-methods" +version = "0.1.0" +edition = "2021" + +[build-dependencies] +risc0-build = "3.0" + +[package.metadata.risc0] +methods = ["guest", "ec-bench"] diff --git a/experiments/risc-zero/methods/build.rs b/experiments/risc-zero/methods/build.rs new file mode 100644 index 0000000..08a8a4e --- /dev/null +++ b/experiments/risc-zero/methods/build.rs @@ -0,0 +1,3 @@ +fn main() { + risc0_build::embed_methods(); +} diff --git a/experiments/risc-zero/methods/ec-bench/Cargo.toml b/experiments/risc-zero/methods/ec-bench/Cargo.toml new file mode 100644 index 0000000..7bfcee6 --- /dev/null +++ b/experiments/risc-zero/methods/ec-bench/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ec-bench-guest" +version = "0.1.0" +edition = "2021" + +[workspace] + +[dependencies] +risc0-zkvm = { version = "3.0", default-features = false, features = ["std"] } +p256 = { version = "0.13", default-features = false, features = ["ecdsa"] } +p384 = { version = "=0.13.0", default-features = false, features = ["ecdsa"] } +ecdsa = { version = "0.16", default-features = false } +sha2 = "0.10" +hex-literal = "0.4" + +[patch.crates-io] +# RISC Zero accelerated crypto +sha2 = { git = "https://github.com/risc0/RustCrypto-hashes", tag = "sha2-v0.10.8-risczero.0" } +crypto-bigint = { git = "https://github.com/risc0/RustCrypto-crypto-bigint", tag = "v0.5.5-risczero.0" } + +# Unified P-256/P-384 RISC Zero acceleration (local fork) +p256 = { path = "../../rustcrypto-elliptic-curves/p256" } +p384 = { path = "../../rustcrypto-elliptic-curves/p384" } +primeorder = { path = "../../rustcrypto-elliptic-curves/primeorder" } diff --git a/experiments/risc-zero/methods/ec-bench/src/main.rs b/experiments/risc-zero/methods/ec-bench/src/main.rs new file mode 100644 index 0000000..1674c68 --- /dev/null +++ b/experiments/risc-zero/methods/ec-bench/src/main.rs @@ -0,0 +1,56 @@ +#![no_main] + +use risc0_zkvm::guest::env; + +risc0_zkvm::guest::entry!(main); + +/// Benchmark type to run +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum BenchType { + P256 = 0, + P384 = 1, +} + +fn main() { + // Read which benchmark to run + let bench_type: u8 = env::read(); + + // Read the test data + let pubkey_bytes: Vec = env::read(); + let message_hash: [u8; 32] = env::read(); + let signature_bytes: Vec = env::read(); + + let result = match bench_type { + 0 => verify_p256(&pubkey_bytes, &message_hash, &signature_bytes), + 1 => verify_p384(&pubkey_bytes, &message_hash, &signature_bytes), + _ => panic!("Unknown benchmark type"), + }; + + // Commit the result + env::commit(&result); +} + +fn verify_p256(pubkey_bytes: &[u8], message_hash: &[u8; 32], signature_bytes: &[u8]) -> bool { + use p256::ecdsa::{signature::hazmat::PrehashVerifier, Signature, VerifyingKey}; + + let verifying_key = VerifyingKey::from_sec1_bytes(pubkey_bytes) + .expect("Invalid P-256 public key"); + + let signature = Signature::from_slice(signature_bytes) + .expect("Invalid P-256 signature"); + + verifying_key.verify_prehash(message_hash, &signature).is_ok() +} + +fn verify_p384(pubkey_bytes: &[u8], message_hash: &[u8; 32], signature_bytes: &[u8]) -> bool { + use p384::ecdsa::{signature::hazmat::PrehashVerifier, Signature, VerifyingKey}; + + let verifying_key = VerifyingKey::from_sec1_bytes(pubkey_bytes) + .expect("Invalid P-384 public key"); + + let signature = Signature::from_slice(signature_bytes) + .expect("Invalid P-384 signature"); + + verifying_key.verify_prehash(message_hash, &signature).is_ok() +} diff --git a/experiments/risc-zero/methods/guest/Cargo.toml b/experiments/risc-zero/methods/guest/Cargo.toml new file mode 100644 index 0000000..14ebd8e --- /dev/null +++ b/experiments/risc-zero/methods/guest/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "vaportpm-zk-guest" +version = "0.1.0" +edition = "2021" + +[workspace] + +[dependencies] +risc0-zkvm = { version = "3.0", default-features = false, features = ["std"] } +vaportpm-verify = { path = "../../../../crates/vaportpm-verify" } +vaportpm-attest = { path = "../../../../crates/vaportpm-attest" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +hex = "0.4" +sha2 = "0.10" +rsa = "=0.9.9" # Force version for risczero patch +pki-types = { package = "rustls-pki-types", version = "1.13" } + +[patch.crates-io] +# RISC Zero accelerated crypto +sha2 = { git = "https://github.com/risc0/RustCrypto-hashes", tag = "sha2-v0.10.8-risczero.0" } +rsa = { git = "https://github.com/risc0/RustCrypto-RSA", tag = "v0.9.9-risczero.0" } +crypto-bigint = { git = "https://github.com/risc0/RustCrypto-crypto-bigint", tag = "v0.5.5-risczero.0" } + +# Unified P-256/P-384 RISC Zero acceleration (local fork) +p256 = { path = "../../rustcrypto-elliptic-curves/p256" } +p384 = { path = "../../rustcrypto-elliptic-curves/p384" } +primeorder = { path = "../../rustcrypto-elliptic-curves/primeorder" } diff --git a/experiments/risc-zero/methods/guest/src/main.rs b/experiments/risc-zero/methods/guest/src/main.rs new file mode 100644 index 0000000..d1567dc --- /dev/null +++ b/experiments/risc-zero/methods/guest/src/main.rs @@ -0,0 +1,81 @@ +#![no_main] + +use pki_types::UnixTime; +use risc0_zkvm::guest::env; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::io::Read; +use std::time::Duration; +use vaportpm_verify::{flat, verify_decoded_attestation_output, CloudProvider, P256PublicKey, PcrBank}; + +risc0_zkvm::guest::entry!(main); + +/// Public inputs committed by the ZK circuit +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZkPublicInputs { + pub pcr_hash: [u8; 32], + pub ak_pubkey: P256PublicKey, + pub nonce: [u8; 32], + pub provider: u8, + pub verified_at: u64, +} + +fn main() { + // Read raw bytes - no serde deserialization! + let mut input_bytes = Vec::::new(); + env::stdin().read_to_end(&mut input_bytes).unwrap(); + + // Last 8 bytes are the verification timestamp + if input_bytes.len() < 8 { + panic!("Input too short - missing timestamp"); + } + let time_bytes: [u8; 8] = input_bytes[input_bytes.len() - 8..].try_into().unwrap(); + let time_secs = u64::from_le_bytes(time_bytes); + let time = UnixTime::since_unix_epoch(Duration::from_secs(time_secs)); + + // Parse flat binary format (everything except the trailing timestamp) + let flat_data = &input_bytes[..input_bytes.len() - 8]; + let decoded = flat::from_bytes(flat_data).expect("Failed to parse flat input"); + + // Verify using decoded path (no hex::decode, no PEM parsing) + let result = + verify_decoded_attestation_output(&decoded, time).expect("Attestation verification failed"); + + // Compute canonical PCR hash from pre-decoded binary data + let pcr_hash = compute_pcr_hash(&decoded.pcrs); + + // Map provider to u8 (root hash already verified against known roots) + let provider = match result.provider { + CloudProvider::Aws => 0u8, + CloudProvider::Gcp => 1u8, + }; + + // Build and commit public inputs + let public_inputs = ZkPublicInputs { + pcr_hash, + ak_pubkey: decoded.ak_pubkey, + nonce: decoded.nonce, + provider, + verified_at: result.verified_at, + }; + + env::commit(&public_inputs); +} + +/// Compute canonical PCR hash from a validated PcrBank +/// +/// Canonicalization: [alg_u16 LE, count] then each [idx, value...] in index order +fn compute_pcr_hash(pcrs: &PcrBank) -> [u8; 32] { + let mut hasher = Sha256::new(); + + let alg_u16 = pcrs.algorithm() as u16; + hasher.update(alg_u16.to_le_bytes()); + hasher.update([24u8]); + + for (idx, value) in pcrs.values().enumerate() { + hasher.update([idx as u8]); + hasher.update(value); + } + + hasher.finalize().into() +} diff --git a/experiments/risc-zero/methods/src/lib.rs b/experiments/risc-zero/methods/src/lib.rs new file mode 100644 index 0000000..1bdb308 --- /dev/null +++ b/experiments/risc-zero/methods/src/lib.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/methods.rs")); diff --git a/experiments/risc-zero/rustcrypto-elliptic-curves b/experiments/risc-zero/rustcrypto-elliptic-curves new file mode 160000 index 0000000..b29e27f --- /dev/null +++ b/experiments/risc-zero/rustcrypto-elliptic-curves @@ -0,0 +1 @@ +Subproject commit b29e27fe3a23c9aec0b6afe06011474d54411070 diff --git a/experiments/risc-zero/src/host.rs b/experiments/risc-zero/src/host.rs new file mode 100644 index 0000000..bcac9bf --- /dev/null +++ b/experiments/risc-zero/src/host.rs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Host-side utilities for the ZK experiment +//! +//! This module provides helpers for running attestation verification +//! inside the RISC Zero zkVM from the host side. + +pub use crate::inputs::ZkPublicInputs; diff --git a/experiments/risc-zero/src/inputs.rs b/experiments/risc-zero/src/inputs.rs new file mode 100644 index 0000000..90d20e9 --- /dev/null +++ b/experiments/risc-zero/src/inputs.rs @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! ZK public inputs for attestation verification + +use serde::{Deserialize, Serialize}; +use serde_big_array::BigArray; + +/// Public inputs committed by the ZK circuit +/// +/// These values are revealed to the verifier and represent the +/// verified attestation claims. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ZkPublicInputs { + /// SHA256 of canonically-serialized PCRs + pub pcr_hash: [u8; 32], + /// P-256 uncompressed public key: 0x04 || x || y + #[serde(with = "BigArray")] + pub ak_pubkey: [u8; 65], + /// Freshness nonce + pub nonce: [u8; 32], + /// Cloud provider: 0 = AWS, 1 = GCP + pub provider: u8, + /// Timestamp used for verification (seconds since Unix epoch) + pub verified_at: u64, +} + +impl ZkPublicInputs { + /// Provider constant for AWS + pub const PROVIDER_AWS: u8 = 0; + /// Provider constant for GCP + pub const PROVIDER_GCP: u8 = 1; +} diff --git a/experiments/risc-zero/src/lib.rs b/experiments/risc-zero/src/lib.rs new file mode 100644 index 0000000..aec0644 --- /dev/null +++ b/experiments/risc-zero/src/lib.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! RISC Zero ZK experiment for vaportpm attestation verification +//! +//! This crate provides an experimental integration of vaportpm attestation +//! verification with RISC Zero's zkVM. The goal is to measure cycle counts +//! and understand the complexity of running attestation verification in ZK. +//! +//! # Structure +//! +//! - `inputs`: Public input types committed by the ZK circuit +//! - `host`: Host-side utilities for running the zkVM +//! +//! # Usage +//! +//! Run the cycle count tests: +//! ```bash +//! cd experiments/risc-zero +//! make cycles +//! ``` + +pub mod host; +pub mod inputs; + +pub use inputs::ZkPublicInputs; diff --git a/experiments/risc-zero/tests/cycle_count.rs b/experiments/risc-zero/tests/cycle_count.rs new file mode 100644 index 0000000..4a39bb5 --- /dev/null +++ b/experiments/risc-zero/tests/cycle_count.rs @@ -0,0 +1,126 @@ +use risc0_zkvm::{default_executor, ExecutorEnv}; +use std::fs; +use vaportpm_attest::a9n::AttestationOutput; +use vaportpm_verify::{flat, DecodedAttestationOutput}; +use vaportpm_zk_methods::VAPORTPM_ZK_GUEST_ELF; + +/// Timestamp for GCP test fixture (Feb 2, 2026 when certificates are valid) +const GCP_FIXTURE_TIMESTAMP_SECS: u64 = 1770019200; + +/// Timestamp for Nitro test fixture (Feb 3, 2026 within cert validity window) +const NITRO_FIXTURE_TIMESTAMP_SECS: u64 = 1770116400; + +#[test] +fn test_gcp_attestation_cycle_count() { + // Load test fixture + let attestation_json = + fs::read_to_string("../../crates/vaportpm-verify/test-gcp-amd-fixture.json") + .expect("Failed to load GCP fixture"); + + // Parse JSON on host + let output: AttestationOutput = + serde_json::from_str(&attestation_json).expect("Failed to parse attestation JSON"); + + // Decode to binary format on host + let decoded = + DecodedAttestationOutput::decode(&output).expect("Failed to decode attestation"); + + // Convert to flat binary format with timestamp appended + let mut flat_bytes = flat::to_bytes(&decoded); + flat_bytes.extend_from_slice(&GCP_FIXTURE_TIMESTAMP_SECS.to_le_bytes()); + + let env = ExecutorEnv::builder() + .write_slice(&flat_bytes) // write_slice instead of write! + .build() + .unwrap(); + + let executor = default_executor(); + let session = executor.execute(env, VAPORTPM_ZK_GUEST_ELF).unwrap(); + + println!(); + println!("=== GCP Attestation Verification (Optimized + zerocopy) ==="); + println!("Flat input size: {} bytes", flat_bytes.len()); + println!("Total cycles: {}", session.cycles()); + println!("Segments: {}", session.segments.len()); + println!(); +} + +#[test] +fn test_nitro_attestation_cycle_count() { + // Load test fixture + let attestation_json = + fs::read_to_string("../../crates/vaportpm-verify/test-nitro-fixture.json") + .expect("Failed to load Nitro fixture"); + + // Parse JSON on host + let output: AttestationOutput = + serde_json::from_str(&attestation_json).expect("Failed to parse attestation JSON"); + + // Decode to binary format on host + let decoded = + DecodedAttestationOutput::decode(&output).expect("Failed to decode attestation"); + + // Convert to flat binary format with timestamp appended + let mut flat_bytes = flat::to_bytes(&decoded); + flat_bytes.extend_from_slice(&NITRO_FIXTURE_TIMESTAMP_SECS.to_le_bytes()); + + let env = ExecutorEnv::builder() + .write_slice(&flat_bytes) + .build() + .unwrap(); + + let executor = default_executor(); + let session = executor.execute(env, VAPORTPM_ZK_GUEST_ELF).unwrap(); + + println!(); + println!("=== Nitro Attestation Verification (Optimized + zerocopy) ==="); + println!("Flat input size: {} bytes", flat_bytes.len()); + println!("Total cycles: {}", session.cycles()); + println!("Segments: {}", session.segments.len()); + println!(); +} + +#[test] +fn test_data_size_comparison() { + // Load test fixture + let attestation_json = + fs::read_to_string("../../crates/vaportpm-verify/test-gcp-amd-fixture.json") + .expect("Failed to load GCP fixture"); + + println!("\n=== Original JSON approach ==="); + println!("JSON string length: {} bytes", attestation_json.len()); + + // Parse JSON on host + let output: AttestationOutput = + serde_json::from_str(&attestation_json).expect("Failed to parse attestation JSON"); + + // Decode to binary format + let decoded = + DecodedAttestationOutput::decode(&output).expect("Failed to decode attestation"); + + // Flat binary format (what we now use) + timestamp + let flat_bytes = flat::to_bytes(&decoded); + println!("\n=== Flat binary approach (zerocopy) ==="); + println!("Flat binary size: {} bytes (+ 8 bytes timestamp)", flat_bytes.len()); + + println!("\nComponent sizes:"); + println!(" header: {} bytes (zerocopy, zero-copy parse)", flat::HEADER_SIZE); + println!(" quote_attest: {} bytes", decoded.quote_attest.len()); + println!(" quote_signature: {} bytes", decoded.quote_signature.len()); + + match &decoded.platform { + vaportpm_verify::DecodedPlatformAttestation::Gcp { cert_chain_der } => { + let total_cert_bytes: usize = cert_chain_der.iter().map(|c| c.len()).sum(); + println!( + " cert_chain_der: {} certs, {} total bytes", + cert_chain_der.len(), + total_cert_bytes + ); + } + _ => {} + } + + let pcr_bytes: usize = decoded.pcrs.values().map(|v| v.len()).sum(); + println!(" pcrs: {} entries, {} total bytes", vaportpm_verify::PCR_COUNT, pcr_bytes); + println!(); +} diff --git a/experiments/risc-zero/tests/ec_benchmarks.rs b/experiments/risc-zero/tests/ec_benchmarks.rs new file mode 100644 index 0000000..a12378b --- /dev/null +++ b/experiments/risc-zero/tests/ec_benchmarks.rs @@ -0,0 +1,188 @@ +//! Isolated EC benchmarks for comparing P-256 and P-384 performance +//! +//! These tests measure cycle counts for single ECDSA signature verifications +//! to isolate EC performance from other factors (JSON parsing, X.509, etc.) + +use risc0_zkvm::{default_executor, ExecutorEnv}; +use vaportpm_zk_methods::EC_BENCH_GUEST_ELF; + +/// P-256 test vector - valid ECDSA P-256 signature +mod p256_test_vector { + use hex_literal::hex; + + // Public key in uncompressed SEC1 format (0x04 || x || y) + pub const PUBLIC_KEY: [u8; 65] = hex!( + "0460fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6" + "7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299" + ); + + // SHA-256 hash of the message "sample" + pub const MESSAGE_HASH: [u8; 32] = hex!( + "af2bdbe1aa9b6ec1e2ade1d694f41fc71a831d0268e9891562113d8a62add1bf" + ); + + // ECDSA signature (r || s) in fixed-size format + pub const SIGNATURE: [u8; 64] = hex!( + "efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716" + "f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8" + ); +} + +/// P-384 test vector - valid ECDSA P-384 signature +mod p384_test_vector { + use hex_literal::hex; + + // Public key in uncompressed SEC1 format (0x04 || x || y) + pub const PUBLIC_KEY: [u8; 97] = hex!( + "043e80bb19d6500788aaadfab3970aa5c39e75d79bf8dc81e823d4908301a6ffb0" + "ee8fc6e4c76cf03d46a7a379769815c90d23c1bcdbcf4dd37f434f05ae9c524c" + "7f7219c3deaa778eefe3e8e620da823c2670cb023321ce851322bbd1c44932aa" + ); + + // SHA-256 hash of the message "sample" (same as P-256 for fair comparison) + pub const MESSAGE_HASH: [u8; 32] = hex!( + "af2bdbe1aa9b6ec1e2ade1d694f41fc71a831d0268e9891562113d8a62add1bf" + ); + + // ECDSA signature (r || s) in fixed-size format + pub const SIGNATURE: [u8; 96] = hex!( + "d4bc0c427c75dcbfa66c3a7f09a54465d43f69d7978ee454d8abf022621f585a" + "70535448bb1e50647009b6ef6f818400efaa015e183dc460bc456057c555ac95" + "27f34cbbbf325986e463531910176a988c4b3468727172d614ccdcade0ae89df" + ); +} + +#[test] +fn test_p256_ecdsa_cycles() { + let bench_type: u8 = 0; // P-256 + let pubkey = p256_test_vector::PUBLIC_KEY.to_vec(); + let message_hash = p256_test_vector::MESSAGE_HASH; + let signature = p256_test_vector::SIGNATURE.to_vec(); + + let env = ExecutorEnv::builder() + .write(&bench_type) + .unwrap() + .write(&pubkey) + .unwrap() + .write(&message_hash) + .unwrap() + .write(&signature) + .unwrap() + .build() + .unwrap(); + + let executor = default_executor(); + let session = executor.execute(env, EC_BENCH_GUEST_ELF).unwrap(); + + // Verify the signature was valid + let result: bool = session.journal.decode().unwrap(); + assert!(result, "P-256 signature verification should succeed"); + + println!(); + println!("=== P-256 ECDSA Verification ==="); + println!("Total cycles: {}", session.cycles()); + println!("Segments: {}", session.segments.len()); + println!(); +} + +#[test] +fn test_p384_ecdsa_cycles() { + let bench_type: u8 = 1; // P-384 + let pubkey = p384_test_vector::PUBLIC_KEY.to_vec(); + let message_hash = p384_test_vector::MESSAGE_HASH; + let signature = p384_test_vector::SIGNATURE.to_vec(); + + let env = ExecutorEnv::builder() + .write(&bench_type) + .unwrap() + .write(&pubkey) + .unwrap() + .write(&message_hash) + .unwrap() + .write(&signature) + .unwrap() + .build() + .unwrap(); + + let executor = default_executor(); + let session = executor.execute(env, EC_BENCH_GUEST_ELF).unwrap(); + + // Verify the signature was valid + let result: bool = session.journal.decode().unwrap(); + assert!(result, "P-384 signature verification should succeed"); + + println!(); + println!("=== P-384 ECDSA Verification ==="); + println!("Total cycles: {}", session.cycles()); + println!("Segments: {}", session.segments.len()); + println!(); +} + +/// Run both benchmarks and print comparison +#[test] +fn test_ec_comparison() { + // P-256 + let p256_cycles = { + let bench_type: u8 = 0; + let pubkey = p256_test_vector::PUBLIC_KEY.to_vec(); + let message_hash = p256_test_vector::MESSAGE_HASH; + let signature = p256_test_vector::SIGNATURE.to_vec(); + + let env = ExecutorEnv::builder() + .write(&bench_type) + .unwrap() + .write(&pubkey) + .unwrap() + .write(&message_hash) + .unwrap() + .write(&signature) + .unwrap() + .build() + .unwrap(); + + let executor = default_executor(); + let session = executor.execute(env, EC_BENCH_GUEST_ELF).unwrap(); + + let result: bool = session.journal.decode().unwrap(); + assert!(result, "P-256 signature verification should succeed"); + + session.cycles() + }; + + // P-384 + let p384_cycles = { + let bench_type: u8 = 1; + let pubkey = p384_test_vector::PUBLIC_KEY.to_vec(); + let message_hash = p384_test_vector::MESSAGE_HASH; + let signature = p384_test_vector::SIGNATURE.to_vec(); + + let env = ExecutorEnv::builder() + .write(&bench_type) + .unwrap() + .write(&pubkey) + .unwrap() + .write(&message_hash) + .unwrap() + .write(&signature) + .unwrap() + .build() + .unwrap(); + + let executor = default_executor(); + let session = executor.execute(env, EC_BENCH_GUEST_ELF).unwrap(); + + let result: bool = session.journal.decode().unwrap(); + assert!(result, "P-384 signature verification should succeed"); + + session.cycles() + }; + + println!(); + println!("=== EC Performance Comparison ==="); + println!("P-256 ECDSA verify: {} cycles", p256_cycles); + println!("P-384 ECDSA verify: {} cycles", p384_cycles); + println!("Ratio (P-384/P-256): {:.2}x", p384_cycles as f64 / p256_cycles as f64); + println!(); + println!("Expected ratio: 1.5-2.0x (due to larger field size)"); + println!(); +}