From 930f45273d086d8a1d09fbaf6e03df69583e7e35 Mon Sep 17 00:00:00 2001 From: HarryR <303926+HarryR@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:52:18 +0000 Subject: [PATCH] vaportpm-attest: no_std attestation surface; verify: auto-detect captured input Make the attestation surface (quote + cert chain + unified JSON) no_std-capable so one payload can attest on every cloud, including from inside a UEFI bootloader. vaportpm-attest: - attest_with(tpm, nonce, fetcher) is the no_std core; the HTTP certificate fetcher is decoupled behind an injected CertFetcher trait. The std attest() (opens /dev/tpm0 + StdHttpFetcher) stays behind the http-fetch feature. - cert/a9n/nsm/roots lifted to no_std (alloc, BTreeMap, core::str). The AWS Nitro NSM document is a TPM vendor command, so it stays in the no_std `attest` surface, not std. - Features: attest (no_std surface), http-fetch (std fetcher, default), std. - Workspace deps set default-features = false at the workspace level; -attest and -verify opt back into std where each needs it. vaportpm-verify: - normalize_attestation_input auto-detects the captured form -- bare JSON, a base64 blob, or either wrapped in ===ATTESTATION=== markers with EC2/Nitro serial-console timestamps interleaved -- so a raw console capture verifies directly. New InputDecode error for malformed input. 138 workspace tests pass; clippy -D warnings clean; vaportpm-attest builds for x86_64-unknown-uefi with --no-default-features --features attest. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 29 +++++---- crates/vaportpm-attest/Cargo.toml | 96 ++++++++++++++++++----------- crates/vaportpm-attest/src/a9n.rs | 57 ++++++++++------- crates/vaportpm-attest/src/cert.rs | 42 ++++++++++--- crates/vaportpm-attest/src/lib.rs | 31 +++++++--- crates/vaportpm-attest/src/nsm.rs | 2 + crates/vaportpm-attest/src/roots.rs | 27 ++++---- crates/vaportpm-verify/Cargo.toml | 21 ++++--- crates/vaportpm-verify/src/error.rs | 3 + crates/vaportpm-verify/src/lib.rs | 66 ++++++++++++++++++-- 10 files changed, 258 insertions(+), 116 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1543155..692d4e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,29 +6,34 @@ resolver = "2" license = "MIT OR Apache-2.0" [workspace.dependencies] -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -serde_bytes = "0.11" +# Deps reused by vaportpm-attest's no_std/UEFI build carry default-features = false +# HERE, at the workspace level -- a member cannot override default-features when +# inheriting (Cargo ignores it), so it must be set once at the source. `alloc` (the +# no_std base each needs) is included; each crate layers `std` on via its own features +# (attest's `std`, verify directly), keeping versions consistent across both crates. +serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } +serde_json = { version = "1.0", default-features = false, features = ["alloc"] } +serde_bytes = { version = "0.11", default-features = false, features = ["alloc"] } serde-big-array = "0.5" zerocopy = { version = "0.8", features = ["derive"] } -base64 = "0.22" -sha1 = "0.10" -sha2 = "0.10" -hmac = "0.12" -anyhow = "1.0" +base64 = { version = "0.22", default-features = false, features = ["alloc"] } +sha1 = { version = "0.10", default-features = false } +sha2 = { version = "0.10", default-features = false } +hmac = { version = "0.12", default-features = false } +anyhow = { version = "1.0", default-features = false } thiserror = "1.0" -hex = "0.4" +hex = { version = "0.4", default-features = false, features = ["alloc"] } # X.509 and crypto for verification -der = { version = "0.7", features = ["alloc", "pem", "oid"] } +der = { version = "0.7", default-features = false, features = ["alloc", "pem", "oid"] } spki = { version = "0.7", features = ["alloc"] } -x509-cert = { version = "0.2", features = ["pem"] } +x509-cert = { version = "0.2", default-features = false, features = ["pem"] } p256 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } p384 = { version = "0.13", features = ["ecdsa", "pem", "pkcs8"] } ecdsa = { version = "0.16", features = ["verifying", "der"] } rsa = { version = "0.9", features = ["sha2"] } coset = "0.3" -ciborium = "0.2" +ciborium = { version = "0.2", default-features = false } # Time types for certificate validation pki-types = { package = "rustls-pki-types", version = "=1.13.0", default-features = false, features = ["std"] } diff --git a/crates/vaportpm-attest/Cargo.toml b/crates/vaportpm-attest/Cargo.toml index 3811791..434a57e 100644 --- a/crates/vaportpm-attest/Cargo.toml +++ b/crates/vaportpm-attest/Cargo.toml @@ -1,61 +1,84 @@ [package] name = "vaportpm-attest" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "Cloud vTPM attestation - minimal TPM 2.0 implementation without C dependencies" license.workspace = true -# no_std-capable core dependencies (always compiled). -# Declared directly (not via workspace) so default-features can be disabled, -# which is required for the no_std/UEFI build. +# All deps are inherited from the workspace, which pins versions and sets +# default-features = false for the no_std/UEFI build (a member cannot override +# default-features when inheriting). std/alloc are layered on via the features below. [dependencies] -anyhow = { version = "1.0", default-features = false } -sha1 = { version = "0.10", default-features = false } -sha2 = { version = "0.10", default-features = false } -hmac = { version = "0.12", default-features = false } -hex = { version = "0.4", default-features = false, features = ["alloc"] } +# Core TPM 2.0 (always compiled). +anyhow = { workspace = true } +sha1 = { workspace = true } +sha2 = { workspace = true } +hmac = { workspace = true } +hex = { workspace = true } -# std-only dependencies (attestation/quote/cert paths) — gated behind the `std` feature -thiserror = { workspace = true, optional = true } -base64 = { workspace = true, optional = true } +# Attestation surface (quote + cert-chain assembly + unified-JSON output), enabled by +# `attest`. Inherits each crate's no_std + alloc base from the workspace. serde = { workspace = true, optional = true } -ciborium = { workspace = true, optional = true } -serde_bytes = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } - -# X.509 certificate parsing (std-only path) +serde_bytes = { workspace = true, optional = true } +base64 = { workspace = true, optional = true } der = { workspace = true, optional = true } x509-cert = { workspace = true, optional = true } +# CBOR for the AWS Nitro NSM attestation document (reached via a TPM vendor command, +# so no_std-capable -- part of `attest`, NOT `std`). +ciborium = { workspace = true, optional = true } + +# std-only error derive (CLI / device paths). +thiserror = { workspace = true, optional = true } -# On UEFI (soft-float, no SSE) force the portable software hash backends. -# The optimized SIMD/asm paths emit 128-bit vector ops that LLVM cannot lower -# for x86_64-unknown-uefi. Cargo unions these features with the base deps, so -# the Linux build keeps its native fast path. +# On UEFI (soft-float, no SSE) force the portable software hash backends. The +# optimized SIMD/asm paths emit 128-bit vector ops LLVM cannot lower for +# x86_64-unknown-uefi. Features union with the base deps, so Linux keeps its fast path. [target.'cfg(target_os = "uefi")'.dependencies] -sha1 = { version = "0.10", default-features = false, features = ["force-soft"] } -sha2 = { version = "0.10", default-features = false, features = ["force-soft"] } +sha1 = { workspace = true, features = ["force-soft"] } +sha2 = { workspace = true, features = ["force-soft"] } [features] -default = ["std"] -# `std` enables the full attestation surface: TPM device I/O via /dev/tpm*, the -# quote/AK/cert-chain paths (a9n, cert, roots, nsm) and the CLI binary. -# With `--no-default-features` only the no_std core remains: the TpmTransport -# trait, command marshalling and PCR operations (e.g. pcr_extend), suitable for -# UEFI use over EFI_TCG2_PROTOCOL. +default = ["std", "http-fetch"] + +# The attestation surface: TPM2_Quote + AK + cert-chain assembly + unified-JSON +# output (modules a9n, cert, roots). no_std + alloc capable. A UEFI caller enables +# this WITHOUT std and supplies its own CertFetcher (EFI_TCG2 for the TPM, +# EFI_TCP4 HTTP for AIA intermediate fetches). +attest = [ + "dep:serde", + "dep:serde_json", + "dep:serde_bytes", + "dep:base64", + "dep:der", + "dep:x509-cert", + "dep:ciborium", +] + +# Built-in std HTTP CertFetcher (StdHttpFetcher, over std::net::TcpStream). Requires +# std; on by default. Disable (default-features = false) to drop std net I/O and +# supply your own CertFetcher. +http-fetch = ["std"] + +# Full std build: TPM device I/O via /dev/tpm*, the CLI binary, Nitro NSM, and the +# attest surface. With `--no-default-features` only the no_std core remains: the +# TpmTransport trait, command marshalling and PCR ops (suitable for UEFI over +# EFI_TCG2_PROTOCOL); add `attest` for the quote/cert/JSON surface there too. std = [ + "attest", "anyhow/std", "hex/std", "sha1/std", "sha2/std", "hmac/std", + "serde/std", + "serde_json/std", + "der/std", + "x509-cert/std", + "base64/std", + "ciborium/std", + "serde_bytes/std", "dep:thiserror", - "dep:base64", - "dep:serde", - "dep:ciborium", - "dep:serde_bytes", - "dep:serde_json", - "dep:der", - "dep:x509-cert", ] [lib] @@ -65,4 +88,5 @@ path = "src/lib.rs" [[bin]] name = "vaportpm-attest" path = "src/bin/attest.rs" -required-features = ["std"] +# The CLI calls attest(), which needs the built-in std HTTP fetcher. +required-features = ["http-fetch"] diff --git a/crates/vaportpm-attest/src/a9n.rs b/crates/vaportpm-attest/src/a9n.rs index 6d3d91f..5aef411 100644 --- a/crates/vaportpm-attest/src/a9n.rs +++ b/crates/vaportpm-attest/src/a9n.rs @@ -7,11 +7,15 @@ //! - Reading PCR values //! - Generating attestation documents +use alloc::collections::BTreeMap; +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; -use crate::cert::{der_to_pem, fetch_cert_chain, DER_SEQUENCE_LONG}; +use crate::cert::{der_to_pem, fetch_cert_chain, CertFetcher, DER_SEQUENCE_LONG}; use crate::{KeyOps, NsmOps, NvOps, PcrOps, PublicKey, Tpm, TPM_RH_ENDORSEMENT}; /// GCP AK certificate NV index (ECC) @@ -22,7 +26,7 @@ const GCP_AK_TEMPLATE_NV_INDEX_ECC: u32 = 0x01c10003; /// Result type for attestation helper functions /// Contains: (ak_pubkeys, attestation_data, gcp_attestation, ak_handle) type AttestResult = ( - HashMap, + BTreeMap, AttestationData, Option, Option, @@ -33,9 +37,9 @@ type AttestResult = ( pub struct AttestationOutput { /// Nonce/challenge used for this attestation (hex-encoded) pub nonce: String, - pub pcrs: HashMap>, + pub pcrs: BTreeMap>, /// Attestation Key public keys (hex-encoded ECC coordinates) - pub ak_pubkeys: HashMap, + pub ak_pubkeys: BTreeMap, pub attestation: AttestationContainer, } @@ -49,7 +53,7 @@ pub struct EccPublicKeyCoords { /// Container for both TPM and optional platform-specific attestations #[derive(Debug, Serialize, Deserialize)] pub struct AttestationContainer { - pub tpm: HashMap, + pub tpm: BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] pub nitro: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -119,13 +123,14 @@ fn is_gcp_tpm(tpm: &mut Tpm) -> bool { /// /// # Errors /// Returns an error if the platform is not recognized (only AWS Nitro and GCP are supported) -pub fn attest(nonce: &[u8]) -> Result { - let mut tpm = Tpm::open_direct()?; - +/// Produce the unified attestation JSON for an already-open TPM, using `fetcher` to +/// retrieve any AIA intermediate certificates. no_std + alloc: a UEFI caller passes a +/// `Tpm` over EFI_TCG2 and a `CertFetcher` over EFI_TCP4. +pub fn attest_with(tpm: &mut Tpm, nonce: &[u8], fetcher: &dyn CertFetcher) -> Result { // Step 1: Detect platform // GCP detection is cheap - just checks for NV index existence let is_nitro = tpm.is_nitro_tpm()?; - let is_gcp = !is_nitro && is_gcp_tpm(&mut tpm); + let is_gcp = !is_nitro && is_gcp_tpm(tpm); // Step 2: Read all allocated PCRs from all banks let all_pcrs = tpm.read_all_allocated_pcrs()?; @@ -150,7 +155,7 @@ pub fn attest(nonce: &[u8]) -> Result { } // Build PCRs output - let mut pcrs_by_alg: HashMap> = HashMap::new(); + let mut pcrs_by_alg: BTreeMap> = BTreeMap::new(); let pcr_map = pcrs_by_alg.entry(pcr_alg.name().to_string()).or_default(); for (idx, value) in &pcr_values { pcr_map.insert(*idx, hex::encode(value)); @@ -159,26 +164,28 @@ pub fn attest(nonce: &[u8]) -> Result { // Step 5: Create or retrieve AK and sign PCRs with TPM2_Quote let (signing_key_public_keys, attestation_data, gcp_attestation, ak_handle) = if is_gcp { // GCP path: recreate AK from Google's template - attest_gcp(&mut tpm, nonce, &pcr_values, pcr_alg)? + attest_gcp(tpm, nonce, &pcr_values, pcr_alg, fetcher)? } else if is_nitro { // Nitro path: create long-term AK, use TPM2_Quote // 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)? + attest_nitro(tpm, nonce, &pcr_values)? } else { return Err(anyhow!( "Unknown platform - only AWS Nitro and GCP Shielded VM are supported" )); }; - let mut tpm_attestations = HashMap::new(); + let mut tpm_attestations = BTreeMap::new(); tpm_attestations.insert("ecc_p256".to_string(), attestation_data); - // Step 6: Get Nitro attestation if on AWS + // Step 6: Get the Nitro NSM document if on AWS (binds the AK pubkey to the Nitro + // root). This rides the TPM vendor command (0x20000001), so it works no_std/UEFI. let nitro_attestation = if is_nitro { if let Some(pk) = signing_key_public_keys.get("ecc_p256") { let public_key_hex = format!("04{}{}", pk.x, pk.y); - let public_key_bytes = hex::decode(&public_key_hex)?; + let public_key_bytes = hex::decode(&public_key_hex) + .map_err(|e| anyhow!("invalid AK public key hex: {e}"))?; match tpm.nsm_attest( None, // user_data @@ -221,6 +228,13 @@ pub fn attest(nonce: &[u8]) -> Result { Ok(json) } +/// Convenience std entrypoint: open /dev/tpm0 and use the built-in HTTP fetcher. +#[cfg(feature = "http-fetch")] +pub fn attest(nonce: &[u8]) -> Result { + let mut tpm = Tpm::open_direct()?; + attest_with(&mut tpm, nonce, &crate::cert::StdHttpFetcher) +} + /// Nitro attestation path: create restricted AK and use TPM2_Quote /// /// Creates a TCG-compliant restricted AK in the endorsement hierarchy, then uses @@ -234,7 +248,7 @@ fn attest_nitro(tpm: &mut Tpm, nonce: &[u8], pcr_values: &[(u8, Vec)]) -> Re // Trust comes from Nitro NSM document binding the AK public key let signing_key = tpm.create_restricted_ak(TPM_RH_ENDORSEMENT)?; - let mut signing_key_public_keys = HashMap::new(); + let mut signing_key_public_keys = BTreeMap::new(); signing_key_public_keys.insert( "ecc_p256".to_string(), EccPublicKeyCoords { @@ -271,6 +285,7 @@ fn attest_gcp( nonce: &[u8], pcr_values: &[(u8, Vec)], pcr_alg: crate::TpmAlg, + fetcher: &dyn CertFetcher, ) -> Result { // Read ECC AK template from NV RAM (prefer ECC over RSA for ECDSA signing) let ak_template = tpm.nv_read(GCP_AK_TEMPLATE_NV_INDEX_ECC)?; @@ -281,7 +296,7 @@ fn attest_gcp( // Extract ECC public key coordinates let signing_key_public_keys = match &ak_result.public_key { PublicKey::Ecc(ecc) => { - let mut pks = HashMap::new(); + let mut pks = BTreeMap::new(); pks.insert( "ecc_p256".to_string(), EccPublicKeyCoords { @@ -307,7 +322,7 @@ fn attest_gcp( let quote_result = tpm.quote(ak_result.handle, nonce, &pcr_selection)?; // Read AK certificate chain from NV RAM - let ak_cert_chain = read_gcp_ak_cert_chain(tpm)?; + let ak_cert_chain = read_gcp_ak_cert_chain(tpm, fetcher)?; let attestation_data = AttestationData { attest_data: hex::encode("e_result.attest_data), @@ -339,7 +354,7 @@ fn build_pcr_bitmap(pcr_values: &[(u8, Vec)]) -> Vec { } /// Read GCP ECC AK certificate chain from NV RAM and fetch issuer certs -fn read_gcp_ak_cert_chain(tpm: &mut Tpm) -> Result { +fn read_gcp_ak_cert_chain(tpm: &mut Tpm, fetcher: &dyn CertFetcher) -> Result { // Read ECC AK certificate (matches the ECC AK template we use) let ak_cert = tpm.nv_read(GCP_AK_CERT_NV_INDEX_ECC)?; @@ -351,7 +366,7 @@ fn read_gcp_ak_cert_chain(tpm: &mut Tpm) -> Result { } // Build full chain by fetching issuer certs via AIA - let chain = fetch_cert_chain(&ak_cert)?; + let chain = fetch_cert_chain(&ak_cert, fetcher)?; // Convert all certs to PEM and concatenate let pem_chain: String = chain diff --git a/crates/vaportpm-attest/src/cert.rs b/crates/vaportpm-attest/src/cert.rs index e1d7d72..f7f3a92 100644 --- a/crates/vaportpm-attest/src/cert.rs +++ b/crates/vaportpm-attest/src/cert.rs @@ -7,17 +7,27 @@ //! - Certificate chain fetching via AIA (Authority Information Access) URLs //! - Extension extraction (SKI, AKI, AIA) +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; use anyhow::{anyhow, Result}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use der::oid::ObjectIdentifier; use der::Decode; -use std::io::{BufRead, BufReader, Read, Write}; -use std::net::TcpStream; -use std::time::Duration; use x509_cert::ext::pkix::name::GeneralName; use x509_cert::ext::pkix::{AuthorityInfoAccessSyntax, AuthorityKeyIdentifier}; use x509_cert::Certificate; +/// Fetches a DER certificate from an `http://` URL. Injected into the certificate +/// chain walker so a no_std/UEFI caller can supply its own transport (e.g. over +/// EFI_TCP4) while std builds use [`StdHttpFetcher`]. AIA caIssuers URLs are plain +/// HTTP by PKI convention, so no TLS is required. +pub trait CertFetcher { + /// Fetch the DER bytes of the certificate at `url` (scheme `http://`). + fn fetch(&self, url: &str) -> Result>; +} + /// DER SEQUENCE tag with 2-byte length (0x30 0x82) /// Used to detect valid X.509 certificates in DER format pub const DER_SEQUENCE_LONG: [u8; 2] = [0x30, 0x82]; @@ -36,7 +46,7 @@ pub fn der_to_pem(der: &[u8], label: &str) -> String { let base64_encoded = STANDARD.encode(der); let mut pem = format!("-----BEGIN {}-----\n", label); for chunk in base64_encoded.as_bytes().chunks(64) { - pem.push_str(std::str::from_utf8(chunk).unwrap()); + pem.push_str(core::str::from_utf8(chunk).unwrap()); pem.push('\n'); } pem.push_str(&format!("-----END {}-----\n", label)); @@ -145,7 +155,7 @@ pub fn extract_aia_url(cert_der: &[u8]) -> Option { /// First attempts to find issuer certificates from embedded trust anchors /// using AKI/SKI matching. Falls back to AIA URL fetching if no embedded /// cert matches. -pub fn fetch_cert_chain(leaf_cert: &[u8]) -> Result>> { +pub fn fetch_cert_chain(leaf_cert: &[u8], fetcher: &dyn CertFetcher) -> Result>> { use crate::roots; let mut chain = vec![leaf_cert.to_vec()]; @@ -177,8 +187,8 @@ pub fn fetch_cert_chain(leaf_cert: &[u8]) -> Result>> { } }; - // Fetch issuer certificate via HTTP - let issuer_cert = fetch_certificate(&aia_url)?; + // Fetch issuer certificate via the injected fetcher (HTTP) + let issuer_cert = fetcher.fetch(&aia_url)?; if !issuer_cert.starts_with(&DER_SEQUENCE_LONG) { return Err(anyhow!( @@ -194,8 +204,24 @@ pub fn fetch_cert_chain(leaf_cert: &[u8]) -> Result>> { Ok(chain) } +/// A [`CertFetcher`] backed by `std::net::TcpStream` (plain HTTP). The default +/// fetcher for std builds; pass it to [`fetch_cert_chain`] / `attest_with`. +#[cfg(feature = "http-fetch")] +pub struct StdHttpFetcher; + +#[cfg(feature = "http-fetch")] +impl CertFetcher for StdHttpFetcher { + fn fetch(&self, url: &str) -> Result> { + std_http_get(url) + } +} + /// Fetch a certificate from an HTTP URL (no TLS support - AIA URLs are HTTP) -pub fn fetch_certificate(url: &str) -> Result> { +#[cfg(feature = "http-fetch")] +fn std_http_get(url: &str) -> Result> { + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::TcpStream; + use std::time::Duration; // Parse URL - only support http:// if !url.starts_with("http://") { return Err(anyhow!("Only HTTP URLs are supported: {}", url)); diff --git a/crates/vaportpm-attest/src/lib.rs b/crates/vaportpm-attest/src/lib.rs index 92efee3..a55a04f 100644 --- a/crates/vaportpm-attest/src/lib.rs +++ b/crates/vaportpm-attest/src/lib.rs @@ -25,16 +25,19 @@ use std::fs::{File, OpenOptions}; #[cfg(feature = "std")] use std::io::{Read, Write}; -#[cfg(feature = "std")] +// Attestation surface (quote + cert chain + JSON). no_std + alloc; gated by `attest`. +#[cfg(feature = "attest")] pub mod a9n; -#[cfg(feature = "std")] +#[cfg(feature = "attest")] pub mod cert; pub mod ek; -#[cfg(feature = "std")] +// NSM (AWS Nitro) attestation is reached via a TPM vendor command, so it is +// no_std-capable and part of the attest surface (not std). +#[cfg(feature = "attest")] pub mod nsm; pub mod nv; pub mod pcr; -#[cfg(feature = "std")] +#[cfg(feature = "attest")] pub mod roots; // Re-export extension traits for convenience @@ -42,13 +45,21 @@ pub use ek::KeyOps; pub use nv::NvOps; pub use pcr::PcrOps; -#[cfg(feature = "std")] +#[cfg(feature = "attest")] pub use nsm::NsmOps; -#[cfg(feature = "std")] +// Attestation core: attest_with takes a &mut Tpm and a CertFetcher, so it works in +// no_std/UEFI with a caller-supplied transport + fetcher. +#[cfg(feature = "attest")] +pub use a9n::attest_with; +#[cfg(feature = "attest")] +pub use cert::{der_to_pem, extract_aki, extract_ski, pem_to_der, CertFetcher}; + +// Convenience std entrypoint: opens /dev/tpm0 and uses the built-in HTTP fetcher. +#[cfg(feature = "http-fetch")] pub use a9n::attest; -#[cfg(feature = "std")] -pub use cert::{der_to_pem, extract_aki, extract_ski, pem_to_der}; +#[cfg(feature = "http-fetch")] +pub use cert::StdHttpFetcher; /// TPM 2.0 command codes #[repr(u32)] @@ -367,8 +378,8 @@ impl CommandBuffer { result } - /// Finalize command with a vendor-specific command code - #[cfg(feature = "std")] + /// Finalize command with a vendor-specific command code (e.g. AWS NSM request) + #[cfg(feature = "attest")] fn finalize_vendor(mut self, tag: TpmSt, vendor_code: u32) -> Vec { let total_size = 10 + self.data.len(); // header is 10 bytes let mut result = Vec::new(); diff --git a/crates/vaportpm-attest/src/nsm.rs b/crates/vaportpm-attest/src/nsm.rs index fc03b96..fbc0178 100644 --- a/crates/vaportpm-attest/src/nsm.rs +++ b/crates/vaportpm-attest/src/nsm.rs @@ -5,6 +5,8 @@ //! Single-approach implementation based on TPM 2.0 spec and AWS trace analysis. //! No "try everything" - either works correctly or fails with clear diagnostics. +use alloc::vec::Vec; + use crate::nv::{NvOps, NV_INDEX_USER_END, NV_INDEX_USER_START, TPM2_PT_NV_BUFFER_MAX}; use crate::nv::{TPMA_NV_AUTHREAD, TPMA_NV_AUTHWRITE}; use crate::{CommandBuffer, Tpm, TpmSt, TPM_ALG_SHA256}; diff --git a/crates/vaportpm-attest/src/roots.rs b/crates/vaportpm-attest/src/roots.rs index 044eebd..249a8f2 100644 --- a/crates/vaportpm-attest/src/roots.rs +++ b/crates/vaportpm-attest/src/roots.rs @@ -7,7 +7,8 @@ //! not hardcoded separately. use crate::cert::{extract_ski, pem_to_der}; -use std::sync::OnceLock; +use alloc::vec; +use alloc::vec::Vec; // ============================================================================ // Embedded certificate PEMs @@ -40,18 +41,14 @@ pub struct CertInfo { pub ski: Vec, } -/// Lazily-initialized certificate info cache -static CERT_INFOS: OnceLock> = OnceLock::new(); - -/// Get all embedded certificate infos, initializing on first call -fn get_cert_infos() -> &'static [CertInfo] { - CERT_INFOS.get_or_init(|| { - // These are compile-time embedded certs - panic if they fail to parse - vec![ - extract_cert_info(AWS_NITRO_ROOT_PEM, "AWS Nitro root"), - extract_cert_info(GCP_EKAK_ROOT_PEM, "GCP EK/AK root"), - ] - }) +/// Parse the embedded roots into (pem, ski) pairs. Recomputed on demand: no_std has +/// no `OnceLock`, and the embedded set is tiny (touched only during a cert-chain walk). +fn cert_infos() -> Vec { + // These are compile-time embedded certs - panic if they fail to parse + vec![ + extract_cert_info(AWS_NITRO_ROOT_PEM, "AWS Nitro root"), + extract_cert_info(GCP_EKAK_ROOT_PEM, "GCP EK/AK root"), + ] } /// Extract certificate info from PEM @@ -73,7 +70,7 @@ fn extract_cert_info(pem: &'static str, name: &str) -> CertInfo { /// /// Returns the PEM-encoded certificate if found. pub fn find_issuer_by_aki(aki: &[u8]) -> Option<&'static str> { - for info in get_cert_infos() { + for info in cert_infos() { if info.ski == aki { return Some(info.pem); } @@ -85,7 +82,7 @@ pub fn find_issuer_by_aki(aki: &[u8]) -> Option<&'static str> { /// /// Returns the Subject Key Identifier bytes. pub fn get_ski(pem: &str) -> Option> { - for info in get_cert_infos() { + for info in cert_infos() { if info.pem == pem { return Some(info.ski.clone()); } diff --git a/crates/vaportpm-verify/Cargo.toml b/crates/vaportpm-verify/Cargo.toml index 179638b..0226a46 100644 --- a/crates/vaportpm-verify/Cargo.toml +++ b/crates/vaportpm-verify/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vaportpm-verify" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "Cloud vTPM attestation verification - TPM and Nitro attestation verification" license.workspace = true @@ -8,10 +8,11 @@ license.workspace = true [dependencies] vaportpm-attest = { path = "../vaportpm-attest" } -# X.509 parsing -der = { workspace = true } +# X.509 parsing. The shared deps are no_std-base in the workspace (for vaportpm-attest's +# UEFI build); vaportpm-verify is a std crate, so it opts each back into `std`. +der = { workspace = true, features = ["std"] } spki = { workspace = true } -x509-cert = { workspace = true } +x509-cert = { workspace = true, features = ["std"] } # Time types pki-types = { workspace = true } @@ -24,15 +25,15 @@ rsa = { workspace = true } # COSE/CBOR for Nitro coset = { workspace = true } -ciborium = { workspace = true } +ciborium = { workspace = true, features = ["std"] } # Common -sha2 = { workspace = true } -hex = { workspace = true } -base64 = { workspace = true } +sha2 = { workspace = true, features = ["std"] } +hex = { workspace = true, features = ["std"] } +base64 = { workspace = true, features = ["std"] } thiserror = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } +serde = { workspace = true, features = ["std"] } +serde_json = { workspace = true, features = ["std"] } zerocopy = { workspace = true } [dev-dependencies] diff --git a/crates/vaportpm-verify/src/error.rs b/crates/vaportpm-verify/src/error.rs index 377fcad..8a55641 100644 --- a/crates/vaportpm-verify/src/error.rs +++ b/crates/vaportpm-verify/src/error.rs @@ -12,6 +12,9 @@ pub enum VerifyError { #[error("Invalid hex encoding: {0}")] HexDecode(#[from] hex::FromHexError), + #[error("Attestation input decode failed: {0}")] + InputDecode(String), + #[error("Invalid attestation structure: {0}")] InvalidAttest(#[from] InvalidAttestReason), diff --git a/crates/vaportpm-verify/src/lib.rs b/crates/vaportpm-verify/src/lib.rs index 64c726a..221e0b8 100644 --- a/crates/vaportpm-verify/src/lib.rs +++ b/crates/vaportpm-verify/src/lib.rs @@ -16,6 +16,7 @@ pub mod pcr; mod tpm; mod x509; +use base64::Engine as _; use serde::Serialize; use x509::parse_cert_chain_pem; @@ -320,11 +321,68 @@ 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 json = normalize_attestation_input(json)?; let output: AttestationOutput = - serde_json::from_str(json).map_err(InvalidAttestReason::JsonParse)?; + serde_json::from_str(&json).map_err(InvalidAttestReason::JsonParse)?; verify_attestation_output(&output, UnixTime::now()) } +/// Normalize a captured attestation into JSON, auto-detecting the format. Accepts bare +/// JSON, a base64 blob, or either wrapped in `===ATTESTATION-BEGIN/END===` markers with +/// EC2/Nitro serial-console timestamps (`[YYYY-MM-DDThh:mm:ss...]`) interleaved -- the +/// payload emits base64 so no serial write is large enough for the console to inject a +/// timestamp mid-line, but any that land between lines are stripped here regardless. +pub fn normalize_attestation_input(input: &str) -> Result { + const BEGIN: &str = "===ATTESTATION-BEGIN==="; + const END: &str = "===ATTESTATION-END==="; + let body = match (input.find(BEGIN), input.find(END)) { + (Some(b), Some(e)) if e > b + BEGIN.len() => &input[b + BEGIN.len()..e], + _ => input, + }; + let body = strip_console_timestamps(body); + let compact: String = body.split_whitespace().collect(); + let looks_base64 = !compact.is_empty() + && !compact.starts_with('{') + && compact + .bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'+' | b'/' | b'=')); + if looks_base64 { + let bytes = base64::engine::general_purpose::STANDARD + .decode(compact.as_bytes()) + .map_err(|e| VerifyError::InputDecode(format!("base64: {e}")))?; + return String::from_utf8(bytes) + .map_err(|e| VerifyError::InputDecode(format!("utf-8: {e}"))); + } + Ok(body) +} + +/// Remove `[YYYY-MM-DDThh:mm:ss...]` serial-console timestamps from `s`, copying every +/// other byte through. The attestation payload (base64/JSON/hex) never contains `[`. +fn strip_console_timestamps(s: &str) -> String { + let b = s.as_bytes(); + let mut out = String::with_capacity(s.len()); + let (mut i, mut last) = (0usize, 0usize); + while i + 5 < b.len() { + if b[i] == b'[' + && b[i + 1].is_ascii_digit() + && b[i + 2].is_ascii_digit() + && b[i + 3].is_ascii_digit() + && b[i + 4].is_ascii_digit() + && b[i + 5] == b'-' + { + if let Some(rel) = s[i..].find(']') { + out.push_str(&s[last..i]); + i += rel + 1; + last = i; + continue; + } + } + i += 1; + } + out.push_str(&s[last..]); + out +} + /// Guards the trust anchor: the precomputed root public-key hash constants must /// stay in sync with the embedded root certificates they claim to represent. /// @@ -386,10 +444,10 @@ mod tests { fn test_reject_empty_attestation() { let output = AttestationOutput { nonce: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), - pcrs: std::collections::HashMap::new(), - ak_pubkeys: std::collections::HashMap::new(), + pcrs: std::collections::BTreeMap::new(), + ak_pubkeys: std::collections::BTreeMap::new(), attestation: AttestationContainer { - tpm: std::collections::HashMap::new(), + tpm: std::collections::BTreeMap::new(), nitro: None, gcp: None, },