diff --git a/Cargo.lock b/Cargo.lock index f9a940e..72f8815 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,12 +170,56 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -301,6 +345,8 @@ dependencies = [ "hex", "http 1.4.0", "mock-tdx", + "nsm-nitro-enclave-utils", + "nsm-nitro-enclave-utils-keygen", "num-bigint", "once_cell", "openssl", @@ -375,6 +421,20 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-nitro-enclaves-nsm-api" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92c1f4471b33f6a7af9ea421b249ed18a11c71156564baf6293148fa6ad1b09" +dependencies = [ + "libc", + "log", + "nix 0.26.4", + "serde", + "serde_bytes", + "serde_cbor", +] + [[package]] name = "axum" version = "0.8.8" @@ -435,7 +495,7 @@ checksum = "9b3d0900c6757c9674b05b0479236458297026e25fb505186dc8d7735091a21c" dependencies = [ "bincode 1.3.3", "jsonwebkey", - "memoffset", + "memoffset 0.9.1", "openssl", "serde", "serde-big-array", @@ -813,7 +873,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" dependencies = [ "ciborium-io", - "half", + "half 2.7.1", ] [[package]] @@ -823,6 +883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -831,8 +892,22 @@ version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -856,6 +931,12 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12170080f3533d6f09a19f81596f836854d0fa4867dc32c8172b8474b4e9de61" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "console" version = "0.16.3" @@ -908,6 +989,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "coset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb98d5e9155e2cf7cd942c8b3033097d4563b6fb0a00b9caecb74669555c058" +dependencies = [ + "ciborium", + "ciborium-io", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1442,6 +1533,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -1810,6 +1902,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + [[package]] name = "half" version = "2.7.1" @@ -2254,6 +2352,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -2437,6 +2541,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2537,6 +2650,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + [[package]] name = "nix" version = "0.31.2" @@ -2547,7 +2673,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -2566,6 +2692,46 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nsm-nitro-enclave-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be8326f0a1c769ee90da2bdaf6e0859c7873b2047a5d06d97f7f2abb273dc15" +dependencies = [ + "aws-nitro-enclaves-nsm-api", + "coset", + "getrandom 0.3.4", + "hex", + "nsm-nitro-enclave-utils-keygen", + "p384", + "ring", + "rustls-pki-types", + "rustls-webpki", + "sealed", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "x509-cert", +] + +[[package]] +name = "nsm-nitro-enclave-utils-keygen" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff968e3b62edc3ac9c6fd324490a6a14278ea6289efeb7d91425feaf62592051" +dependencies = [ + "clap", + "p384", + "rand_core 0.6.4", + "sec1", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "x509-cert", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2685,6 +2851,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -3590,6 +3762,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sec1" version = "0.7.3" @@ -3670,6 +3853,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half 1.8.3", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4129,6 +4322,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio" version = "1.50.0" @@ -4447,6 +4661,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.22.0" @@ -4490,7 +4710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba782755fc073877e567c2253c0be48e4aa9a254c232d36d3985dfae0bd5205" dependencies = [ "libc", - "nix", + "nix 0.31.2", ] [[package]] @@ -4966,7 +5186,10 @@ checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ "const-oid", "der", + "sha1", + "signature", "spki", + "tls_codec", ] [[package]] diff --git a/crates/attestation/Cargo.toml b/crates/attestation/Cargo.toml index f27bcb5..d1feaa6 100644 --- a/crates/attestation/Cargo.toml +++ b/crates/attestation/Cargo.toml @@ -15,6 +15,8 @@ tokio = { workspace = true, features = ["fs"] } tokio-rustls = { workspace = true, default-features = false } anyhow = "1.0.100" +nsm-nitro-enclave-utils = { version = "0.1.3", default-features = false, features = ["nitro", "verify"] } +nsm-nitro-enclave-utils-keygen = { version = "=0.1.3", optional = true } pem-rfc7468 = { version = "0.7.0", features = ["std"] } tdx-attest = { git = "https://github.com/Dstack-TEE/dstack.git", rev = "4f602dddc0542cd34da031c90ac0b3a560f316ed" } base64 = "0.22.1" @@ -41,6 +43,8 @@ openssl = { version = "0.10.79", optional = true } [dev-dependencies] mock-tdx = { workspace = true } +nsm-nitro-enclave-utils = { version = "0.1.3", default-features = false, features = ["nitro", "pki", "seed", "verify"] } +nsm-nitro-enclave-utils-keygen = "=0.1.3" tempfile = "3.23.0" tokio-rustls = { workspace = true, default-features = true } @@ -53,7 +57,12 @@ default = [] azure = ["tss-esapi", "az-tdx-vtpm", "openssl"] # Allows mock quotes used in tests and exposes related functions for testing -mock = ["dep:mock-tdx"] +mock = [ + "dep:mock-tdx", + "dep:nsm-nitro-enclave-utils-keygen", + "nsm-nitro-enclave-utils/pki", + "nsm-nitro-enclave-utils/seed", +] [lints] workspace = true diff --git a/crates/attestation/assets/aws-nitro-enclaves-root-g1.der b/crates/attestation/assets/aws-nitro-enclaves-root-g1.der new file mode 100644 index 0000000..994d128 Binary files /dev/null and b/crates/attestation/assets/aws-nitro-enclaves-root-g1.der differ diff --git a/crates/attestation/src/lib.rs b/crates/attestation/src/lib.rs index 73bc5ba..5ddb71c 100644 --- a/crates/attestation/src/lib.rs +++ b/crates/attestation/src/lib.rs @@ -4,6 +4,7 @@ pub mod azure; pub mod dcap; pub mod measurements; +pub mod nitro; use std::{ fmt::{self, Display, Formatter}, @@ -18,7 +19,7 @@ use pccs::{Pccs, PccsError}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::{dcap::DcapVerificationError, measurements::MeasurementPolicy}; +use crate::{dcap::DcapVerificationError, measurements::MeasurementPolicy, nitro::NitroError}; /// Used in attestation type detection to check if we are on GCP const GCP_METADATA_API: &str = "http://metadata.google.internal"; @@ -60,6 +61,7 @@ impl AttestationExchangeMessage { .map_err(DcapVerificationError::from)?; Ok(Some(MultiMeasurements::from_dcap_qvl_quote("e)?)) } + AttestationType::AwsNitro => Ok(Some(nitro::get_measurements(&self.attestation)?)), } } } @@ -79,6 +81,8 @@ pub enum AttestationType { QemuTdx, /// DCAP TDX DcapTdx, + /// AWS Nitro Enclaves + AwsNitro, } impl AttestationType { @@ -90,6 +94,7 @@ impl AttestationType { AttestationType::QemuTdx => "qemu-tdx", AttestationType::GcpTdx => "gcp-tdx", AttestationType::DcapTdx => "dcap-tdx", + AttestationType::AwsNitro => "aws-nitro", } } @@ -102,6 +107,9 @@ impl AttestationType { return Ok(AttestationType::AzureTdx); } } + if nitro::running_on_nitro() { + return Ok(AttestationType::AwsNitro); + } // Otherwise try DCAP quote - this internally checks that the quote provider // is `tdx_guest` if tdx_attest::get_quote(&[0; 64]).is_ok() { @@ -235,6 +243,7 @@ impl AttestationGenerator { AttestationType::DcapTdx | AttestationType::GcpTdx | AttestationType::QemuTdx => { dcap::create_dcap_attestation(input_data) } + AttestationType::AwsNitro => Ok(nitro::create_nitro_attestation(input_data)?), } } @@ -406,6 +415,10 @@ impl AttestationVerifier { ) .await? } + AttestationType::AwsNitro => nitro::verify_nitro_attestation( + attestation_exchange_message.attestation, + expected_input_data, + )?, }; // Do a measurement / attestation type policy check @@ -467,6 +480,10 @@ impl AttestationVerifier { pccs, )? } + AttestationType::AwsNitro => nitro::verify_nitro_attestation( + attestation_exchange_message.attestation, + expected_input_data, + )?, }; // Do a measurement / attestation type policy check @@ -586,6 +603,8 @@ pub enum AttestationError { QuoteGeneration(#[from] tdx_attest::TdxAttestError), #[error("DCAP verification: {0}")] DcapVerification(#[from] DcapVerificationError), + #[error("Nitro attestation: {0}")] + Nitro(#[from] NitroError), #[error("Attestation type not supported")] AttestationTypeNotSupported, #[error("Attestation type not accepted")] diff --git a/crates/attestation/src/measurements.rs b/crates/attestation/src/measurements.rs index db8c2c9..8560143 100644 --- a/crates/attestation/src/measurements.rs +++ b/crates/attestation/src/measurements.rs @@ -75,12 +75,23 @@ fn parse_azure_pcr_index(value: &str) -> Result { Ok(index) } +fn parse_nitro_pcr_index(value: &str) -> Result { + let index = value.parse::()?; + + if index > 31 { + return Err(MeasurementFormatError::BadRegisterIndex); + } + + Ok(index) +} + /// Represents a set of measurements values for one of the supported CVM /// platforms #[derive(Clone, PartialEq)] pub enum MultiMeasurements { Dcap(HashMap), Azure(HashMap), + Nitro(HashMap), NoAttestation, } @@ -93,6 +104,9 @@ impl fmt::Debug for MultiMeasurements { Self::Azure(measurements) => { f.debug_tuple("Azure").field(&AzureHexDebug(measurements)).finish() } + Self::Nitro(measurements) => { + f.debug_tuple("Nitro").field(&NitroHexDebug(measurements)).finish() + } Self::NoAttestation => f.write_str("NoAttestation"), } } @@ -132,11 +146,29 @@ impl fmt::Debug for AzureHexDebug<'_> { } } +/// Used to display Nitro measurements as hex +struct NitroHexDebug<'a>(&'a HashMap); + +impl fmt::Debug for NitroHexDebug<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let mut entries: Vec<_> = self.0.iter().collect(); + entries.sort_by_key(|(index, _)| **index); + + let mut map = f.debug_map(); + for (index, value) in entries { + let hex_value = hex::encode(value); + map.entry(index, &hex_value); + } + map.finish() + } +} + /// Expected measurement values for policy enforcement #[derive(Debug, Clone, PartialEq)] pub enum ExpectedMeasurements { Dcap(HashMap>), Azure(HashMap>), + Nitro(HashMap>), NoAttestation, } @@ -152,6 +184,10 @@ impl MultiMeasurements { .iter() .map(|(index, value)| (index.to_string(), hex::encode(value))) .collect(), + MultiMeasurements::Nitro(nitro_measurements) => nitro_measurements + .iter() + .map(|(index, value)| (index.to_string(), hex::encode(value))) + .collect(), MultiMeasurements::NoAttestation => HashMap::new(), }; @@ -163,7 +199,7 @@ impl MultiMeasurements { input: &str, attestation_type: AttestationType, ) -> Result { - let measurements_map: HashMap = serde_json::from_str(input)?; + let measurements_map: HashMap = serde_json::from_str(input)?; Ok(match attestation_type { AttestationType::None => Self::NoAttestation, @@ -172,7 +208,7 @@ impl MultiMeasurements { .into_iter() .map(|(k, v)| { Ok(( - k as u32, + k.parse::()? as u32, hex::decode(v)? .try_into() .map_err(|_| MeasurementFormatError::BadLength)?, @@ -184,8 +220,9 @@ impl MultiMeasurements { let measurements_map = measurements_map .into_iter() .map(|(k, v)| { + let index = k.parse::()?; Ok(( - k.try_into()?, + index.try_into()?, hex::decode(v)? .try_into() .map_err(|_| MeasurementFormatError::BadLength)?, @@ -194,6 +231,20 @@ impl MultiMeasurements { .collect::>()?; Self::Dcap(measurements_map) } + AttestationType::AwsNitro => Self::Nitro( + measurements_map + .into_iter() + .map(|(k, v)| { + let value = hex::decode(v)?; + if value.len() != crate::nitro::NITRO_PCR_LENGTH { + return Err(MeasurementFormatError::BadLength); + } + let value: [u8; 48] = + value.try_into().map_err(|_| MeasurementFormatError::BadLength)?; + Ok((parse_nitro_pcr_index(&k)?, value)) + }) + .collect::>()?, + ), }) } @@ -249,9 +300,9 @@ pub enum MeasurementFormatError { AttestationTypeNotValid, #[error("Hex: {0}")] Hex(#[from] hex::FromHexError), - #[error("Expected 48 byte value")] + #[error("Unexpected measurement value length")] BadLength, - #[error("TDX quote register index must be in the ranger 0-3")] + #[error("Invalid measurement register index")] BadRegisterIndex, #[error("ParseInt: {0}")] ParseInt(#[from] std::num::ParseIntError), @@ -296,6 +347,7 @@ impl MeasurementRecord { AttestationType::DcapTdx | AttestationType::GcpTdx | AttestationType::QemuTdx => { ExpectedMeasurements::Dcap(HashMap::new()) } + AttestationType::AwsNitro => ExpectedMeasurements::Nitro(HashMap::new()), }, } } @@ -337,6 +389,7 @@ impl MeasurementPolicy { MeasurementRecord::allow_any_measurement(AttestationType::QemuTdx), MeasurementRecord::allow_any_measurement(AttestationType::GcpTdx), MeasurementRecord::allow_any_measurement(AttestationType::AzureTdx), + MeasurementRecord::allow_any_measurement(AttestationType::AwsNitro), ], } } @@ -356,17 +409,30 @@ impl MeasurementPolicy { /// Expect mock measurements used in tests #[cfg(any(test, feature = "mock"))] pub fn mock() -> Self { + let nitro_measurements = match crate::nitro::mock_nitro_measurements() { + MultiMeasurements::Nitro(measurements) => { + measurements.into_iter().map(|(index, value)| (index, vec![value])).collect() + } + _ => unreachable!("mock_nitro_measurements must return Nitro measurements"), + }; + Self { - accepted_measurements: vec![MeasurementRecord { - measurement_id: "test".to_string(), - measurements: ExpectedMeasurements::Dcap(HashMap::from([ - (DcapMeasurementRegister::MRTD, vec![mock_tdx::MOCK_MRTD]), - (DcapMeasurementRegister::RTMR0, vec![mock_tdx::MOCK_RTMR0]), - (DcapMeasurementRegister::RTMR1, vec![mock_tdx::MOCK_RTMR1]), - (DcapMeasurementRegister::RTMR2, vec![mock_tdx::MOCK_RTMR2]), - (DcapMeasurementRegister::RTMR3, vec![mock_tdx::MOCK_RTMR3]), - ])), - }], + accepted_measurements: vec![ + MeasurementRecord { + measurement_id: "test".to_string(), + measurements: ExpectedMeasurements::Dcap(HashMap::from([ + (DcapMeasurementRegister::MRTD, vec![mock_tdx::MOCK_MRTD]), + (DcapMeasurementRegister::RTMR0, vec![mock_tdx::MOCK_RTMR0]), + (DcapMeasurementRegister::RTMR1, vec![mock_tdx::MOCK_RTMR1]), + (DcapMeasurementRegister::RTMR2, vec![mock_tdx::MOCK_RTMR2]), + (DcapMeasurementRegister::RTMR3, vec![mock_tdx::MOCK_RTMR3]), + ])), + }, + MeasurementRecord { + measurement_id: "test-nitro".to_string(), + measurements: ExpectedMeasurements::Nitro(nitro_measurements), + }, + ], } } @@ -402,6 +468,18 @@ impl MeasurementPolicy { } false } + MultiMeasurements::Nitro(nitro_measurements) => { + if let ExpectedMeasurements::Nitro(expected) = &measurement_record.measurements { + for (k, v) in expected.iter() { + match nitro_measurements.get(k) { + Some(actual_value) if v.iter().any(|v| actual_value == v) => {} + _ => return false, + } + } + return true; + } + false + } MultiMeasurements::NoAttestation => { matches!(measurement_record.measurements, ExpectedMeasurements::NoAttestation) } @@ -505,6 +583,38 @@ impl MeasurementPolicy { } } + fn parse_measurement_entry_vec( + entry: &MeasurementEntry, + register_name: &str, + expected_len: usize, + ) -> Result, MeasurementFormatError> { + let parse_hex_value = |hex_str: &str| -> Result<[u8; 48], MeasurementFormatError> { + let value = hex::decode(hex_str)?; + if value.len() != expected_len { + return Err(MeasurementFormatError::BadLength); + } + value.try_into().map_err(|_| MeasurementFormatError::BadLength) + }; + + match (&entry.expected, &entry.expected_any) { + (Some(single), None) => Ok(vec![parse_hex_value(single)?]), + (None, Some(any_list)) => { + if any_list.is_empty() { + return Err(MeasurementFormatError::EmptyExpectedAny( + register_name.to_string(), + )); + } + any_list.iter().map(|hex_str| parse_hex_value(hex_str)).collect() + } + (Some(_), Some(_)) => Err(MeasurementFormatError::BothExpectedAndExpectedAny( + register_name.to_string(), + )), + (None, None) => { + Err(MeasurementFormatError::NoExpectedValue(register_name.to_string())) + } + } + } + let records_simple: Vec = serde_json::from_slice(&json_bytes)?; let mut measurement_policy = Vec::new(); @@ -543,6 +653,23 @@ impl MeasurementPolicy { MeasurementFormatError, >>()?, ), + AttestationType::AwsNitro => ExpectedMeasurements::Nitro( + measurements + .iter() + .map(|(index_str, entry)| { + let index = parse_nitro_pcr_index(index_str)?; + Ok(( + index, + parse_measurement_entry_vec( + entry, + index_str, + crate::nitro::NITRO_PCR_LENGTH, + )?, + )) + }) + .collect::>, MeasurementFormatError>>( + )?, + ), }; measurement_policy.push(MeasurementRecord { @@ -1018,6 +1145,134 @@ mod tests { assert!(matches!(result, Err(MeasurementFormatError::BadRegisterIndex))); } + #[tokio::test] + async fn test_parse_nitro_numeric_pcr_keys() { + let pcr0 = "11".repeat(crate::nitro::NITRO_PCR_LENGTH); + let pcr8 = "22".repeat(crate::nitro::NITRO_PCR_LENGTH); + let json = format!( + r#"[ + {{ + "measurement_id": "nitro-pcrs", + "attestation_type": "aws-nitro", + "measurements": {{ + "0": {{ "expected_any": ["{pcr0}"] }}, + "8": {{ "expected_any": ["{pcr8}"] }} + }} + }} + ]"# + ); + + let policy = MeasurementPolicy::from_json_bytes(json.as_bytes().to_vec()).unwrap(); + let record = &policy.accepted_measurements[0]; + + if let ExpectedMeasurements::Nitro(nitro) = &record.measurements { + assert_eq!(nitro.keys().collect::>(), HashSet::from([&0, &8])); + } else { + panic!("Expected ExpectedMeasurements::Nitro"); + } + } + + #[tokio::test] + async fn test_parse_nitro_rejects_prefixed_pcr_keys() { + let pcr0 = "11".repeat(crate::nitro::NITRO_PCR_LENGTH); + let json = format!( + r#"[ + {{ + "attestation_type": "aws-nitro", + "measurements": {{ + "pcr0": {{ "expected_any": ["{pcr0}"] }} + }} + }} + ]"# + ); + + let result = MeasurementPolicy::from_json_bytes(json.as_bytes().to_vec()); + assert!(matches!(result, Err(MeasurementFormatError::ParseInt(_)))); + } + + #[tokio::test] + async fn test_parse_nitro_rejects_out_of_range_pcr_keys() { + let pcr32 = "11".repeat(crate::nitro::NITRO_PCR_LENGTH); + let json = format!( + r#"[ + {{ + "attestation_type": "aws-nitro", + "measurements": {{ + "32": {{ "expected_any": ["{pcr32}"] }} + }} + }} + ]"# + ); + + let result = MeasurementPolicy::from_json_bytes(json.as_bytes().to_vec()); + assert!(matches!(result, Err(MeasurementFormatError::BadRegisterIndex))); + } + + #[tokio::test] + async fn test_parse_nitro_rejects_bad_value_lengths() { + let short_pcr = "11".repeat(crate::nitro::NITRO_PCR_LENGTH - 1); + let json = format!( + r#"[ + {{ + "attestation_type": "aws-nitro", + "measurements": {{ + "0": {{ "expected_any": ["{short_pcr}"] }} + }} + }} + ]"# + ); + + let result = MeasurementPolicy::from_json_bytes(json.as_bytes().to_vec()); + assert!(matches!(result, Err(MeasurementFormatError::BadLength))); + } + + #[tokio::test] + async fn test_check_nitro_measurement_with_or_semantics() { + let first = "00".repeat(crate::nitro::NITRO_PCR_LENGTH); + let second = "11".repeat(crate::nitro::NITRO_PCR_LENGTH); + let json = format!( + r#"[ + {{ + "measurement_id": "nitro-or", + "attestation_type": "aws-nitro", + "measurements": {{ + "0": {{ + "expected_any": ["{first}", "{second}"] + }} + }} + }} + ]"# + ); + + let policy = MeasurementPolicy::from_json_bytes(json.as_bytes().to_vec()).unwrap(); + + let measurements1 = MultiMeasurements::Nitro(HashMap::from([(0, [0u8; 48])])); + assert!(policy.check_measurement(&measurements1).is_ok()); + + let measurements2 = MultiMeasurements::Nitro(HashMap::from([(0, [0x11u8; 48])])); + assert!(policy.check_measurement(&measurements2).is_ok()); + + let measurements3 = MultiMeasurements::Nitro(HashMap::from([(0, [0x22u8; 48])])); + assert!(policy.check_measurement(&measurements3).is_err()); + } + + #[test] + fn test_nitro_header_round_trip() { + let measurements = MultiMeasurements::Nitro(HashMap::from([ + (0, [0xabu8; crate::nitro::NITRO_PCR_LENGTH]), + (8, [0xcdu8; crate::nitro::NITRO_PCR_LENGTH]), + ])); + + let header = measurements.to_header_format().unwrap(); + let parsed = MultiMeasurements::from_header_format( + header.to_str().unwrap(), + AttestationType::AwsNitro, + ) + .unwrap(); + + assert_eq!(parsed, measurements); + } + /// Checks that the Debug implementation for MultiMeasurements displays /// them as hex #[test] @@ -1038,6 +1293,13 @@ mod tests { assert!(azure_debug.contains("Azure")); assert!(azure_debug.contains(&hex::encode(azure_register_value))); assert!(!azure_debug.contains(&format!("{azure_register_value:?}"))); + + let nitro_register_value = [0xabu8; crate::nitro::NITRO_PCR_LENGTH]; + let nitro = MultiMeasurements::Nitro(HashMap::from([(8u32, nitro_register_value)])); + let nitro_debug = format!("{nitro:?}"); + assert!(nitro_debug.contains("Nitro")); + assert!(nitro_debug.contains(&hex::encode(nitro_register_value))); + assert!(!nitro_debug.contains(&format!("{nitro_register_value:?}"))); } #[tokio::test] diff --git a/crates/attestation/src/nitro.rs b/crates/attestation/src/nitro.rs new file mode 100644 index 0000000..de37900 --- /dev/null +++ b/crates/attestation/src/nitro.rs @@ -0,0 +1,387 @@ +//! AWS Nitro Enclaves attestation generation and verification. + +use std::collections::HashMap; + +use nsm_nitro_enclave_utils::{ + api::{ + ByteBuf, + Time, + nsm::{AttestationDoc, Digest, ErrorCode, Request, Response}, + }, + driver::{Driver, nitro::Nitro}, + verify::AttestationDocVerifierExt, +}; +use thiserror::Error; + +use crate::measurements::MultiMeasurements; + +const AWS_ROOT_CERT_DER: &[u8] = include_bytes!("../assets/aws-nitro-enclaves-root-g1.der"); +pub(crate) const NITRO_PCR_LENGTH: usize = 48; + +/// Generate a Nitro attestation document using the Nitro Secure Module. +pub fn create_nitro_attestation(input_data: [u8; 64]) -> Result, NitroError> { + let nitro = Nitro::init(); + request_attestation(&nitro, input_data) +} + +/// Return true if we can successfully talk to the Nitro Secure Module. +pub(crate) fn running_on_nitro() -> bool { + let nitro = Nitro::init(); + matches!(nitro.process_request(Request::DescribeNSM), Response::DescribeNSM { .. }) +} + +fn request_attestation(driver: &impl Driver, input_data: [u8; 64]) -> Result, NitroError> { + match driver.process_request(Request::Attestation { + nonce: Some(ByteBuf::from(input_data.to_vec())), + user_data: None, + public_key: None, + }) { + Response::Attestation { document } => Ok(document), + Response::Error(error) => Err(NitroError::Nsm(error)), + response => Err(NitroError::UnexpectedResponse(format!("{response:?}"))), + } +} + +/// Verify a Nitro attestation document and return its PCR measurements. +pub fn verify_nitro_attestation( + input: Vec, + expected_input_data: [u8; 64], +) -> Result { + let doc = decode_with_accepted_roots(&input)?; + + match doc.nonce.as_ref() { + Some(nonce) if nonce.as_ref() == expected_input_data.as_slice() => {} + _ => return Err(NitroError::InputMismatch), + } + + measurements_from_doc(&doc) +} + +/// Extract Nitro PCR measurements from a verified attestation document. +pub fn get_measurements(input: &[u8]) -> Result { + let doc = decode_with_accepted_roots(input)?; + measurements_from_doc(&doc) +} + +fn decode_with_accepted_roots(input: &[u8]) -> Result { + let production_result = decode_with_root(input, AWS_ROOT_CERT_DER); + #[allow(clippy::needless_match)] + match production_result { + Ok(doc) => Ok(doc), + Err(production_error) => { + #[cfg(any(test, feature = "mock"))] + { + let mock_root = mock_nitro_root_cert_der()?; + if let Ok(doc) = decode_with_root(input, &mock_root) { + return Ok(doc); + } + } + + Err(production_error) + } + } +} + +fn decode_with_root(input: &[u8], root_cert_der: &[u8]) -> Result { + decode_with_root_at_time(input, root_cert_der, Time::default()) +} + +fn decode_with_root_at_time( + input: &[u8], + root_cert_der: &[u8], + time: Time, +) -> Result { + AttestationDoc::from_cose(input, root_cert_der, time) + .map_err(|err| NitroError::Verification(format!("{err:?}"))) +} + +fn measurements_from_doc(doc: &AttestationDoc) -> Result { + if doc.digest != Digest::SHA384 { + return Err(NitroError::UnsupportedDigest(doc.digest)); + } + + let mut measurements = HashMap::new(); + for (index, value) in &doc.pcrs { + let index = u32::try_from(*index).map_err(|_| NitroError::InvalidPcrIndex(*index))?; + if index > 31 { + return Err(NitroError::InvalidPcrIndex(index as usize)); + } + if value.as_ref().len() != NITRO_PCR_LENGTH { + return Err(NitroError::BadPcrLength { + index, + expected: NITRO_PCR_LENGTH, + actual: value.as_ref().len(), + }); + } + measurements.insert( + index, + value.as_ref().try_into().map_err(|_| NitroError::BadPcrLength { + index, + expected: NITRO_PCR_LENGTH, + actual: value.as_ref().len(), + })?, + ); + } + + Ok(MultiMeasurements::Nitro(measurements)) +} + +#[cfg(any(test, feature = "mock"))] +fn mock_nitro_pki() -> &'static nsm_nitro_enclave_utils_keygen::NsmCertChain { + use std::time::Duration; + + use once_cell::sync::Lazy; + + static MOCK_NITRO_PKI: Lazy = Lazy::new(|| { + nsm_nitro_enclave_utils_keygen::NsmCertChain::generate(Duration::from_secs(3600)) + }); + + &MOCK_NITRO_PKI +} + +#[cfg(any(test, feature = "mock"))] +fn mock_nitro_root_cert_der() -> Result, NitroError> { + use nsm_nitro_enclave_utils_keygen::DerEncodeExt; + + mock_nitro_pki().root.to_der().map_err(|err| NitroError::MockPki(format!("{err:?}"))) +} + +#[cfg(any(test, feature = "mock"))] +fn mock_nitro_pcrs() -> nsm_nitro_enclave_utils::pcr::Pcrs { + use std::collections::BTreeMap; + + use nsm_nitro_enclave_utils::pcr::{PcrIndex, Pcrs}; + + Pcrs::seed(BTreeMap::from([ + (PcrIndex::Zero, "attested-tls-mock-nitro-pcr0".to_string()), + (PcrIndex::One, "attested-tls-mock-nitro-pcr1".to_string()), + (PcrIndex::Two, "attested-tls-mock-nitro-pcr2".to_string()), + (PcrIndex::Three, "attested-tls-mock-nitro-pcr3".to_string()), + (PcrIndex::Four, "attested-tls-mock-nitro-pcr4".to_string()), + (PcrIndex::Eight, "attested-tls-mock-nitro-pcr8".to_string()), + ])) +} + +/// Create a locally signed mock Nitro attestation document for tests and +/// local development. +#[cfg(any(test, feature = "mock"))] +pub fn create_mock_nitro_attestation(input_data: [u8; 64]) -> Result, NitroError> { + use nsm_nitro_enclave_utils::{api::SecretKey, driver::dev::DevNitro}; + use nsm_nitro_enclave_utils_keygen::DerEncodeExt; + + let pki = mock_nitro_pki(); + let signing_key_bytes = pki.end_signer.signing_key.to_bytes(); + let signing_key = SecretKey::from_slice(signing_key_bytes.as_ref()) + .map_err(|err| NitroError::MockPki(format!("{err:?}")))?; + + let nitro = DevNitro::builder( + signing_key, + ByteBuf::from( + pki.end_signer.cert.to_der().map_err(|err| NitroError::MockPki(format!("{err:?}")))?, + ), + ) + .ca_bundle(vec![ByteBuf::from( + pki.int.to_der().map_err(|err| NitroError::MockPki(format!("{err:?}")))?, + )]) + .pcrs(mock_nitro_pcrs()) + .build(); + + request_attestation(&nitro, input_data) +} + +/// Mock Nitro PCR values used in tests and local development. +#[cfg(any(test, feature = "mock"))] +pub fn mock_nitro_measurements() -> MultiMeasurements { + use nsm_nitro_enclave_utils::pcr::PcrIndex; + + let pcrs = mock_nitro_pcrs(); + MultiMeasurements::Nitro(HashMap::from([ + (0, pcrs.get(PcrIndex::Zero).as_ref().try_into().unwrap()), + (1, pcrs.get(PcrIndex::One).as_ref().try_into().unwrap()), + (2, pcrs.get(PcrIndex::Two).as_ref().try_into().unwrap()), + (3, pcrs.get(PcrIndex::Three).as_ref().try_into().unwrap()), + (4, pcrs.get(PcrIndex::Four).as_ref().try_into().unwrap()), + (8, pcrs.get(PcrIndex::Eight).as_ref().try_into().unwrap()), + ])) +} + +/// An error when generating or verifying AWS Nitro attestation. +#[derive(Error, Debug)] +pub enum NitroError { + #[error("Nitro Secure Module returned error: {0:?}")] + Nsm(ErrorCode), + #[error("Unexpected Nitro Secure Module response: {0}")] + UnexpectedResponse(String), + #[error("Nitro attestation verification: {0}")] + Verification(String), + #[error("Nitro attestation nonce is not as expected")] + InputMismatch, + #[error("Unsupported Nitro digest: {0:?}; expected SHA384")] + UnsupportedDigest(Digest), + #[error("Invalid Nitro PCR index: {0}")] + InvalidPcrIndex(usize), + #[error("Nitro PCR {index} has length {actual}, expected {expected}")] + BadPcrLength { index: u32, expected: usize, actual: usize }, + #[cfg(any(test, feature = "mock"))] + #[error("Mock Nitro PKI: {0}")] + MockPki(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AttestationExchangeMessage, AttestationType, AttestationVerifier}; + + #[test] + fn aws_signed_attestation_fixture_has_expected_measurements() { + let attestation = include_bytes!("../test-assets/aws-nitro-attestation-sample.bin"); + let timestamp_millis = 1_680_010_000_000; + let doc = decode_with_root_at_time( + attestation, + AWS_ROOT_CERT_DER, + Time::new(Box::new(move || timestamp_millis)), + ) + .unwrap(); + let measurements = measurements_from_doc(&doc).unwrap(); + let mut expected_pcrs = HashMap::from([ + ( + 3, + hex::decode( + "e48b6ac6bab30e3717d28c2c88f2ba8b614e454590eb00b26170eef0d707b5b8e3a97662c20b2ced6192d3aaa2f5e24e", + ) + .unwrap() + .try_into() + .unwrap(), + ), + ( + 4, + hex::decode( + "3413af1370600b63aef6362b3d2506bcd6b6c263c8736b913d09e83c8bf24f93eb23eb87b15672586ef78c4289594acd", + ) + .unwrap() + .try_into() + .unwrap(), + ), + ]); + for index in 0..16 { + expected_pcrs.entry(index).or_insert([0u8; NITRO_PCR_LENGTH]); + } + let expected = MultiMeasurements::Nitro(expected_pcrs); + + assert_eq!(measurements, expected); + } + + #[test] + fn another_aws_signed_attestation_fixture_has_expected_measurements() { + let attestation = include_bytes!("../test-assets/aws-nitro-1779365257362730433"); + let timestamp_millis = 1_779_365_255_000; + let doc = decode_with_root_at_time( + attestation, + AWS_ROOT_CERT_DER, + Time::new(Box::new(move || timestamp_millis)), + ) + .unwrap(); + let measurements = measurements_from_doc(&doc).unwrap(); + let mut expected_pcrs = HashMap::from([ + ( + 0, + hex::decode( + "5fd25293fa7f5682ab2290f0850da91ff42e7e37f79498a7f133dac86a66e678e3c399891a119d82ab35b2fca0d647fe", + ) + .unwrap() + .try_into() + .unwrap(), + ), + ( + 1, + hex::decode( + "0343b056cd8485ca7890ddd833476d78460aed2aa161548e4e26bedf321726696257d623e8805f3f605946b3d8b0c6aa", + ) + .unwrap() + .try_into() + .unwrap(), + ), + ( + 2, + hex::decode( + "c48f4b4ddb0711cac8c94de79f3e96e387eb52693cc3b1fb664ef90c7f9c5df602a16e7dabe6cad52e8791223ddf602b", + ) + .unwrap() + .try_into() + .unwrap(), + ), + ( + 3, + hex::decode( + "ea81e40d742a2d5c5f9e099f5abce802914b55d7b2df6eeda212f8b4a96581a15689220b8dfcdda1825e3cfe2b7d6a06", + ) + .unwrap() + .try_into() + .unwrap(), + ), + ( + 4, + hex::decode( + "50b5a4fd0bdcb66bfb04830da2a8baccf172629c3b30b8486c78ef06b18596fc4375fc0be0761e7033b41bf20ba28b41", + ) + .unwrap() + .try_into() + .unwrap(), + ), + ]); + for index in 0..16 { + expected_pcrs.entry(index).or_insert([0u8; NITRO_PCR_LENGTH]); + } + let expected = MultiMeasurements::Nitro(expected_pcrs); + + assert_eq!(measurements, expected); + } + + #[tokio::test] + async fn mock_nitro_verifier_supports_async_and_sync_verification() { + let input_data = [7u8; 64]; + let attestation = create_mock_nitro_attestation(input_data).unwrap(); + let exchange = + AttestationExchangeMessage { attestation_type: AttestationType::AwsNitro, attestation }; + let verifier = AttestationVerifier::mock(); + + let async_measurements = + verifier.verify_attestation(exchange.clone(), input_data).await.unwrap().unwrap(); + let sync_measurements = + verifier.verify_attestation_sync(exchange, input_data).unwrap().unwrap(); + + assert_eq!(async_measurements, mock_nitro_measurements()); + assert_eq!(sync_measurements, mock_nitro_measurements()); + } + + #[test] + fn nonce_mismatch_is_rejected() { + let attestation = create_mock_nitro_attestation([1u8; 64]).unwrap(); + let err = verify_nitro_attestation(attestation, [2u8; 64]).unwrap_err(); + + assert!(matches!(err, NitroError::InputMismatch)); + } + + #[test] + fn unsupported_digest_is_rejected() { + use std::collections::BTreeMap; + + use nsm_nitro_enclave_utils::api::ByteBuf; + + let doc = AttestationDoc { + module_id: "test".to_string(), + digest: Digest::SHA256, + timestamp: 0, + pcrs: BTreeMap::from([(0usize, ByteBuf::from(vec![0u8; 32]))]), + certificate: ByteBuf::from(Vec::new()), + cabundle: Vec::new(), + public_key: None, + user_data: None, + nonce: None, + }; + + let err = measurements_from_doc(&doc).unwrap_err(); + + assert!(matches!(err, NitroError::UnsupportedDigest(Digest::SHA256))); + } +} diff --git a/crates/attestation/test-assets/aws-nitro-1779365257362730433 b/crates/attestation/test-assets/aws-nitro-1779365257362730433 new file mode 100644 index 0000000..8e92a64 Binary files /dev/null and b/crates/attestation/test-assets/aws-nitro-1779365257362730433 differ diff --git a/crates/attestation/test-assets/aws-nitro-attestation-sample.bin b/crates/attestation/test-assets/aws-nitro-attestation-sample.bin new file mode 100644 index 0000000..e8dc09f Binary files /dev/null and b/crates/attestation/test-assets/aws-nitro-attestation-sample.bin differ