From 7365599c9626ade92df15826cbdbef46229092fa Mon Sep 17 00:00:00 2001 From: user <303926+HarryR@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:42:12 +0800 Subject: [PATCH 01/10] Initial RISC-ZERO integration test --- .gitignore | 5 +- experiments/risc-zero/Cargo.toml | 28 ++++ experiments/risc-zero/Makefile | 22 ++++ experiments/risc-zero/README.md | 122 ++++++++++++++++++ experiments/risc-zero/methods/Cargo.toml | 10 ++ experiments/risc-zero/methods/build.rs | 3 + .../risc-zero/methods/guest/Cargo.toml | 25 ++++ .../risc-zero/methods/guest/src/main.rs | 110 ++++++++++++++++ experiments/risc-zero/methods/src/lib.rs | 1 + experiments/risc-zero/src/host.rs | 8 ++ experiments/risc-zero/src/inputs.rs | 32 +++++ experiments/risc-zero/src/lib.rs | 25 ++++ experiments/risc-zero/tests/cycle_count.rs | 75 +++++++++++ 13 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 experiments/risc-zero/Cargo.toml create mode 100644 experiments/risc-zero/Makefile create mode 100644 experiments/risc-zero/README.md create mode 100644 experiments/risc-zero/methods/Cargo.toml create mode 100644 experiments/risc-zero/methods/build.rs create mode 100644 experiments/risc-zero/methods/guest/Cargo.toml create mode 100644 experiments/risc-zero/methods/guest/src/main.rs create mode 100644 experiments/risc-zero/methods/src/lib.rs create mode 100644 experiments/risc-zero/src/host.rs create mode 100644 experiments/risc-zero/src/inputs.rs create mode 100644 experiments/risc-zero/src/lib.rs create mode 100644 experiments/risc-zero/tests/cycle_count.rs diff --git a/.gitignore b/.gitignore index 855ecc1..7bc5684 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/target +target Cargo.lock .ssh .rustup @@ -13,3 +13,6 @@ Cargo.lock .config .gitconfig .cargo +.bash_history +.risc0 +.bashrc \ 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..d6d99b9 --- /dev/null +++ b/experiments/risc-zero/Cargo.toml @@ -0,0 +1,28 @@ +[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 diff --git a/experiments/risc-zero/Makefile b/experiments/risc-zero/Makefile new file mode 100644 index 0000000..898b8f5 --- /dev/null +++ b/experiments/risc-zero/Makefile @@ -0,0 +1,22 @@ +.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: + cargo test cycle_count -- --nocapture + +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..cfb96a2 --- /dev/null +++ b/experiments/risc-zero/README.md @@ -0,0 +1,122 @@ +# RISC Zero ZK Verification Experiment + +This experiment runs `verify_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 === +Total cycles: 1993676 +Segments: 3 + +=== Nitro Attestation Verification === +Total cycles: 315958121 +Segments: 318 +``` + +## Public Inputs + +The ZK circuit commits the following public inputs: + +| Field | Size | Description | +|-------|------|-------------| +| `pcr_hash` | 32 bytes | SHA256 of canonically-serialized PCRs | +| `ak_pubkey` | 65 bytes | P-256 uncompressed: `0x04 \|\| x \|\| y` | +| `nonce` | 32 bytes | Freshness nonce | +| `provider` | 1 byte | 0 = AWS, 1 = GCP | +| `root_pubkey_hash` | 32 bytes | SHA256 of root CA public key | + +## Structure + +``` +experiments/risc-zero/ +├── Cargo.toml # Host crate (standalone workspace) +├── Makefile # Build/test commands +├── src/ +│ ├── lib.rs # Library root +│ ├── host.rs # Host utilities +│ └── inputs.rs # ZkPublicInputs type +├── tests/ +│ └── cycle_count.rs # Integration tests +└── 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 **guest program** (`methods/guest/src/main.rs`) runs inside the zkVM: + - Reads attestation JSON and timestamp from host + - Calls `verify_attestation_output()` (same verification as native) + - Computes canonical PCR hash + - Commits public inputs to the journal + +2. The **host** (`tests/cycle_count.rs`) provides inputs and measures cycles: + - Loads test fixtures (GCP AMD and Nitro attestations) + - Builds executor environment with inputs + - Runs guest in dev mode (no real proofs) + - Reports cycle counts per segment + +## 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 signatures | +| `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. + +### P-384 Support (Nitro) + +AWS Nitro uses **P-384 ECDSA** for its certificate chain, which currently lacks a dedicated RISC Zero precompile. This explains the ~160x cycle difference between GCP (~2M) and Nitro (~316M). + +The `risc0-bigint2` crate (v1.4.x) includes P-384 field support, suggesting the team is aware and working on it. The `k256` precompile uses `modmul_u256_denormalized` intrinsics, and `p256` uses `risc0_bigint2::field` - a similar approach for P-384 would dramatically reduce Nitro verification cycles. + +For reference, GCP verification with RSA-4096 (3 certificates) achieves ~2M cycles, demonstrating the effectiveness of the RSA precompile. + +## 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 ~2M cycles +- Nitro verification awaits P-384 precompile support for practical use diff --git a/experiments/risc-zero/methods/Cargo.toml b/experiments/risc-zero/methods/Cargo.toml new file mode 100644 index 0000000..d15b2ea --- /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"] 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/guest/Cargo.toml b/experiments/risc-zero/methods/guest/Cargo.toml new file mode 100644 index 0000000..87805c7 --- /dev/null +++ b/experiments/risc-zero/methods/guest/Cargo.toml @@ -0,0 +1,25 @@ +[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" +serde-big-array = "0.5" +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" } +p256 = { git = "https://github.com/risc0/RustCrypto-elliptic-curves", tag = "p256/v0.13.2-risczero.1" } +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" } 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..8387b56 --- /dev/null +++ b/experiments/risc-zero/methods/guest/src/main.rs @@ -0,0 +1,110 @@ +#![no_main] + +use risc0_zkvm::guest::env; +use serde::{Deserialize, Serialize}; +use serde_big_array::BigArray; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use vaportpm_attest::a9n::AttestationOutput; +use vaportpm_verify::{verify_attestation_output, CloudProvider}; + +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], + #[serde(with = "BigArray")] + pub ak_pubkey: [u8; 65], + pub nonce: [u8; 32], + pub provider: u8, + pub root_pubkey_hash: [u8; 32], +} + +fn main() { + // Read inputs from host + let attestation_json: String = env::read(); + let time_secs: u64 = env::read(); + + // Parse attestation + let output: AttestationOutput = + serde_json::from_str(&attestation_json).expect("Failed to parse attestation JSON"); + + // Run EXACT SAME verification as native + let time = pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(time_secs)); + let result = + verify_attestation_output(&output, time).expect("Attestation verification failed"); + + // Compute canonical PCR hash + let pcr_hash = compute_pcr_hash(&output.pcrs); + + // Extract AK public key + let ak_pk = output.ak_pubkeys.get("ecc_p256").expect("Missing AK"); + let mut ak_pubkey = [0u8; 65]; + ak_pubkey[0] = 0x04; + let x_bytes = hex::decode(&ak_pk.x).expect("Invalid AK x coordinate"); + let y_bytes = hex::decode(&ak_pk.y).expect("Invalid AK y coordinate"); + ak_pubkey[1..33].copy_from_slice(&x_bytes); + ak_pubkey[33..65].copy_from_slice(&y_bytes); + + // Parse nonce + let nonce_bytes = hex::decode(&output.nonce).expect("Invalid nonce"); + let mut nonce = [0u8; 32]; + nonce.copy_from_slice(&nonce_bytes); + + // Parse root pubkey hash + let root_hash_bytes = hex::decode(&result.root_pubkey_hash).expect("Invalid root hash"); + let mut root_pubkey_hash = [0u8; 32]; + root_pubkey_hash.copy_from_slice(&root_hash_bytes); + + // Map provider to u8 + let provider = match result.provider { + CloudProvider::Aws => 0u8, + CloudProvider::Gcp => 1u8, + }; + + // Build and commit public inputs + let public_inputs = ZkPublicInputs { + pcr_hash, + ak_pubkey, + nonce, + provider, + root_pubkey_hash, + }; + + env::commit(&public_inputs); +} + +/// Compute canonical PCR hash +/// +/// Canonicalization: sort by algorithm name, then by PCR index +fn compute_pcr_hash( + pcrs: &std::collections::HashMap>, +) -> [u8; 32] { + let mut hasher = Sha256::new(); + + // Sort algorithm names + let mut alg_names: Vec<_> = pcrs.keys().collect(); + alg_names.sort(); + + for alg_name in alg_names { + let pcr_map = &pcrs[alg_name]; + + // Add algorithm name (length-prefixed) + hasher.update(&[alg_name.len() as u8]); + hasher.update(alg_name.as_bytes()); + + // Add PCR count + hasher.update(&[pcr_map.len() as u8]); + + // BTreeMap is already sorted by key + for (idx, value_hex) in pcr_map { + let value_bytes = hex::decode(value_hex).expect("valid hex"); + hasher.update(&[*idx]); + hasher.update(&[value_bytes.len() as u8]); + hasher.update(&value_bytes); + } + } + + 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/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..a494b95 --- /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, + /// SHA256 of root CA public key + pub root_pubkey_hash: [u8; 32], +} + +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..7b39806 --- /dev/null +++ b/experiments/risc-zero/tests/cycle_count.rs @@ -0,0 +1,75 @@ +use risc0_zkvm::{default_executor, ExecutorEnv}; +use std::fs; +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"); + + let time_secs: u64 = GCP_FIXTURE_TIMESTAMP_SECS; + + let env = ExecutorEnv::builder() + .write(&attestation_json) + .unwrap() + .write(&time_secs) + .unwrap() + .build() + .unwrap(); + + let executor = default_executor(); + let session = executor.execute(env, VAPORTPM_ZK_GUEST_ELF).unwrap(); + + println!(); + println!("=== GCP Attestation Verification ==="); + println!("Total cycles: {}", session.cycles()); + println!("Segments: {}", session.segments.len()); + + /* + for (i, segment) in session.segments.iter().enumerate() { + println!(" Segment {}: {} cycles", i, segment.cycles); + } + */ + 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"); + + let time_secs: u64 = NITRO_FIXTURE_TIMESTAMP_SECS; + + let env = ExecutorEnv::builder() + .write(&attestation_json) + .unwrap() + .write(&time_secs) + .unwrap() + .build() + .unwrap(); + + let executor = default_executor(); + let session = executor.execute(env, VAPORTPM_ZK_GUEST_ELF).unwrap(); + + println!(); + println!("=== Nitro Attestation Verification ==="); + println!("Total cycles: {}", session.cycles()); + println!("Segments: {}", session.segments.len()); + + /* + for (i, segment) in session.segments.iter().enumerate() { + println!(" Segment {}: {} cycles", i, segment.cycles); + } + */ + println!(); +} From 5366f076ab8603275ae32fd18f58e11a5090fea0 Mon Sep 17 00:00:00 2001 From: user <303926+HarryR@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:44:38 +0800 Subject: [PATCH 02/10] RISC-ZERO proving experiment --- .gitignore | 3 +- .gitmodules | 4 + experiments/risc-zero/.gitignore | 1 + experiments/risc-zero/Cargo.toml | 1 + experiments/risc-zero/Makefile | 2 +- experiments/risc-zero/methods/Cargo.toml | 2 +- .../risc-zero/methods/ec-bench/Cargo.toml | 24 +++ .../risc-zero/methods/ec-bench/src/main.rs | 56 ++++++ .../risc-zero/methods/guest/Cargo.toml | 6 +- .../risc-zero/rustcrypto-elliptic-curves | 1 + experiments/risc-zero/tests/ec_benchmarks.rs | 188 ++++++++++++++++++ 11 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 .gitmodules create mode 100644 experiments/risc-zero/.gitignore create mode 100644 experiments/risc-zero/methods/ec-bench/Cargo.toml create mode 100644 experiments/risc-zero/methods/ec-bench/src/main.rs create mode 160000 experiments/risc-zero/rustcrypto-elliptic-curves create mode 100644 experiments/risc-zero/tests/ec_benchmarks.rs diff --git a/.gitignore b/.gitignore index 7bc5684..46b3967 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ Cargo.lock .cargo .bash_history .risc0 -.bashrc \ No newline at end of file +.bashrc +/go \ No newline at end of file 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/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 index d6d99b9..1ed254b 100644 --- a/experiments/risc-zero/Cargo.toml +++ b/experiments/risc-zero/Cargo.toml @@ -26,3 +26,4 @@ pki-types = { package = "rustls-pki-types", version = "1.13" } [dev-dependencies] # For integration tests +hex-literal = "0.4" diff --git a/experiments/risc-zero/Makefile b/experiments/risc-zero/Makefile index 898b8f5..b5ab876 100644 --- a/experiments/risc-zero/Makefile +++ b/experiments/risc-zero/Makefile @@ -11,7 +11,7 @@ test: # Cycle count specifically cycles: - cargo test cycle_count -- --nocapture + RISC0_PPROF_OUT=./profile.pb cargo test test_nitro_attestation_cycle_count -- --nocapture && ~/go/bin/go tool pprof -text profile.pb clean: cargo clean diff --git a/experiments/risc-zero/methods/Cargo.toml b/experiments/risc-zero/methods/Cargo.toml index d15b2ea..4564bb4 100644 --- a/experiments/risc-zero/methods/Cargo.toml +++ b/experiments/risc-zero/methods/Cargo.toml @@ -7,4 +7,4 @@ edition = "2021" risc0-build = "3.0" [package.metadata.risc0] -methods = ["guest"] +methods = ["guest", "ec-bench"] 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 index 87805c7..404c7ab 100644 --- a/experiments/risc-zero/methods/guest/Cargo.toml +++ b/experiments/risc-zero/methods/guest/Cargo.toml @@ -20,6 +20,10 @@ 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" } -p256 = { git = "https://github.com/risc0/RustCrypto-elliptic-curves", tag = "p256/v0.13.2-risczero.1" } 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/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/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!(); +} From 4aa029300a89d4fafef4ad6d45a34c2ddc37b028 Mon Sep 17 00:00:00 2001 From: user <303926+HarryR@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:51:48 +0800 Subject: [PATCH 03/10] Updated README --- experiments/risc-zero/Makefile | 2 +- experiments/risc-zero/README.md | 41 +++++++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/experiments/risc-zero/Makefile b/experiments/risc-zero/Makefile index b5ab876..9c4e552 100644 --- a/experiments/risc-zero/Makefile +++ b/experiments/risc-zero/Makefile @@ -11,7 +11,7 @@ test: # Cycle count specifically cycles: - RISC0_PPROF_OUT=./profile.pb cargo test test_nitro_attestation_cycle_count -- --nocapture && ~/go/bin/go tool pprof -text profile.pb + RISC0_PPROF_OUT=./profile.pb cargo test cycle_count -- --nocapture && ~/go/bin/go tool pprof -text profile.pb clean: cargo clean diff --git a/experiments/risc-zero/README.md b/experiments/risc-zero/README.md index cfb96a2..68dccf0 100644 --- a/experiments/risc-zero/README.md +++ b/experiments/risc-zero/README.md @@ -36,12 +36,12 @@ RISC0_DEV_MODE=1 cargo test -- --nocapture ``` === GCP Attestation Verification === -Total cycles: 1993676 +Total cycles: 1998559 Segments: 3 === Nitro Attestation Verification === -Total cycles: 315958121 -Segments: 318 +Total cycles: 5027644 +Segments: 6 ``` ## Public Inputs @@ -62,6 +62,7 @@ The ZK circuit commits the following public inputs: 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 @@ -98,19 +99,33 @@ The guest uses RISC Zero's patched crypto crates for hardware-accelerated precom | Crate | Precompile | Notes | |-------|------------|-------| | `sha2` | SHA-256/SHA-384 | Used extensively in cert validation | -| `p256` | P-256 ECDSA | GCP uses P-256 for signatures | +| `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. +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** for its certificate chain, which currently lacks a dedicated RISC Zero precompile. This explains the ~160x cycle difference between GCP (~2M) and Nitro (~316M). +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`. -The `risc0-bigint2` crate (v1.4.x) includes P-384 field support, suggesting the team is aware and working on it. The `k256` precompile uses `modmul_u256_denormalized` intrinsics, and `p256` uses `risc0_bigint2::field` - a similar approach for P-384 would dramatically reduce Nitro verification cycles. +**Upstream PR:** https://github.com/risc0/RustCrypto-elliptic-curves/pull/15 -For reference, GCP verification with RSA-4096 (3 certificates) achieves ~2M cycles, demonstrating the effectiveness of the RSA precompile. +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 ~2.5x slower than GCP + +Nitro attestation requires **5 P-384 ECDSA verifications**: +- 1 COSE signature verification (attestation document) +- 4 certificate chain verifications (leaf → instance → zonal → regional → root) + +Each P-384 verification costs ~400-500k cycles. The breakdown from profiling: +- P-384 EC scalar multiplication: ~30% of total cycles +- SHA-512 (used by SHA-384): ~15% of total cycles +- Bigint operations: ~29% of total cycles + +GCP uses RSA-4096 (which has a dedicated precompile) and P-256, requiring fewer expensive operations. ## Notes @@ -119,4 +134,12 @@ For reference, GCP verification with RSA-4096 (3 certificates) achieves ~2M cycl - The main project is completely unchanged - Cycle counts give rough indication of proving cost - GCP verification is production-viable at ~2M cycles -- Nitro verification awaits P-384 precompile support for practical use +- Nitro verification is viable at ~5M cycles with P-384 acceleration (pending upstream merge) + +## Dependencies + +This experiment requires the P-384 accelerated elliptic-curves fork. After cloning, initialize the submodule: + +```bash +git submodule update --init --recursive +``` From a32e66a4db0d5c615e9e785a8f33e22103a17227 Mon Sep 17 00:00:00 2001 From: user <303926+HarryR@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:35:00 +0800 Subject: [PATCH 04/10] Big reduction in verification cost by moving to internal binary serialization --- Cargo.toml | 2 + crates/vaportpm-verify/Cargo.toml | 2 + crates/vaportpm-verify/src/bin/verify.rs | 39 +- crates/vaportpm-verify/src/flat.rs | 199 ++++++++ crates/vaportpm-verify/src/gcp.rs | 120 ++--- crates/vaportpm-verify/src/lib.rs | 431 +++++++----------- crates/vaportpm-verify/src/nitro.rs | 366 ++++++--------- crates/vaportpm-verify/src/x509.rs | 15 +- experiments/risc-zero/Cargo.toml | 1 + experiments/risc-zero/Makefile | 6 +- .../risc-zero/methods/guest/src/main.rs | 103 ++--- experiments/risc-zero/src/inputs.rs | 4 +- experiments/risc-zero/tests/cycle_count.rs | 95 +++- 13 files changed, 761 insertions(+), 622 deletions(-) create mode 100644 crates/vaportpm-verify/src/flat.rs 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/crates/vaportpm-verify/Cargo.toml b/crates/vaportpm-verify/Cargo.toml index 9748104..9f4cab3 100644 --- a/crates/vaportpm-verify/Cargo.toml +++ b/crates/vaportpm-verify/Cargo.toml @@ -33,6 +33,8 @@ base64 = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde-big-array = { workspace = true } +zerocopy = { workspace = true } [lib] name = "vaportpm_verify" diff --git a/crates/vaportpm-verify/src/bin/verify.rs b/crates/vaportpm-verify/src/bin/verify.rs index bf77011..6d453ab 100644 --- a/crates/vaportpm-verify/src/bin/verify.rs +++ b/crates/vaportpm-verify/src/bin/verify.rs @@ -5,11 +5,45 @@ //! Reads attestation JSON from a file or stdin, verifies it, //! and outputs the verification result as JSON. +use std::collections::BTreeMap; use std::fs; use std::io::{self, Read}; use std::process::ExitCode; -use vaportpm_verify::verify_attestation_json; +use serde::Serialize; +use vaportpm_verify::{verify_attestation_json, CloudProvider, VerificationResult}; + +/// JSON-friendly output with hex-encoded binary fields +#[derive(Serialize)] +struct VerificationResultJson { + nonce: String, + provider: CloudProvider, + /// PCR values grouped by algorithm: {"sha256": {"0": "abc...", ...}, "sha384": {...}} + pcrs: BTreeMap>, +} + +impl From for VerificationResultJson { + fn from(result: VerificationResult) -> Self { + // Group PCRs by algorithm and convert to hex + let mut pcrs: BTreeMap> = BTreeMap::new(); + for ((alg_id, idx), value) in result.pcrs { + let alg_name = match alg_id { + 0 => "sha256", + 1 => "sha384", + _ => continue, + }; + pcrs.entry(alg_name.to_string()) + .or_default() + .insert(idx, hex::encode(value)); + } + + VerificationResultJson { + nonce: hex::encode(result.nonce), + provider: result.provider, + pcrs, + } + } +} fn main() -> ExitCode { let args: Vec = std::env::args().collect(); @@ -35,7 +69,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/flat.rs b/crates/vaportpm-verify/src/flat.rs new file mode 100644 index 0000000..466166f --- /dev/null +++ b/crates/vaportpm-verify/src/flat.rs @@ -0,0 +1,199 @@ +// 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()`. + +use std::collections::BTreeMap; + +use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout}; + +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, + pub pcr_count: u8, + 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 header = FlatHeader { + nonce: decoded.nonce, + ak_pubkey: decoded.ak_pubkey, + platform_type, + quote_attest_len: decoded.quote_attest.len() as u16, + quote_signature_len: decoded.quote_signature.len() as u16, + pcr_count: decoded.pcrs.len() as u8, + platform_data_len: platform_data.len() as u16, + }; + + let mut buf = Vec::with_capacity(HEADER_SIZE + 2048 + platform_data.len()); + + // Write header as bytes (zerocopy ensures correct layout) + buf.extend_from_slice(header.as_bytes()); + + // Write PCRs: [alg_id, pcr_idx, len, value...] + for ((alg_id, pcr_idx), value) in &decoded.pcrs { + buf.push(*alg_id); + buf.push(*pcr_idx); + buf.push(value.len() as u8); + 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(VerifyError::InvalidAttest(format!( + "input too short: {} < {}", + data.len(), + HEADER_SIZE + ))); + } + + // Zero-copy header parsing! + let (header, _suffix) = FlatHeader::ref_from_prefix(data) + .map_err(|_| VerifyError::InvalidAttest("failed to parse header".into()))?; + + let quote_attest_len = header.quote_attest_len as usize; + let quote_signature_len = header.quote_signature_len as usize; + let pcr_count = header.pcr_count as usize; + let platform_data_len = header.platform_data_len as usize; + + let mut offset = HEADER_SIZE; + + // Parse PCRs + let mut pcrs = BTreeMap::new(); + for _ in 0..pcr_count { + if offset + 3 > data.len() { + return Err(VerifyError::InvalidAttest("truncated PCR header".into())); + } + let alg_id = data[offset]; + let pcr_idx = data[offset + 1]; + let value_len = data[offset + 2] as usize; + offset += 3; + + if offset + value_len > data.len() { + return Err(VerifyError::InvalidAttest("truncated PCR value".into())); + } + pcrs.insert((alg_id, pcr_idx), data[offset..offset + value_len].to_vec()); + offset += value_len; + } + + // Parse quote data + if offset + quote_attest_len > data.len() { + return Err(VerifyError::InvalidAttest("truncated 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(VerifyError::InvalidAttest( + "truncated 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(VerifyError::InvalidAttest("truncated 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(VerifyError::InvalidAttest("empty 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(VerifyError::InvalidAttest("truncated 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(VerifyError::InvalidAttest("truncated 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(VerifyError::InvalidAttest(format!( + "unknown platform type: {}", + header.platform_type + ))) + } + }; + + Ok(DecodedAttestationOutput { + nonce: header.nonce, + pcrs, + ak_pubkey: header.ak_pubkey, + quote_attest, + quote_signature, + platform, + }) +} diff --git a/crates/vaportpm-verify/src/gcp.rs b/crates/vaportpm-verify/src/gcp.rs index 3d21a23..af9f950 100644 --- a/crates/vaportpm-verify/src/gcp.rs +++ b/crates/vaportpm-verify/src/gcp.rs @@ -4,15 +4,15 @@ use std::collections::BTreeMap; +use der::Decode; use pki_types::UnixTime; use sha2::{Digest, Sha256}; +use x509_cert::Certificate; 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}; - -use vaportpm_attest::a9n::{AttestationOutput, GcpAttestationData}; +use crate::x509::{extract_public_key, validate_tpm_cert_chain}; +use crate::{roots, DecodedAttestationOutput, VerificationResult}; /// Verify GCP Shielded VM attestation /// @@ -21,77 +21,100 @@ use vaportpm_attest::a9n::{AttestationOutput, GcpAttestationData}; /// 2. Validates AK certificate chain to Google's root CA /// 3. Verifies Quote signature with AK public key from certificate /// 4. Verifies PCR digest matches claimed PCR values -pub fn verify_gcp_attestation( - output: &AttestationOutput, - gcp: &GcpAttestationData, +/// +/// 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 + // Parse DER → Certificate (still needed for chain validation) + let certs: Vec = cert_chain_der .iter() - .next() - .ok_or_else(|| VerifyError::NoValidAttestation("Missing TPM attestation".into()))?; + .map(|der| { + Certificate::from_der(der) + .map_err(|e| VerifyError::CertificateParse(format!("Invalid DER cert: {}", e))) + }) + .collect::>()?; + + if certs.is_empty() { + return Err(VerifyError::ChainValidation( + "Empty certificate chain".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)?; + // Parse TPM2_Quote attestation + let quote_info = parse_quote_attest(&decoded.quote_attest)?; - // 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 { + // Verify nonce matches Quote + if decoded.nonce != quote_info.nonce.as_slice() { return Err(VerifyError::InvalidAttest(format!( - "Nonce field does not match nonce in Quote. \ - Field: {}, Quote: {}", - output.nonce, + "Nonce does not match Quote. Expected: {}, Quote: {}", + hex::encode(decoded.nonce), hex::encode("e_info.nonce) ))); } // Validate AK certificate chain to GCP root - let chain_result = validate_tpm_cert_chain(&parse_cert_chain_pem(&gcp.ak_cert_chain)?, time)?; + let chain_result = validate_tpm_cert_chain(&certs, time)?; + + // Extract AK public key from leaf certificate for comparison + let ak_pubkey_from_cert = extract_public_key(&certs[0])?; - // 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 the AK public key matches the one in the decoded input + if ak_pubkey_from_cert != decoded.ak_pubkey { + return Err(VerifyError::SignatureInvalid(format!( + "AK public key mismatch: cert has {}, decoded has {}", + hex::encode(&ak_pubkey_from_cert), + hex::encode(decoded.ak_pubkey) + ))); + } - // 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 Quote signature with AK public key + verify_ecdsa_p256( + &decoded.quote_attest, + &decoded.quote_signature, + &decoded.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() { + // Verify we have SHA-256 PCRs (algorithm ID 0) + let has_sha256_pcrs = decoded.pcrs.keys().any(|(alg_id, _)| *alg_id == 0); + if !has_sha256_pcrs { return Err(VerifyError::InvalidAttest( - "SHA-256 PCRs map is empty - at least one PCR required".into(), + "Missing SHA-256 PCRs - required for GCP attestation".into(), )); } - verify_pcr_digest_matches("e_info, pcrs)?; - // Verify root is a known GCP root - fail if not recognized + // Verify PCR digest matches claimed PCR values + verify_pcr_digest_matches("e_info, &decoded.pcrs)?; + + // Verify root is a known GCP root 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 + hex::encode(chain_result.root_pubkey_hash) )) })?; + // Convert nonce to fixed-size array + let nonce: [u8; 32] = quote_info + .nonce + .as_slice() + .try_into() + .map_err(|_| VerifyError::InvalidAttest("nonce is not 32 bytes".into()))?; + 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, + pcrs: &BTreeMap<(u8, u8), Vec>, ) -> 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 @@ -111,14 +134,9 @@ fn verify_pcr_digest_matches( 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); + // Look up by (algorithm_id=0 for SHA-256, pcr_index) + if let Some(pcr_value) = pcrs.get(&(0, pcr_idx)) { + hasher.update(pcr_value); } else { return Err(VerifyError::InvalidAttest(format!( "PCR {} selected in Quote but not present in attestation", diff --git a/crates/vaportpm-verify/src/lib.rs b/crates/vaportpm-verify/src/lib.rs index 1849e9b..6fcb65f 100644 --- a/crates/vaportpm-verify/src/lib.rs +++ b/crates/vaportpm-verify/src/lib.rs @@ -17,7 +17,8 @@ mod x509; use std::collections::BTreeMap; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use serde_big_array::BigArray; // Re-export error type pub use error::VerifyError; @@ -28,9 +29,6 @@ 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, @@ -51,82 +49,41 @@ 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, - } - - 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(); - - RootHashes { - aws_nitro: aws_hash, - gcp_ekak_amd: gcp_amd_hash, - } - } - - 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)) - } - - fn get_hashes() -> &'static RootHashes { - ROOT_HASHES.get_or_init(compute_root_hashes) - } + /// 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, + ]; - /// 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 - } - - /// 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 - } + /// 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, + ]; /// 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 { + #[inline] + pub fn provider_from_hash(hash: &[u8; 32]) -> Option { + if hash == &AWS_NITRO_ROOT_HASH { Some(CloudProvider::Aws) - } else if hash == hashes.gcp_ekak_amd { + } else if hash == &GCP_EKAK_ROOT_HASH { Some(CloudProvider::Gcp) } else { None } } - - /// 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() - } } // Re-export types from vaportpm_attest for convenience @@ -135,17 +92,146 @@ 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, Serialize, Deserialize)] +pub struct DecodedAttestationOutput { + /// Nonce (32 bytes, already decoded from hex) + pub nonce: [u8; 32], + + /// PCR values: (algorithm_id, pcr_index) → value + /// Algorithm IDs: 0 = SHA-256, 1 = SHA-384 + pub pcrs: BTreeMap<(u8, u8), Vec>, + + /// AK public key (65 bytes SEC1 uncompressed: 0x04 || x || y) + #[serde(with = "BigArray")] + pub ak_pubkey: [u8; 65], + + /// 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, Serialize, Deserialize)] +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; + +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(|_| VerifyError::InvalidAttest("nonce must be 32 bytes".into()))?; + + let ak_pk = output.ak_pubkeys.get("ecc_p256").ok_or_else(|| { + VerifyError::NoValidAttestation("missing ecc_p256 AK public key".into()) + })?; + let ak_x = hex::decode(&ak_pk.x)?; + let ak_y = hex::decode(&ak_pk.y)?; + let mut ak_pubkey = [0u8; 65]; + ak_pubkey[0] = 0x04; + ak_pubkey[1..33].copy_from_slice(&ak_x); + ak_pubkey[33..65].copy_from_slice(&ak_y); + + let tpm = output.attestation.tpm.get("ecc_p256").ok_or_else(|| { + VerifyError::NoValidAttestation("missing ecc_p256 TPM attestation".into()) + })?; + let quote_attest = hex::decode(&tpm.attest_data)?; + let quote_signature = hex::decode(&tpm.signature)?; + + let mut pcrs = BTreeMap::new(); + for (alg_name, pcr_map) in &output.pcrs { + let alg_id = match alg_name.as_str() { + "sha256" => 0u8, + "sha384" => 1u8, + _ => continue, + }; + for (idx, hex_value) in pcr_map { + let value = hex::decode(hex_value)?; + pcrs.insert((alg_id, *idx), value); + } + } + + 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| { + VerifyError::CertificateParse(format!("Failed to encode cert as DER: {}", e)) + })?; + 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(VerifyError::NoValidAttestation( + "no platform attestation".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)] 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, + /// PCR values from the attestation: (algorithm_id, pcr_index) -> raw digest bytes + /// Algorithm IDs: 0 = SHA-256, 1 = SHA-384 + pub pcrs: BTreeMap<(u8, u8), Vec>, + /// Timestamp when verification was performed (seconds since Unix epoch) + pub verified_at: u64, } /// Verify an entire AttestationOutput @@ -170,7 +256,6 @@ pub struct VerificationResult { /// - `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 /// /// # Errors /// Returns `NoValidAttestation` if no supported verification path is available. @@ -178,167 +263,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); - } - - // 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); - } + // Decode to binary format (all hex/PEM parsing happens here) + let decoded = DecodedAttestationOutput::decode(output)?; - // 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 @@ -382,12 +311,8 @@ mod tests { // 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 + // Nonce should be 32 bytes + assert_eq!(result.nonce.len(), 32); } #[test] @@ -402,25 +327,20 @@ mod tests { // 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 the nonce from the Quote (binary) + let expected_nonce = + hex::decode("8a543108a653b4a1162232744cc9b945017a449dea4fbb0ca62f42d3ef145562") + .unwrap(); + assert_eq!(result.nonce.as_slice(), expected_nonce.as_slice()); // 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,8 +350,12 @@ 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(_)))); + assert!( + result.is_err(), + "Should reject attestation with missing components" + ); } /// Test that tampering with the AK public key field is detected @@ -527,16 +451,13 @@ mod tdx_tests { // Should be GCP assert_eq!(result.provider, CloudProvider::Gcp); - // Should have the nonce from the Quote - assert_eq!( - result.nonce, - "6424632e79ec068f2189adf46d121b9a10f758c45a18c52f630da14600d4317b" - ); + // Should have the nonce from the Quote (binary) + let expected_nonce = + hex::decode("6424632e79ec068f2189adf46d121b9a10f758c45a18c52f630da14600d4317b") + .unwrap(); + assert_eq!(result.nonce.as_slice(), expected_nonce.as_slice()); // 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..c811949 100644 --- a/crates/vaportpm-verify/src/nitro.rs +++ b/crates/vaportpm-verify/src/nitro.rs @@ -9,81 +9,73 @@ 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::tpm::{parse_quote_attest, verify_ecdsa_p256}; use crate::x509::{extract_public_key, validate_tpm_cert_chain}; +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>, + /// Public key (raw bytes, if provided) + pub public_key: Option>, + /// Nonce (raw bytes, if provided) + pub nonce: Option>, } -/// Verify Nitro attestation document +/// Verify Nitro TPM attestation with pre-decoded data /// -/// # 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) +/// 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 /// -/// # 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>, +/// 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 { + // Parse TPM2_Quote attestation + let quote_info = parse_quote_attest(&decoded.quote_attest)?; + + // Verify nonce matches Quote + if decoded.nonce != quote_info.nonce.as_slice() { + return Err(VerifyError::InvalidAttest(format!( + "Nonce does not match Quote. Expected: {}, Quote: {}", + hex::encode(decoded.nonce), + hex::encode("e_info.nonce) + ))); + } + + // Verify AK signature over TPM2_Quote + verify_ecdsa_p256( + &decoded.quote_attest, + &decoded.quote_signature, + &decoded.ak_pubkey, + )?; - // Parse the COSE Sign1 structure (NSM returns untagged COSE) - let cose_sign1 = CoseSign1::from_slice(&document_bytes) + // Parse and verify Nitro document (COSE signature, cert chain) + let cose_sign1 = CoseSign1::from_slice(document_bytes) .map_err(|e| VerifyError::CoseVerify(format!("Failed to parse COSE Sign1: {}", e)))?; - // Extract the payload let payload = cose_sign1 .payload .as_ref() .ok_or_else(|| VerifyError::CoseVerify("Missing payload".into()))?; - // 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)))?; - // Extract document fields let doc_map = match &doc_value { CborValue::Map(m) => m, _ => return Err(VerifyError::CborParse("Payload is not a map".into())), @@ -91,25 +83,6 @@ pub fn verify_nitro_attestation( 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")?; @@ -119,7 +92,6 @@ pub fn verify_nitro_attestation( .map_err(|e| VerifyError::CertificateParse(format!("Invalid leaf cert: {}", e)))?; // 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) @@ -127,44 +99,127 @@ pub fn verify_nitro_attestation( 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 using leaf certificate 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 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, + // Extract signed values from Nitro document + let signed_pubkey = nitro_doc.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_doc.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 (binary comparison) + if decoded.ak_pubkey.as_slice() != signed_pubkey.as_slice() { + return Err(VerifyError::SignatureInvalid(format!( + "TPM signing key does not match Nitro public_key binding: {} != {}", + hex::encode(decoded.ak_pubkey), + hex::encode(signed_pubkey) + ))); + } + + // Verify TPM nonce matches Nitro nonce (binary comparison) + if quote_info.nonce.as_slice() != signed_nonce.as_slice() { + return Err(VerifyError::SignatureInvalid(format!( + "TPM nonce does not match Nitro nonce: {} != {}", + hex::encode("e_info.nonce), + hex::encode(signed_nonce) + ))); + } + + // Verify we have SHA-384 PCRs (algorithm ID 1) + let has_sha384_pcrs = decoded.pcrs.keys().any(|(alg_id, _)| *alg_id == 1); + if !has_sha384_pcrs { + return Err(VerifyError::InvalidAttest( + "Missing SHA-384 PCRs - required for Nitro attestation".into(), + )); + } + + let signed_pcrs = &nitro_doc.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 (binary comparison) + // Look up in decoded.pcrs using (algorithm_id=1 for SHA-384, pcr_index) + for (idx, signed_value) in signed_pcrs.iter() { + match decoded.pcrs.get(&(1, *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, + hex::encode(claimed_value), + hex::encode(signed_value) + ))); + } + None => { + return Err(VerifyError::SignatureInvalid(format!( + "PCR {} in signed Nitro document but missing from attestation", + idx + ))); + } + } + } + + // Verify root is known + 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.", + hex::encode(chain_result.root_pubkey_hash) + )) + })?; + + // Convert nonce to fixed-size array + let nonce: [u8; 32] = quote_info + .nonce + .as_slice() + .try_into() + .map_err(|_| VerifyError::InvalidAttest("nonce is not 32 bytes".into()))?; + + Ok(VerificationResult { + nonce, + provider, + pcrs: decoded.pcrs.clone(), + verified_at: time.as_secs(), }) } /// 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(VerifyError::InvalidAttest(format!( + "Unexpected Nitro digest algorithm: expected SHA384, got {}", + digest + ))); + } - // 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)); + // Optional fields (binary) + let public_key = extract_cbor_bytes_optional(map, "public_key"); + let nonce = extract_cbor_bytes_optional(map, "nonce"); Ok(NitroDocument { - module_id, - timestamp, pcrs, public_key, - user_data, nonce, - digest, }) } @@ -182,34 +237,6 @@ 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))) -} - /// Extract bytes field from CBOR map fn extract_cbor_bytes(map: &[(CborValue, CborValue)], key: &str) -> Result, VerifyError> { for (k, v) in map { @@ -272,7 +299,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) @@ -306,7 +333,7 @@ fn extract_cbor_pcrs(map: &[(CborValue, CborValue)]) -> Result 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] @@ -680,41 +641,4 @@ mod tests { let result = extract_cbor_pcrs(&map); assert!(matches!(result, Err(VerifyError::PcrIndexOutOfBounds(_)))); } - - // === Malicious Integer 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"); - assert!( - matches!(result, Err(VerifyError::CborParse(_))), - "Should reject negative timestamp, 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); - } - - #[test] - fn test_accept_max_i64_timestamp() { - // i64::MAX is valid and fits in u64 - let map = vec![( - CborValue::Text("timestamp".to_string()), - CborValue::Integer(i64::MAX.into()), - )]; - let result = extract_cbor_integer(&map, "timestamp"); - assert_eq!(result.unwrap(), i64::MAX as u64); - } } diff --git a/crates/vaportpm-verify/src/x509.rs b/crates/vaportpm-verify/src/x509.rs index 928b6d8..22ff642 100644 --- a/crates/vaportpm-verify/src/x509.rs +++ b/crates/vaportpm-verify/src/x509.rs @@ -267,17 +267,16 @@ pub fn extract_public_key(cert: &Certificate) -> Result, VerifyError> { 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 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, + /// SHA-256 hash of the root CA's public key + pub root_pubkey_hash: [u8; 32], } /// Validate certificate chain with rigid X.509 validation @@ -574,8 +573,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] diff --git a/experiments/risc-zero/Cargo.toml b/experiments/risc-zero/Cargo.toml index 1ed254b..8fdab5d 100644 --- a/experiments/risc-zero/Cargo.toml +++ b/experiments/risc-zero/Cargo.toml @@ -27,3 +27,4 @@ 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 index 9c4e552..998ea0b 100644 --- a/experiments/risc-zero/Makefile +++ b/experiments/risc-zero/Makefile @@ -11,7 +11,11 @@ test: # Cycle count specifically cycles: - RISC0_PPROF_OUT=./profile.pb cargo test cycle_count -- --nocapture && ~/go/bin/go tool pprof -text profile.pb + RISC0_PPROF_OUT=./profile.pb cargo test --release test_gcp_attestation_cycle_count -- --nocapture && ~/go/bin/go tool pprof -text 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 diff --git a/experiments/risc-zero/methods/guest/src/main.rs b/experiments/risc-zero/methods/guest/src/main.rs index 8387b56..7e4449e 100644 --- a/experiments/risc-zero/methods/guest/src/main.rs +++ b/experiments/risc-zero/methods/guest/src/main.rs @@ -1,12 +1,14 @@ #![no_main] +use pki_types::UnixTime; use risc0_zkvm::guest::env; use serde::{Deserialize, Serialize}; use serde_big_array::BigArray; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; -use vaportpm_attest::a9n::AttestationOutput; -use vaportpm_verify::{verify_attestation_output, CloudProvider}; +use std::io::Read; +use std::time::Duration; +use vaportpm_verify::{flat, verify_decoded_attestation_output, CloudProvider}; risc0_zkvm::guest::entry!(main); @@ -18,46 +20,34 @@ pub struct ZkPublicInputs { pub ak_pubkey: [u8; 65], pub nonce: [u8; 32], pub provider: u8, - pub root_pubkey_hash: [u8; 32], + pub verified_at: u64, } fn main() { - // Read inputs from host - let attestation_json: String = env::read(); - let time_secs: u64 = env::read(); + // Read raw bytes - no serde deserialization! + let mut input_bytes = Vec::::new(); + env::stdin().read_to_end(&mut input_bytes).unwrap(); - // Parse attestation - let output: AttestationOutput = - serde_json::from_str(&attestation_json).expect("Failed to parse attestation JSON"); + // 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"); - // Run EXACT SAME verification as native - let time = pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(time_secs)); + // Verify using decoded path (no hex::decode, no PEM parsing) let result = - verify_attestation_output(&output, time).expect("Attestation verification failed"); - - // Compute canonical PCR hash - let pcr_hash = compute_pcr_hash(&output.pcrs); - - // Extract AK public key - let ak_pk = output.ak_pubkeys.get("ecc_p256").expect("Missing AK"); - let mut ak_pubkey = [0u8; 65]; - ak_pubkey[0] = 0x04; - let x_bytes = hex::decode(&ak_pk.x).expect("Invalid AK x coordinate"); - let y_bytes = hex::decode(&ak_pk.y).expect("Invalid AK y coordinate"); - ak_pubkey[1..33].copy_from_slice(&x_bytes); - ak_pubkey[33..65].copy_from_slice(&y_bytes); - - // Parse nonce - let nonce_bytes = hex::decode(&output.nonce).expect("Invalid nonce"); - let mut nonce = [0u8; 32]; - nonce.copy_from_slice(&nonce_bytes); - - // Parse root pubkey hash - let root_hash_bytes = hex::decode(&result.root_pubkey_hash).expect("Invalid root hash"); - let mut root_pubkey_hash = [0u8; 32]; - root_pubkey_hash.copy_from_slice(&root_hash_bytes); - - // Map provider to u8 + 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(&decoded.pcrs); + + // Map provider to u8 (root hash already verified against known roots) let provider = match result.provider { CloudProvider::Aws => 0u8, CloudProvider::Gcp => 1u8, @@ -66,43 +56,36 @@ fn main() { // Build and commit public inputs let public_inputs = ZkPublicInputs { pcr_hash, - ak_pubkey, - nonce, + ak_pubkey: decoded.ak_pubkey, + nonce: decoded.nonce, provider, - root_pubkey_hash, + verified_at: result.verified_at, }; env::commit(&public_inputs); } -/// Compute canonical PCR hash +/// Compute canonical PCR hash from pre-decoded binary PCR data /// -/// Canonicalization: sort by algorithm name, then by PCR index -fn compute_pcr_hash( - pcrs: &std::collections::HashMap>, -) -> [u8; 32] { +/// Canonicalization: sort by algorithm ID, then by PCR index +fn compute_pcr_hash_decoded(pcrs: &BTreeMap<(u8, u8), Vec>) -> [u8; 32] { let mut hasher = Sha256::new(); - // Sort algorithm names - let mut alg_names: Vec<_> = pcrs.keys().collect(); - alg_names.sort(); - - for alg_name in alg_names { - let pcr_map = &pcrs[alg_name]; - - // Add algorithm name (length-prefixed) - hasher.update(&[alg_name.len() as u8]); - hasher.update(alg_name.as_bytes()); + // Group PCRs by algorithm + let mut by_alg: BTreeMap>> = BTreeMap::new(); + for ((alg_id, idx), value) in pcrs { + by_alg.entry(*alg_id).or_default().insert(*idx, value); + } - // Add PCR count - hasher.update(&[pcr_map.len() as u8]); + // Process in algorithm order (0=sha256, 1=sha384) + for (alg_id, pcr_map) in &by_alg { + // Add algorithm ID and PCR count (2 bytes total) + hasher.update(&[*alg_id, pcr_map.len() as u8]); // BTreeMap is already sorted by key - for (idx, value_hex) in pcr_map { - let value_bytes = hex::decode(value_hex).expect("valid hex"); + for (idx, value_bytes) in pcr_map { hasher.update(&[*idx]); - hasher.update(&[value_bytes.len() as u8]); - hasher.update(&value_bytes); + hasher.update(*value_bytes); } } diff --git a/experiments/risc-zero/src/inputs.rs b/experiments/risc-zero/src/inputs.rs index a494b95..90d20e9 100644 --- a/experiments/risc-zero/src/inputs.rs +++ b/experiments/risc-zero/src/inputs.rs @@ -20,8 +20,8 @@ pub struct ZkPublicInputs { pub nonce: [u8; 32], /// Cloud provider: 0 = AWS, 1 = GCP pub provider: u8, - /// SHA256 of root CA public key - pub root_pubkey_hash: [u8; 32], + /// Timestamp used for verification (seconds since Unix epoch) + pub verified_at: u64, } impl ZkPublicInputs { diff --git a/experiments/risc-zero/tests/cycle_count.rs b/experiments/risc-zero/tests/cycle_count.rs index 7b39806..28ff831 100644 --- a/experiments/risc-zero/tests/cycle_count.rs +++ b/experiments/risc-zero/tests/cycle_count.rs @@ -1,5 +1,7 @@ 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) @@ -15,13 +17,20 @@ fn test_gcp_attestation_cycle_count() { fs::read_to_string("../../crates/vaportpm-verify/test-gcp-amd-fixture.json") .expect("Failed to load GCP fixture"); - let time_secs: u64 = GCP_FIXTURE_TIMESTAMP_SECS; + // 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(&attestation_json) - .unwrap() - .write(&time_secs) - .unwrap() + .write_slice(&flat_bytes) // write_slice instead of write! .build() .unwrap(); @@ -29,15 +38,10 @@ fn test_gcp_attestation_cycle_count() { let session = executor.execute(env, VAPORTPM_ZK_GUEST_ELF).unwrap(); println!(); - println!("=== GCP Attestation Verification ==="); + println!("=== GCP Attestation Verification (Optimized + zerocopy) ==="); + println!("Flat input size: {} bytes", flat_bytes.len()); println!("Total cycles: {}", session.cycles()); println!("Segments: {}", session.segments.len()); - - /* - for (i, segment) in session.segments.iter().enumerate() { - println!(" Segment {}: {} cycles", i, segment.cycles); - } - */ println!(); } @@ -48,13 +52,20 @@ fn test_nitro_attestation_cycle_count() { fs::read_to_string("../../crates/vaportpm-verify/test-nitro-fixture.json") .expect("Failed to load Nitro fixture"); - let time_secs: u64 = NITRO_FIXTURE_TIMESTAMP_SECS; + // 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(&attestation_json) - .unwrap() - .write(&time_secs) - .unwrap() + .write_slice(&flat_bytes) .build() .unwrap(); @@ -62,14 +73,54 @@ fn test_nitro_attestation_cycle_count() { let session = executor.execute(env, VAPORTPM_ZK_GUEST_ELF).unwrap(); println!(); - println!("=== Nitro Attestation Verification ==="); + 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()); - /* - for (i, segment) in session.segments.iter().enumerate() { - println!(" Segment {}: {} cycles", i, segment.cycles); + // 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", decoded.pcrs.len(), pcr_bytes); println!(); } From 1fecbcdb85e3e99b6ee924be89e34e53d93e696d Mon Sep 17 00:00:00 2001 From: user <303926+HarryR@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:45:26 +0800 Subject: [PATCH 05/10] Added more extensive verification tests for edge cases --- Makefile | 13 +- crates/vaportpm-attest/src/a9n.rs | 21 +- crates/vaportpm-verify/Cargo.toml | 3 + crates/vaportpm-verify/src/gcp.rs | 209 ++++---- crates/vaportpm-verify/src/lib.rs | 157 +----- crates/vaportpm-verify/src/nitro.rs | 717 ++++++++++++++++++++++---- crates/vaportpm-verify/src/tpm.rs | 56 ++ crates/vaportpm-verify/tests/gcp.rs | 478 +++++++++++++++++ crates/vaportpm-verify/tests/nitro.rs | 435 ++++++++++++++++ 9 files changed, 1719 insertions(+), 370 deletions(-) create mode 100644 crates/vaportpm-verify/tests/gcp.rs create mode 100644 crates/vaportpm-verify/tests/nitro.rs 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/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 9f4cab3..b375a1b 100644 --- a/crates/vaportpm-verify/Cargo.toml +++ b/crates/vaportpm-verify/Cargo.toml @@ -36,6 +36,9 @@ serde_json = { workspace = true } serde-big-array = { workspace = true } zerocopy = { workspace = true } +[dev-dependencies] +rcgen = "0.13" + [lib] name = "vaportpm_verify" path = "src/lib.rs" diff --git a/crates/vaportpm-verify/src/gcp.rs b/crates/vaportpm-verify/src/gcp.rs index af9f950..aca5b76 100644 --- a/crates/vaportpm-verify/src/gcp.rs +++ b/crates/vaportpm-verify/src/gcp.rs @@ -2,32 +2,20 @@ //! 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::VerifyError; -use crate::tpm::{parse_quote_attest, verify_ecdsa_p256, TpmQuoteInfo}; +use crate::tpm::{parse_quote_attest, verify_ecdsa_p256, verify_pcr_digest_matches}; use crate::x509::{extract_public_key, validate_tpm_cert_chain}; +use crate::CloudProvider; use crate::{roots, DecodedAttestationOutput, VerificationResult}; -/// 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 -/// 4. Verifies PCR digest matches claimed PCR values -/// -/// All inputs should be pre-decoded binary data (DER certs, raw bytes). -pub fn verify_gcp_decoded( - decoded: &DecodedAttestationOutput, +fn verify_gcp_certs( cert_chain_der: &[Vec], time: UnixTime, -) -> Result { +) -> Result<(Vec, CloudProvider), VerifyError> { // Parse DER → Certificate (still needed for chain validation) let certs: Vec = cert_chain_der .iter() @@ -43,20 +31,87 @@ pub fn verify_gcp_decoded( )); } - // Parse TPM2_Quote attestation - let quote_info = parse_quote_attest(&decoded.quote_attest)?; + // Validate AK certificate chain to GCP root + let chain_result = validate_tpm_cert_chain(&certs, time)?; - // Verify nonce matches Quote - if decoded.nonce != quote_info.nonce.as_slice() { - return Err(VerifyError::InvalidAttest(format!( - "Nonce does not match Quote. Expected: {}, Quote: {}", - hex::encode(decoded.nonce), - hex::encode("e_info.nonce) + // Verify root is a known GCP root + 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.", + 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(VerifyError::ChainValidation(format!( + "GCP verification path requires GCP root CA, got {:?}", + provider ))); } - // Validate AK certificate chain to GCP root - let chain_result = validate_tpm_cert_chain(&certs, time)?; + Ok((certs, provider)) +} + +/// Verify GCP Shielded VM attestation +/// +/// This verification path: +/// 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 +/// +/// 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 { + // Enforce that only SHA-256 PCRs (algorithm ID 0) are present. + // The GCP path only verifies SHA-256 PCRs (covered by the TPM Quote's + // PCR digest and the AK certificate chain). Any other bank would be + // unverified data passed through to the output. + if decoded.pcrs.is_empty() { + return Err(VerifyError::InvalidAttest( + "Missing SHA-256 PCRs - required for GCP attestation".into(), + )); + } + for (alg_id, pcr_idx) in decoded.pcrs.keys() { + if *alg_id != 0 { + return Err(VerifyError::InvalidAttest(format!( + "GCP attestation contains non-SHA-256 PCR (alg_id={}, pcr={}); \ + only SHA-256 PCRs are verified in the GCP path", + alg_id, pcr_idx + ))); + } + } + + // Enforce all 24 SHA-256 PCRs are present. + // Complete, unambiguous PCR state — no selective omission. + for pcr_idx in 0..24u8 { + if !decoded.pcrs.contains_key(&(0, pcr_idx)) { + return Err(VerifyError::InvalidAttest(format!( + "Missing SHA-256 PCR {} - all 24 PCRs (0-23) are required for GCP attestation", + pcr_idx + ))); + } + } + + // Reject any PCR indices outside 0-23 + for (_alg_id, pcr_idx) in decoded.pcrs.keys() { + if *pcr_idx > 23 { + return Err(VerifyError::InvalidAttest(format!( + "PCR index {} out of range; only PCRs 0-23 are valid", + pcr_idx + ))); + } + } + + // Parse TPM2_Quote attestation (structure only — not yet authenticated) + let quote_info = parse_quote_attest(&decoded.quote_attest)?; + + let (certs, provider) = verify_gcp_certs(cert_chain_der, time)?; // Extract AK public key from leaf certificate for comparison let ak_pubkey_from_cert = extract_public_key(&certs[0])?; @@ -70,32 +125,55 @@ pub fn verify_gcp_decoded( ))); } - // Verify Quote signature with AK public key + // Verify Quote signature with AK public key — this authenticates + // the quote data. All checks below trust the parsed quote_info + // because the signature covers the entire attest structure. verify_ecdsa_p256( &decoded.quote_attest, &decoded.quote_signature, &decoded.ak_pubkey, )?; - // Verify we have SHA-256 PCRs (algorithm ID 0) - let has_sha256_pcrs = decoded.pcrs.keys().any(|(alg_id, _)| *alg_id == 0); - if !has_sha256_pcrs { - return Err(VerifyError::InvalidAttest( - "Missing SHA-256 PCRs - required for GCP attestation".into(), - )); + // --- Quote is now authenticated; safe to trust its contents --- + + // Enforce that the TPM Quote selects exactly one PCR bank: SHA-256 (0x000B), + // and that it selects all 24 PCRs (bitmap 0xFF 0xFF 0xFF). + if quote_info.pcr_select.len() != 1 { + return Err(VerifyError::InvalidAttest(format!( + "GCP path requires exactly one PCR bank selection, got {}", + quote_info.pcr_select.len() + ))); + } + let (quote_alg, quote_bitmap) = "e_info.pcr_select[0]; + if *quote_alg != 0x000B { + return Err(VerifyError::InvalidAttest(format!( + "GCP path requires TPM Quote to select SHA-256 PCRs (0x000B), got 0x{:04X}", + quote_alg + ))); + } + if quote_bitmap.len() < 3 + || quote_bitmap[0] != 0xFF + || quote_bitmap[1] != 0xFF + || quote_bitmap[2] != 0xFF + { + return Err(VerifyError::InvalidAttest(format!( + "GCP path requires all 24 PCRs selected in Quote bitmap, got {:?}", + quote_bitmap + ))); + } + + // Verify nonce matches Quote + if decoded.nonce != quote_info.nonce.as_slice() { + return Err(VerifyError::InvalidAttest(format!( + "Nonce does not match Quote. Expected: {}, Quote: {}", + hex::encode(decoded.nonce), + hex::encode("e_info.nonce) + ))); } // Verify PCR digest matches claimed PCR values verify_pcr_digest_matches("e_info, &decoded.pcrs)?; - // Verify root is a known GCP root - 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.", - hex::encode(chain_result.root_pubkey_hash) - )) - })?; - // Convert nonce to fixed-size array let nonce: [u8; 32] = quote_info .nonce @@ -110,52 +188,3 @@ pub fn verify_gcp_decoded( 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<(u8, u8), Vec>, -) -> 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; - // Look up by (algorithm_id=0 for SHA-256, pcr_index) - if let Some(pcr_value) = pcrs.get(&(0, pcr_idx)) { - 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 6fcb65f..8e00d4d 100644 --- a/crates/vaportpm-verify/src/lib.rs +++ b/crates/vaportpm-verify/src/lib.rs @@ -24,7 +24,7 @@ use serde_big_array::BigArray; 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}; +pub use tpm::{parse_quote_attest, verify_ecdsa_p256, verify_pcr_digest_matches, TpmQuoteInfo}; // Re-export from vaportpm_attest pub use vaportpm_attest::TpmAlg; @@ -285,58 +285,6 @@ pub fn verify_attestation_json(json: &str) -> Result 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 should be 32 bytes - assert_eq!(result.nonce.len(), 32); - } - - #[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 (binary) - let expected_nonce = - hex::decode("8a543108a653b4a1162232744cc9b945017a449dea4fbb0ca62f42d3ef145562") - .unwrap(); - assert_eq!(result.nonce.as_slice(), expected_nonce.as_slice()); - - // Should have PCR values - assert!(!result.pcrs.is_empty()); - } - #[test] fn test_reject_empty_attestation() { let output = AttestationOutput { @@ -357,107 +305,4 @@ mod tests { "Should reject attestation with missing components" ); } - - /// 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 (binary) - let expected_nonce = - hex::decode("6424632e79ec068f2189adf46d121b9a10f758c45a18c52f630da14600d4317b") - .unwrap(); - assert_eq!(result.nonce.as_slice(), expected_nonce.as_slice()); - - // Should have PCR values - assert!(!result.pcrs.is_empty()); - } } diff --git a/crates/vaportpm-verify/src/nitro.rs b/crates/vaportpm-verify/src/nitro.rs index c811949..a77a842 100644 --- a/crates/vaportpm-verify/src/nitro.rs +++ b/crates/vaportpm-verify/src/nitro.rs @@ -15,8 +15,9 @@ use x509_cert::Certificate; use pki_types::UnixTime; use crate::error::VerifyError; -use crate::tpm::{parse_quote_attest, verify_ecdsa_p256}; +use crate::tpm::{parse_quote_attest, verify_ecdsa_p256, verify_pcr_digest_matches, TpmQuoteInfo}; use crate::x509::{extract_public_key, validate_tpm_cert_chain}; +use crate::CloudProvider; use crate::{roots, DecodedAttestationOutput, VerificationResult}; /// Parsed Nitro attestation document (internal) @@ -33,11 +34,19 @@ struct NitroDocument { /// Verify Nitro TPM attestation with pre-decoded data /// -/// 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 +/// 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. +/// +/// 2. **Parse authenticated Nitro document**: extract PCRs, public_key, nonce +/// from the now-authenticated COSE payload. +/// +/// 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( @@ -45,26 +54,42 @@ pub fn verify_nitro_decoded( document_bytes: &[u8], time: UnixTime, ) -> Result { - // Parse TPM2_Quote attestation - let quote_info = parse_quote_attest(&decoded.quote_attest)?; + // === 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)?; - // Verify nonce matches Quote - if decoded.nonce != quote_info.nonce.as_slice() { - return Err(VerifyError::InvalidAttest(format!( - "Nonce does not match Quote. Expected: {}, Quote: {}", - hex::encode(decoded.nonce), - hex::encode("e_info.nonce) - ))); - } + // === Phase 2: Verify TPM Quote authenticity === + // The Nitro document binds the AK public key — verify the Quote was + // signed by that key. + let quote_info = verify_tpm_quote_signature(decoded, &nitro_doc)?; - // Verify AK signature over TPM2_Quote - verify_ecdsa_p256( - &decoded.quote_attest, - &decoded.quote_signature, - &decoded.ak_pubkey, - )?; + // === Phase 3: Cross-verify all authenticated data === + 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(|_| VerifyError::InvalidAttest("nonce is not 32 bytes".into()))?; + + Ok(VerificationResult { + nonce, + provider, + pcrs: decoded.pcrs.clone(), + verified_at: time.as_secs(), + }) +} - // Parse and verify Nitro document (COSE signature, cert chain) +/// 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| VerifyError::CoseVerify(format!("Failed to parse COSE Sign1: {}", e)))?; @@ -73,25 +98,23 @@ pub fn verify_nitro_decoded( .as_ref() .ok_or_else(|| VerifyError::CoseVerify("Missing payload".into()))?; - let doc_value: CborValue = ciborium::from_reader(payload.as_slice()) + // 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| VerifyError::CborParse(format!("Failed to parse payload: {}", e)))?; - 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())), }; - let nitro_doc = parse_nitro_document(doc_map)?; - - // 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)))?; - // Build chain in leaf-to-root order let mut chain = vec![leaf_cert]; for ca_der in cabundle.into_iter().rev() { let ca_cert = Certificate::from_der(&ca_der) @@ -99,26 +122,49 @@ pub fn verify_nitro_decoded( chain.push(ca_cert); } - // Verify COSE signature using leaf certificate + // Verify COSE signature let leaf_pubkey = extract_public_key(&chain[0])?; verify_cose_signature(&cose_sign1, &leaf_pubkey, payload)?; - // Validate certificate chain + // Validate certificate chain and time validity let chain_result = validate_tpm_cert_chain(&chain, time)?; - // Extract signed values from Nitro document + // Verify root is a known AWS root + 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.", + hex::encode(chain_result.root_pubkey_hash) + )) + })?; + + if provider != CloudProvider::Aws { + return Err(VerifyError::ChainValidation(format!( + "Nitro verification path requires AWS root CA, got {:?}", + provider + ))); + } + + // --- COSE document is now authenticated; safe to parse its contents --- + let nitro_doc = parse_nitro_document(payload_map)?; + + Ok((nitro_doc, provider)) +} + +/// Phase 2: Verify the TPM Quote was signed by the AK that the Nitro document binds. +/// +/// Returns the authenticated TpmQuoteInfo. +fn verify_tpm_quote_signature( + decoded: &DecodedAttestationOutput, + nitro_doc: &NitroDocument, +) -> Result { + // The Nitro document's public_key field tells us which AK to trust. + // Verify the claimed AK matches before we use it for signature verification. let signed_pubkey = nitro_doc.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_doc.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 (binary comparison) if decoded.ak_pubkey.as_slice() != signed_pubkey.as_slice() { return Err(VerifyError::SignatureInvalid(format!( "TPM signing key does not match Nitro public_key binding: {} != {}", @@ -127,7 +173,73 @@ pub fn verify_nitro_decoded( ))); } - // Verify TPM nonce matches Nitro nonce (binary comparison) + // Parse and verify TPM2_Quote + let quote_info = parse_quote_attest(&decoded.quote_attest)?; + + verify_ecdsa_p256( + &decoded.quote_attest, + &decoded.quote_signature, + &decoded.ak_pubkey, + )?; + + // --- Quote is now authenticated; safe to trust its contents --- + + Ok(quote_info) +} + +/// Phase 3: Cross-verify all authenticated data from the Nitro document and TPM Quote. +/// +/// At this point both the Nitro document (COSE) and TPM Quote (ECDSA) are +/// authenticated. This function verifies they agree on nonce, PCR values, +/// and PCR digest. +fn verify_nitro_bindings( + decoded: &DecodedAttestationOutput, + quote_info: &TpmQuoteInfo, + nitro_doc: &NitroDocument, +) -> Result<(), VerifyError> { + // --- Quote structure enforcement --- + + // Exactly one PCR bank: SHA-384 (0x000C), all 24 PCRs selected. + if quote_info.pcr_select.len() != 1 { + return Err(VerifyError::InvalidAttest(format!( + "Nitro path requires exactly one PCR bank selection, got {}", + quote_info.pcr_select.len() + ))); + } + let (quote_alg, quote_bitmap) = "e_info.pcr_select[0]; + if *quote_alg != 0x000C { + return Err(VerifyError::InvalidAttest(format!( + "Nitro path requires TPM Quote to select SHA-384 PCRs (0x000C), got 0x{:04X}", + quote_alg + ))); + } + if quote_bitmap.len() < 3 + || quote_bitmap[0] != 0xFF + || quote_bitmap[1] != 0xFF + || quote_bitmap[2] != 0xFF + { + return Err(VerifyError::InvalidAttest(format!( + "Nitro path requires all 24 PCRs selected in Quote bitmap, got {:?}", + quote_bitmap + ))); + } + + // --- Nonce verification --- + + if decoded.nonce != quote_info.nonce.as_slice() { + return Err(VerifyError::InvalidAttest(format!( + "Nonce does not match Quote. Expected: {}, Quote: {}", + hex::encode(decoded.nonce), + hex::encode("e_info.nonce) + ))); + } + + let signed_nonce = nitro_doc.nonce.as_ref().ok_or_else(|| { + VerifyError::NoValidAttestation( + "Nitro document missing nonce field - cannot verify freshness".into(), + ) + })?; + if quote_info.nonce.as_slice() != signed_nonce.as_slice() { return Err(VerifyError::SignatureInvalid(format!( "TPM nonce does not match Nitro nonce: {} != {}", @@ -136,14 +248,44 @@ pub fn verify_nitro_decoded( ))); } - // Verify we have SHA-384 PCRs (algorithm ID 1) - let has_sha384_pcrs = decoded.pcrs.keys().any(|(alg_id, _)| *alg_id == 1); - if !has_sha384_pcrs { + // --- PCR enforcement --- + + // Only SHA-384 PCRs allowed (algorithm ID 1). Any other bank would be + // unverified data passed through to the output. + if decoded.pcrs.is_empty() { return Err(VerifyError::InvalidAttest( "Missing SHA-384 PCRs - required for Nitro attestation".into(), )); } + for (alg_id, pcr_idx) in decoded.pcrs.keys() { + if *alg_id != 1 { + return Err(VerifyError::InvalidAttest(format!( + "Nitro attestation contains non-SHA-384 PCR (alg_id={}, pcr={}); \ + only SHA-384 PCRs are verified in the Nitro path", + alg_id, pcr_idx + ))); + } + if *pcr_idx > 23 { + return Err(VerifyError::InvalidAttest(format!( + "PCR index {} out of range; only PCRs 0-23 are valid", + pcr_idx + ))); + } + } + + // All 24 PCRs must be present — complete, unambiguous state. + for pcr_idx in 0..24u8 { + if !decoded.pcrs.contains_key(&(1, pcr_idx)) { + return Err(VerifyError::InvalidAttest(format!( + "Missing SHA-384 PCR {} - all 24 PCRs (0-23) are required for Nitro attestation", + pcr_idx + ))); + } + } + + // --- Bidirectional PCR match against Nitro-signed values --- + let signed_pcrs = &nitro_doc.pcrs; if signed_pcrs.is_empty() { return Err(VerifyError::InvalidAttest( @@ -151,13 +293,9 @@ pub fn verify_nitro_decoded( )); } - // All signed PCRs must be present and match (binary comparison) - // Look up in decoded.pcrs using (algorithm_id=1 for SHA-384, pcr_index) for (idx, signed_value) in signed_pcrs.iter() { match decoded.pcrs.get(&(1, *idx)) { - Some(claimed_value) if claimed_value == signed_value => { - // Match - good - } + Some(claimed_value) if claimed_value == signed_value => {} Some(claimed_value) => { return Err(VerifyError::SignatureInvalid(format!( "PCR {} SHA-384 mismatch: claimed {} != signed {}", @@ -175,27 +313,22 @@ pub fn verify_nitro_decoded( } } - // Verify root is known - 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.", - hex::encode(chain_result.root_pubkey_hash) - )) - })?; + for (_alg_id, pcr_idx) in decoded.pcrs.keys() { + if !signed_pcrs.contains_key(pcr_idx) { + return Err(VerifyError::InvalidAttest(format!( + "PCR {} in attestation but not signed by Nitro document", + pcr_idx + ))); + } + } - // Convert nonce to fixed-size array - let nonce: [u8; 32] = quote_info - .nonce - .as_slice() - .try_into() - .map_err(|_| VerifyError::InvalidAttest("nonce is not 32 bytes".into()))?; + // --- PCR digest: cryptographic binding --- + // COSE signature authenticates the Nitro document (including PCR values). + // ECDSA signature authenticates the TPM Quote (including PCR digest). + // This check proves the PCR digest covers the same values. + verify_pcr_digest_matches(quote_info, &decoded.pcrs)?; - Ok(VerificationResult { - nonce, - provider, - pcrs: decoded.pcrs.clone(), - verified_at: time.as_secs(), - }) + Ok(()) } /// Parse Nitro document fields from CBOR map @@ -407,6 +540,7 @@ fn verify_cose_signature( #[allow(clippy::useless_vec)] mod tests { use super::*; + use sha2::Sha256; // === CBOR Field Extraction Tests === @@ -530,51 +664,6 @@ mod tests { assert!(matches!(result, Err(VerifyError::CborParse(_)))); } - // === 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()); - } - // === PCR Index Bounds Tests === #[test] @@ -641,4 +730,404 @@ mod tests { let result = extract_cbor_pcrs(&map); assert!(matches!(result, Err(VerifyError::PcrIndexOutOfBounds(_)))); } + + // === parse_nitro_document Tests === + + #[test] + 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::InvalidAttest(ref msg)) if msg.contains("Unexpected Nitro digest algorithm")), + "Should reject SHA256 digest, got: {:?}", + result + ); + } + + #[test] + fn test_parse_nitro_document_public_key_absent() { + let map = vec![ + ( + CborValue::Text("digest".to_string()), + CborValue::Text("SHA384".to_string()), + ), + ( + CborValue::Text("pcrs".to_string()), + CborValue::Map(vec![( + CborValue::Integer(0.into()), + CborValue::Bytes(vec![0x00; 48]), + )]), + ), + // public_key field is absent + ]; + let result = parse_nitro_document(&map).unwrap(); + assert_eq!(result.public_key, None); + } + + #[test] + fn test_parse_nitro_document_public_key_null() { + let map = vec![ + ( + CborValue::Text("digest".to_string()), + CborValue::Text("SHA384".to_string()), + ), + ( + CborValue::Text("pcrs".to_string()), + CborValue::Map(vec![( + CborValue::Integer(0.into()), + CborValue::Bytes(vec![0x00; 48]), + )]), + ), + (CborValue::Text("public_key".to_string()), CborValue::Null), + ]; + let result = parse_nitro_document(&map).unwrap(); + assert_eq!(result.public_key, None); + } + + #[test] + fn test_parse_nitro_document_public_key_present() { + let pk = vec![0x04; 65]; + let map = vec![ + ( + CborValue::Text("digest".to_string()), + CborValue::Text("SHA384".to_string()), + ), + ( + CborValue::Text("pcrs".to_string()), + CborValue::Map(vec![( + CborValue::Integer(0.into()), + CborValue::Bytes(vec![0x00; 48]), + )]), + ), + ( + CborValue::Text("public_key".to_string()), + CborValue::Bytes(pk.clone()), + ), + ]; + let result = parse_nitro_document(&map).unwrap(); + assert_eq!(result.public_key, Some(pk)); + } + + #[test] + fn test_parse_nitro_document_nonce_absent() { + let map = vec![ + ( + CborValue::Text("digest".to_string()), + CborValue::Text("SHA384".to_string()), + ), + ( + CborValue::Text("pcrs".to_string()), + CborValue::Map(vec![( + CborValue::Integer(0.into()), + CborValue::Bytes(vec![0x00; 48]), + )]), + ), + // nonce field is absent + ]; + let result = parse_nitro_document(&map).unwrap(); + assert_eq!(result.nonce, None); + } + + #[test] + fn test_parse_nitro_document_nonce_null() { + let map = vec![ + ( + CborValue::Text("digest".to_string()), + CborValue::Text("SHA384".to_string()), + ), + ( + CborValue::Text("pcrs".to_string()), + CborValue::Map(vec![( + CborValue::Integer(0.into()), + CborValue::Bytes(vec![0x00; 48]), + )]), + ), + (CborValue::Text("nonce".to_string()), CborValue::Null), + ]; + let result = parse_nitro_document(&map).unwrap(); + assert_eq!(result.nonce, None); + } + + #[test] + fn test_parse_nitro_document_nonce_present() { + let nonce = vec![0xAB; 32]; + let map = vec![ + ( + CborValue::Text("digest".to_string()), + CborValue::Text("SHA384".to_string()), + ), + ( + CborValue::Text("pcrs".to_string()), + CborValue::Map(vec![( + CborValue::Integer(0.into()), + CborValue::Bytes(vec![0x00; 48]), + )]), + ), + ( + CborValue::Text("nonce".to_string()), + CborValue::Bytes(nonce.clone()), + ), + ]; + let result = parse_nitro_document(&map).unwrap(); + assert_eq!(result.nonce, Some(nonce)); + } + + // === extract_cbor_byte_array edge cases === + + #[test] + 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("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_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 Tests === + + /// Build a consistent (DecodedAttestationOutput, TpmQuoteInfo, NitroDocument) triple + /// where `verify_nitro_bindings` succeeds. Tests tweak individual fields to trigger errors. + fn make_valid_bindings_inputs() -> (DecodedAttestationOutput, TpmQuoteInfo, NitroDocument) { + let nonce = [0xAA; 32]; + + // 24 SHA-384 PCRs: value = vec![idx; 48] + let mut decoded_pcrs = BTreeMap::new(); + let mut nitro_pcrs = BTreeMap::new(); + for idx in 0u8..24 { + let value = vec![idx; 48]; + decoded_pcrs.insert((1, idx), value.clone()); // alg_id 1 = SHA-384 + nitro_pcrs.insert(idx, value); + } + + // Compute the PCR digest: SHA-256 of concatenated PCR values in order (0..23) + let mut hasher = Sha256::new(); + for idx in 0u8..24 { + hasher.update(vec![idx; 48]); + } + let pcr_digest = hasher.finalize().to_vec(); + + let decoded = DecodedAttestationOutput { + nonce, + pcrs: decoded_pcrs, + ak_pubkey: [0x04; 65], + quote_attest: vec![], + quote_signature: vec![], + platform: crate::DecodedPlatformAttestation::Nitro { document: vec![] }, + }; + + let quote_info = TpmQuoteInfo { + nonce: nonce.to_vec(), + signer_name: vec![0x00; 34], + pcr_select: vec![(0x000C, vec![0xFF, 0xFF, 0xFF])], // SHA-384, all 24 selected + pcr_digest, + }; + + let nitro_doc = NitroDocument { + pcrs: nitro_pcrs, + public_key: Some(vec![0x04; 65]), + nonce: Some(nonce.to_vec()), + }; + + (decoded, quote_info, nitro_doc) + } + + #[test] + fn test_bindings_happy_path() { + let (decoded, quote_info, nitro_doc) = make_valid_bindings_inputs(); + let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); + assert!(result.is_ok(), "Happy path should succeed: {:?}", result); + } + + #[test] + fn test_bindings_reject_multiple_pcr_banks() { + let (decoded, mut quote_info, nitro_doc) = make_valid_bindings_inputs(); + quote_info.pcr_select.push((0x000B, vec![0xFF, 0xFF, 0xFF])); + let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("exactly one PCR bank selection, got 2")), + "Expected multiple PCR bank error, got: {:?}", + result + ); + } + + #[test] + fn test_bindings_reject_wrong_pcr_algorithm() { + let (decoded, mut quote_info, nitro_doc) = make_valid_bindings_inputs(); + quote_info.pcr_select[0].0 = 0x000B; // SHA-256 instead of SHA-384 + let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("SHA-384 PCRs (0x000C), got 0x000B")), + "Expected wrong algorithm error, got: {:?}", + result + ); + } + + #[test] + fn test_bindings_reject_partial_pcr_bitmap() { + let (decoded, mut quote_info, nitro_doc) = make_valid_bindings_inputs(); + quote_info.pcr_select[0].1 = vec![0xFF, 0xFF, 0xFE]; // PCR 23 not selected + let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("all 24 PCRs selected in Quote bitmap")), + "Expected partial bitmap error, got: {:?}", + result + ); + } + + #[test] + fn test_bindings_reject_nonce_mismatch_decoded_vs_quote() { + let (mut decoded, quote_info, nitro_doc) = make_valid_bindings_inputs(); + decoded.nonce = [0xBB; 32]; // Different from quote_info.nonce + let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("Nonce does not match Quote")), + "Expected nonce mismatch error, got: {:?}", + result + ); + } + + #[test] + fn test_bindings_reject_missing_nitro_nonce() { + let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); + nitro_doc.nonce = None; + let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::NoValidAttestation(ref msg)) if msg.contains("missing nonce field")), + "Expected missing nonce error, got: {:?}", + result + ); + } + + #[test] + fn test_bindings_reject_nitro_nonce_mismatch() { + let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); + nitro_doc.nonce = Some(vec![0xCC; 32]); // Different from quote nonce + let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::SignatureInvalid(ref msg)) if msg.contains("TPM nonce does not match Nitro nonce")), + "Expected Nitro nonce mismatch error, got: {:?}", + result + ); + } + + #[test] + fn test_bindings_reject_empty_signed_pcrs() { + let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); + nitro_doc.pcrs = BTreeMap::new(); + let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("no signed PCRs")), + "Expected empty signed PCRs error, got: {:?}", + result + ); + } + + #[test] + fn test_bindings_reject_signed_pcr_missing_from_attestation() { + let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); + // Add PCR 24 to nitro_doc but it doesn't exist in decoded.pcrs + nitro_doc.pcrs.insert(24, vec![0xFF; 48]); + let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::SignatureInvalid(ref msg)) if msg.contains("in signed Nitro document but missing")), + "Expected signed PCR missing error, got: {:?}", + result + ); + } + + #[test] + fn test_bindings_reject_attestation_pcr_not_signed() { + let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); + nitro_doc.pcrs.remove(&0); // Remove PCR 0 from signed set + let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("in attestation but not signed")), + "Expected unsigned PCR error, got: {:?}", + result + ); + } + + #[test] + fn test_bindings_reject_pcr_value_mismatch() { + let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); + nitro_doc.pcrs.insert(0, vec![0xFF; 48]); // Different value for PCR 0 + let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::SignatureInvalid(ref msg)) if msg.contains("PCR 0 SHA-384 mismatch")), + "Expected PCR value mismatch error, got: {:?}", + result + ); + } + + // === verify_tpm_quote_signature Tests === + + /// Build a (DecodedAttestationOutput, NitroDocument) pair with matching AK pubkey / public_key. + /// The quote_attest and quote_signature are dummy since we only test pre-crypto error paths. + fn make_valid_quote_sig_inputs() -> (DecodedAttestationOutput, NitroDocument) { + let ak_pubkey = [0x04; 65]; + + let decoded = DecodedAttestationOutput { + nonce: [0xAA; 32], + pcrs: BTreeMap::new(), + ak_pubkey, + quote_attest: vec![], + quote_signature: vec![], + platform: crate::DecodedPlatformAttestation::Nitro { document: vec![] }, + }; + + let nitro_doc = NitroDocument { + pcrs: BTreeMap::new(), + public_key: Some(ak_pubkey.to_vec()), + nonce: Some(vec![0xAA; 32]), + }; + + (decoded, nitro_doc) + } + + #[test] + fn test_quote_sig_reject_missing_public_key() { + let (decoded, mut nitro_doc) = make_valid_quote_sig_inputs(); + nitro_doc.public_key = None; + let result = verify_tpm_quote_signature(&decoded, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::NoValidAttestation(ref msg)) if msg.contains("missing public_key field")), + "Expected missing public_key error, got: {:?}", + result + ); + } + + #[test] + fn test_quote_sig_reject_ak_mismatch() { + let (decoded, mut nitro_doc) = make_valid_quote_sig_inputs(); + nitro_doc.public_key = Some(vec![0x05; 65]); // Different key + let result = verify_tpm_quote_signature(&decoded, &nitro_doc); + assert!( + matches!(result, Err(VerifyError::SignatureInvalid(ref msg)) if msg.contains("does not match Nitro public_key binding")), + "Expected AK mismatch error, got: {:?}", + result + ); + } } diff --git a/crates/vaportpm-verify/src/tpm.rs b/crates/vaportpm-verify/src/tpm.rs index 6a4d888..c738c2f 100644 --- a/crates/vaportpm-verify/src/tpm.rs +++ b/crates/vaportpm-verify/src/tpm.rs @@ -6,6 +6,8 @@ use ecdsa::signature::hazmat::PrehashVerifier; use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256VerifyingKey}; use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + use crate::error::VerifyError; /// Verify ECDSA-SHA256 signature over a message @@ -223,6 +225,60 @@ impl<'a> SafeCursor<'a> { } } +/// Verify that the PCR digest in a Quote matches the claimed PCR values +/// +/// The TPM Quote contains a PCR selection (which banks/indices were quoted) +/// and a digest over those PCR values. This function recomputes the digest +/// from the claimed PCR values and compares it to the signed digest. +/// +/// Supports multiple PCR banks: +/// - TPM_ALG_SHA256 (0x000B) → decoded algorithm ID 0 +/// - TPM_ALG_SHA384 (0x000C) → decoded algorithm ID 1 +pub fn verify_pcr_digest_matches( + quote_info: &TpmQuoteInfo, + pcrs: &BTreeMap<(u8, u8), Vec>, +) -> 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 + let mut hasher = Sha256::new(); + + for (alg, bitmap) in "e_info.pcr_select { + let decoded_alg_id = match *alg { + 0x000B => 0u8, // TPM_ALG_SHA256 + 0x000C => 1u8, // TPM_ALG_SHA384 + _ => 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) = pcrs.get(&(decoded_alg_id, pcr_idx)) { + hasher.update(pcr_value); + } else { + return Err(VerifyError::InvalidAttest(format!( + "PCR {} (alg 0x{:04X}) selected in Quote but not present in attestation", + pcr_idx, alg + ))); + } + } + } + } + } + + 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(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/vaportpm-verify/tests/gcp.rs b/crates/vaportpm-verify/tests/gcp.rs new file mode 100644 index 0000000..e2547ab --- /dev/null +++ b/crates/vaportpm-verify/tests/gcp.rs @@ -0,0 +1,478 @@ +// 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 der::Decode; +use vaportpm_verify::{ + verify_attestation_output, verify_decoded_attestation_output, CloudProvider, + DecodedAttestationOutput, DecodedPlatformAttestation, EccPublicKeyCoords, 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!(!result.pcrs.is_empty()); +} + +#[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!(!result.pcrs.is_empty()); +} + +// ============================================================================= +// 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(ref msg)) if msg.contains("AK public key mismatch")), + "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(ref msg)) if msg.contains("PCR digest mismatch")), + "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(ref msg)) if msg.contains("Nonce does not match")), + "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 — non-SHA-256 PCR rejection +#[test] +fn test_gcp_reject_non_sha256_pcrs() { + let mut output = load_gcp_amd_fixture(); + + // Remove the SHA-256 bank entirely, substitute a SHA-384 entry + // so that decode() doesn't fail on empty PCRs + output.pcrs.remove("sha256"); + let mut sha384_pcrs = BTreeMap::new(); + sha384_pcrs.insert( + 0u8, + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + ); + output.pcrs.insert("sha384".to_string(), sha384_pcrs); + + let result = verify_attestation_output(&output, gcp_amd_fixture_time()); + assert!( + matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("SHA-256")), + "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: gcp.rs — "all 24 PCRs (0-23) are required" +#[test] +fn test_gcp_reject_missing_pcr() { + let mut output = load_gcp_amd_fixture(); + + // Remove PCR 0 — this now hits the "all 24 required" check before + // reaching the Quote-level pcr_select check. + 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(ref msg)) if msg.contains("all 24 PCRs")), + "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(ref msg)) if msg.contains("not yet valid")), + "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(ref msg)) if msg.contains("expired")), + "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(ref msg)) if msg.contains("Empty certificate chain")), + "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(ref msg)) if msg.contains("Invalid DER cert")), + "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 fake leaf cert + let leaf_x509 = + x509_cert::Certificate::from_der(leaf_cert.der()).expect("generated cert should parse"); + let ak_pubkey_vec = vaportpm_verify::extract_public_key(&leaf_x509).unwrap(); + let mut ak_pubkey = [0u8; 65]; + ak_pubkey.copy_from_slice(&ak_pubkey_vec); + decoded.ak_pubkey = ak_pubkey; + + // 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(ref msg)) if msg.contains("Unknown root CA")), + "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..a0e5afd --- /dev/null +++ b/crates/vaportpm-verify/tests/nitro.rs @@ -0,0 +1,435 @@ +// 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, CloudProvider, + DecodedAttestationOutput, DecodedPlatformAttestation, EccPublicKeyCoords, 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!(!result.pcrs.is_empty()); + + // Verify SHA-384 PCRs are present + let has_sha384 = result.pcrs.keys().any(|(alg_id, _)| *alg_id == 1); + assert!(has_sha384, "Should have SHA-384 PCRs"); +} + +// ============================================================================= +// 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(_))), + "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(ref msg)) if msg.contains("Nonce does not match")), + "Should reject nonce that doesn't match Quote extraData, got: {:?}", + result + ); +} + +// ============================================================================= +// Tampering: PCR values +// ============================================================================= + +/// Attacker modifies a SHA-384 PCR value. +/// +/// Detected at: nitro.rs — claimed PCR values don't match the signed +/// values in the Nitro NSM document. +#[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::SignatureInvalid(_))), + "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(ref msg)) if msg.contains("not yet valid")), + "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(ref msg)) if msg.contains("expired")), + "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 — non-SHA-384 PCR rejection +#[test] +fn test_nitro_reject_non_sha384_pcrs() { + let mut output = load_nitro_fixture(); + + // Remove the SHA-384 bank entirely, substitute a SHA-256 entry + // so that decode() doesn't fail on empty PCRs + output.pcrs.remove("sha384"); + 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(ref msg)) if msg.contains("SHA-384")), + "Should reject attestation with non-SHA-384 PCRs, got: {:?}", + result + ); +} + +/// Attestation has SHA-384 PCRs but also includes SHA-256 PCRs. +/// The Nitro path must reject extra unverified PCR banks — they would +/// pass through to the output as unverified data. +/// +/// Detected at: nitro.rs — non-SHA-384 PCR rejection +#[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 + 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(ref msg)) if msg.contains("non-SHA-384")), + "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 + ); +} + +/// Empty PCR map at the decoded level. +/// +/// The COSE and ECDSA signatures still pass (unmodified), but Phase 3 +/// rejects the empty PCRs before cross-verification. +/// +/// Covers: nitro.rs — "Missing SHA-384 PCRs - required for Nitro attestation" +#[test] +fn test_nitro_decoded_reject_empty_pcrs() { + let mut decoded = decode_nitro_fixture(); + decoded.pcrs.clear(); + + let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); + assert!( + matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("Missing SHA-384 PCRs")), + "Should reject empty PCRs, got: {:?}", + result + ); +} + +/// Decoded PCRs contain a non-SHA-384 entry (alg_id=0 is SHA-256). +/// +/// Covers: nitro.rs — "non-SHA-384 PCR" rejection at decoded level +#[test] +fn test_nitro_decoded_reject_non_sha384_pcr() { + let mut decoded = decode_nitro_fixture(); + + // Replace all PCRs with SHA-256 (alg_id=0) entries + let sha384_values: Vec<(u8, Vec)> = decoded + .pcrs + .iter() + .filter(|((alg, _), _)| *alg == 1) + .map(|((_alg, idx), val)| (*idx, val.clone())) + .collect(); + + decoded.pcrs.clear(); + for (idx, val) in sha384_values { + // Insert as SHA-256 (alg_id=0) instead of SHA-384 (alg_id=1) + decoded.pcrs.insert((0, idx), val); + } + + let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); + assert!( + matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("non-SHA-384")), + "Should reject non-SHA-384 PCRs at decoded level, got: {:?}", + result + ); +} + +/// Decoded PCRs contain an index above 23. +/// +/// Covers: nitro.rs — "PCR index {} out of range; only PCRs 0-23 are valid" +#[test] +fn test_nitro_decoded_reject_pcr_index_out_of_range() { + let mut decoded = decode_nitro_fixture(); + + // Add a PCR with index 24 (out of range) + decoded.pcrs.insert((1, 24), vec![0x00; 48]); + + let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); + assert!( + matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("out of range")), + "Should reject PCR index > 23, got: {:?}", + result + ); +} + +/// Decoded PCRs are missing one of the 24 required SHA-384 entries. +/// +/// Covers: nitro.rs — "Missing SHA-384 PCR {} - all 24 PCRs (0-23) are required" +#[test] +fn test_nitro_decoded_reject_missing_pcr() { + let mut decoded = decode_nitro_fixture(); + + // Remove PCR 5 (arbitrary choice) + decoded.pcrs.remove(&(1, 5)); + + let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); + assert!( + matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("all 24 PCRs")), + "Should reject missing SHA-384 PCR, got: {:?}", + result + ); +} From 02c0b3ddc6221c429f37938e752f5334c2c2abb9 Mon Sep 17 00:00:00 2001 From: user <303926+HarryR@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:38:49 +0800 Subject: [PATCH 06/10] Many more tests * Generate ephemeral CAs guarded by #[cfg(test)] * We can now exercise all code paths when in test mode * Error types are returned as enums/structs * We're now exercising most/all tests through the public API --- crates/vaportpm-verify/Cargo.toml | 3 +- .../src/ephemeral_gcp_tests.rs | 291 +++++++ .../src/ephemeral_nitro_tests.rs | 785 ++++++++++++++++++ crates/vaportpm-verify/src/error.rs | 330 +++++++- crates/vaportpm-verify/src/flat.rs | 59 +- crates/vaportpm-verify/src/gcp.rs | 87 +- crates/vaportpm-verify/src/lib.rs | 93 ++- crates/vaportpm-verify/src/nitro.rs | 623 ++++---------- crates/vaportpm-verify/src/test_support.rs | 472 +++++++++++ crates/vaportpm-verify/src/tpm.rs | 140 ++-- crates/vaportpm-verify/src/x509.rs | 262 +++--- crates/vaportpm-verify/tests/gcp.rs | 76 +- crates/vaportpm-verify/tests/nitro.rs | 76 +- 13 files changed, 2469 insertions(+), 828 deletions(-) create mode 100644 crates/vaportpm-verify/src/ephemeral_gcp_tests.rs create mode 100644 crates/vaportpm-verify/src/ephemeral_nitro_tests.rs create mode 100644 crates/vaportpm-verify/src/test_support.rs diff --git a/crates/vaportpm-verify/Cargo.toml b/crates/vaportpm-verify/Cargo.toml index b375a1b..b6e88c2 100644 --- a/crates/vaportpm-verify/Cargo.toml +++ b/crates/vaportpm-verify/Cargo.toml @@ -38,6 +38,8 @@ zerocopy = { workspace = true } [dev-dependencies] rcgen = "0.13" +p256 = { workspace = true, features = ["pkcs8"] } +p384 = { workspace = true, features = ["pkcs8"] } [lib] name = "vaportpm_verify" @@ -46,4 +48,3 @@ path = "src/lib.rs" [[bin]] name = "vaportpm-verify" path = "src/bin/verify.rs" - 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..4bbbe83 --- /dev/null +++ b/crates/vaportpm-verify/src/ephemeral_gcp_tests.rs @@ -0,0 +1,291 @@ +// 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::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![ + (0x000Bu16, vec![0xFF, 0xFF, 0xFF]), + (0x000Cu16, 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 (0x000C) instead of SHA-256 (0x000B) + let pcr_select = vec![(0x000Cu16, 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: 0x000B, + 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 = [0x04; 65]; + + 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_empty_pcrs() { + let nonce = [0xBB; 32]; + let pcrs = test_support::make_gcp_pcrs(); + let (mut decoded, time, _guard) = test_support::build_valid_gcp(&nonce, &pcrs); + + // Clear all PCRs + decoded.pcrs.clear(); + + let result = verify_decoded_attestation_output(&decoded, time); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::MissingSha256Pcrs + )) + ), + "Expected MissingSha256Pcrs error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_gcp_reject_pcr_index_out_of_range() { + let nonce = [0xBB; 32]; + let mut pcrs = test_support::make_gcp_pcrs(); + // Add an extra PCR with index 24 (out of range) + pcrs.insert((0, 24), vec![0xAA; 32]); + + let (mut decoded, time, _guard) = + test_support::build_valid_gcp(&nonce, &test_support::make_gcp_pcrs()); + + // Replace PCRs with our set that includes the out-of-range index + decoded.pcrs = pcrs; + + let result = verify_decoded_attestation_output(&decoded, time); + assert!( + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::PcrIndexOutOfRange { index: 24 } + )) + ), + "Expected PcrIndexOutOfRange 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![(0x000Bu16, 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![(0x000Bu16, 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..a818a57 --- /dev/null +++ b/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs @@ -0,0 +1,785 @@ +// 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, + NoValidAttestationReason, SignatureInvalidReason, VerifyError, +}; +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 (pubkey_65bytes, pkcs8_der). +fn ephemeral_ak() -> ([u8; 65], 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 mut ak_pubkey = [0u8; 65]; + ak_pubkey.copy_from_slice(ak_point.as_bytes()); + (ak_pubkey, ak_pkcs8) +} + +/// Helper: convert decoded PCRs (alg_id, idx) → idx-only map for COSE. +fn to_nitro_pcr_map(pcrs: &BTreeMap<(u8, u8), Vec>) -> BTreeMap> { + pcrs.iter() + .map(|((_alg, idx), val)| (*idx, val.clone())) + .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![ + (0x000Cu16, vec![0xFF, 0xFF, 0xFF]), + (0x000Bu16, 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 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_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::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 pcr_digest = test_support::compute_pcr_digest(&pcrs); + // SHA-256 (0x000B) instead of SHA-384 (0x000C) + let pcr_select = vec![(0x000Bu16, 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_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::InvalidAttest( + InvalidAttestReason::WrongPcrAlgorithm { + expected: 0x000C, + 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 pcr_digest = test_support::compute_pcr_digest(&pcrs); + // PCR 23 deselected + let pcr_select = vec![(0x000Cu16, 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_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::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 pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(0x000Cu16, 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_pubkey), + 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::NoValidAttestation( + NoValidAttestationReason::MissingNonce + )) + ), + "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 pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(0x000Cu16, 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_pubkey), + 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 pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(0x000Cu16, 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 + Some(&ak_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::InvalidAttest( + InvalidAttestReason::EmptySignedPcrs + )) + ), + "Expected empty signed PCRs error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_pcr_missing_from_attestation() { + 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![(0x000Cu16, 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_pubkey), + Some(&nonce), + &chain.cose_signing_key, + ); + + // decoded is missing PCR 5 → hits "all 24 PCRs" check + let mut decoded_pcrs = pcrs.clone(); + decoded_pcrs.remove(&(1, 5)); + let decoded = DecodedAttestationOutput { + nonce, + pcrs: decoded_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::MissingPcr { .. } + )) + ), + "Expected missing PCR error, got: {:?}", + result + ); +} + +#[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) + 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 pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(0x000Cu16, 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_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::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 + 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 pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(0x000Cu16, 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_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::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![(0x000Cu16, 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::NoValidAttestation( + NoValidAttestationReason::MissingPublicKey + )) + ), + "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![(0x000Cu16, 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::AkNitroBindingMismatch + )) + ), + "Expected AK mismatch error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_wrong_provider_root() { + // Build a valid Nitro attestation but register the root as GCP. + // The COSE signature and cert chain will validate, but the provider + // check should reject it: "requires AWS root CA, got Gcp". + 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 pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(0x000Cu16, 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_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::ChainValidation( + ChainValidationReason::WrongProvider { + expected: CloudProvider::Aws, + got: CloudProvider::Gcp, + } + )) + ), + "Expected wrong provider error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_unknown_root_ca() { + // Build a valid Nitro attestation but don't register the root at all. + // The COSE signature and cert chain validate, but provider_from_hash + // returns None → "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 pcr_digest = test_support::compute_pcr_digest(&pcrs); + let pcr_select = vec![(0x000Cu16, 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_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()); + assert!( + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::UnknownRootCa { .. } + )) + ), + "Expected unknown root CA error, got: {:?}", + result + ); +} + +#[test] +fn test_ephemeral_nitro_reject_payload_not_map() { + // Build a COSE Sign1 whose payload is a CBOR array, not a map. + // PayloadNotMap fires before signature verification, so the signature + // and other fields don't need to be valid. + 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: [0x04; 65], + 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() { + // Build a valid COSE doc then re-encode it with a truncated signature. + // The flow: parse COSE → extract payload map → extract cert/cabundle → + // parse certs → verify_cose_signature → check sig length → error. + 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(); + + // 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_pubkey), + 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![(0x000Cu16, 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 + ); +} diff --git a/crates/vaportpm-verify/src/error.rs b/crates/vaportpm-verify/src/error.rs index 52001f0..94490b1 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 PCR algorithm 0x{expected:04X}, got 0x{got:04X}")] + WrongPcrAlgorithm { expected: u16, 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 SHA-256 PCRs - required for GCP attestation")] + MissingSha256Pcrs, + + #[error("Missing SHA-384 PCRs - required for Nitro attestation")] + MissingSha384Pcrs, + + #[error("Contains non-SHA-256 PCR (alg_id={alg_id}, pcr={pcr_idx}); only SHA-256 PCRs are verified in the GCP path")] + UnexpectedPcrAlgorithmGcp { alg_id: u8, pcr_idx: u8 }, + + #[error("Contains non-SHA-384 PCR (alg_id={alg_id}, pcr={pcr_idx}); only SHA-384 PCRs are verified in the Nitro path")] + UnexpectedPcrAlgorithmNitro { alg_id: u8, pcr_idx: u8 }, + + #[error("PCR index {index} out of range; only PCRs 0-23 are valid")] + PcrIndexOutOfRange { index: u8 }, + + #[error("Missing PCR {index} - all 24 PCRs (0-23) are required")] + MissingPcr { index: u8 }, + + #[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 {pcr_index} (alg 0x{algorithm:04X}) selected in Quote but not present in attestation" + )] + PcrSelectedButMissing { pcr_index: u8, algorithm: u16 }, + + #[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 signing key does not match Nitro public_key binding")] + AkNitroBindingMismatch, + + #[error("TPM nonce does not match Nitro nonce")] + NitroNonceMismatch, + + #[error("PCR {index} SHA-384 mismatch between claimed and signed value")] + PcrValueMismatch { index: u8 }, + + #[error("PCR {index} in signed Nitro document but missing from attestation")] + PcrMissingFromAttestation { 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("Nitro document missing public_key field - cannot bind TPM signing key")] + MissingPublicKey, + + #[error("Nitro document missing nonce field - cannot verify freshness")] + MissingNonce, + + #[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 index 466166f..222a3f3 100644 --- a/crates/vaportpm-verify/src/flat.rs +++ b/crates/vaportpm-verify/src/flat.rs @@ -8,6 +8,7 @@ use std::collections::BTreeMap; use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout}; +use crate::error::InvalidAttestReason; use crate::{DecodedAttestationOutput, DecodedPlatformAttestation, VerifyError}; /// Platform type constants @@ -89,16 +90,16 @@ pub fn to_bytes(decoded: &DecodedAttestationOutput) -> Vec { /// Parse flat binary format using zerocopy for header pub fn from_bytes(data: &[u8]) -> Result { if data.len() < HEADER_SIZE { - return Err(VerifyError::InvalidAttest(format!( - "input too short: {} < {}", - 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(|_| VerifyError::InvalidAttest("failed to parse header".into()))?; + 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; @@ -111,7 +112,10 @@ pub fn from_bytes(data: &[u8]) -> Result let mut pcrs = BTreeMap::new(); for _ in 0..pcr_count { if offset + 3 > data.len() { - return Err(VerifyError::InvalidAttest("truncated PCR header".into())); + return Err(InvalidAttestReason::FlatTruncated { + field: "PCR header", + } + .into()); } let alg_id = data[offset]; let pcr_idx = data[offset + 1]; @@ -119,7 +123,7 @@ pub fn from_bytes(data: &[u8]) -> Result offset += 3; if offset + value_len > data.len() { - return Err(VerifyError::InvalidAttest("truncated PCR value".into())); + return Err(InvalidAttestReason::FlatTruncated { field: "PCR value" }.into()); } pcrs.insert((alg_id, pcr_idx), data[offset..offset + value_len].to_vec()); offset += value_len; @@ -127,29 +131,39 @@ pub fn from_bytes(data: &[u8]) -> Result // Parse quote data if offset + quote_attest_len > data.len() { - return Err(VerifyError::InvalidAttest("truncated quote_attest".into())); + 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(VerifyError::InvalidAttest( - "truncated quote_signature".into(), - )); + 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(VerifyError::InvalidAttest("truncated platform data".into())); + 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(VerifyError::InvalidAttest("empty GCP platform data".into())); + return Err(InvalidAttestReason::FlatTruncated { + field: "GCP platform data", + } + .into()); } let cert_count = platform_bytes[0] as usize; let mut poffset = 1; @@ -157,7 +171,10 @@ pub fn from_bytes(data: &[u8]) -> Result let mut cert_lens = Vec::with_capacity(cert_count); for _ in 0..cert_count { if poffset + 2 > platform_bytes.len() { - return Err(VerifyError::InvalidAttest("truncated cert length".into())); + return Err(InvalidAttestReason::FlatTruncated { + field: "cert length", + } + .into()); } let len = u16::from_le_bytes(platform_bytes[poffset..poffset + 2].try_into().unwrap()) @@ -169,7 +186,7 @@ pub fn from_bytes(data: &[u8]) -> Result let mut cert_chain_der = Vec::with_capacity(cert_count); for len in cert_lens { if poffset + len > platform_bytes.len() { - return Err(VerifyError::InvalidAttest("truncated cert data".into())); + return Err(InvalidAttestReason::FlatTruncated { field: "cert data" }.into()); } cert_chain_der.push(platform_bytes[poffset..poffset + len].to_vec()); poffset += len; @@ -181,10 +198,10 @@ pub fn from_bytes(data: &[u8]) -> Result document: platform_bytes.to_vec(), }, _ => { - return Err(VerifyError::InvalidAttest(format!( - "unknown platform type: {}", - header.platform_type - ))) + return Err(InvalidAttestReason::UnknownPlatformType { + platform_type: header.platform_type, + } + .into()) } }; diff --git a/crates/vaportpm-verify/src/gcp.rs b/crates/vaportpm-verify/src/gcp.rs index aca5b76..4876c16 100644 --- a/crates/vaportpm-verify/src/gcp.rs +++ b/crates/vaportpm-verify/src/gcp.rs @@ -6,7 +6,10 @@ use der::Decode; use pki_types::UnixTime; use x509_cert::Certificate; -use crate::error::VerifyError; +use crate::error::{ + CertificateParseReason, ChainValidationReason, InvalidAttestReason, SignatureInvalidReason, + VerifyError, +}; use crate::tpm::{parse_quote_attest, verify_ecdsa_p256, verify_pcr_digest_matches}; use crate::x509::{extract_public_key, validate_tpm_cert_chain}; use crate::CloudProvider; @@ -21,14 +24,12 @@ fn verify_gcp_certs( .iter() .map(|der| { Certificate::from_der(der) - .map_err(|e| VerifyError::CertificateParse(format!("Invalid DER cert: {}", e))) + .map_err(|e| CertificateParseReason::InvalidDer(e.to_string())) }) .collect::>()?; if certs.is_empty() { - return Err(VerifyError::ChainValidation( - "Empty certificate chain".into(), - )); + return Err(ChainValidationReason::EmptyChain.into()); } // Validate AK certificate chain to GCP root @@ -36,18 +37,18 @@ fn verify_gcp_certs( // Verify root is a known GCP root 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.", - hex::encode(chain_result.root_pubkey_hash) - )) + 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(VerifyError::ChainValidation(format!( - "GCP verification path requires GCP root CA, got {:?}", - provider - ))); + return Err(ChainValidationReason::WrongProvider { + expected: CloudProvider::Gcp, + got: provider, + } + .into()); } Ok((certs, provider)) @@ -73,17 +74,15 @@ pub fn verify_gcp_decoded( // PCR digest and the AK certificate chain). Any other bank would be // unverified data passed through to the output. if decoded.pcrs.is_empty() { - return Err(VerifyError::InvalidAttest( - "Missing SHA-256 PCRs - required for GCP attestation".into(), - )); + return Err(InvalidAttestReason::MissingSha256Pcrs.into()); } for (alg_id, pcr_idx) in decoded.pcrs.keys() { if *alg_id != 0 { - return Err(VerifyError::InvalidAttest(format!( - "GCP attestation contains non-SHA-256 PCR (alg_id={}, pcr={}); \ - only SHA-256 PCRs are verified in the GCP path", - alg_id, pcr_idx - ))); + return Err(InvalidAttestReason::UnexpectedPcrAlgorithmGcp { + alg_id: *alg_id, + pcr_idx: *pcr_idx, + } + .into()); } } @@ -91,20 +90,14 @@ pub fn verify_gcp_decoded( // Complete, unambiguous PCR state — no selective omission. for pcr_idx in 0..24u8 { if !decoded.pcrs.contains_key(&(0, pcr_idx)) { - return Err(VerifyError::InvalidAttest(format!( - "Missing SHA-256 PCR {} - all 24 PCRs (0-23) are required for GCP attestation", - pcr_idx - ))); + return Err(InvalidAttestReason::MissingPcr { index: pcr_idx }.into()); } } // Reject any PCR indices outside 0-23 for (_alg_id, pcr_idx) in decoded.pcrs.keys() { if *pcr_idx > 23 { - return Err(VerifyError::InvalidAttest(format!( - "PCR index {} out of range; only PCRs 0-23 are valid", - pcr_idx - ))); + return Err(InvalidAttestReason::PcrIndexOutOfRange { index: *pcr_idx }.into()); } } @@ -118,11 +111,7 @@ pub fn verify_gcp_decoded( // Verify the AK public key matches the one in the decoded input if ak_pubkey_from_cert != decoded.ak_pubkey { - return Err(VerifyError::SignatureInvalid(format!( - "AK public key mismatch: cert has {}, decoded has {}", - hex::encode(&ak_pubkey_from_cert), - hex::encode(decoded.ak_pubkey) - ))); + return Err(SignatureInvalidReason::AkPublicKeyMismatch.into()); } // Verify Quote signature with AK public key — this authenticates @@ -139,36 +128,30 @@ pub fn verify_gcp_decoded( // Enforce that the TPM Quote selects exactly one PCR bank: SHA-256 (0x000B), // and that it selects all 24 PCRs (bitmap 0xFF 0xFF 0xFF). if quote_info.pcr_select.len() != 1 { - return Err(VerifyError::InvalidAttest(format!( - "GCP path requires exactly one PCR bank selection, got {}", - quote_info.pcr_select.len() - ))); + return Err(InvalidAttestReason::MultiplePcrBanks { + count: quote_info.pcr_select.len(), + } + .into()); } let (quote_alg, quote_bitmap) = "e_info.pcr_select[0]; if *quote_alg != 0x000B { - return Err(VerifyError::InvalidAttest(format!( - "GCP path requires TPM Quote to select SHA-256 PCRs (0x000B), got 0x{:04X}", - quote_alg - ))); + return Err(InvalidAttestReason::WrongPcrAlgorithm { + expected: 0x000B, + got: *quote_alg, + } + .into()); } if quote_bitmap.len() < 3 || quote_bitmap[0] != 0xFF || quote_bitmap[1] != 0xFF || quote_bitmap[2] != 0xFF { - return Err(VerifyError::InvalidAttest(format!( - "GCP path requires all 24 PCRs selected in Quote bitmap, got {:?}", - quote_bitmap - ))); + return Err(InvalidAttestReason::PartialPcrBitmap.into()); } // Verify nonce matches Quote if decoded.nonce != quote_info.nonce.as_slice() { - return Err(VerifyError::InvalidAttest(format!( - "Nonce does not match Quote. Expected: {}, Quote: {}", - hex::encode(decoded.nonce), - hex::encode("e_info.nonce) - ))); + return Err(InvalidAttestReason::NonceMismatch.into()); } // Verify PCR digest matches claimed PCR values @@ -179,7 +162,7 @@ pub fn verify_gcp_decoded( .nonce .as_slice() .try_into() - .map_err(|_| VerifyError::InvalidAttest("nonce is not 32 bytes".into()))?; + .map_err(|_| InvalidAttestReason::NonceLengthInvalid)?; Ok(VerificationResult { nonce, diff --git a/crates/vaportpm-verify/src/lib.rs b/crates/vaportpm-verify/src/lib.rs index 8e00d4d..dfd6c13 100644 --- a/crates/vaportpm-verify/src/lib.rs +++ b/crates/vaportpm-verify/src/lib.rs @@ -20,8 +20,12 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_big_array::BigArray; -// Re-export error type -pub use error::VerifyError; +// Re-export error types +pub use error::{ + CborParseReason, CertificateParseReason, ChainValidationReason, CoseVerifyReason, + InvalidAttestReason, NoValidAttestationReason, PcrIndexOutOfBoundsReason, + SignatureInvalidReason, VerifyError, +}; // Re-export TPM types and functions (only those used by verification paths) pub use tpm::{parse_quote_attest, verify_ecdsa_p256, verify_pcr_digest_matches, TpmQuoteInfo}; @@ -81,9 +85,57 @@ pub mod roots { } 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 } } + + #[cfg(test)] + pub use test_registry::{register_test_root, TestRootGuard}; + + #[cfg(test)] + mod test_registry { + use super::CloudProvider; + use std::cell::RefCell; + use std::collections::HashMap; + + thread_local! { + static TEST_ROOTS: RefCell> = + RefCell::new(HashMap::new()); + } + + /// RAII guard that removes a test root on drop (even on panic). + pub struct TestRootGuard { + hash: [u8; 32], + } + + 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 } + } + + /// 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()) + } + } } // Re-export types from vaportpm_attest for convenience @@ -131,6 +183,9 @@ pub enum DecodedPlatformAttestation { pub mod flat; +#[cfg(test)] +mod test_support; + impl DecodedAttestationOutput { /// Decode an AttestationOutput to binary format pub fn decode(output: &AttestationOutput) -> Result { @@ -139,11 +194,12 @@ impl DecodedAttestationOutput { let nonce_bytes = hex::decode(&output.nonce)?; let nonce: [u8; 32] = nonce_bytes .try_into() - .map_err(|_| VerifyError::InvalidAttest("nonce must be 32 bytes".into()))?; + .map_err(|_| InvalidAttestReason::NonceLengthInvalid)?; - let ak_pk = output.ak_pubkeys.get("ecc_p256").ok_or_else(|| { - VerifyError::NoValidAttestation("missing ecc_p256 AK public key".into()) - })?; + 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 mut ak_pubkey = [0u8; 65]; @@ -151,9 +207,11 @@ impl DecodedAttestationOutput { ak_pubkey[1..33].copy_from_slice(&ak_x); ak_pubkey[33..65].copy_from_slice(&ak_y); - let tpm = output.attestation.tpm.get("ecc_p256").ok_or_else(|| { - VerifyError::NoValidAttestation("missing ecc_p256 TPM attestation".into()) - })?; + 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)?; @@ -176,17 +234,13 @@ impl DecodedAttestationOutput { .iter() .map(|c| c.to_der()) .collect::>() - .map_err(|e| { - VerifyError::CertificateParse(format!("Failed to encode cert as DER: {}", e)) - })?; + .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(VerifyError::NoValidAttestation( - "no platform attestation".into(), - )); + return Err(NoValidAttestationReason::NoPlatform.into()); }; Ok(DecodedAttestationOutput { @@ -276,11 +330,16 @@ pub fn verify_attestation_output( /// 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 tests { use super::*; diff --git a/crates/vaportpm-verify/src/nitro.rs b/crates/vaportpm-verify/src/nitro.rs index a77a842..e5fdcd4 100644 --- a/crates/vaportpm-verify/src/nitro.rs +++ b/crates/vaportpm-verify/src/nitro.rs @@ -14,7 +14,11 @@ use x509_cert::Certificate; use pki_types::UnixTime; -use crate::error::VerifyError; +use crate::error::{ + CborParseReason, CertificateParseReason, ChainValidationReason, CoseVerifyReason, + InvalidAttestReason, NoValidAttestationReason, PcrIndexOutOfBoundsReason, + SignatureInvalidReason, VerifyError, +}; use crate::tpm::{parse_quote_attest, verify_ecdsa_p256, verify_pcr_digest_matches, TpmQuoteInfo}; use crate::x509::{extract_public_key, validate_tpm_cert_chain}; use crate::CloudProvider; @@ -71,7 +75,7 @@ pub fn verify_nitro_decoded( .nonce .as_slice() .try_into() - .map_err(|_| VerifyError::InvalidAttest("nonce is not 32 bytes".into()))?; + .map_err(|_| InvalidAttestReason::NonceLengthInvalid)?; Ok(VerificationResult { nonce, @@ -91,21 +95,21 @@ fn verify_nitro_cose_chain( ) -> Result<(NitroDocument, CloudProvider), VerifyError> { // Parse COSE Sign1 envelope let cose_sign1 = CoseSign1::from_slice(document_bytes) - .map_err(|e| VerifyError::CoseVerify(format!("Failed to parse COSE Sign1: {}", e)))?; + .map_err(|e| CoseVerifyReason::CoseSign1ParseFailed(e.to_string()))?; let payload = cose_sign1 .payload .as_ref() - .ok_or_else(|| VerifyError::CoseVerify("Missing payload".into()))?; + .ok_or(CoseVerifyReason::MissingPayload)?; // 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| VerifyError::CborParse(format!("Failed to parse payload: {}", e)))?; + .map_err(|e| CborParseReason::DeserializeFailed(e.to_string()))?; 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 cert_der = extract_cbor_bytes(payload_map, "certificate")?; @@ -113,12 +117,12 @@ fn verify_nitro_cose_chain( // 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()))?; 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); } @@ -131,17 +135,17 @@ fn verify_nitro_cose_chain( // Verify root is a known AWS root 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.", - hex::encode(chain_result.root_pubkey_hash) - )) + VerifyError::ChainValidation(ChainValidationReason::UnknownRootCa { + hash: hex::encode(chain_result.root_pubkey_hash), + }) })?; if provider != CloudProvider::Aws { - return Err(VerifyError::ChainValidation(format!( - "Nitro verification path requires AWS root CA, got {:?}", - provider - ))); + return Err(ChainValidationReason::WrongProvider { + expected: CloudProvider::Aws, + got: provider, + } + .into()); } // --- COSE document is now authenticated; safe to parse its contents --- @@ -159,18 +163,13 @@ fn verify_tpm_quote_signature( ) -> Result { // The Nitro document's public_key field tells us which AK to trust. // Verify the claimed AK matches before we use it for signature verification. - let signed_pubkey = nitro_doc.public_key.as_ref().ok_or_else(|| { - VerifyError::NoValidAttestation( - "Nitro document missing public_key field - cannot bind TPM signing key".into(), - ) - })?; + let signed_pubkey = nitro_doc + .public_key + .as_ref() + .ok_or(NoValidAttestationReason::MissingPublicKey)?; if decoded.ak_pubkey.as_slice() != signed_pubkey.as_slice() { - return Err(VerifyError::SignatureInvalid(format!( - "TPM signing key does not match Nitro public_key binding: {} != {}", - hex::encode(decoded.ak_pubkey), - hex::encode(signed_pubkey) - ))); + return Err(SignatureInvalidReason::AkNitroBindingMismatch.into()); } // Parse and verify TPM2_Quote @@ -201,51 +200,40 @@ fn verify_nitro_bindings( // Exactly one PCR bank: SHA-384 (0x000C), all 24 PCRs selected. if quote_info.pcr_select.len() != 1 { - return Err(VerifyError::InvalidAttest(format!( - "Nitro path requires exactly one PCR bank selection, got {}", - quote_info.pcr_select.len() - ))); + return Err(InvalidAttestReason::MultiplePcrBanks { + count: quote_info.pcr_select.len(), + } + .into()); } let (quote_alg, quote_bitmap) = "e_info.pcr_select[0]; if *quote_alg != 0x000C { - return Err(VerifyError::InvalidAttest(format!( - "Nitro path requires TPM Quote to select SHA-384 PCRs (0x000C), got 0x{:04X}", - quote_alg - ))); + return Err(InvalidAttestReason::WrongPcrAlgorithm { + expected: 0x000C, + got: *quote_alg, + } + .into()); } if quote_bitmap.len() < 3 || quote_bitmap[0] != 0xFF || quote_bitmap[1] != 0xFF || quote_bitmap[2] != 0xFF { - return Err(VerifyError::InvalidAttest(format!( - "Nitro path requires all 24 PCRs selected in Quote bitmap, got {:?}", - quote_bitmap - ))); + return Err(InvalidAttestReason::PartialPcrBitmap.into()); } // --- Nonce verification --- if decoded.nonce != quote_info.nonce.as_slice() { - return Err(VerifyError::InvalidAttest(format!( - "Nonce does not match Quote. Expected: {}, Quote: {}", - hex::encode(decoded.nonce), - hex::encode("e_info.nonce) - ))); + return Err(InvalidAttestReason::NonceMismatch.into()); } - let signed_nonce = nitro_doc.nonce.as_ref().ok_or_else(|| { - VerifyError::NoValidAttestation( - "Nitro document missing nonce field - cannot verify freshness".into(), - ) - })?; + let signed_nonce = nitro_doc + .nonce + .as_ref() + .ok_or(NoValidAttestationReason::MissingNonce)?; if quote_info.nonce.as_slice() != signed_nonce.as_slice() { - return Err(VerifyError::SignatureInvalid(format!( - "TPM nonce does not match Nitro nonce: {} != {}", - hex::encode("e_info.nonce), - hex::encode(signed_nonce) - ))); + return Err(SignatureInvalidReason::NitroNonceMismatch.into()); } // --- PCR enforcement --- @@ -253,34 +241,26 @@ fn verify_nitro_bindings( // Only SHA-384 PCRs allowed (algorithm ID 1). Any other bank would be // unverified data passed through to the output. if decoded.pcrs.is_empty() { - return Err(VerifyError::InvalidAttest( - "Missing SHA-384 PCRs - required for Nitro attestation".into(), - )); + return Err(InvalidAttestReason::MissingSha384Pcrs.into()); } for (alg_id, pcr_idx) in decoded.pcrs.keys() { if *alg_id != 1 { - return Err(VerifyError::InvalidAttest(format!( - "Nitro attestation contains non-SHA-384 PCR (alg_id={}, pcr={}); \ - only SHA-384 PCRs are verified in the Nitro path", - alg_id, pcr_idx - ))); + return Err(InvalidAttestReason::UnexpectedPcrAlgorithmNitro { + alg_id: *alg_id, + pcr_idx: *pcr_idx, + } + .into()); } if *pcr_idx > 23 { - return Err(VerifyError::InvalidAttest(format!( - "PCR index {} out of range; only PCRs 0-23 are valid", - pcr_idx - ))); + return Err(InvalidAttestReason::PcrIndexOutOfRange { index: *pcr_idx }.into()); } } // All 24 PCRs must be present — complete, unambiguous state. for pcr_idx in 0..24u8 { if !decoded.pcrs.contains_key(&(1, pcr_idx)) { - return Err(VerifyError::InvalidAttest(format!( - "Missing SHA-384 PCR {} - all 24 PCRs (0-23) are required for Nitro attestation", - pcr_idx - ))); + return Err(InvalidAttestReason::MissingPcr { index: pcr_idx }.into()); } } @@ -288,37 +268,32 @@ fn verify_nitro_bindings( let signed_pcrs = &nitro_doc.pcrs; if signed_pcrs.is_empty() { - return Err(VerifyError::InvalidAttest( - "Nitro document contains no signed PCRs".into(), - )); + return Err(InvalidAttestReason::EmptySignedPcrs.into()); } for (idx, signed_value) in signed_pcrs.iter() { match decoded.pcrs.get(&(1, *idx)) { Some(claimed_value) if claimed_value == signed_value => {} - Some(claimed_value) => { - return Err(VerifyError::SignatureInvalid(format!( - "PCR {} SHA-384 mismatch: claimed {} != signed {}", - idx, - hex::encode(claimed_value), - hex::encode(signed_value) - ))); + Some(_) => { + return Err(SignatureInvalidReason::PcrValueMismatch { index: *idx }.into()); } None => { - return Err(VerifyError::SignatureInvalid(format!( - "PCR {} in signed Nitro document but missing from attestation", - idx - ))); + // Note: This should never happen! But is defense-in-depth + // the 'all 24 PCRs' check guarantees decoded.pcrs contains (1,0) through (1,23) + // extract_cbor_pcrs caps Nitro PCR indices at 23 + return Err( + SignatureInvalidReason::PcrMissingFromAttestation { index: *idx }.into(), + ); } } } for (_alg_id, pcr_idx) in decoded.pcrs.keys() { if !signed_pcrs.contains_key(pcr_idx) { - return Err(VerifyError::InvalidAttest(format!( - "PCR {} in attestation but not signed by Nitro document", - pcr_idx - ))); + return Err(InvalidAttestReason::PcrNotSigned { + pcr_index: *pcr_idx, + } + .into()); } } @@ -336,10 +311,7 @@ fn parse_nitro_document(map: &[(CborValue, CborValue)]) -> Result Result 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 { @@ -367,11 +342,14 @@ fn extract_cbor_text(map: &[(CborValue, CborValue)], key: &str) -> Result 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 { @@ -381,7 +359,7 @@ fn extract_cbor_bytes(map: &[(CborValue, CborValue)], key: &str) -> Result Opt /// 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 { @@ -421,7 +399,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) @@ -454,16 +432,17 @@ 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, val.clone()); @@ -475,9 +454,7 @@ fn extract_cbor_pcrs(map: &[(CborValue, CborValue)]) -> Result = vec![]; let result = extract_cbor_pcrs(&map); - assert!(matches!(result, Err(VerifyError::CborParse(_)))); + assert!(matches!( + result, + Err(VerifyError::CborParse(CborParseReason::MissingPcrs)) + )); } // === PCR Index Bounds Tests === @@ -700,7 +693,12 @@ mod tests { )]), )]; let result = extract_cbor_pcrs(&map); - assert!(matches!(result, Err(VerifyError::PcrIndexOutOfBounds(_)))); + assert!(matches!( + result, + Err(VerifyError::PcrIndexOutOfBounds( + PcrIndexOutOfBoundsReason::ExceedsMaximum { .. } + )) + )); } #[test] @@ -714,7 +712,12 @@ mod tests { )]), )]; let result = extract_cbor_pcrs(&map); - assert!(matches!(result, Err(VerifyError::PcrIndexOutOfBounds(_)))); + assert!(matches!( + result, + Err(VerifyError::PcrIndexOutOfBounds( + PcrIndexOutOfBoundsReason::ExceedsMaximum { .. } + )) + )); } #[test] @@ -728,7 +731,12 @@ mod tests { )]), )]; let result = extract_cbor_pcrs(&map); - assert!(matches!(result, Err(VerifyError::PcrIndexOutOfBounds(_)))); + assert!(matches!( + result, + Err(VerifyError::PcrIndexOutOfBounds( + PcrIndexOutOfBoundsReason::Negative { .. } + )) + )); } // === parse_nitro_document Tests === @@ -750,140 +758,17 @@ mod tests { ]; let result = parse_nitro_document(&map); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("Unexpected Nitro digest algorithm")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::WrongDigestAlgorithm { .. } + )) + ), "Should reject SHA256 digest, got: {:?}", result ); } - #[test] - fn test_parse_nitro_document_public_key_absent() { - let map = vec![ - ( - CborValue::Text("digest".to_string()), - CborValue::Text("SHA384".to_string()), - ), - ( - CborValue::Text("pcrs".to_string()), - CborValue::Map(vec![( - CborValue::Integer(0.into()), - CborValue::Bytes(vec![0x00; 48]), - )]), - ), - // public_key field is absent - ]; - let result = parse_nitro_document(&map).unwrap(); - assert_eq!(result.public_key, None); - } - - #[test] - fn test_parse_nitro_document_public_key_null() { - let map = vec![ - ( - CborValue::Text("digest".to_string()), - CborValue::Text("SHA384".to_string()), - ), - ( - CborValue::Text("pcrs".to_string()), - CborValue::Map(vec![( - CborValue::Integer(0.into()), - CborValue::Bytes(vec![0x00; 48]), - )]), - ), - (CborValue::Text("public_key".to_string()), CborValue::Null), - ]; - let result = parse_nitro_document(&map).unwrap(); - assert_eq!(result.public_key, None); - } - - #[test] - fn test_parse_nitro_document_public_key_present() { - let pk = vec![0x04; 65]; - let map = vec![ - ( - CborValue::Text("digest".to_string()), - CborValue::Text("SHA384".to_string()), - ), - ( - CborValue::Text("pcrs".to_string()), - CborValue::Map(vec![( - CborValue::Integer(0.into()), - CborValue::Bytes(vec![0x00; 48]), - )]), - ), - ( - CborValue::Text("public_key".to_string()), - CborValue::Bytes(pk.clone()), - ), - ]; - let result = parse_nitro_document(&map).unwrap(); - assert_eq!(result.public_key, Some(pk)); - } - - #[test] - fn test_parse_nitro_document_nonce_absent() { - let map = vec![ - ( - CborValue::Text("digest".to_string()), - CborValue::Text("SHA384".to_string()), - ), - ( - CborValue::Text("pcrs".to_string()), - CborValue::Map(vec![( - CborValue::Integer(0.into()), - CborValue::Bytes(vec![0x00; 48]), - )]), - ), - // nonce field is absent - ]; - let result = parse_nitro_document(&map).unwrap(); - assert_eq!(result.nonce, None); - } - - #[test] - fn test_parse_nitro_document_nonce_null() { - let map = vec![ - ( - CborValue::Text("digest".to_string()), - CborValue::Text("SHA384".to_string()), - ), - ( - CborValue::Text("pcrs".to_string()), - CborValue::Map(vec![( - CborValue::Integer(0.into()), - CborValue::Bytes(vec![0x00; 48]), - )]), - ), - (CborValue::Text("nonce".to_string()), CborValue::Null), - ]; - let result = parse_nitro_document(&map).unwrap(); - assert_eq!(result.nonce, None); - } - - #[test] - fn test_parse_nitro_document_nonce_present() { - let nonce = vec![0xAB; 32]; - let map = vec![ - ( - CborValue::Text("digest".to_string()), - CborValue::Text("SHA384".to_string()), - ), - ( - CborValue::Text("pcrs".to_string()), - CborValue::Map(vec![( - CborValue::Integer(0.into()), - CborValue::Bytes(vec![0x00; 48]), - )]), - ), - ( - CborValue::Text("nonce".to_string()), - CborValue::Bytes(nonce.clone()), - ), - ]; - let result = parse_nitro_document(&map).unwrap(); - assert_eq!(result.nonce, Some(nonce)); - } - // === extract_cbor_byte_array edge cases === #[test] @@ -906,228 +791,6 @@ mod tests { assert_eq!(result[2], vec![7, 8, 9]); } - // === verify_nitro_bindings Tests === - - /// Build a consistent (DecodedAttestationOutput, TpmQuoteInfo, NitroDocument) triple - /// where `verify_nitro_bindings` succeeds. Tests tweak individual fields to trigger errors. - fn make_valid_bindings_inputs() -> (DecodedAttestationOutput, TpmQuoteInfo, NitroDocument) { - let nonce = [0xAA; 32]; - - // 24 SHA-384 PCRs: value = vec![idx; 48] - let mut decoded_pcrs = BTreeMap::new(); - let mut nitro_pcrs = BTreeMap::new(); - for idx in 0u8..24 { - let value = vec![idx; 48]; - decoded_pcrs.insert((1, idx), value.clone()); // alg_id 1 = SHA-384 - nitro_pcrs.insert(idx, value); - } - - // Compute the PCR digest: SHA-256 of concatenated PCR values in order (0..23) - let mut hasher = Sha256::new(); - for idx in 0u8..24 { - hasher.update(vec![idx; 48]); - } - let pcr_digest = hasher.finalize().to_vec(); - - let decoded = DecodedAttestationOutput { - nonce, - pcrs: decoded_pcrs, - ak_pubkey: [0x04; 65], - quote_attest: vec![], - quote_signature: vec![], - platform: crate::DecodedPlatformAttestation::Nitro { document: vec![] }, - }; - - let quote_info = TpmQuoteInfo { - nonce: nonce.to_vec(), - signer_name: vec![0x00; 34], - pcr_select: vec![(0x000C, vec![0xFF, 0xFF, 0xFF])], // SHA-384, all 24 selected - pcr_digest, - }; - - let nitro_doc = NitroDocument { - pcrs: nitro_pcrs, - public_key: Some(vec![0x04; 65]), - nonce: Some(nonce.to_vec()), - }; - - (decoded, quote_info, nitro_doc) - } - - #[test] - fn test_bindings_happy_path() { - let (decoded, quote_info, nitro_doc) = make_valid_bindings_inputs(); - let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); - assert!(result.is_ok(), "Happy path should succeed: {:?}", result); - } - - #[test] - fn test_bindings_reject_multiple_pcr_banks() { - let (decoded, mut quote_info, nitro_doc) = make_valid_bindings_inputs(); - quote_info.pcr_select.push((0x000B, vec![0xFF, 0xFF, 0xFF])); - let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("exactly one PCR bank selection, got 2")), - "Expected multiple PCR bank error, got: {:?}", - result - ); - } - - #[test] - fn test_bindings_reject_wrong_pcr_algorithm() { - let (decoded, mut quote_info, nitro_doc) = make_valid_bindings_inputs(); - quote_info.pcr_select[0].0 = 0x000B; // SHA-256 instead of SHA-384 - let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("SHA-384 PCRs (0x000C), got 0x000B")), - "Expected wrong algorithm error, got: {:?}", - result - ); - } - - #[test] - fn test_bindings_reject_partial_pcr_bitmap() { - let (decoded, mut quote_info, nitro_doc) = make_valid_bindings_inputs(); - quote_info.pcr_select[0].1 = vec![0xFF, 0xFF, 0xFE]; // PCR 23 not selected - let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("all 24 PCRs selected in Quote bitmap")), - "Expected partial bitmap error, got: {:?}", - result - ); - } - - #[test] - fn test_bindings_reject_nonce_mismatch_decoded_vs_quote() { - let (mut decoded, quote_info, nitro_doc) = make_valid_bindings_inputs(); - decoded.nonce = [0xBB; 32]; // Different from quote_info.nonce - let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("Nonce does not match Quote")), - "Expected nonce mismatch error, got: {:?}", - result - ); - } - - #[test] - fn test_bindings_reject_missing_nitro_nonce() { - let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); - nitro_doc.nonce = None; - let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::NoValidAttestation(ref msg)) if msg.contains("missing nonce field")), - "Expected missing nonce error, got: {:?}", - result - ); - } - - #[test] - fn test_bindings_reject_nitro_nonce_mismatch() { - let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); - nitro_doc.nonce = Some(vec![0xCC; 32]); // Different from quote nonce - let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::SignatureInvalid(ref msg)) if msg.contains("TPM nonce does not match Nitro nonce")), - "Expected Nitro nonce mismatch error, got: {:?}", - result - ); - } - - #[test] - fn test_bindings_reject_empty_signed_pcrs() { - let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); - nitro_doc.pcrs = BTreeMap::new(); - let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("no signed PCRs")), - "Expected empty signed PCRs error, got: {:?}", - result - ); - } - - #[test] - fn test_bindings_reject_signed_pcr_missing_from_attestation() { - let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); - // Add PCR 24 to nitro_doc but it doesn't exist in decoded.pcrs - nitro_doc.pcrs.insert(24, vec![0xFF; 48]); - let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::SignatureInvalid(ref msg)) if msg.contains("in signed Nitro document but missing")), - "Expected signed PCR missing error, got: {:?}", - result - ); - } - - #[test] - fn test_bindings_reject_attestation_pcr_not_signed() { - let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); - nitro_doc.pcrs.remove(&0); // Remove PCR 0 from signed set - let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("in attestation but not signed")), - "Expected unsigned PCR error, got: {:?}", - result - ); - } - - #[test] - fn test_bindings_reject_pcr_value_mismatch() { - let (decoded, quote_info, mut nitro_doc) = make_valid_bindings_inputs(); - nitro_doc.pcrs.insert(0, vec![0xFF; 48]); // Different value for PCR 0 - let result = verify_nitro_bindings(&decoded, "e_info, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::SignatureInvalid(ref msg)) if msg.contains("PCR 0 SHA-384 mismatch")), - "Expected PCR value mismatch error, got: {:?}", - result - ); - } - - // === verify_tpm_quote_signature Tests === - - /// Build a (DecodedAttestationOutput, NitroDocument) pair with matching AK pubkey / public_key. - /// The quote_attest and quote_signature are dummy since we only test pre-crypto error paths. - fn make_valid_quote_sig_inputs() -> (DecodedAttestationOutput, NitroDocument) { - let ak_pubkey = [0x04; 65]; - - let decoded = DecodedAttestationOutput { - nonce: [0xAA; 32], - pcrs: BTreeMap::new(), - ak_pubkey, - quote_attest: vec![], - quote_signature: vec![], - platform: crate::DecodedPlatformAttestation::Nitro { document: vec![] }, - }; - - let nitro_doc = NitroDocument { - pcrs: BTreeMap::new(), - public_key: Some(ak_pubkey.to_vec()), - nonce: Some(vec![0xAA; 32]), - }; - - (decoded, nitro_doc) - } - - #[test] - fn test_quote_sig_reject_missing_public_key() { - let (decoded, mut nitro_doc) = make_valid_quote_sig_inputs(); - nitro_doc.public_key = None; - let result = verify_tpm_quote_signature(&decoded, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::NoValidAttestation(ref msg)) if msg.contains("missing public_key field")), - "Expected missing public_key error, got: {:?}", - result - ); - } - - #[test] - fn test_quote_sig_reject_ak_mismatch() { - let (decoded, mut nitro_doc) = make_valid_quote_sig_inputs(); - nitro_doc.public_key = Some(vec![0x05; 65]); // Different key - let result = verify_tpm_quote_signature(&decoded, &nitro_doc); - assert!( - matches!(result, Err(VerifyError::SignatureInvalid(ref msg)) if msg.contains("does not match Nitro public_key binding")), - "Expected AK mismatch error, got: {:?}", - result - ); - } + // 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/test_support.rs b/crates/vaportpm-verify/src/test_support.rs new file mode 100644 index 0000000..8dddb8a --- /dev/null +++ b/crates/vaportpm-verify/src/test_support.rs @@ -0,0 +1,472 @@ +// 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::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. `(0x000C, 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: &BTreeMap<(u8, u8), Vec>) -> Vec { + let mut hasher = Sha256::new(); + for ((_alg, _idx), value) in pcrs { + 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 (SEC1 uncompressed, 65 bytes) + pub ak_pubkey: [u8; 65], + /// 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 mut ak_pubkey = [0u8; 65]; + ak_pubkey.copy_from_slice(&ak_pubkey_vec); + + // 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). Value = vec![idx; 48]. +pub fn make_nitro_pcrs() -> BTreeMap<(u8, u8), Vec> { + let mut pcrs = BTreeMap::new(); + for idx in 0u8..24 { + pcrs.insert((1, idx), vec![idx; 48]); // alg_id 1 = SHA-384 + } + pcrs +} + +/// Build 24 SHA-256 PCR values (for GCP). Value = vec![idx; 32]. +pub fn make_gcp_pcrs() -> BTreeMap<(u8, u8), Vec> { + let mut pcrs = BTreeMap::new(); + for idx in 0u8..24 { + pcrs.insert((0, idx), vec![idx; 32]); // alg_id 0 = SHA-256 + } + pcrs +} + +/// 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: &BTreeMap<(u8, u8), Vec>, +) -> (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 mut ak_pubkey = [0u8; 65]; + ak_pubkey.copy_from_slice(ak_point.as_bytes()); + + // Build TPM Quote + let pcr_digest = compute_pcr_digest(pcrs); + let pcr_select = vec![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; // SHA-384, all 24 + 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 ((alg, idx), val) in pcrs { + assert_eq!(*alg, 1, "Nitro PCRs must be SHA-384 (alg_id=1)"); + nitro_pcrs.insert(*idx, val.clone()); + } + + // Build COSE document + let cose_doc = build_nitro_cose_doc( + &chain.leaf_der, + std::slice::from_ref(&chain.root_der), + &nitro_pcrs, + Some(&ak_pubkey), + 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: &BTreeMap<(u8, u8), Vec>, +) -> (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![(0x000Bu16, vec![0xFF, 0xFF, 0xFF])]; // SHA-256, all 24 + 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 c738c2f..88e4cd6 100644 --- a/crates/vaportpm-verify/src/tpm.rs +++ b/crates/vaportpm-verify/src/tpm.rs @@ -8,7 +8,7 @@ use sha2::{Digest, Sha256}; use std::collections::BTreeMap; -use crate::error::VerifyError; +use crate::error::{InvalidAttestReason, SignatureInvalidReason, VerifyError}; /// Verify ECDSA-SHA256 signature over a message pub fn verify_ecdsa_p256( @@ -18,18 +18,20 @@ pub fn verify_ecdsa_p256( ) -> 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(()) } // ============================================================================= @@ -73,43 +75,45 @@ pub 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, @@ -131,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; @@ -144,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())); } @@ -257,10 +270,11 @@ pub fn verify_pcr_digest_matches( if let Some(pcr_value) = pcrs.get(&(decoded_alg_id, pcr_idx)) { hasher.update(pcr_value); } else { - return Err(VerifyError::InvalidAttest(format!( - "PCR {} (alg 0x{:04X}) selected in Quote but not present in attestation", - pcr_idx, alg - ))); + return Err(InvalidAttestReason::PcrSelectedButMissing { + pcr_index: pcr_idx, + algorithm: *alg, + } + .into()); } } } @@ -269,11 +283,7 @@ pub fn verify_pcr_digest_matches( 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) - ))); + return Err(InvalidAttestReason::PcrDigestMismatch.into()); } Ok(()) diff --git a/crates/vaportpm-verify/src/x509.rs b/crates/vaportpm-verify/src/x509.rs index 22ff642..fe25193 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"); @@ -154,19 +154,14 @@ pub 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,9 +223,9 @@ 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 @@ -263,7 +236,7 @@ pub fn extract_public_key(cert: &Certificate) -> Result, VerifyError> { 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()) } @@ -313,16 +286,14 @@ pub fn validate_tpm_cert_chain( 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 === @@ -335,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 @@ -357,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()); } } } @@ -419,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(); @@ -439,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()); } } } @@ -531,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()); } } @@ -657,9 +588,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] @@ -675,9 +609,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] @@ -689,9 +626,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] @@ -699,9 +639,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] @@ -710,9 +653,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 index e2547ab..031575f 100644 --- a/crates/vaportpm-verify/tests/gcp.rs +++ b/crates/vaportpm-verify/tests/gcp.rs @@ -12,9 +12,9 @@ use std::time::Duration; use der::Decode; use vaportpm_verify::{ - verify_attestation_output, verify_decoded_attestation_output, CloudProvider, - DecodedAttestationOutput, DecodedPlatformAttestation, EccPublicKeyCoords, UnixTime, - VerifyError, + verify_attestation_output, verify_decoded_attestation_output, CertificateParseReason, + ChainValidationReason, CloudProvider, DecodedAttestationOutput, DecodedPlatformAttestation, + EccPublicKeyCoords, InvalidAttestReason, SignatureInvalidReason, UnixTime, VerifyError, }; use vaportpm_verify::AttestationOutput; @@ -104,7 +104,12 @@ fn test_gcp_reject_tampered_ak_public_key() { let result = verify_attestation_output(&output, gcp_amd_fixture_time()); assert!( - matches!(result, Err(VerifyError::SignatureInvalid(ref msg)) if msg.contains("AK public key mismatch")), + matches!( + result, + Err(VerifyError::SignatureInvalid( + SignatureInvalidReason::AkPublicKeyMismatch + )) + ), "Should reject tampered AK public key, got: {:?}", result ); @@ -132,7 +137,12 @@ fn test_gcp_reject_tampered_pcr_value() { let result = verify_attestation_output(&output, gcp_amd_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("PCR digest mismatch")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::PcrDigestMismatch + )) + ), "Should reject tampered PCR value, got: {:?}", result ); @@ -214,7 +224,12 @@ fn test_gcp_reject_tampered_nonce_correct_length() { let result = verify_attestation_output(&output, gcp_amd_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("Nonce does not match")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::NonceMismatch + )) + ), "Should reject nonce that doesn't match Quote extraData, got: {:?}", result ); @@ -246,7 +261,12 @@ fn test_gcp_reject_non_sha256_pcrs() { let result = verify_attestation_output(&output, gcp_amd_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("SHA-256")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::UnexpectedPcrAlgorithmGcp { .. } + )) + ), "Should reject attestation with non-SHA-256 PCRs, got: {:?}", result ); @@ -271,7 +291,12 @@ fn test_gcp_reject_missing_pcr() { let result = verify_attestation_output(&output, gcp_amd_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("all 24 PCRs")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::MissingPcr { .. } + )) + ), "Should reject when a PCR is missing, got: {:?}", result ); @@ -337,7 +362,12 @@ fn test_gcp_reject_cert_not_yet_valid() { let result = verify_attestation_output(&output, past_time); assert!( - matches!(result, Err(VerifyError::ChainValidation(ref msg)) if msg.contains("not yet valid")), + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::CertNotYetValid { .. } + )) + ), "Should reject cert not yet valid, got: {:?}", result ); @@ -358,7 +388,12 @@ fn test_gcp_reject_cert_expired() { let result = verify_attestation_output(&output, future_time); assert!( - matches!(result, Err(VerifyError::ChainValidation(ref msg)) if msg.contains("expired")), + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::CertExpired { .. } + )) + ), "Should reject expired cert, got: {:?}", result ); @@ -387,7 +422,12 @@ fn test_gcp_decoded_reject_empty_cert_chain() { let result = verify_decoded_attestation_output(&decoded, gcp_amd_fixture_time()); assert!( - matches!(result, Err(VerifyError::ChainValidation(ref msg)) if msg.contains("Empty certificate chain")), + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::EmptyChain + )) + ), "Should reject empty cert chain in decoded path, got: {:?}", result ); @@ -405,7 +445,12 @@ fn test_gcp_decoded_reject_invalid_der_cert() { let result = verify_decoded_attestation_output(&decoded, gcp_amd_fixture_time()); assert!( - matches!(result, Err(VerifyError::CertificateParse(ref msg)) if msg.contains("Invalid DER cert")), + matches!( + result, + Err(VerifyError::CertificateParse( + CertificateParseReason::InvalidDer(_) + )) + ), "Should reject invalid DER cert, got: {:?}", result ); @@ -471,7 +516,12 @@ fn test_gcp_decoded_reject_unknown_root_ca() { let result = verify_decoded_attestation_output(&decoded, gcp_amd_fixture_time()); assert!( - matches!(result, Err(VerifyError::ChainValidation(ref msg)) if msg.contains("Unknown root CA")), + 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 index a0e5afd..00a30af 100644 --- a/crates/vaportpm-verify/tests/nitro.rs +++ b/crates/vaportpm-verify/tests/nitro.rs @@ -9,9 +9,9 @@ use std::collections::BTreeMap; use std::time::Duration; use vaportpm_verify::{ - verify_attestation_output, verify_decoded_attestation_output, CloudProvider, - DecodedAttestationOutput, DecodedPlatformAttestation, EccPublicKeyCoords, UnixTime, - VerifyError, + verify_attestation_output, verify_decoded_attestation_output, ChainValidationReason, + CloudProvider, DecodedAttestationOutput, DecodedPlatformAttestation, EccPublicKeyCoords, + InvalidAttestReason, UnixTime, VerifyError, }; use vaportpm_verify::AttestationOutput; @@ -99,7 +99,12 @@ fn test_nitro_reject_tampered_nonce_wrong_length() { let result = verify_attestation_output(&output, nitro_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(_))), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::NonceLengthInvalid + )) + ), "Should reject wrong-length nonce, got: {:?}", result ); @@ -120,7 +125,12 @@ fn test_nitro_reject_tampered_nonce_correct_length() { let result = verify_attestation_output(&output, nitro_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("Nonce does not match")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::NonceMismatch + )) + ), "Should reject nonce that doesn't match Quote extraData, got: {:?}", result ); @@ -228,7 +238,12 @@ fn test_nitro_reject_cert_not_yet_valid() { let result = verify_attestation_output(&output, past_time); assert!( - matches!(result, Err(VerifyError::ChainValidation(ref msg)) if msg.contains("not yet valid")), + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::CertNotYetValid { .. } + )) + ), "Should reject cert not yet valid, got: {:?}", result ); @@ -246,7 +261,12 @@ fn test_nitro_reject_cert_expired() { let result = verify_attestation_output(&output, future_time); assert!( - matches!(result, Err(VerifyError::ChainValidation(ref msg)) if msg.contains("expired")), + matches!( + result, + Err(VerifyError::ChainValidation( + ChainValidationReason::CertExpired { .. } + )) + ), "Should reject expired cert, got: {:?}", result ); @@ -277,7 +297,12 @@ fn test_nitro_reject_non_sha384_pcrs() { let result = verify_attestation_output(&output, nitro_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("SHA-384")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::UnexpectedPcrAlgorithmNitro { .. } + )) + ), "Should reject attestation with non-SHA-384 PCRs, got: {:?}", result ); @@ -302,7 +327,12 @@ fn test_nitro_reject_extra_sha256_pcrs() { let result = verify_attestation_output(&output, nitro_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("non-SHA-384")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::UnexpectedPcrAlgorithmNitro { .. } + )) + ), "Should reject attestation with extra SHA-256 PCRs alongside SHA-384, got: {:?}", result ); @@ -363,7 +393,12 @@ fn test_nitro_decoded_reject_empty_pcrs() { let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("Missing SHA-384 PCRs")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::MissingSha384Pcrs + )) + ), "Should reject empty PCRs, got: {:?}", result ); @@ -392,7 +427,12 @@ fn test_nitro_decoded_reject_non_sha384_pcr() { let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("non-SHA-384")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::UnexpectedPcrAlgorithmNitro { .. } + )) + ), "Should reject non-SHA-384 PCRs at decoded level, got: {:?}", result ); @@ -410,7 +450,12 @@ fn test_nitro_decoded_reject_pcr_index_out_of_range() { let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("out of range")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::PcrIndexOutOfRange { .. } + )) + ), "Should reject PCR index > 23, got: {:?}", result ); @@ -428,7 +473,12 @@ fn test_nitro_decoded_reject_missing_pcr() { let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); assert!( - matches!(result, Err(VerifyError::InvalidAttest(ref msg)) if msg.contains("all 24 PCRs")), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::MissingPcr { .. } + )) + ), "Should reject missing SHA-384 PCR, got: {:?}", result ); From 974b262d09b247cea0df31010e46fe66ad5fa8b1 Mon Sep 17 00:00:00 2001 From: user <303926+HarryR@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:19:20 +0800 Subject: [PATCH 07/10] Consolidated errors + enums + added more consistent tests to verify reduced duplication --- crates/vaportpm-verify/Cargo.toml | 1 - crates/vaportpm-verify/src/bin/verify.rs | 22 +- .../src/ephemeral_gcp_tests.rs | 65 +-- .../src/ephemeral_nitro_tests.rs | 201 ++++----- crates/vaportpm-verify/src/error.rs | 62 +-- crates/vaportpm-verify/src/flat.rs | 42 +- crates/vaportpm-verify/src/gcp.rs | 90 +--- crates/vaportpm-verify/src/lib.rs | 53 +-- crates/vaportpm-verify/src/nitro.rs | 220 ++------- crates/vaportpm-verify/src/pcr.rs | 425 ++++++++++++++++++ crates/vaportpm-verify/src/test_support.rs | 54 +-- crates/vaportpm-verify/src/tpm.rs | 115 +++-- crates/vaportpm-verify/src/x509.rs | 32 +- crates/vaportpm-verify/tests/gcp.rs | 54 ++- crates/vaportpm-verify/tests/nitro.rs | 160 ++----- .../risc-zero/methods/guest/Cargo.toml | 1 - .../risc-zero/methods/guest/src/main.rs | 36 +- experiments/risc-zero/tests/cycle_count.rs | 2 +- 18 files changed, 863 insertions(+), 772 deletions(-) create mode 100644 crates/vaportpm-verify/src/pcr.rs diff --git a/crates/vaportpm-verify/Cargo.toml b/crates/vaportpm-verify/Cargo.toml index b6e88c2..179638b 100644 --- a/crates/vaportpm-verify/Cargo.toml +++ b/crates/vaportpm-verify/Cargo.toml @@ -33,7 +33,6 @@ base64 = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde-big-array = { workspace = true } zerocopy = { workspace = true } [dev-dependencies] diff --git a/crates/vaportpm-verify/src/bin/verify.rs b/crates/vaportpm-verify/src/bin/verify.rs index 6d453ab..294236b 100644 --- a/crates/vaportpm-verify/src/bin/verify.rs +++ b/crates/vaportpm-verify/src/bin/verify.rs @@ -24,19 +24,19 @@ struct VerificationResultJson { impl From for VerificationResultJson { fn from(result: VerificationResult) -> Self { - // Group PCRs by algorithm and convert to hex - let mut pcrs: BTreeMap> = BTreeMap::new(); - for ((alg_id, idx), value) in result.pcrs { - let alg_name = match alg_id { - 0 => "sha256", - 1 => "sha384", - _ => continue, - }; - pcrs.entry(alg_name.to_string()) - .or_default() - .insert(idx, hex::encode(value)); + let alg_name = match result.pcrs.algorithm() { + vaportpm_verify::PcrAlgorithm::Sha256 => "sha256", + vaportpm_verify::PcrAlgorithm::Sha384 => "sha384", + }; + + 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(alg_name.to_string(), pcr_map); + VerificationResultJson { nonce: hex::encode(result.nonce), provider: result.provider, diff --git a/crates/vaportpm-verify/src/ephemeral_gcp_tests.rs b/crates/vaportpm-verify/src/ephemeral_gcp_tests.rs index 4bbbe83..216e4de 100644 --- a/crates/vaportpm-verify/src/ephemeral_gcp_tests.rs +++ b/crates/vaportpm-verify/src/ephemeral_gcp_tests.rs @@ -9,6 +9,7 @@ use crate::error::{ ChainValidationReason, InvalidAttestReason, SignatureInvalidReason, VerifyError, }; +use crate::pcr::{P256PublicKey, PcrAlgorithm}; use crate::roots::register_test_root; use crate::test_support; use crate::{ @@ -41,8 +42,8 @@ fn test_ephemeral_gcp_reject_multiple_pcr_banks() { let pcr_digest = test_support::compute_pcr_digest(&pcrs); // TWO banks in pcr_select let pcr_select = vec![ - (0x000Bu16, vec![0xFF, 0xFF, 0xFF]), - (0x000Cu16, vec![0xFF, 0xFF, 0xFF]), + (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); @@ -81,8 +82,8 @@ fn test_ephemeral_gcp_reject_wrong_pcr_algorithm() { let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Gcp); let pcr_digest = test_support::compute_pcr_digest(&pcrs); - // SHA-384 (0x000C) instead of SHA-256 (0x000B) - let pcr_select = vec![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; + // 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); @@ -104,7 +105,7 @@ fn test_ephemeral_gcp_reject_wrong_pcr_algorithm() { result, Err(VerifyError::InvalidAttest( InvalidAttestReason::WrongPcrAlgorithm { - expected: 0x000B, + expected: PcrAlgorithm::Sha256, got: 0x000C, } )) @@ -143,7 +144,10 @@ fn test_ephemeral_gcp_reject_ak_pubkey_mismatch() { 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 = [0x04; 65]; + decoded.ak_pubkey = P256PublicKey { + x: [0x04; 32], + y: [0x04; 32], + }; let result = verify_decoded_attestation_output(&decoded, time); assert!( @@ -159,49 +163,28 @@ fn test_ephemeral_gcp_reject_ak_pubkey_mismatch() { } #[test] -fn test_ephemeral_gcp_reject_empty_pcrs() { - let nonce = [0xBB; 32]; - let pcrs = test_support::make_gcp_pcrs(); - let (mut decoded, time, _guard) = test_support::build_valid_gcp(&nonce, &pcrs); - - // Clear all PCRs - decoded.pcrs.clear(); - - let result = verify_decoded_attestation_output(&decoded, time); - assert!( - matches!( - result, - Err(VerifyError::InvalidAttest( - InvalidAttestReason::MissingSha256Pcrs - )) - ), - "Expected MissingSha256Pcrs error, got: {:?}", - result - ); -} - -#[test] -fn test_ephemeral_gcp_reject_pcr_index_out_of_range() { +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 mut pcrs = test_support::make_gcp_pcrs(); - // Add an extra PCR with index 24 (out of range) - pcrs.insert((0, 24), vec![0xAA; 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, &test_support::make_gcp_pcrs()); - - // Replace PCRs with our set that includes the out-of-range index - decoded.pcrs = pcrs; + 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::PcrIndexOutOfRange { index: 24 } + InvalidAttestReason::WrongPcrBankAlgorithm { + expected: PcrAlgorithm::Sha256, + got: PcrAlgorithm::Sha384, + } )) ), - "Expected PcrIndexOutOfRange error, got: {:?}", + "Expected WrongPcrBankAlgorithm error, got: {:?}", result ); } @@ -216,7 +199,7 @@ fn test_ephemeral_gcp_reject_partial_pcr_bitmap() { let pcr_digest = test_support::compute_pcr_digest(&pcrs); // Correct algorithm but partial bitmap — only first 16 PCRs selected - let pcr_select = vec![(0x000Bu16, vec![0xFF, 0xFF, 0x00])]; + 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); @@ -258,7 +241,7 @@ fn test_ephemeral_gcp_reject_wrong_provider_root() { let guard = register_test_root(chain.root_pubkey_hash, CloudProvider::Aws); let pcr_digest = test_support::compute_pcr_digest(&pcrs); - let pcr_select = vec![(0x000Bu16, vec![0xFF, 0xFF, 0xFF])]; + 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); diff --git a/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs b/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs index a818a57..4be5c6e 100644 --- a/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs +++ b/crates/vaportpm-verify/src/ephemeral_nitro_tests.rs @@ -12,8 +12,9 @@ use p256::pkcs8::DecodePrivateKey as _; use crate::error::{ CborParseReason, ChainValidationReason, CoseVerifyReason, InvalidAttestReason, - NoValidAttestationReason, SignatureInvalidReason, VerifyError, + SignatureInvalidReason, VerifyError, }; +use crate::pcr::{P256PublicKey, PcrAlgorithm, PcrBank}; use crate::roots::register_test_root; use crate::test_support; use crate::{ @@ -21,21 +22,21 @@ use crate::{ DecodedPlatformAttestation, }; -/// Helper: generate an ephemeral P-256 AK key pair, returning (pubkey_65bytes, pkcs8_der). -fn ephemeral_ak() -> ([u8; 65], Vec) { +/// 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 mut ak_pubkey = [0u8; 65]; - ak_pubkey.copy_from_slice(ak_point.as_bytes()); + let ak_pubkey = P256PublicKey::from_sec1_uncompressed(ak_point.as_bytes()).unwrap(); (ak_pubkey, ak_pkcs8) } -/// Helper: convert decoded PCRs (alg_id, idx) → idx-only map for COSE. -fn to_nitro_pcr_map(pcrs: &BTreeMap<(u8, u8), Vec>) -> BTreeMap> { - pcrs.iter() - .map(|((_alg, idx), val)| (*idx, val.clone())) +/// 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() } @@ -62,13 +63,14 @@ fn test_ephemeral_nitro_reject_multiple_pcr_banks() { let nitro_pcrs = to_nitro_pcr_map(&pcrs); let pcr_select = vec![ - (0x000Cu16, vec![0xFF, 0xFF, 0xFF]), - (0x000Bu16, vec![0xFF, 0xFF, 0xFF]), + (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); @@ -76,7 +78,7 @@ fn test_ephemeral_nitro_reject_multiple_pcr_banks() { &chain.leaf_der, std::slice::from_ref(&chain.root_der), &nitro_pcrs, - Some(&ak_pubkey), + Some(&ak_sec1), Some(&nonce), &chain.cose_signing_key, ); @@ -112,16 +114,17 @@ fn test_ephemeral_nitro_reject_wrong_pcr_algorithm() { 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 (0x000B) instead of SHA-384 (0x000C) - let pcr_select = vec![(0x000Bu16, vec![0xFF, 0xFF, 0xFF])]; + // 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_pubkey), + Some(&ak_sec1), Some(&nonce), &chain.cose_signing_key, ); @@ -141,7 +144,7 @@ fn test_ephemeral_nitro_reject_wrong_pcr_algorithm() { result, Err(VerifyError::InvalidAttest( InvalidAttestReason::WrongPcrAlgorithm { - expected: 0x000C, + expected: PcrAlgorithm::Sha384, got: 0x000B, } )) @@ -160,16 +163,17 @@ fn test_ephemeral_nitro_reject_partial_pcr_bitmap() { 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![(0x000Cu16, vec![0xFF, 0xFF, 0xFE])]; + 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_pubkey), + Some(&ak_sec1), Some(&nonce), &chain.cose_signing_key, ); @@ -226,15 +230,16 @@ fn test_ephemeral_nitro_reject_missing_nitro_nonce() { 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![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; + 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_pubkey), + Some(&ak_sec1), None, // no nonce &chain.cose_signing_key, ); @@ -252,9 +257,9 @@ fn test_ephemeral_nitro_reject_missing_nitro_nonce() { assert!( matches!( result, - Err(VerifyError::NoValidAttestation( - NoValidAttestationReason::MissingNonce - )) + Err(VerifyError::CborParse(CborParseReason::MissingField { + field: "nonce" + })) ), "Expected missing nonce error, got: {:?}", result @@ -271,15 +276,16 @@ fn test_ephemeral_nitro_reject_nitro_nonce_mismatch() { 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![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; + 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_pubkey), + Some(&ak_sec1), Some(&wrong_nonce), // different nonce &chain.cose_signing_key, ); @@ -314,15 +320,16 @@ fn test_ephemeral_nitro_reject_empty_signed_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![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; + 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 - Some(&ak_pubkey), + &BTreeMap::new(), // empty PCRs in COSE document + Some(&ak_sec1), Some(&nonce), &chain.cose_signing_key, ); @@ -349,75 +356,34 @@ fn test_ephemeral_nitro_reject_empty_signed_pcrs() { ); } -#[test] -fn test_ephemeral_nitro_reject_pcr_missing_from_attestation() { - 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![(0x000Cu16, 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_pubkey), - Some(&nonce), - &chain.cose_signing_key, - ); - - // decoded is missing PCR 5 → hits "all 24 PCRs" check - let mut decoded_pcrs = pcrs.clone(); - decoded_pcrs.remove(&(1, 5)); - let decoded = DecodedAttestationOutput { - nonce, - pcrs: decoded_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::MissingPcr { .. } - )) - ), - "Expected missing PCR 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) + // 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![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; + 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_pubkey), + Some(&ak_sec1), Some(&nonce), &chain.cose_signing_key, ); @@ -449,22 +415,24 @@ 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 + // 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![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; + 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_pubkey), + Some(&ak_sec1), Some(&nonce), &chain.cose_signing_key, ); @@ -501,7 +469,7 @@ fn test_ephemeral_nitro_reject_missing_public_key() { 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![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; + 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( @@ -526,9 +494,9 @@ fn test_ephemeral_nitro_reject_missing_public_key() { assert!( matches!( result, - Err(VerifyError::NoValidAttestation( - NoValidAttestationReason::MissingPublicKey - )) + Err(VerifyError::CborParse(CborParseReason::MissingField { + field: "public_key" + })) ), "Expected missing public_key error, got: {:?}", result @@ -545,7 +513,7 @@ fn test_ephemeral_nitro_reject_ak_mismatch() { 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![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; + 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 @@ -573,7 +541,7 @@ fn test_ephemeral_nitro_reject_ak_mismatch() { matches!( result, Err(VerifyError::SignatureInvalid( - SignatureInvalidReason::AkNitroBindingMismatch + SignatureInvalidReason::AkPublicKeyMismatch )) ), "Expected AK mismatch error, got: {:?}", @@ -583,9 +551,6 @@ fn test_ephemeral_nitro_reject_ak_mismatch() { #[test] fn test_ephemeral_nitro_reject_wrong_provider_root() { - // Build a valid Nitro attestation but register the root as GCP. - // The COSE signature and cert chain will validate, but the provider - // check should reject it: "requires AWS root CA, got Gcp". let nonce = [0xAA; 32]; let pcrs = test_support::make_nitro_pcrs(); let nitro_pcrs = to_nitro_pcr_map(&pcrs); @@ -594,15 +559,16 @@ fn test_ephemeral_nitro_reject_wrong_provider_root() { // 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![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; + 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_pubkey), + Some(&ak_sec1), Some(&nonce), &chain.cose_signing_key, ); @@ -634,9 +600,6 @@ fn test_ephemeral_nitro_reject_wrong_provider_root() { #[test] fn test_ephemeral_nitro_reject_unknown_root_ca() { - // Build a valid Nitro attestation but don't register the root at all. - // The COSE signature and cert chain validate, but provider_from_hash - // returns None → "Unknown root CA". let nonce = [0xAA; 32]; let pcrs = test_support::make_nitro_pcrs(); let nitro_pcrs = to_nitro_pcr_map(&pcrs); @@ -644,15 +607,16 @@ fn test_ephemeral_nitro_reject_unknown_root_ca() { 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![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; + 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_pubkey), + Some(&ak_sec1), Some(&nonce), &chain.cose_signing_key, ); @@ -680,9 +644,6 @@ fn test_ephemeral_nitro_reject_unknown_root_ca() { #[test] fn test_ephemeral_nitro_reject_payload_not_map() { - // Build a COSE Sign1 whose payload is a CBOR array, not a map. - // PayloadNotMap fires before signature verification, so the signature - // and other fields don't need to be valid. use coset::{iana, CborSerializable, CoseSign1, HeaderBuilder}; let payload_array = ciborium::Value::Array(vec![ciborium::Value::Integer(42.into())]); @@ -706,7 +667,10 @@ fn test_ephemeral_nitro_reject_payload_not_map() { let decoded = DecodedAttestationOutput { nonce, pcrs: test_support::make_nitro_pcrs(), - ak_pubkey: [0x04; 65], + ak_pubkey: P256PublicKey { + x: [0x04; 32], + y: [0x04; 32], + }, quote_attest: vec![], quote_signature: vec![], platform: DecodedPlatformAttestation::Nitro { document }, @@ -725,9 +689,6 @@ fn test_ephemeral_nitro_reject_payload_not_map() { #[test] fn test_ephemeral_nitro_reject_invalid_signature_length() { - // Build a valid COSE doc then re-encode it with a truncated signature. - // The flow: parse COSE → extract payload map → extract cert/cabundle → - // parse certs → verify_cose_signature → check sig length → error. use coset::{CborSerializable, CoseSign1}; let nonce = [0xAA; 32]; @@ -737,13 +698,14 @@ fn test_ephemeral_nitro_reject_invalid_signature_length() { 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_pubkey), + Some(&ak_sec1), Some(&nonce), &chain.cose_signing_key, ); @@ -754,7 +716,7 @@ fn test_ephemeral_nitro_reject_invalid_signature_length() { let bad_doc = cose.to_vec().unwrap(); let pcr_digest = test_support::compute_pcr_digest(&pcrs); - let pcr_select = vec![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; + 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); @@ -783,3 +745,30 @@ fn test_ephemeral_nitro_reject_invalid_signature_length() { 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 94490b1..3ad03bb 100644 --- a/crates/vaportpm-verify/src/error.rs +++ b/crates/vaportpm-verify/src/error.rs @@ -66,8 +66,11 @@ pub enum InvalidAttestReason { #[error("Requires exactly one PCR bank selection, got {count}")] MultiplePcrBanks { count: usize }, - #[error("Requires TPM Quote to select PCR algorithm 0x{expected:04X}, got 0x{got:04X}")] - WrongPcrAlgorithm { expected: u16, got: u16 }, + #[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, @@ -78,23 +81,37 @@ pub enum InvalidAttestReason { #[error("Nonce is not 32 bytes")] NonceLengthInvalid, - #[error("Missing SHA-256 PCRs - required for GCP attestation")] - MissingSha256Pcrs, + #[error("Missing PCR {index} - all 24 PCRs (0-23) are required")] + MissingPcr { index: u8 }, - #[error("Missing SHA-384 PCRs - required for Nitro attestation")] - MissingSha384Pcrs, + // PCR bank validation (pcr.rs) + #[error("PCR bank is empty")] + PcrBankEmpty, - #[error("Contains non-SHA-256 PCR (alg_id={alg_id}, pcr={pcr_idx}); only SHA-256 PCRs are verified in the GCP path")] - UnexpectedPcrAlgorithmGcp { alg_id: u8, pcr_idx: u8 }, + #[error("PCR bank contains mixed algorithms")] + PcrBankMixedAlgorithms, - #[error("Contains non-SHA-384 PCR (alg_id={alg_id}, pcr={pcr_idx}); only SHA-384 PCRs are verified in the Nitro path")] - UnexpectedPcrAlgorithmNitro { alg_id: u8, pcr_idx: u8 }, + #[error("PCR bank has {got} entries, expected {expected}")] + PcrBankWrongCount { expected: usize, got: usize }, - #[error("PCR index {index} out of range; only PCRs 0-23 are valid")] - PcrIndexOutOfRange { index: u8 }, + #[error("PCR {index} value has wrong length: expected {expected}, got {got}")] + PcrValueWrongLength { + index: u8, + expected: usize, + got: usize, + }, - #[error("Missing PCR {index} - all 24 PCRs (0-23) are required")] - MissingPcr { index: u8 }, + #[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, @@ -102,11 +119,6 @@ pub enum InvalidAttestReason { #[error("PCR {pcr_index} in attestation but not signed by Nitro document")] PcrNotSigned { pcr_index: u8 }, - #[error( - "PCR {pcr_index} (alg 0x{algorithm:04X}) selected in Quote but not present in attestation" - )] - PcrSelectedButMissing { pcr_index: u8, algorithm: u16 }, - #[error("PCR digest mismatch")] PcrDigestMismatch, @@ -149,17 +161,11 @@ pub enum SignatureInvalidReason { #[error("AK public key mismatch between certificate and decoded input")] AkPublicKeyMismatch, - #[error("TPM signing key does not match Nitro public_key binding")] - AkNitroBindingMismatch, - #[error("TPM nonce does not match Nitro nonce")] NitroNonceMismatch, #[error("PCR {index} SHA-384 mismatch between claimed and signed value")] PcrValueMismatch { index: u8 }, - - #[error("PCR {index} in signed Nitro document but missing from attestation")] - PcrMissingFromAttestation { index: u8 }, } // ============================================================================= @@ -333,12 +339,6 @@ pub enum PcrIndexOutOfBoundsReason { #[derive(Debug, Error)] pub enum NoValidAttestationReason { - #[error("Nitro document missing public_key field - cannot bind TPM signing key")] - MissingPublicKey, - - #[error("Nitro document missing nonce field - cannot verify freshness")] - MissingNonce, - #[error("Missing ecc_p256 AK public key")] MissingAkPublicKey, diff --git a/crates/vaportpm-verify/src/flat.rs b/crates/vaportpm-verify/src/flat.rs index 222a3f3..8cd3dc7 100644 --- a/crates/vaportpm-verify/src/flat.rs +++ b/crates/vaportpm-verify/src/flat.rs @@ -9,6 +9,7 @@ use std::collections::BTreeMap; use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout}; use crate::error::InvalidAttestReason; +use crate::pcr::{P256PublicKey, PcrBank}; use crate::{DecodedAttestationOutput, DecodedPlatformAttestation, VerifyError}; /// Platform type constants @@ -54,13 +55,16 @@ pub fn to_bytes(decoded: &DecodedAttestationOutput) -> Vec { DecodedPlatformAttestation::Nitro { document } => document.clone(), }; + let alg_u16 = decoded.pcrs.algorithm() as u16; + let digest_len = decoded.pcrs.algorithm().digest_len(); + let header = FlatHeader { nonce: decoded.nonce, - ak_pubkey: decoded.ak_pubkey, + 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_count: decoded.pcrs.len() as u8, + pcr_count: crate::pcr::PCR_COUNT as u8, platform_data_len: platform_data.len() as u16, }; @@ -69,11 +73,11 @@ pub fn to_bytes(decoded: &DecodedAttestationOutput) -> Vec { // Write header as bytes (zerocopy ensures correct layout) buf.extend_from_slice(header.as_bytes()); - // Write PCRs: [alg_id, pcr_idx, len, value...] - for ((alg_id, pcr_idx), value) in &decoded.pcrs { - buf.push(*alg_id); - buf.push(*pcr_idx); - buf.push(value.len() as u8); + // Write PCRs: [alg_u16(2 bytes LE), pcr_idx, len, value...] + for (idx, value) in decoded.pcrs.values().enumerate() { + buf.extend_from_slice(&alg_u16.to_le_bytes()); + buf.push(idx as u8); + buf.push(digest_len as u8); buf.extend_from_slice(value); } @@ -108,24 +112,27 @@ pub fn from_bytes(data: &[u8]) -> Result let mut offset = HEADER_SIZE; - // Parse PCRs + // Parse PCRs: [alg_u16(2 bytes LE), pcr_idx, len, value...] let mut pcrs = BTreeMap::new(); for _ in 0..pcr_count { - if offset + 3 > data.len() { + if offset + 4 > data.len() { return Err(InvalidAttestReason::FlatTruncated { field: "PCR header", } .into()); } - let alg_id = data[offset]; - let pcr_idx = data[offset + 1]; - let value_len = data[offset + 2] as usize; - offset += 3; + let alg_u16 = u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap()); + let pcr_idx = data[offset + 2]; + let value_len = data[offset + 3] as usize; + offset += 4; if offset + value_len > data.len() { return Err(InvalidAttestReason::FlatTruncated { field: "PCR value" }.into()); } - pcrs.insert((alg_id, pcr_idx), data[offset..offset + value_len].to_vec()); + pcrs.insert( + (alg_u16, pcr_idx), + data[offset..offset + value_len].to_vec(), + ); offset += value_len; } @@ -205,10 +212,13 @@ pub fn from_bytes(data: &[u8]) -> Result } }; + let pcr_bank = PcrBank::from_btree_map(&pcrs)?; + let ak_pubkey = P256PublicKey::from_sec1_uncompressed(&header.ak_pubkey)?; + Ok(DecodedAttestationOutput { nonce: header.nonce, - pcrs, - ak_pubkey: header.ak_pubkey, + pcrs: pcr_bank, + ak_pubkey, quote_attest, quote_signature, platform, diff --git a/crates/vaportpm-verify/src/gcp.rs b/crates/vaportpm-verify/src/gcp.rs index 4876c16..cde3778 100644 --- a/crates/vaportpm-verify/src/gcp.rs +++ b/crates/vaportpm-verify/src/gcp.rs @@ -10,7 +10,8 @@ use crate::error::{ CertificateParseReason, ChainValidationReason, InvalidAttestReason, SignatureInvalidReason, VerifyError, }; -use crate::tpm::{parse_quote_attest, verify_ecdsa_p256, verify_pcr_digest_matches}; +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}; @@ -69,95 +70,18 @@ pub fn verify_gcp_decoded( cert_chain_der: &[Vec], time: UnixTime, ) -> Result { - // Enforce that only SHA-256 PCRs (algorithm ID 0) are present. - // The GCP path only verifies SHA-256 PCRs (covered by the TPM Quote's - // PCR digest and the AK certificate chain). Any other bank would be - // unverified data passed through to the output. - if decoded.pcrs.is_empty() { - return Err(InvalidAttestReason::MissingSha256Pcrs.into()); - } - for (alg_id, pcr_idx) in decoded.pcrs.keys() { - if *alg_id != 0 { - return Err(InvalidAttestReason::UnexpectedPcrAlgorithmGcp { - alg_id: *alg_id, - pcr_idx: *pcr_idx, - } - .into()); - } - } - - // Enforce all 24 SHA-256 PCRs are present. - // Complete, unambiguous PCR state — no selective omission. - for pcr_idx in 0..24u8 { - if !decoded.pcrs.contains_key(&(0, pcr_idx)) { - return Err(InvalidAttestReason::MissingPcr { index: pcr_idx }.into()); - } - } - - // Reject any PCR indices outside 0-23 - for (_alg_id, pcr_idx) in decoded.pcrs.keys() { - if *pcr_idx > 23 { - return Err(InvalidAttestReason::PcrIndexOutOfRange { index: *pcr_idx }.into()); - } - } - - // Parse TPM2_Quote attestation (structure only — not yet authenticated) - let quote_info = parse_quote_attest(&decoded.quote_attest)?; - let (certs, provider) = verify_gcp_certs(cert_chain_der, time)?; - // Extract AK public key from leaf certificate for comparison + // Verify AK public key from cert chain matches the decoded input let ak_pubkey_from_cert = extract_public_key(&certs[0])?; - - // Verify the AK public key matches the one in the decoded input - if ak_pubkey_from_cert != decoded.ak_pubkey { + let ak_sec1 = decoded.ak_pubkey.to_sec1_uncompressed(); + if ak_pubkey_from_cert != ak_sec1 { return Err(SignatureInvalidReason::AkPublicKeyMismatch.into()); } - // Verify Quote signature with AK public key — this authenticates - // the quote data. All checks below trust the parsed quote_info - // because the signature covers the entire attest structure. - verify_ecdsa_p256( - &decoded.quote_attest, - &decoded.quote_signature, - &decoded.ak_pubkey, - )?; - - // --- Quote is now authenticated; safe to trust its contents --- - - // Enforce that the TPM Quote selects exactly one PCR bank: SHA-256 (0x000B), - // and that it selects all 24 PCRs (bitmap 0xFF 0xFF 0xFF). - 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 != 0x000B { - return Err(InvalidAttestReason::WrongPcrAlgorithm { - expected: 0x000B, - 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 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)?; + // Verify TPM Quote: signature, PCR bank (SHA-256), nonce, PCR digest + let quote_info = verify_quote(decoded, PcrAlgorithm::Sha256)?; - // Convert nonce to fixed-size array let nonce: [u8; 32] = quote_info .nonce .as_slice() diff --git a/crates/vaportpm-verify/src/lib.rs b/crates/vaportpm-verify/src/lib.rs index dfd6c13..2a191ac 100644 --- a/crates/vaportpm-verify/src/lib.rs +++ b/crates/vaportpm-verify/src/lib.rs @@ -12,13 +12,14 @@ mod error; mod gcp; mod nitro; +pub mod pcr; mod tpm; mod x509; use std::collections::BTreeMap; -use serde::{Deserialize, Serialize}; -use serde_big_array::BigArray; +use serde::Serialize; +use x509::parse_cert_chain_pem; // Re-export error types pub use error::{ @@ -27,17 +28,8 @@ pub use error::{ SignatureInvalidReason, VerifyError, }; -// Re-export TPM types and functions (only those used by verification paths) -pub use tpm::{parse_quote_attest, verify_ecdsa_p256, verify_pcr_digest_matches, TpmQuoteInfo}; - -// Re-export from vaportpm_attest -pub use vaportpm_attest::TpmAlg; - -// 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 PCR types +pub use pcr::{P256PublicKey, PcrAlgorithm, PcrBank, PCR_COUNT}; // Re-export time type for testing pub use pki_types::UnixTime; @@ -148,18 +140,16 @@ pub use vaportpm_attest::a9n::{ /// /// 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, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct DecodedAttestationOutput { /// Nonce (32 bytes, already decoded from hex) pub nonce: [u8; 32], - /// PCR values: (algorithm_id, pcr_index) → value - /// Algorithm IDs: 0 = SHA-256, 1 = SHA-384 - pub pcrs: BTreeMap<(u8, u8), Vec>, + /// Validated PCR bank (single algorithm, exactly 24 values) + pub pcrs: PcrBank, - /// AK public key (65 bytes SEC1 uncompressed: 0x04 || x || y) - #[serde(with = "BigArray")] - pub ak_pubkey: [u8; 65], + /// AK public key (P-256) + pub ak_pubkey: P256PublicKey, /// TPM Quote attest_data (raw bytes) pub quote_attest: Vec, @@ -172,7 +162,7 @@ pub struct DecodedAttestationOutput { } /// Platform-specific attestation data in decoded binary format -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub enum DecodedPlatformAttestation { /// GCP: certificate chain as DER bytes (leaf first) Gcp { cert_chain_der: Vec> }, @@ -202,10 +192,7 @@ impl DecodedAttestationOutput { .ok_or(NoValidAttestationReason::MissingAkPublicKey)?; let ak_x = hex::decode(&ak_pk.x)?; let ak_y = hex::decode(&ak_pk.y)?; - let mut ak_pubkey = [0u8; 65]; - ak_pubkey[0] = 0x04; - ak_pubkey[1..33].copy_from_slice(&ak_x); - ak_pubkey[33..65].copy_from_slice(&ak_y); + let ak_pubkey = P256PublicKey::from_coords(&ak_x, &ak_y)?; let tpm = output .attestation @@ -215,18 +202,19 @@ impl DecodedAttestationOutput { let quote_attest = hex::decode(&tpm.attest_data)?; let quote_signature = hex::decode(&tpm.signature)?; - let mut pcrs = BTreeMap::new(); + let mut pcrs_raw = BTreeMap::new(); for (alg_name, pcr_map) in &output.pcrs { let alg_id = match alg_name.as_str() { - "sha256" => 0u8, - "sha384" => 1u8, + "sha256" => PcrAlgorithm::Sha256 as u16, + "sha384" => PcrAlgorithm::Sha384 as u16, _ => continue, }; for (idx, hex_value) in pcr_map { let value = hex::decode(hex_value)?; - pcrs.insert((alg_id, *idx), value); + pcrs_raw.insert((alg_id, *idx), value); } } + let pcrs = PcrBank::from_btree_map(&pcrs_raw)?; let platform = if let Some(ref gcp) = output.attestation.gcp { let certs = parse_cert_chain_pem(&gcp.ak_cert_chain)?; @@ -275,15 +263,14 @@ pub fn verify_decoded_attestation_output( } /// Result of successful attestation verification -#[derive(Debug, Serialize)] +#[derive(Debug)] pub struct VerificationResult { /// 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: (algorithm_id, pcr_index) -> raw digest bytes - /// Algorithm IDs: 0 = SHA-256, 1 = SHA-384 - pub pcrs: BTreeMap<(u8, u8), Vec>, + /// Validated PCR bank from the attestation + pub pcrs: PcrBank, /// Timestamp when verification was performed (seconds since Unix epoch) pub verified_at: u64, } diff --git a/crates/vaportpm-verify/src/nitro.rs b/crates/vaportpm-verify/src/nitro.rs index e5fdcd4..d800518 100644 --- a/crates/vaportpm-verify/src/nitro.rs +++ b/crates/vaportpm-verify/src/nitro.rs @@ -16,10 +16,10 @@ use pki_types::UnixTime; use crate::error::{ CborParseReason, CertificateParseReason, ChainValidationReason, CoseVerifyReason, - InvalidAttestReason, NoValidAttestationReason, PcrIndexOutOfBoundsReason, - SignatureInvalidReason, VerifyError, + InvalidAttestReason, PcrIndexOutOfBoundsReason, SignatureInvalidReason, VerifyError, }; -use crate::tpm::{parse_quote_attest, verify_ecdsa_p256, verify_pcr_digest_matches, TpmQuoteInfo}; +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}; @@ -30,10 +30,10 @@ 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 (raw bytes, if provided) - pub public_key: Option>, - /// Nonce (raw bytes, if provided) - pub nonce: Option>, + /// 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 TPM attestation with pre-decoded data @@ -62,12 +62,16 @@ pub fn verify_nitro_decoded( // Establish that this document came from AWS before parsing its contents. let (nitro_doc, provider) = verify_nitro_cose_chain(document_bytes, time)?; - // === Phase 2: Verify TPM Quote authenticity === - // The Nitro document binds the AK public key — verify the Quote was - // signed by that key. - let quote_info = verify_tpm_quote_signature(decoded, &nitro_doc)?; + // === 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)?; - // === Phase 3: Cross-verify all authenticated data === + // === Phase 4: Cross-verify Nitro-specific bindings === verify_nitro_bindings(decoded, "e_info, &nitro_doc)?; // Convert nonce to fixed-size array @@ -154,155 +158,44 @@ fn verify_nitro_cose_chain( Ok((nitro_doc, provider)) } -/// Phase 2: Verify the TPM Quote was signed by the AK that the Nitro document binds. -/// -/// Returns the authenticated TpmQuoteInfo. -fn verify_tpm_quote_signature( - decoded: &DecodedAttestationOutput, - nitro_doc: &NitroDocument, -) -> Result { - // The Nitro document's public_key field tells us which AK to trust. - // Verify the claimed AK matches before we use it for signature verification. - let signed_pubkey = nitro_doc - .public_key - .as_ref() - .ok_or(NoValidAttestationReason::MissingPublicKey)?; - - if decoded.ak_pubkey.as_slice() != signed_pubkey.as_slice() { - return Err(SignatureInvalidReason::AkNitroBindingMismatch.into()); - } - - // Parse and verify TPM2_Quote - let quote_info = parse_quote_attest(&decoded.quote_attest)?; - - verify_ecdsa_p256( - &decoded.quote_attest, - &decoded.quote_signature, - &decoded.ak_pubkey, - )?; - - // --- Quote is now authenticated; safe to trust its contents --- - - Ok(quote_info) -} - -/// Phase 3: Cross-verify all authenticated data from the Nitro document and TPM Quote. +/// Cross-verify Nitro-specific bindings after the TPM Quote has been authenticated. /// -/// At this point both the Nitro document (COSE) and TPM Quote (ECDSA) are -/// authenticated. This function verifies they agree on nonce, PCR values, -/// and PCR digest. +/// 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> { - // --- Quote structure enforcement --- - - // Exactly one PCR bank: SHA-384 (0x000C), 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 != 0x000C { - return Err(InvalidAttestReason::WrongPcrAlgorithm { - expected: 0x000C, - 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()); - } - - // --- Nonce verification --- - - if decoded.nonce != quote_info.nonce.as_slice() { - return Err(InvalidAttestReason::NonceMismatch.into()); - } - - let signed_nonce = nitro_doc - .nonce - .as_ref() - .ok_or(NoValidAttestationReason::MissingNonce)?; - - if quote_info.nonce.as_slice() != signed_nonce.as_slice() { + // --- Nonce: COSE document must agree with authenticated Quote --- + if quote_info.nonce.as_slice() != nitro_doc.nonce.as_slice() { return Err(SignatureInvalidReason::NitroNonceMismatch.into()); } - // --- PCR enforcement --- - - // Only SHA-384 PCRs allowed (algorithm ID 1). Any other bank would be - // unverified data passed through to the output. - if decoded.pcrs.is_empty() { - return Err(InvalidAttestReason::MissingSha384Pcrs.into()); - } - - for (alg_id, pcr_idx) in decoded.pcrs.keys() { - if *alg_id != 1 { - return Err(InvalidAttestReason::UnexpectedPcrAlgorithmNitro { - alg_id: *alg_id, - pcr_idx: *pcr_idx, - } - .into()); - } - if *pcr_idx > 23 { - return Err(InvalidAttestReason::PcrIndexOutOfRange { index: *pcr_idx }.into()); - } - } - - // All 24 PCRs must be present — complete, unambiguous state. - for pcr_idx in 0..24u8 { - if !decoded.pcrs.contains_key(&(1, pcr_idx)) { - return Err(InvalidAttestReason::MissingPcr { index: pcr_idx }.into()); - } - } - - // --- Bidirectional PCR match against Nitro-signed values --- - + // --- 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() { - match decoded.pcrs.get(&(1, *idx)) { - Some(claimed_value) if claimed_value == signed_value => {} - Some(_) => { - return Err(SignatureInvalidReason::PcrValueMismatch { index: *idx }.into()); - } - None => { - // Note: This should never happen! But is defense-in-depth - // the 'all 24 PCRs' check guarantees decoded.pcrs contains (1,0) through (1,23) - // extract_cbor_pcrs caps Nitro PCR indices at 23 - return Err( - SignatureInvalidReason::PcrMissingFromAttestation { index: *idx }.into(), - ); - } + let claimed_value = decoded.pcrs.get(*idx as usize); + if claimed_value != signed_value.as_slice() { + return Err(SignatureInvalidReason::PcrValueMismatch { index: *idx }.into()); } } - for (_alg_id, pcr_idx) in decoded.pcrs.keys() { - if !signed_pcrs.contains_key(pcr_idx) { - return Err(InvalidAttestReason::PcrNotSigned { - pcr_index: *pcr_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()); } } - // --- PCR digest: cryptographic binding --- - // COSE signature authenticates the Nitro document (including PCR values). - // ECDSA signature authenticates the TPM Quote (including PCR digest). - // This check proves the PCR digest covers the same values. - verify_pcr_digest_matches(quote_info, &decoded.pcrs)?; - Ok(()) } @@ -317,9 +210,9 @@ fn parse_nitro_document(map: &[(CborValue, CborValue)]) -> 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 -} - /// Extract byte array field from CBOR map fn extract_cbor_byte_array( map: &[(CborValue, CborValue)], @@ -612,30 +488,6 @@ mod tests { )); } - #[test] - fn test_extract_cbor_bytes_optional_present() { - let map = vec![( - CborValue::Text("data".to_string()), - CborValue::Bytes(vec![1, 2, 3]), - )]; - let result = extract_cbor_bytes_optional(&map, "data"); - assert_eq!(result, Some(vec![1, 2, 3])); - } - - #[test] - fn test_extract_cbor_bytes_optional_null() { - let map = vec![(CborValue::Text("data".to_string()), CborValue::Null)]; - let result = extract_cbor_bytes_optional(&map, "data"); - assert_eq!(result, None); - } - - #[test] - fn test_extract_cbor_bytes_optional_missing() { - let map: Vec<(CborValue, CborValue)> = vec![]; - let result = extract_cbor_bytes_optional(&map, "data"); - assert_eq!(result, None); - } - #[test] fn test_extract_cbor_pcrs_valid() { let map = make_test_map(); diff --git a/crates/vaportpm-verify/src/pcr.rs b/crates/vaportpm-verify/src/pcr.rs new file mode 100644 index 0000000..d14ed4d --- /dev/null +++ b/crates/vaportpm-verify/src/pcr.rs @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Validated PCR bank type and supporting types. + +use std::collections::BTreeMap; + +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, "SHA-256"), + PcrAlgorithm::Sha384 => write!(f, "SHA-384"), + } + } +} + +/// 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 a `BTreeMap<(u16, u8), Vec>` keyed by `(tpm_alg_id, pcr_index)`. + /// + /// Validates: single algorithm, all 24 indices present, correct value lengths. + pub fn from_btree_map(pcrs: &BTreeMap<(u16, u8), Vec>) -> Result { + if pcrs.is_empty() { + return Err(InvalidAttestReason::PcrBankEmpty.into()); + } + + // Determine the single algorithm + let first_alg = pcrs.keys().next().unwrap().0; + for (alg_id, _) in pcrs.keys() { + if *alg_id != first_alg { + return Err(InvalidAttestReason::PcrBankMixedAlgorithms.into()); + } + } + + let algorithm = PcrAlgorithm::try_from(first_alg) + .map_err(|alg_id| InvalidAttestReason::UnknownPcrAlgorithm { alg_id })?; + + // Check count + if pcrs.len() != PCR_COUNT { + return Err(InvalidAttestReason::PcrBankWrongCount { + expected: PCR_COUNT, + got: pcrs.len(), + } + .into()); + } + + let alg_key = algorithm as u16; + match algorithm { + PcrAlgorithm::Sha256 => { + let mut values = [[0u8; 32]; PCR_COUNT]; + for idx in 0..PCR_COUNT as u8 { + let value = pcrs + .get(&(alg_key, idx)) + .ok_or(InvalidAttestReason::MissingPcr { index: idx })?; + if value.len() != 32 { + return Err(InvalidAttestReason::PcrValueWrongLength { + index: idx, + expected: 32, + got: value.len(), + } + .into()); + } + values[idx as usize].copy_from_slice(value); + } + Ok(PcrBank::Sha256(values)) + } + PcrAlgorithm::Sha384 => { + let mut values = [[0u8; 48]; PCR_COUNT]; + for idx in 0..PCR_COUNT as u8 { + let value = pcrs + .get(&(alg_key, idx)) + .ok_or(InvalidAttestReason::MissingPcr { index: idx })?; + if value.len() != 48 { + return Err(InvalidAttestReason::PcrValueWrongLength { + index: idx, + expected: 48, + got: value.len(), + } + .into()); + } + values[idx as usize].copy_from_slice(value); + } + Ok(PcrBank::Sha384(values)) + } + } + } + + /// 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 } + } + + /// Convert back to `BTreeMap<(u16, u8), Vec>` keyed by `(tpm_alg_id, pcr_index)`. + pub fn to_btree_map(&self) -> BTreeMap<(u16, u8), Vec> { + let alg_key = self.algorithm() as u16; + let mut map = BTreeMap::new(); + for idx in 0..PCR_COUNT { + map.insert((alg_key, idx as u8), self.get(idx).to_vec()); + } + map + } +} + +/// 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::*; + + fn make_sha256_btree() -> BTreeMap<(u16, u8), Vec> { + let mut m = BTreeMap::new(); + for idx in 0..24u8 { + m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); + } + m + } + + fn make_sha384_btree() -> BTreeMap<(u16, u8), Vec> { + let mut m = BTreeMap::new(); + for idx in 0..24u8 { + m.insert((PcrAlgorithm::Sha384 as u16, idx), vec![idx; 48]); + } + m + } + + #[test] + fn test_from_btree_map_sha256() { + let m = make_sha256_btree(); + let bank = PcrBank::from_btree_map(&m).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_btree_map_sha384() { + let m = make_sha384_btree(); + let bank = PcrBank::from_btree_map(&m).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_empty() { + let m = BTreeMap::new(); + let err = PcrBank::from_btree_map(&m).unwrap_err(); + assert!(matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::PcrBankEmpty) + )); + } + + #[test] + fn test_reject_mixed_algorithms() { + let mut m = BTreeMap::new(); + for idx in 0..23u8 { + m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); + } + m.insert((PcrAlgorithm::Sha384 as u16, 23), vec![23; 48]); // wrong algorithm + let err = PcrBank::from_btree_map(&m).unwrap_err(); + assert!(matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::PcrBankMixedAlgorithms) + )); + } + + #[test] + fn test_reject_wrong_count() { + let mut m = BTreeMap::new(); + for idx in 0..23u8 { + // only 23 + m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); + } + let err = PcrBank::from_btree_map(&m).unwrap_err(); + assert!(matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::PcrBankWrongCount { .. }) + )); + } + + #[test] + fn test_reject_wrong_value_length() { + let mut m = BTreeMap::new(); + for idx in 0..24u8 { + if idx == 5 { + m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 48]); // wrong length for SHA-256 + } else { + m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); + } + } + let err = PcrBank::from_btree_map(&m).unwrap_err(); + assert!(matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::PcrValueWrongLength { + index: 5, + expected: 32, + got: 48 + }) + )); + } + + #[test] + fn test_reject_unknown_algorithm() { + let mut m = BTreeMap::new(); + for idx in 0..24u8 { + m.insert((0x9999, idx), vec![idx; 32]); // unknown TPM algorithm + } + let err = PcrBank::from_btree_map(&m).unwrap_err(); + assert!(matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::UnknownPcrAlgorithm { alg_id: 0x9999 }) + )); + } + + #[test] + fn test_reject_index_out_of_range() { + let mut m = BTreeMap::new(); + for idx in 0..23u8 { + m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); + } + m.insert((PcrAlgorithm::Sha256 as u16, 24), vec![24; 32]); // out of range + // This has 24 entries but index 23 is missing and 24 is present + let err = PcrBank::from_btree_map(&m).unwrap_err(); + assert!(matches!( + err, + VerifyError::InvalidAttest(InvalidAttestReason::MissingPcr { index: 23 }) + )); + } + + #[test] + fn test_to_btree_map_roundtrip() { + let m = make_sha256_btree(); + let bank = PcrBank::from_btree_map(&m).unwrap(); + let m2 = bank.to_btree_map(); + assert_eq!(m, m2); + } + + #[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)); + } + + #[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 index 8dddb8a..0991721 100644 --- a/crates/vaportpm-verify/src/test_support.rs +++ b/crates/vaportpm-verify/src/test_support.rs @@ -15,6 +15,7 @@ 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}; @@ -31,7 +32,7 @@ const TPM_ST_ATTEST_QUOTE: u16 = 0x8018; /// Build a raw TPM2B_ATTEST (Quote) structure. /// -/// `pcr_select`: list of `(alg_u16, bitmap_bytes)` — e.g. `(0x000C, vec![0xFF, 0xFF, 0xFF])`. +/// `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], @@ -76,9 +77,9 @@ pub fn build_tpm_quote_attest( } /// Compute the PCR digest (SHA-256 of concatenated PCR values in index order). -pub fn compute_pcr_digest(pcrs: &BTreeMap<(u8, u8), Vec>) -> Vec { +pub fn compute_pcr_digest(pcrs: &PcrBank) -> Vec { let mut hasher = Sha256::new(); - for ((_alg, _idx), value) in pcrs { + for value in pcrs.values() { hasher.update(value); } hasher.finalize().to_vec() @@ -295,8 +296,8 @@ pub struct GcpChainKeys { pub leaf_der: Vec, /// AK signing key (P-256, PKCS8 DER) — from the leaf cert pub ak_signing_key: Vec, - /// AK public key (SEC1 uncompressed, 65 bytes) - pub ak_pubkey: [u8; 65], + /// AK public key (P-256) + pub ak_pubkey: P256PublicKey, /// Root public key hash (SHA-256) pub root_pubkey_hash: [u8; 32], } @@ -332,8 +333,7 @@ pub fn generate_gcp_chain() -> GcpChainKeys { 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 mut ak_pubkey = [0u8; 65]; - ak_pubkey.copy_from_slice(&ak_pubkey_vec); + let ak_pubkey = P256PublicKey::from_sec1_uncompressed(&ak_pubkey_vec).unwrap(); // Compute root pubkey hash let root_x509 = @@ -362,22 +362,22 @@ pub fn ephemeral_time() -> UnixTime { UnixTime::since_unix_epoch(Duration::from_secs(EPHEMERAL_TIMESTAMP_SECS)) } -/// Build 24 SHA-384 PCR values (for Nitro). Value = vec![idx; 48]. -pub fn make_nitro_pcrs() -> BTreeMap<(u8, u8), Vec> { - let mut pcrs = BTreeMap::new(); +/// Build 24 SHA-384 PCR values (for Nitro). +pub fn make_nitro_pcrs() -> PcrBank { + let mut m = BTreeMap::new(); for idx in 0u8..24 { - pcrs.insert((1, idx), vec![idx; 48]); // alg_id 1 = SHA-384 + m.insert((PcrAlgorithm::Sha384 as u16, idx), vec![idx; 48]); } - pcrs + PcrBank::from_btree_map(&m).unwrap() } -/// Build 24 SHA-256 PCR values (for GCP). Value = vec![idx; 32]. -pub fn make_gcp_pcrs() -> BTreeMap<(u8, u8), Vec> { - let mut pcrs = BTreeMap::new(); +/// Build 24 SHA-256 PCR values (for GCP). +pub fn make_gcp_pcrs() -> PcrBank { + let mut m = BTreeMap::new(); for idx in 0u8..24 { - pcrs.insert((0, idx), vec![idx; 32]); // alg_id 0 = SHA-256 + m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); } - pcrs + PcrBank::from_btree_map(&m).unwrap() } /// Build a complete, cryptographically valid Nitro attestation. @@ -386,7 +386,7 @@ pub fn make_gcp_pcrs() -> BTreeMap<(u8, u8), Vec> { /// The guard must be held alive for the duration of the test. pub fn build_valid_nitro( nonce: &[u8; 32], - pcrs: &BTreeMap<(u8, u8), Vec>, + pcrs: &PcrBank, ) -> (DecodedAttestationOutput, UnixTime, TestRootGuard) { let chain = generate_nitro_chain(); @@ -401,28 +401,28 @@ pub fn build_valid_nitro( 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 mut ak_pubkey = [0u8; 65]; - ak_pubkey.copy_from_slice(ak_point.as_bytes()); + 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![(0x000Cu16, vec![0xFF, 0xFF, 0xFF])]; // SHA-384, all 24 + 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 ((alg, idx), val) in pcrs { - assert_eq!(*alg, 1, "Nitro PCRs must be SHA-384 (alg_id=1)"); - nitro_pcrs.insert(*idx, val.clone()); + 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_pubkey), + Some(&ak_sec1), Some(nonce), &chain.cose_signing_key, ); @@ -444,7 +444,7 @@ pub fn build_valid_nitro( /// Returns `(DecodedAttestationOutput, UnixTime, TestRootGuard)`. pub fn build_valid_gcp( nonce: &[u8; 32], - pcrs: &BTreeMap<(u8, u8), Vec>, + pcrs: &PcrBank, ) -> (DecodedAttestationOutput, UnixTime, TestRootGuard) { let chain = generate_gcp_chain(); @@ -453,7 +453,7 @@ pub fn build_valid_gcp( // Build TPM Quote let pcr_digest = compute_pcr_digest(pcrs); - let pcr_select = vec![(0x000Bu16, vec![0xFF, 0xFF, 0xFF])]; // SHA-256, all 24 + 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); diff --git a/crates/vaportpm-verify/src/tpm.rs b/crates/vaportpm-verify/src/tpm.rs index 88e4cd6..fdec8d7 100644 --- a/crates/vaportpm-verify/src/tpm.rs +++ b/crates/vaportpm-verify/src/tpm.rs @@ -6,12 +6,11 @@ use ecdsa::signature::hazmat::PrehashVerifier; use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256VerifyingKey}; use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; - 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], @@ -49,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)>, @@ -71,7 +71,7 @@ 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) @@ -240,45 +240,15 @@ impl<'a> SafeCursor<'a> { /// Verify that the PCR digest in a Quote matches the claimed PCR values /// -/// The TPM Quote contains a PCR selection (which banks/indices were quoted) -/// and a digest over those PCR values. This function recomputes the digest -/// from the claimed PCR values and compares it to the signed digest. -/// -/// Supports multiple PCR banks: -/// - TPM_ALG_SHA256 (0x000B) → decoded algorithm ID 0 -/// - TPM_ALG_SHA384 (0x000C) → decoded algorithm ID 1 -pub fn verify_pcr_digest_matches( +/// 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: &BTreeMap<(u8, u8), Vec>, + pcrs: &PcrBank, ) -> 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 let mut hasher = Sha256::new(); - - for (alg, bitmap) in "e_info.pcr_select { - let decoded_alg_id = match *alg { - 0x000B => 0u8, // TPM_ALG_SHA256 - 0x000C => 1u8, // TPM_ALG_SHA384 - _ => 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) = pcrs.get(&(decoded_alg_id, pcr_idx)) { - hasher.update(pcr_value); - } else { - return Err(InvalidAttestReason::PcrSelectedButMissing { - pcr_index: pcr_idx, - algorithm: *alg, - } - .into()); - } - } - } - } + for value in pcrs.values() { + hasher.update(value); } let computed_digest = hasher.finalize(); @@ -289,6 +259,67 @@ pub fn verify_pcr_digest_matches( 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 fe25193..96a5dbd 100644 --- a/crates/vaportpm-verify/src/x509.rs +++ b/crates/vaportpm-verify/src/x509.rs @@ -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,7 +150,7 @@ 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; @@ -231,7 +231,7 @@ fn base64_decode(input: &str) -> Result, VerifyError> { /// 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 @@ -241,13 +241,13 @@ pub fn extract_public_key(cert: &Certificate) -> Result, VerifyError> { } /// Compute SHA-256 hash of public key -pub fn hash_public_key(pubkey_bytes: &[u8]) -> [u8; 32] { +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 { +pub(crate) struct ChainValidationResult { /// SHA-256 hash of the root CA's public key pub root_pubkey_hash: [u8; 32], } @@ -281,7 +281,7 @@ 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 { @@ -484,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::*; diff --git a/crates/vaportpm-verify/tests/gcp.rs b/crates/vaportpm-verify/tests/gcp.rs index 031575f..f163d66 100644 --- a/crates/vaportpm-verify/tests/gcp.rs +++ b/crates/vaportpm-verify/tests/gcp.rs @@ -10,11 +10,11 @@ use std::collections::BTreeMap; use std::time::Duration; -use der::Decode; use vaportpm_verify::{ verify_attestation_output, verify_decoded_attestation_output, CertificateParseReason, ChainValidationReason, CloudProvider, DecodedAttestationOutput, DecodedPlatformAttestation, - EccPublicKeyCoords, InvalidAttestReason, SignatureInvalidReason, UnixTime, VerifyError, + EccPublicKeyCoords, InvalidAttestReason, P256PublicKey, PcrAlgorithm, SignatureInvalidReason, + UnixTime, VerifyError, }; use vaportpm_verify::AttestationOutput; @@ -64,7 +64,7 @@ fn test_gcp_amd_fixture_verifies() { hex::decode("8a543108a653b4a1162232744cc9b945017a449dea4fbb0ca62f42d3ef145562").unwrap(); assert_eq!(result.nonce.as_slice(), expected_nonce.as_slice()); - assert!(!result.pcrs.is_empty()); + assert_eq!(result.pcrs.algorithm(), PcrAlgorithm::Sha256); } #[test] @@ -79,7 +79,7 @@ fn test_gcp_tdx_fixture_verifies() { hex::decode("6424632e79ec068f2189adf46d121b9a10f758c45a18c52f630da14600d4317b").unwrap(); assert_eq!(result.nonce.as_slice(), expected_nonce.as_slice()); - assert!(!result.pcrs.is_empty()); + assert_eq!(result.pcrs.algorithm(), PcrAlgorithm::Sha256); } // ============================================================================= @@ -243,20 +243,20 @@ fn test_gcp_reject_tampered_nonce_correct_length() { /// GCP verification explicitly requires SHA-256 PCRs and rejects /// any non-SHA-256 bank. /// -/// Detected at: gcp.rs — non-SHA-256 PCR rejection +/// 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 a SHA-384 entry - // so that decode() doesn't fail on empty PCRs + // Remove the SHA-256 bank entirely, substitute 24 SHA-384 entries + // so that PcrBank::from_btree_map succeeds. GCP then rejects it + // with WrongPcrBankAlgorithm. output.pcrs.remove("sha256"); let mut sha384_pcrs = BTreeMap::new(); - sha384_pcrs.insert( - 0u8, - "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - .to_string(), - ); + 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()); @@ -264,7 +264,10 @@ fn test_gcp_reject_non_sha256_pcrs() { matches!( result, Err(VerifyError::InvalidAttest( - InvalidAttestReason::UnexpectedPcrAlgorithmGcp { .. } + InvalidAttestReason::WrongPcrBankAlgorithm { + expected: PcrAlgorithm::Sha256, + got: PcrAlgorithm::Sha384, + } )) ), "Should reject attestation with non-SHA-256 PCRs, got: {:?}", @@ -278,13 +281,13 @@ fn test_gcp_reject_non_sha256_pcrs() { /// Removing a PCR from the attestation when all 24 are required. /// -/// Detected at: gcp.rs — "all 24 PCRs (0-23) are required" +/// Detected at: PcrBank::from_btree_map — rejects incomplete PCR sets #[test] fn test_gcp_reject_missing_pcr() { let mut output = load_gcp_amd_fixture(); - // Remove PCR 0 — this now hits the "all 24 required" check before - // reaching the Quote-level pcr_select check. + // Remove PCR 0 — hits PcrBankWrongCount at decode time + // (23 entries instead of 24) if let Some(sha256_pcrs) = output.pcrs.get_mut("sha256") { sha256_pcrs.remove(&0u8); } @@ -294,7 +297,10 @@ fn test_gcp_reject_missing_pcr() { matches!( result, Err(VerifyError::InvalidAttest( - InvalidAttestReason::MissingPcr { .. } + InvalidAttestReason::PcrBankWrongCount { + expected: 24, + got: 23 + } )) ), "Should reject when a PCR is missing, got: {:?}", @@ -492,13 +498,13 @@ fn test_gcp_decoded_reject_unknown_root_ca() { // Start from the real fixture (has valid quote_attest, nonce, PCRs) let mut decoded = decode_gcp_amd_fixture(); - // Extract AK public key from the fake leaf cert - let leaf_x509 = - x509_cert::Certificate::from_der(leaf_cert.der()).expect("generated cert should parse"); - let ak_pubkey_vec = vaportpm_verify::extract_public_key(&leaf_x509).unwrap(); - let mut ak_pubkey = [0u8; 65]; - ak_pubkey.copy_from_slice(&ak_pubkey_vec); - decoded.ak_pubkey = ak_pubkey; + // 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 diff --git a/crates/vaportpm-verify/tests/nitro.rs b/crates/vaportpm-verify/tests/nitro.rs index 00a30af..463ad86 100644 --- a/crates/vaportpm-verify/tests/nitro.rs +++ b/crates/vaportpm-verify/tests/nitro.rs @@ -11,7 +11,7 @@ use std::time::Duration; use vaportpm_verify::{ verify_attestation_output, verify_decoded_attestation_output, ChainValidationReason, CloudProvider, DecodedAttestationOutput, DecodedPlatformAttestation, EccPublicKeyCoords, - InvalidAttestReason, UnixTime, VerifyError, + InvalidAttestReason, PcrAlgorithm, UnixTime, VerifyError, }; use vaportpm_verify::AttestationOutput; @@ -49,11 +49,7 @@ fn test_nitro_fixture_verifies() { hex::decode("230af3f7c0ec43ccf99a4cab47ac61469a36ea74b1e79740fdf8ccfc8f56161a").unwrap(); assert_eq!(result.nonce.as_slice(), expected_nonce.as_slice()); - assert!(!result.pcrs.is_empty()); - - // Verify SHA-384 PCRs are present - let has_sha384 = result.pcrs.keys().any(|(alg_id, _)| *alg_id == 1); - assert!(has_sha384, "Should have SHA-384 PCRs"); + assert_eq!(result.pcrs.algorithm(), PcrAlgorithm::Sha384); } // ============================================================================= @@ -142,8 +138,8 @@ fn test_nitro_reject_tampered_nonce_correct_length() { /// Attacker modifies a SHA-384 PCR value. /// -/// Detected at: nitro.rs — claimed PCR values don't match the signed -/// values in the Nitro NSM document. +/// 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(); @@ -157,7 +153,12 @@ fn test_nitro_reject_tampered_pcr_values() { let result = verify_attestation_output(&output, nitro_fixture_time()); assert!( - matches!(result, Err(VerifyError::SignatureInvalid(_))), + matches!( + result, + Err(VerifyError::InvalidAttest( + InvalidAttestReason::PcrDigestMismatch + )) + ), "Should reject tampered PCR values, got: {:?}", result ); @@ -280,19 +281,20 @@ fn test_nitro_reject_cert_expired() { /// Nitro verification explicitly requires SHA-384 PCRs and rejects /// any non-SHA-384 bank. /// -/// Detected at: nitro.rs — non-SHA-384 PCR rejection +/// 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 a SHA-256 entry - // so that decode() doesn't fail on empty PCRs + // Remove the SHA-384 bank entirely, substitute 24 SHA-256 entries + // so that PcrBank::from_btree_map succeeds. Nitro then rejects it + // with WrongPcrBankAlgorithm. output.pcrs.remove("sha384"); let mut sha256_pcrs = BTreeMap::new(); - sha256_pcrs.insert( - 0u8, - "0000000000000000000000000000000000000000000000000000000000000000".to_string(), - ); + 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()); @@ -300,7 +302,10 @@ fn test_nitro_reject_non_sha384_pcrs() { matches!( result, Err(VerifyError::InvalidAttest( - InvalidAttestReason::UnexpectedPcrAlgorithmNitro { .. } + InvalidAttestReason::WrongPcrBankAlgorithm { + expected: PcrAlgorithm::Sha384, + got: PcrAlgorithm::Sha256, + } )) ), "Should reject attestation with non-SHA-384 PCRs, got: {:?}", @@ -309,15 +314,15 @@ fn test_nitro_reject_non_sha384_pcrs() { } /// Attestation has SHA-384 PCRs but also includes SHA-256 PCRs. -/// The Nitro path must reject extra unverified PCR banks — they would -/// pass through to the output as unverified data. +/// PcrBank rejects mixed algorithms at decode time. /// -/// Detected at: nitro.rs — non-SHA-384 PCR rejection +/// Detected at: PcrBank::from_btree_map — 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 + // Add a SHA-256 bank alongside the existing SHA-384 bank. + // PcrBank::from_btree_map rejects mixed algorithms. let mut sha256_pcrs = BTreeMap::new(); sha256_pcrs.insert( 0u8, @@ -330,7 +335,7 @@ fn test_nitro_reject_extra_sha256_pcrs() { matches!( result, Err(VerifyError::InvalidAttest( - InvalidAttestReason::UnexpectedPcrAlgorithmNitro { .. } + InvalidAttestReason::PcrBankMixedAlgorithms )) ), "Should reject attestation with extra SHA-256 PCRs alongside SHA-384, got: {:?}", @@ -380,106 +385,11 @@ fn test_nitro_decoded_reject_corrupted_cose_document() { ); } -/// Empty PCR map at the decoded level. -/// -/// The COSE and ECDSA signatures still pass (unmodified), but Phase 3 -/// rejects the empty PCRs before cross-verification. -/// -/// Covers: nitro.rs — "Missing SHA-384 PCRs - required for Nitro attestation" -#[test] -fn test_nitro_decoded_reject_empty_pcrs() { - let mut decoded = decode_nitro_fixture(); - decoded.pcrs.clear(); - - let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); - assert!( - matches!( - result, - Err(VerifyError::InvalidAttest( - InvalidAttestReason::MissingSha384Pcrs - )) - ), - "Should reject empty PCRs, got: {:?}", - result - ); -} - -/// Decoded PCRs contain a non-SHA-384 entry (alg_id=0 is SHA-256). -/// -/// Covers: nitro.rs — "non-SHA-384 PCR" rejection at decoded level -#[test] -fn test_nitro_decoded_reject_non_sha384_pcr() { - let mut decoded = decode_nitro_fixture(); - - // Replace all PCRs with SHA-256 (alg_id=0) entries - let sha384_values: Vec<(u8, Vec)> = decoded - .pcrs - .iter() - .filter(|((alg, _), _)| *alg == 1) - .map(|((_alg, idx), val)| (*idx, val.clone())) - .collect(); - - decoded.pcrs.clear(); - for (idx, val) in sha384_values { - // Insert as SHA-256 (alg_id=0) instead of SHA-384 (alg_id=1) - decoded.pcrs.insert((0, idx), val); - } - - let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); - assert!( - matches!( - result, - Err(VerifyError::InvalidAttest( - InvalidAttestReason::UnexpectedPcrAlgorithmNitro { .. } - )) - ), - "Should reject non-SHA-384 PCRs at decoded level, got: {:?}", - result - ); -} - -/// Decoded PCRs contain an index above 23. -/// -/// Covers: nitro.rs — "PCR index {} out of range; only PCRs 0-23 are valid" -#[test] -fn test_nitro_decoded_reject_pcr_index_out_of_range() { - let mut decoded = decode_nitro_fixture(); - - // Add a PCR with index 24 (out of range) - decoded.pcrs.insert((1, 24), vec![0x00; 48]); - - let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); - assert!( - matches!( - result, - Err(VerifyError::InvalidAttest( - InvalidAttestReason::PcrIndexOutOfRange { .. } - )) - ), - "Should reject PCR index > 23, got: {:?}", - result - ); -} - -/// Decoded PCRs are missing one of the 24 required SHA-384 entries. -/// -/// Covers: nitro.rs — "Missing SHA-384 PCR {} - all 24 PCRs (0-23) are required" -#[test] -fn test_nitro_decoded_reject_missing_pcr() { - let mut decoded = decode_nitro_fixture(); - - // Remove PCR 5 (arbitrary choice) - decoded.pcrs.remove(&(1, 5)); - - let result = verify_decoded_attestation_output(&decoded, nitro_fixture_time()); - assert!( - matches!( - result, - Err(VerifyError::InvalidAttest( - InvalidAttestReason::MissingPcr { .. } - )) - ), - "Should reject missing SHA-384 PCR, 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_btree_map. diff --git a/experiments/risc-zero/methods/guest/Cargo.toml b/experiments/risc-zero/methods/guest/Cargo.toml index 404c7ab..14ebd8e 100644 --- a/experiments/risc-zero/methods/guest/Cargo.toml +++ b/experiments/risc-zero/methods/guest/Cargo.toml @@ -12,7 +12,6 @@ vaportpm-attest = { path = "../../../../crates/vaportpm-attest" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" hex = "0.4" -serde-big-array = "0.5" sha2 = "0.10" rsa = "=0.9.9" # Force version for risczero patch pki-types = { package = "rustls-pki-types", version = "1.13" } diff --git a/experiments/risc-zero/methods/guest/src/main.rs b/experiments/risc-zero/methods/guest/src/main.rs index 7e4449e..d1567dc 100644 --- a/experiments/risc-zero/methods/guest/src/main.rs +++ b/experiments/risc-zero/methods/guest/src/main.rs @@ -3,12 +3,10 @@ use pki_types::UnixTime; use risc0_zkvm::guest::env; use serde::{Deserialize, Serialize}; -use serde_big_array::BigArray; use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; use std::io::Read; use std::time::Duration; -use vaportpm_verify::{flat, verify_decoded_attestation_output, CloudProvider}; +use vaportpm_verify::{flat, verify_decoded_attestation_output, CloudProvider, P256PublicKey, PcrBank}; risc0_zkvm::guest::entry!(main); @@ -16,8 +14,7 @@ risc0_zkvm::guest::entry!(main); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ZkPublicInputs { pub pcr_hash: [u8; 32], - #[serde(with = "BigArray")] - pub ak_pubkey: [u8; 65], + pub ak_pubkey: P256PublicKey, pub nonce: [u8; 32], pub provider: u8, pub verified_at: u64, @@ -45,7 +42,7 @@ fn main() { 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(&decoded.pcrs); + let pcr_hash = compute_pcr_hash(&decoded.pcrs); // Map provider to u8 (root hash already verified against known roots) let provider = match result.provider { @@ -65,28 +62,19 @@ fn main() { env::commit(&public_inputs); } -/// Compute canonical PCR hash from pre-decoded binary PCR data +/// Compute canonical PCR hash from a validated PcrBank /// -/// Canonicalization: sort by algorithm ID, then by PCR index -fn compute_pcr_hash_decoded(pcrs: &BTreeMap<(u8, u8), Vec>) -> [u8; 32] { +/// 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(); - // Group PCRs by algorithm - let mut by_alg: BTreeMap>> = BTreeMap::new(); - for ((alg_id, idx), value) in pcrs { - by_alg.entry(*alg_id).or_default().insert(*idx, value); - } - - // Process in algorithm order (0=sha256, 1=sha384) - for (alg_id, pcr_map) in &by_alg { - // Add algorithm ID and PCR count (2 bytes total) - hasher.update(&[*alg_id, pcr_map.len() as u8]); + let alg_u16 = pcrs.algorithm() as u16; + hasher.update(alg_u16.to_le_bytes()); + hasher.update([24u8]); - // BTreeMap is already sorted by key - for (idx, value_bytes) in pcr_map { - hasher.update(&[*idx]); - hasher.update(*value_bytes); - } + for (idx, value) in pcrs.values().enumerate() { + hasher.update([idx as u8]); + hasher.update(value); } hasher.finalize().into() diff --git a/experiments/risc-zero/tests/cycle_count.rs b/experiments/risc-zero/tests/cycle_count.rs index 28ff831..4a39bb5 100644 --- a/experiments/risc-zero/tests/cycle_count.rs +++ b/experiments/risc-zero/tests/cycle_count.rs @@ -121,6 +121,6 @@ fn test_data_size_comparison() { } let pcr_bytes: usize = decoded.pcrs.values().map(|v| v.len()).sum(); - println!(" pcrs: {} entries, {} total bytes", decoded.pcrs.len(), pcr_bytes); + println!(" pcrs: {} entries, {} total bytes", vaportpm_verify::PCR_COUNT, pcr_bytes); println!(); } From edb8489636f101953424d9d138101a4d010f52ad Mon Sep 17 00:00:00 2001 From: user <303926+HarryR@users.noreply.github.com> Date: Sat, 7 Feb 2026 00:03:04 +0800 Subject: [PATCH 08/10] Removed unnecessary usage of btree & sorting for intermediate formats --- crates/vaportpm-verify/src/bin/verify.rs | 7 +- crates/vaportpm-verify/src/flat.rs | 61 ++--- crates/vaportpm-verify/src/lib.rs | 21 +- crates/vaportpm-verify/src/pcr.rs | 252 ++++++++------------- crates/vaportpm-verify/src/test_support.rs | 12 +- crates/vaportpm-verify/tests/gcp.rs | 12 +- crates/vaportpm-verify/tests/nitro.rs | 8 +- 7 files changed, 143 insertions(+), 230 deletions(-) diff --git a/crates/vaportpm-verify/src/bin/verify.rs b/crates/vaportpm-verify/src/bin/verify.rs index 294236b..57d93ca 100644 --- a/crates/vaportpm-verify/src/bin/verify.rs +++ b/crates/vaportpm-verify/src/bin/verify.rs @@ -24,18 +24,13 @@ struct VerificationResultJson { impl From for VerificationResultJson { fn from(result: VerificationResult) -> Self { - let alg_name = match result.pcrs.algorithm() { - vaportpm_verify::PcrAlgorithm::Sha256 => "sha256", - vaportpm_verify::PcrAlgorithm::Sha384 => "sha384", - }; - 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(alg_name.to_string(), pcr_map); + pcrs.insert(result.pcrs.algorithm().to_string(), pcr_map); VerificationResultJson { nonce: hex::encode(result.nonce), diff --git a/crates/vaportpm-verify/src/flat.rs b/crates/vaportpm-verify/src/flat.rs index 8cd3dc7..b0b8dbe 100644 --- a/crates/vaportpm-verify/src/flat.rs +++ b/crates/vaportpm-verify/src/flat.rs @@ -3,13 +3,14 @@ //! 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()`. - -use std::collections::BTreeMap; +//! +//! 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, PcrBank}; +use crate::pcr::{P256PublicKey, PcrAlgorithm, PcrBank, PCR_COUNT}; use crate::{DecodedAttestationOutput, DecodedPlatformAttestation, VerifyError}; /// Platform type constants @@ -25,7 +26,8 @@ pub struct FlatHeader { pub platform_type: u8, pub quote_attest_len: u16, pub quote_signature_len: u16, - pub pcr_count: u8, + /// TPM algorithm ID (0x000B = SHA-256, 0x000C = SHA-384) + pub pcr_algorithm: u16, pub platform_data_len: u16, } @@ -55,8 +57,9 @@ pub fn to_bytes(decoded: &DecodedAttestationOutput) -> Vec { DecodedPlatformAttestation::Nitro { document } => document.clone(), }; - let alg_u16 = decoded.pcrs.algorithm() as u16; - let digest_len = decoded.pcrs.algorithm().digest_len(); + 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, @@ -64,20 +67,17 @@ pub fn to_bytes(decoded: &DecodedAttestationOutput) -> Vec { platform_type, quote_attest_len: decoded.quote_attest.len() as u16, quote_signature_len: decoded.quote_signature.len() as u16, - pcr_count: crate::pcr::PCR_COUNT as u8, + pcr_algorithm: algorithm as u16, platform_data_len: platform_data.len() as u16, }; - let mut buf = Vec::with_capacity(HEADER_SIZE + 2048 + platform_data.len()); + 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: [alg_u16(2 bytes LE), pcr_idx, len, value...] - for (idx, value) in decoded.pcrs.values().enumerate() { - buf.extend_from_slice(&alg_u16.to_le_bytes()); - buf.push(idx as u8); - buf.push(digest_len as u8); + // Write PCRs: 24 contiguous values in index order, no per-entry headers + for value in decoded.pcrs.values() { buf.extend_from_slice(value); } @@ -101,40 +101,26 @@ pub fn from_bytes(data: &[u8]) -> Result .into()); } - // Zero-copy header parsing! + // 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 pcr_count = header.pcr_count as usize; let platform_data_len = header.platform_data_len as usize; - let mut offset = HEADER_SIZE; + 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(); - // Parse PCRs: [alg_u16(2 bytes LE), pcr_idx, len, value...] - let mut pcrs = BTreeMap::new(); - for _ in 0..pcr_count { - if offset + 4 > data.len() { - return Err(InvalidAttestReason::FlatTruncated { - field: "PCR header", - } - .into()); - } - let alg_u16 = u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap()); - let pcr_idx = data[offset + 2]; - let value_len = data[offset + 3] as usize; - offset += 4; + let mut offset = HEADER_SIZE; - if offset + value_len > data.len() { - return Err(InvalidAttestReason::FlatTruncated { field: "PCR value" }.into()); - } - pcrs.insert( - (alg_u16, pcr_idx), - data[offset..offset + value_len].to_vec(), - ); - offset += value_len; + // 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() { @@ -212,7 +198,6 @@ pub fn from_bytes(data: &[u8]) -> Result } }; - let pcr_bank = PcrBank::from_btree_map(&pcrs)?; let ak_pubkey = P256PublicKey::from_sec1_uncompressed(&header.ak_pubkey)?; Ok(DecodedAttestationOutput { diff --git a/crates/vaportpm-verify/src/lib.rs b/crates/vaportpm-verify/src/lib.rs index 2a191ac..d46c159 100644 --- a/crates/vaportpm-verify/src/lib.rs +++ b/crates/vaportpm-verify/src/lib.rs @@ -16,8 +16,6 @@ pub mod pcr; mod tpm; mod x509; -use std::collections::BTreeMap; - use serde::Serialize; use x509::parse_cert_chain_pem; @@ -202,19 +200,24 @@ impl DecodedAttestationOutput { let quote_attest = hex::decode(&tpm.attest_data)?; let quote_signature = hex::decode(&tpm.signature)?; - let mut pcrs_raw = BTreeMap::new(); + let mut pcr_entries = Vec::new(); + let mut algorithm = None; for (alg_name, pcr_map) in &output.pcrs { - let alg_id = match alg_name.as_str() { - "sha256" => PcrAlgorithm::Sha256 as u16, - "sha384" => PcrAlgorithm::Sha384 as u16, + 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 { - let value = hex::decode(hex_value)?; - pcrs_raw.insert((alg_id, *idx), value); + pcr_entries.push((*idx, hex::decode(hex_value)?)); } } - let pcrs = PcrBank::from_btree_map(&pcrs_raw)?; + 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)?; diff --git a/crates/vaportpm-verify/src/pcr.rs b/crates/vaportpm-verify/src/pcr.rs index d14ed4d..ded46f0 100644 --- a/crates/vaportpm-verify/src/pcr.rs +++ b/crates/vaportpm-verify/src/pcr.rs @@ -2,8 +2,6 @@ //! Validated PCR bank type and supporting types. -use std::collections::BTreeMap; - use crate::error::{InvalidAttestReason, VerifyError}; /// Number of PCRs in a complete bank. @@ -43,8 +41,8 @@ impl TryFrom for PcrAlgorithm { impl std::fmt::Display for PcrAlgorithm { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PcrAlgorithm::Sha256 => write!(f, "SHA-256"), - PcrAlgorithm::Sha384 => write!(f, "SHA-384"), + PcrAlgorithm::Sha256 => write!(f, "sha256"), + PcrAlgorithm::Sha384 => write!(f, "sha384"), } } } @@ -63,71 +61,87 @@ pub enum PcrBank { } impl PcrBank { - /// Construct from a `BTreeMap<(u16, u8), Vec>` keyed by `(tpm_alg_id, pcr_index)`. + /// Construct from an algorithm and indexed PCR values. /// - /// Validates: single algorithm, all 24 indices present, correct value lengths. - pub fn from_btree_map(pcrs: &BTreeMap<(u16, u8), Vec>) -> Result { - if pcrs.is_empty() { - return Err(InvalidAttestReason::PcrBankEmpty.into()); + /// 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; + } } - - // Determine the single algorithm - let first_alg = pcrs.keys().next().unwrap().0; - for (alg_id, _) in pcrs.keys() { - if *alg_id != first_alg { - return Err(InvalidAttestReason::PcrBankMixedAlgorithms.into()); + for idx in 0..PCR_COUNT as u8 { + if !seen[idx as usize] { + return Err(InvalidAttestReason::MissingPcr { index: idx }.into()); } } - let algorithm = PcrAlgorithm::try_from(first_alg) - .map_err(|alg_id| InvalidAttestReason::UnknownPcrAlgorithm { alg_id })?; + // 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)) + } + } + } - // Check count - if pcrs.len() != PCR_COUNT { - return Err(InvalidAttestReason::PcrBankWrongCount { - expected: PCR_COUNT, - got: pcrs.len(), + /// 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()); } - - let alg_key = algorithm as u16; match algorithm { PcrAlgorithm::Sha256 => { - let mut values = [[0u8; 32]; PCR_COUNT]; - for idx in 0..PCR_COUNT as u8 { - let value = pcrs - .get(&(alg_key, idx)) - .ok_or(InvalidAttestReason::MissingPcr { index: idx })?; - if value.len() != 32 { - return Err(InvalidAttestReason::PcrValueWrongLength { - index: idx, - expected: 32, - got: value.len(), - } - .into()); - } - values[idx as usize].copy_from_slice(value); + 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(values)) + Ok(PcrBank::Sha256(bank)) } PcrAlgorithm::Sha384 => { - let mut values = [[0u8; 48]; PCR_COUNT]; - for idx in 0..PCR_COUNT as u8 { - let value = pcrs - .get(&(alg_key, idx)) - .ok_or(InvalidAttestReason::MissingPcr { index: idx })?; - if value.len() != 48 { - return Err(InvalidAttestReason::PcrValueWrongLength { - index: idx, - expected: 48, - got: value.len(), - } - .into()); - } - values[idx as usize].copy_from_slice(value); + 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(values)) + Ok(PcrBank::Sha384(bank)) } } } @@ -152,16 +166,6 @@ impl PcrBank { pub fn values(&self) -> PcrIter<'_> { PcrIter { bank: self, idx: 0 } } - - /// Convert back to `BTreeMap<(u16, u8), Vec>` keyed by `(tpm_alg_id, pcr_index)`. - pub fn to_btree_map(&self) -> BTreeMap<(u16, u8), Vec> { - let alg_key = self.algorithm() as u16; - let mut map = BTreeMap::new(); - for idx in 0..PCR_COUNT { - map.insert((alg_key, idx as u8), self.get(idx).to_vec()); - } - map - } } /// Iterator over PCR values as `&[u8]` slices. @@ -236,26 +240,10 @@ impl P256PublicKey { mod tests { use super::*; - fn make_sha256_btree() -> BTreeMap<(u16, u8), Vec> { - let mut m = BTreeMap::new(); - for idx in 0..24u8 { - m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); - } - m - } - - fn make_sha384_btree() -> BTreeMap<(u16, u8), Vec> { - let mut m = BTreeMap::new(); - for idx in 0..24u8 { - m.insert((PcrAlgorithm::Sha384 as u16, idx), vec![idx; 48]); - } - m - } - #[test] - fn test_from_btree_map_sha256() { - let m = make_sha256_btree(); - let bank = PcrBank::from_btree_map(&m).unwrap(); + 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]); @@ -263,63 +251,37 @@ mod tests { } #[test] - fn test_from_btree_map_sha384() { - let m = make_sha384_btree(); - let bank = PcrBank::from_btree_map(&m).unwrap(); + 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_empty() { - let m = BTreeMap::new(); - let err = PcrBank::from_btree_map(&m).unwrap_err(); + 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::PcrBankEmpty) - )); - } - - #[test] - fn test_reject_mixed_algorithms() { - let mut m = BTreeMap::new(); - for idx in 0..23u8 { - m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); - } - m.insert((PcrAlgorithm::Sha384 as u16, 23), vec![23; 48]); // wrong algorithm - let err = PcrBank::from_btree_map(&m).unwrap_err(); - assert!(matches!( - err, - VerifyError::InvalidAttest(InvalidAttestReason::PcrBankMixedAlgorithms) - )); - } - - #[test] - fn test_reject_wrong_count() { - let mut m = BTreeMap::new(); - for idx in 0..23u8 { - // only 23 - m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); - } - let err = PcrBank::from_btree_map(&m).unwrap_err(); - assert!(matches!( - err, - VerifyError::InvalidAttest(InvalidAttestReason::PcrBankWrongCount { .. }) + VerifyError::InvalidAttest(InvalidAttestReason::MissingPcr { index: 23 }) )); } #[test] fn test_reject_wrong_value_length() { - let mut m = BTreeMap::new(); - for idx in 0..24u8 { - if idx == 5 { - m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 48]); // wrong length for SHA-256 - } else { - m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); - } - } - let err = PcrBank::from_btree_map(&m).unwrap_err(); + 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 { @@ -330,42 +292,20 @@ mod tests { )); } - #[test] - fn test_reject_unknown_algorithm() { - let mut m = BTreeMap::new(); - for idx in 0..24u8 { - m.insert((0x9999, idx), vec![idx; 32]); // unknown TPM algorithm - } - let err = PcrBank::from_btree_map(&m).unwrap_err(); - assert!(matches!( - err, - VerifyError::InvalidAttest(InvalidAttestReason::UnknownPcrAlgorithm { alg_id: 0x9999 }) - )); - } - #[test] fn test_reject_index_out_of_range() { - let mut m = BTreeMap::new(); - for idx in 0..23u8 { - m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); - } - m.insert((PcrAlgorithm::Sha256 as u16, 24), vec![24; 32]); // out of range - // This has 24 entries but index 23 is missing and 24 is present - let err = PcrBank::from_btree_map(&m).unwrap_err(); + // 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_to_btree_map_roundtrip() { - let m = make_sha256_btree(); - let bank = PcrBank::from_btree_map(&m).unwrap(); - let m2 = bank.to_btree_map(); - assert_eq!(m, m2); - } - #[test] fn test_pcr_algorithm_properties() { assert_eq!(PcrAlgorithm::Sha256 as u16, 0x000B); @@ -375,6 +315,8 @@ mod tests { 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] diff --git a/crates/vaportpm-verify/src/test_support.rs b/crates/vaportpm-verify/src/test_support.rs index 0991721..f38bdfd 100644 --- a/crates/vaportpm-verify/src/test_support.rs +++ b/crates/vaportpm-verify/src/test_support.rs @@ -364,20 +364,12 @@ pub fn ephemeral_time() -> UnixTime { /// Build 24 SHA-384 PCR values (for Nitro). pub fn make_nitro_pcrs() -> PcrBank { - let mut m = BTreeMap::new(); - for idx in 0u8..24 { - m.insert((PcrAlgorithm::Sha384 as u16, idx), vec![idx; 48]); - } - PcrBank::from_btree_map(&m).unwrap() + 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 { - let mut m = BTreeMap::new(); - for idx in 0u8..24 { - m.insert((PcrAlgorithm::Sha256 as u16, idx), vec![idx; 32]); - } - PcrBank::from_btree_map(&m).unwrap() + PcrBank::from_values(PcrAlgorithm::Sha256, (0u8..24).map(|i| (i, vec![i; 32]))).unwrap() } /// Build a complete, cryptographically valid Nitro attestation. diff --git a/crates/vaportpm-verify/tests/gcp.rs b/crates/vaportpm-verify/tests/gcp.rs index f163d66..ff41d61 100644 --- a/crates/vaportpm-verify/tests/gcp.rs +++ b/crates/vaportpm-verify/tests/gcp.rs @@ -249,7 +249,7 @@ 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_btree_map succeeds. GCP then rejects it + // so that PcrBank::from_values succeeds. GCP then rejects it // with WrongPcrBankAlgorithm. output.pcrs.remove("sha256"); let mut sha384_pcrs = BTreeMap::new(); @@ -281,13 +281,12 @@ fn test_gcp_reject_non_sha256_pcrs() { /// Removing a PCR from the attestation when all 24 are required. /// -/// Detected at: PcrBank::from_btree_map — rejects incomplete PCR sets +/// 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 PcrBankWrongCount at decode time - // (23 entries instead of 24) + // Remove PCR 0 — hits MissingPcr at decode time if let Some(sha256_pcrs) = output.pcrs.get_mut("sha256") { sha256_pcrs.remove(&0u8); } @@ -297,10 +296,7 @@ fn test_gcp_reject_missing_pcr() { matches!( result, Err(VerifyError::InvalidAttest( - InvalidAttestReason::PcrBankWrongCount { - expected: 24, - got: 23 - } + InvalidAttestReason::MissingPcr { index: 0 } )) ), "Should reject when a PCR is missing, got: {:?}", diff --git a/crates/vaportpm-verify/tests/nitro.rs b/crates/vaportpm-verify/tests/nitro.rs index 463ad86..b8dae1f 100644 --- a/crates/vaportpm-verify/tests/nitro.rs +++ b/crates/vaportpm-verify/tests/nitro.rs @@ -287,7 +287,7 @@ 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_btree_map succeeds. Nitro then rejects it + // so that PcrBank::from_values succeeds. Nitro then rejects it // with WrongPcrBankAlgorithm. output.pcrs.remove("sha384"); let mut sha256_pcrs = BTreeMap::new(); @@ -316,13 +316,13 @@ fn test_nitro_reject_non_sha384_pcrs() { /// Attestation has SHA-384 PCRs but also includes SHA-256 PCRs. /// PcrBank rejects mixed algorithms at decode time. /// -/// Detected at: PcrBank::from_btree_map — PcrBankMixedAlgorithms +/// 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_btree_map rejects mixed algorithms. + // PcrBank::from_values rejects mixed algorithms. let mut sha256_pcrs = BTreeMap::new(); sha256_pcrs.insert( 0u8, @@ -392,4 +392,4 @@ fn test_nitro_decoded_reject_corrupted_cose_document() { // - 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_btree_map. +// These invariants are now tested in pcr.rs unit tests via PcrBank::from_values. From e3f2815993300200f9a93a30fdfc52c8884ea31d Mon Sep 17 00:00:00 2001 From: user <303926+HarryR@users.noreply.github.com> Date: Sat, 7 Feb 2026 00:22:59 +0800 Subject: [PATCH 09/10] Added flat encoding tests, updated minor discreps. in docs --- .gitignore | 3 +- README.md | 4 +- crates/vaportpm-attest/AWS-NITRO.md | 2 +- crates/vaportpm-verify/README.md | 34 +- crates/vaportpm-verify/src/flat_tests.rs | 510 +++++++++++++++++++++++ crates/vaportpm-verify/src/lib.rs | 8 +- 6 files changed, 537 insertions(+), 24 deletions(-) create mode 100644 crates/vaportpm-verify/src/flat_tests.rs diff --git a/.gitignore b/.gitignore index 46b3967..7881bf3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ Cargo.lock .bash_history .risc0 .bashrc -/go \ No newline at end of file +/go +old 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-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 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/lib.rs b/crates/vaportpm-verify/src/lib.rs index d46c159..1094a33 100644 --- a/crates/vaportpm-verify/src/lib.rs +++ b/crates/vaportpm-verify/src/lib.rs @@ -297,9 +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 +/// - `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. @@ -329,6 +329,8 @@ pub fn verify_attestation_json(json: &str) -> Result Date: Sat, 7 Feb 2026 00:52:04 +0800 Subject: [PATCH 10/10] Updated experiments doc --- experiments/risc-zero/Makefile | 6 ++- experiments/risc-zero/README.md | 85 ++++++++++++++++++--------------- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/experiments/risc-zero/Makefile b/experiments/risc-zero/Makefile index 998ea0b..5afa3e5 100644 --- a/experiments/risc-zero/Makefile +++ b/experiments/risc-zero/Makefile @@ -11,8 +11,10 @@ test: # Cycle count specifically cycles: - RISC0_PPROF_OUT=./profile.pb cargo test --release test_gcp_attestation_cycle_count -- --nocapture && ~/go/bin/go tool pprof -text profile.pb - #RISC0_PPROF_OUT=./profile.pb cargo test --release test_nitro_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_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 diff --git a/experiments/risc-zero/README.md b/experiments/risc-zero/README.md index 68dccf0..ddf10d3 100644 --- a/experiments/risc-zero/README.md +++ b/experiments/risc-zero/README.md @@ -1,6 +1,6 @@ # RISC Zero ZK Verification Experiment -This experiment runs `verify_attestation_output` inside RISC Zero zkVM to measure cycle counts and understand complexity. +This experiment runs `verify_decoded_attestation_output` inside RISC Zero zkVM to measure cycle counts and understand complexity. ## Prerequisites @@ -35,26 +35,36 @@ RISC0_DEV_MODE=1 cargo test -- --nocapture ### Expected Output ``` -=== GCP Attestation Verification === -Total cycles: 1998559 -Segments: 3 - -=== Nitro Attestation Verification === -Total cycles: 5027644 -Segments: 6 +=== 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: +The ZK circuit commits the following public inputs to the journal: -| Field | Size | Description | +| Field | Type | Description | |-------|------|-------------| -| `pcr_hash` | 32 bytes | SHA256 of canonically-serialized PCRs | -| `ak_pubkey` | 65 bytes | P-256 uncompressed: `0x04 \|\| x \|\| y` | -| `nonce` | 32 bytes | Freshness nonce | -| `provider` | 1 byte | 0 = AWS, 1 = GCP | -| `root_pubkey_hash` | 32 bytes | SHA256 of root CA public key | +| `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 @@ -68,7 +78,8 @@ experiments/risc-zero/ │ ├── host.rs # Host utilities │ └── inputs.rs # ZkPublicInputs type ├── tests/ -│ └── cycle_count.rs # Integration tests +│ ├── cycle_count.rs # Integration tests (cycle measurement) +│ └── ec_benchmarks.rs # EC operation benchmarks └── methods/ ├── Cargo.toml # Methods crate ├── build.rs # Embeds guest ELF @@ -80,17 +91,18 @@ experiments/risc-zero/ ## How It Works -1. The **guest program** (`methods/guest/src/main.rs`) runs inside the zkVM: - - Reads attestation JSON and timestamp from host - - Calls `verify_attestation_output()` (same verification as native) - - Computes canonical PCR hash - - Commits public inputs to the journal - -2. The **host** (`tests/cycle_count.rs`) provides inputs and measures cycles: +1. The **host** (`tests/cycle_count.rs`) prepares inputs and measures cycles: - Loads test fixtures (GCP AMD and Nitro attestations) - - Builds executor environment with inputs - - Runs guest in dev mode (no real proofs) - - Reports cycle counts per segment + - 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 @@ -114,18 +126,15 @@ AWS Nitro uses **P-384 ECDSA** exclusively for its certificate chain. This exper 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 ~2.5x slower than GCP +### Why Nitro is ~4.7x slower than GCP -Nitro attestation requires **5 P-384 ECDSA verifications**: -- 1 COSE signature verification (attestation document) -- 4 certificate chain verifications (leaf → instance → zonal → regional → root) +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) -Each P-384 verification costs ~400-500k cycles. The breakdown from profiling: -- P-384 EC scalar multiplication: ~30% of total cycles -- SHA-512 (used by SHA-384): ~15% of total cycles -- Bigint operations: ~29% of total cycles +GCP uses RSA-4096 (cheap with dedicated precompile) and a single P-256 ECDSA verification (~250k cycles), keeping the total well under 1M cycles. -GCP uses RSA-4096 (which has a dedicated precompile) and P-256, requiring fewer expensive operations. +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 @@ -133,12 +142,12 @@ GCP uses RSA-4096 (which has a dedicated precompile) and P-256, requiring fewer - 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 ~2M cycles -- Nitro verification is viable at ~5M cycles with P-384 acceleration (pending upstream merge) +- 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. After cloning, initialize the submodule: +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