diff --git a/attestation-agent/attestation-agent/Cargo.toml b/attestation-agent/attestation-agent/Cargo.toml index ed15a8bc2..fb21cc0f8 100644 --- a/attestation-agent/attestation-agent/Cargo.toml +++ b/attestation-agent/attestation-agent/Cargo.toml @@ -48,6 +48,9 @@ tonic = { workspace = true, optional = true } ttrpc = { workspace = true, features = ["async"], optional = true } [dev-dependencies] +attester = { path = "../attester", default-features = false, features = [ + "test-utils", +] } rstest.workspace = true serial_test.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/attestation-agent/attestation-agent/src/bin/grpc-aa/main.rs b/attestation-agent/attestation-agent/src/bin/grpc-aa/main.rs index d44fb4de8..8bbf9d663 100644 --- a/attestation-agent/attestation-agent/src/bin/grpc-aa/main.rs +++ b/attestation-agent/attestation-agent/src/bin/grpc-aa/main.rs @@ -117,12 +117,14 @@ rpc: grpc let mut aa = AttestationAgent::new(config).context("start AA")?; let mut initdata_digest = None; + let mut initdata_alg = None; if let Some(initdata_toml_path) = cli.initdata_toml { info!("Initdata TOML file is given by parameter"); let initdata_toml = std::fs::read_to_string(&initdata_toml_path).context("read initdata toml file")?; - let (_, digest) = Initdata::parse_and_get_digest(&initdata_toml)?; + let (initdata, digest) = Initdata::parse_and_get_digest(&initdata_toml)?; aa.set_initdata_toml(initdata_toml); + initdata_alg = Some(initdata.algorithm); initdata_digest = Some(digest); } else if let Some(initdata) = cli.initdata_digest { info!("Initdata digest is given by parameter"); @@ -132,20 +134,31 @@ rpc: grpc initdata_digest = Some(initdata); } + // Open the eventlog first, so that initdata measured into it starts from a recovered log. + aa.init().await.context("init AA")?; + if let Some(initdata_digest) = initdata_digest { - let res = aa.bind_init_data(&initdata_digest).await.context( - "The initdata supplied by the parameter is inconsistent with that of the current platform.", - )?; + let res = aa + .bind_init_data(&initdata_digest) + .await + .context("Failed to bind initdata to the platform")?; match res { attester::InitDataResult::Ok => info!("Check initdata passed."), attester::InitDataResult::Unsupported => { info!("Platform does not support initdata checking. Jumping.") } + attester::InitDataResult::NotBound => { + // The event content names the hash algorithm, which only the TOML gives. + let alg = initdata_alg + .context("Initdata is not bound: pass --initdata-toml so AA can measure it")?; + aa.measure_init_data(alg, &initdata_digest) + .await + .context("Failed to measure initdata")?; + } } } - aa.init().await.context("init AA")?; debug!( "Attestation gRPC service listening on: {:?}", cli.attestation_sock diff --git a/attestation-agent/attestation-agent/src/bin/grpc-aa/server.rs b/attestation-agent/attestation-agent/src/bin/grpc-aa/server.rs index 3e654852d..73be22044 100644 --- a/attestation-agent/attestation-agent/src/bin/grpc-aa/server.rs +++ b/attestation-agent/attestation-agent/src/bin/grpc-aa/server.rs @@ -4,7 +4,7 @@ // use anyhow::*; -use attestation_agent::{AttestationAPIs, AttestationAgent, RuntimeMeasurement}; +use attestation_agent::{AttestationAPIs, AttestationAgent, InitDataResult, RuntimeMeasurement}; use protos::grpc::aa::attestation_agent::{ BindInitDataRequest, BindInitDataResponse, ExtendRuntimeMeasurementRequest, ExtendRuntimeMeasurementResponse, GetAdditionalEvidenceRequest, GetAdditionalTeesRequest, @@ -143,7 +143,8 @@ impl AttestationAgentService for AA { debug!("AA (grpc): bind init data ..."); - self.inner + let result = self + .inner .bind_init_data(&request.digest) .await .map_err(|e| { @@ -151,6 +152,14 @@ impl AttestationAgentService for AA { Status::internal(format!("[ERROR:{AGENT_NAME}] AA binding init data failed")) })?; + // A digest alone can't be measured instead, so an unset register is a failure here. + if let InitDataResult::NotBound = result { + error!("AA (grpc): init data is not bound: the platform register for it is unset"); + return Err(Status::failed_precondition(format!( + "[ERROR:{AGENT_NAME}] AA init data is not bound" + ))); + } + debug!("AA (grpc): init data binding successfully!"); let reply = BindInitDataResponse {}; diff --git a/attestation-agent/attestation-agent/src/bin/ttrpc-aa.rs b/attestation-agent/attestation-agent/src/bin/ttrpc-aa.rs index 19551098d..29a69681b 100644 --- a/attestation-agent/attestation-agent/src/bin/ttrpc-aa.rs +++ b/attestation-agent/attestation-agent/src/bin/ttrpc-aa.rs @@ -141,12 +141,14 @@ rpc: ttrpc let mut aa = AttestationAgent::new(config).context("start AA")?; let mut initdata_digest = None; + let mut initdata_alg = None; if let Some(initdata_toml_path) = cli.initdata_toml { info!("Initdata TOML file is given by parameter"); let initdata_toml = std::fs::read_to_string(&initdata_toml_path).context("read initdata toml file")?; - let (_, digest) = Initdata::parse_and_get_digest(&initdata_toml)?; + let (initdata, digest) = Initdata::parse_and_get_digest(&initdata_toml)?; aa.set_initdata_toml(initdata_toml); + initdata_alg = Some(initdata.algorithm); initdata_digest = Some(digest); } else if let Some(initdata) = cli.initdata_digest { info!("Initdata digest is given by parameter"); @@ -156,20 +158,31 @@ rpc: ttrpc initdata_digest = Some(initdata); } + // Open the eventlog first, so that initdata measured into it starts from a recovered log. + aa.init().await.context("init AA")?; + if let Some(initdata_digest) = initdata_digest { - let res = aa.bind_init_data(&initdata_digest).await.context( - "The initdata supplied by the parameter is inconsistent with that of the current platform.", - )?; + let res = aa + .bind_init_data(&initdata_digest) + .await + .context("Failed to bind initdata to the platform")?; match res { attester::InitDataResult::Ok => info!("Check initdata passed."), attester::InitDataResult::Unsupported => { info!("Platform does not support initdata checking. Jumping.") } + attester::InitDataResult::NotBound => { + // The event content names the hash algorithm, which only the TOML gives. + let alg = initdata_alg + .context("Initdata is not bound: pass --initdata-toml so AA can measure it")?; + aa.measure_init_data(alg, &initdata_digest) + .await + .context("Failed to measure initdata")?; + } } } - aa.init().await.context("init AA")?; let att = start_ttrpc_service(aa)?; let mut atts = Server::new() diff --git a/attestation-agent/attestation-agent/src/eventlog/mod.rs b/attestation-agent/attestation-agent/src/eventlog/mod.rs index 414573646..e82706c3d 100644 --- a/attestation-agent/attestation-agent/src/eventlog/mod.rs +++ b/attestation-agent/attestation-agent/src/eventlog/mod.rs @@ -9,7 +9,7 @@ use std::{ fmt::Display, fs::{File, remove_file}, io::{Read, Seek, SeekFrom, Write}, - path::Path, + path::{Path, PathBuf}, sync::Arc, }; @@ -35,7 +35,8 @@ pub struct EventLog { writer: Box, rtmr_extender: Arc, alg: HashAlgorithm, - pcr: u64, + log_path: PathBuf, + wal_path: PathBuf, } trait Writer: Sync + Send { @@ -97,6 +98,9 @@ struct WalCache { /// The offset of the event data in the event log. event_offset: u64, + + /// The PCR being extended. + pcr: u64, } impl EventLog { @@ -108,23 +112,31 @@ impl EventLog { (tcg2_event.to_le_bytes(), digest) } - pub async fn new(rtmr_extender: Arc, pcr: u64) -> Result { + pub async fn new(rtmr_extender: Arc) -> Result { tokio::fs::create_dir_all(EVENTLOG_PARENT_DIR_PATH) .await .context("create eventlog parent dir")?; - let mut writer = - Box::new(FileWriter::open(Path::new(EVENTLOG_PATH)).context("open AAEL file")?); + Self::open(rtmr_extender, Path::new(EVENTLOG_PATH), WAL_CACHE.into()).await + } + + pub(crate) async fn open( + rtmr_extender: Arc, + log_path: &Path, + wal_path: PathBuf, + ) -> Result { + let mut writer = Box::new(FileWriter::open(log_path).context("open AAEL file")?); let alg = rtmr_extender.ccel_hash_algorithm(); // if any WAL cache file exists, we should handle recovering from crash - match Self::read_wal_cache(alg.digest_len()) { + match Self::read_wal_cache_from(&wal_path, alg.digest_len()) { Ok(Some(wal_cache)) => { warn!("Recover from a previous crash."); + let wal_pcr = wal_cache.pcr; let current_pcr = rtmr_extender - .get_runtime_measurement(pcr) + .get_runtime_measurement(wal_pcr) .await .context("get runtime measurement")?; let aael_event = Event::try_from(&wal_cache.event_data[..])?; - let rtmr = rtmr_extender.pcr_to_ccmr(pcr); + let rtmr = rtmr_extender.pcr_to_ccmr(wal_pcr); let (tcg2_event_data, tcg2_event_digest) = Self::serialize_tcg2_event(aael_event, rtmr, alg); @@ -137,24 +149,25 @@ impl EventLog { if digest_to_be_updated != wal_cache.expected_pcr { bail!( - "fatal error when recovering. The eventlog file {EVENTLOG_PATH} is probably corrupted, or other process has extend the target PCR {pcr}." + "fatal error when recovering. The eventlog file {EVENTLOG_PATH} is probably corrupted, or other process has extend the target PCR {wal_pcr}." ) } // else, update the PCR rtmr_extender - .extend_runtime_measurement(tcg2_event_digest, pcr) + .extend_runtime_measurement(tcg2_event_digest, wal_pcr) .await?; } writer.seek(wal_cache.event_offset)?; writer.write(&tcg2_event_data)?; - Self::clean_wal_cache()?; + remove_file(&wal_path)?; Ok(Self { writer, rtmr_extender, alg, - pcr, + log_path: log_path.to_path_buf(), + wal_path, }) } Err(_) => bail!( @@ -164,33 +177,39 @@ impl EventLog { writer, rtmr_extender, alg, - pcr, + log_path: log_path.to_path_buf(), + wal_path, }), } } + /// Contents of the logged events with this domain and operation, oldest first. + /// Only meaningful after WAL recovery, which `open` performs before returning. + pub fn logged_contents(&self, domain: &str, operation: &str) -> Result> { + let path = self.log_path.display(); + let log = + std::fs::read(&self.log_path).with_context(|| format!("read AAEL file {path}"))?; + let entries = + tcg2::aael_plaintexts(&log).with_context(|| format!("parse AAEL file {path}"))?; + let prefix = format!("{domain} {operation} "); + Ok(entries + .into_iter() + .filter_map(|e| e.strip_prefix(&prefix).map(str::to_owned)) + .collect()) + } + /// Record the event and the target digest into cache file before write, this would do /// help when there is a crash between extending PCR and logging event. fn write_wal_cache(&self, wal_cache: WalCache) -> Result<()> { - let mut file = File::create(WAL_CACHE)?; + let mut file = File::create(&self.wal_path)?; file.write_all(&wal_cache.event_offset.to_be_bytes())?; + file.write_all(&wal_cache.pcr.to_be_bytes())?; file.write_all(wal_cache.expected_pcr.as_ref())?; file.write_all(wal_cache.event_data.as_ref())?; file.sync_data()?; Ok(()) } - /// Remove the wal cache file. - pub fn clean_wal_cache() -> Result<()> { - remove_file(WAL_CACHE)?; - Ok(()) - } - - /// Try to read the wal cache file. - fn read_wal_cache(digest_len: usize) -> Result> { - Self::read_wal_cache_from(Path::new(WAL_CACHE), digest_len) - } - fn read_wal_cache_from(path: &Path, digest_len: usize) -> Result> { if !path.exists() { return Ok(None); @@ -200,6 +219,10 @@ impl EventLog { file.read_exact(&mut event_offset)?; let event_offset = u64::from_be_bytes(event_offset); + let mut pcr = [0u8; 8]; + file.read_exact(&mut pcr)?; + let pcr = u64::from_be_bytes(pcr); + let mut expected_pcr = vec![0u8; digest_len]; file.read_exact(&mut expected_pcr)?; @@ -210,6 +233,7 @@ impl EventLog { expected_pcr, event_data, event_offset, + pcr, })) } @@ -227,7 +251,7 @@ impl EventLog { /// we can remove the WAL cache file mechanism. pub async fn extend_entry(&mut self, log_entry: Event<'_>, pcr: u64) -> Result<()> { let aael_event_data = log_entry.to_string(); - let rtmr = self.rtmr_extender.pcr_to_ccmr(self.pcr); + let rtmr = self.rtmr_extender.pcr_to_ccmr(pcr); let (tcg2_event_data, event_digest) = Self::serialize_tcg2_event(log_entry, rtmr, self.alg); let mut current_pcr = self.rtmr_extender.get_runtime_measurement(pcr).await?; @@ -239,6 +263,7 @@ impl EventLog { expected_pcr, event_data: aael_event_data, event_offset, + pcr, }; self.write_wal_cache(wal_cache) .context("write wal cache file failed")?; @@ -250,7 +275,7 @@ impl EventLog { .write(&tcg2_event_data) .context("write log entry")?; - Self::clean_wal_cache().context("remove wal cache file failed")?; + remove_file(&self.wal_path).context("remove wal cache file failed")?; Ok(()) } } @@ -319,7 +344,9 @@ impl Display for Event<'_> { mod tests { use super::*; use attester::detect_tee_type; + use attester::test_utils::FakeTdx; use rstest::rstest; + use std::collections::HashMap; use std::sync::{Arc, Mutex}; struct TestWriter { @@ -349,6 +376,7 @@ mod tests { let wal_path = tmp.path().join("wal_cache"); let mut raw = vec![0u8, 0, 0, 0, 0, 0, 2, 0]; + raw.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 8]); raw.extend_from_slice(&[0xab; 32]); raw.extend_from_slice(b"domain operation content"); std::fs::write(&wal_path, raw).unwrap(); @@ -357,6 +385,7 @@ mod tests { .unwrap() .unwrap(); assert_eq!(wal_cache.event_offset, 512); + assert_eq!(wal_cache.pcr, 8); assert_eq!(wal_cache.expected_pcr, vec![0xab; 32]); assert_eq!(wal_cache.event_data, "domain operation content"); } @@ -375,6 +404,91 @@ mod tests { assert_eq!(std::fs::read(&log_path).unwrap(), b"01AB456789"); } + async fn open_fake(dir: &Path, fake: FakeTdx) -> Result { + let attester: BoxedAttester = Box::new(fake); + EventLog::open(Arc::new(attester), &dir.join("eventlog"), dir.join("wal")).await + } + + fn event() -> Event<'static> { + Event::new("domain", "operation", "content").unwrap() + } + + /// The target register recorded in each entry. Each entry is laid out as + /// `Tcg2EventEntry::to_le_bytes` writes it: + /// + /// | field | type | bytes | notes | + /// |------------------|------|-------|---------------------------------------| + /// | target register | u32 | 4 | CC MR index, e.g. 3 (RTMR2) for PCR 8 | + /// | event type | u32 | 4 | | + /// | digest count | u32 | 4 | always 1: one SHA-384 digest | + /// | algorithm ID | u16 | 2 | 0x000C = SHA-384 | + /// | digest | | 48 | SHA-384 output | + /// | event data len | u32 | 4 | | + /// | event data | | len | | + fn logged_registers(log: &[u8]) -> Vec { + // target register + event type + digest count + const HEADER_LEN: usize = 4 + 4 + 4; + const ALG_ID_LEN: usize = 2; + + let mut registers = vec![]; + let mut i = 0; + while i < log.len() { + registers.push(u32::from_le_bytes(log[i..i + 4].try_into().unwrap())); + let data_len_at = i + HEADER_LEN + ALG_ID_LEN + HashAlgorithm::Sha384.digest_len(); + let data_len = + u32::from_le_bytes(log[data_len_at..data_len_at + 4].try_into().unwrap()) as usize; + i = data_len_at + 4 + data_len; + } + registers + } + + #[tokio::test] + async fn test_extend_entry_logs_the_register_it_extends() { + let tmp = tempfile::tempdir().unwrap(); + let registers = Arc::new(Mutex::new(HashMap::new())); + let fake = FakeTdx { + registers: registers.clone(), + ..Default::default() + }; + let mut eventlog = open_fake(tmp.path(), fake).await.unwrap(); + eventlog.extend_entry(event(), 8).await.unwrap(); + + let log = std::fs::read(tmp.path().join("eventlog")).unwrap(); + assert_eq!(logged_registers(&log), vec![3]); + assert!(!registers.lock().unwrap().contains_key(&17)); + } + + #[tokio::test] + async fn test_wal_recovery_extends_the_register_it_was_given() { + let tmp = tempfile::tempdir().unwrap(); + let registers = Arc::new(Mutex::new(HashMap::new())); + let crashing = FakeTdx { + registers: registers.clone(), + crash_after_extend: true, + }; + let mut eventlog = open_fake(tmp.path(), crashing).await.unwrap(); + assert!(eventlog.extend_entry(event(), 8).await.is_err()); + drop(eventlog); + let pcr8 = registers.lock().unwrap()[&8].clone(); + + let fake = FakeTdx { + registers: registers.clone(), + ..Default::default() + }; + open_fake(tmp.path(), fake).await.unwrap(); + + { + let registers = registers.lock().unwrap(); + assert!( + !registers.contains_key(&17), + "recovery extended the default PCR" + ); + assert_eq!(registers[&8], pcr8, "PCR 8 must not be extended twice"); + } + let log = std::fs::read(tmp.path().join("eventlog")).unwrap(); + assert_eq!(logged_registers(&log), vec![3]); + } + #[test] fn test_content() { let a_str = "hello"; @@ -401,9 +515,10 @@ mod tests { let mut el = EventLog { writer: Box::new(tw), - pcr: 17, rtmr_extender: Arc::new(rtmr_extender), alg: HashAlgorithm::Sha384, + log_path: EVENTLOG_PATH.into(), + wal_path: WAL_CACHE.into(), }; el.extend_entry( @@ -482,7 +597,7 @@ mod tests { let tee = detect_tee_type(); let rtmr_extender = BoxedAttester::try_from(tee).expect("Failed to create BoxedAttester from Tee type"); - let mut eventlog = EventLog::new(Arc::new(rtmr_extender), 17).await.unwrap(); + let mut eventlog = EventLog::new(Arc::new(rtmr_extender)).await.unwrap(); eventlog .extend_entry( Event { @@ -518,7 +633,7 @@ mod tests { let tee = detect_tee_type(); let rtmr_extender = BoxedAttester::try_from(tee).expect("Failed to create BoxedAttester from Tee type"); - let mut eventlog = EventLog::new(Arc::new(rtmr_extender), 17).await.unwrap(); + let mut eventlog = EventLog::new(Arc::new(rtmr_extender)).await.unwrap(); eventlog .extend_entry( Event { diff --git a/attestation-agent/attestation-agent/src/eventlog/tcg2.rs b/attestation-agent/attestation-agent/src/eventlog/tcg2.rs index b0147eb46..ff1b774c6 100644 --- a/attestation-agent/attestation-agent/src/eventlog/tcg2.rs +++ b/attestation-agent/attestation-agent/src/eventlog/tcg2.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // +use anyhow::{Context, Result, bail}; use kbs_types::HashAlgorithm; use serde::Serialize; @@ -163,3 +164,95 @@ impl Tcg2EventEntry { (self, digest) } } + +/// Return the AAEL plaintext of each entry in a log written by `EventLog`. +/// +/// Every entry has the shape `Tcg2EventEntry::to_le_bytes` produces, so a truncated tail +/// or any unexpected header is an error rather than something to skip. +pub fn aael_plaintexts(mut log: &[u8]) -> Result> { + fn take<'a>(buf: &mut &'a [u8], n: usize) -> Result<&'a [u8]> { + if buf.len() < n { + bail!("truncated AAEL entry"); + } + let (head, tail) = buf.split_at(n); + *buf = tail; + Ok(head) + } + fn u32_le(buf: &mut &[u8]) -> Result { + Ok(u32::from_le_bytes(take(buf, 4)?.try_into()?)) + } + + let mut plaintexts = vec![]; + while !log.is_empty() { + let _register = u32_le(&mut log)?; + let event_type = u32_le(&mut log)?; + let digest_count = u32_le(&mut log)?; + if event_type != EV_EVENT_TAG_TYPE || digest_count != 1 { + bail!("unexpected AAEL entry header"); + } + let digest_len = match u16::from_le_bytes(take(&mut log, 2)?.try_into()?) { + 0xB | 0x12 => 32, + 0xC => 48, + 0xD => 64, + alg => bail!("unknown AAEL digest algorithm {alg:#x}"), + }; + take(&mut log, digest_len)?; + let data_len = u32_le(&mut log)? as usize; + let mut data = take(&mut log, data_len)?; + let tag = u32_le(&mut data)?; + if tag != AAEL_TAGGED_EVENT_ID || u32_le(&mut data)? as usize != data.len() { + bail!("malformed AAEL tagged event"); + } + plaintexts.push(std::str::from_utf8(data).context("AAEL plaintext is not UTF-8")?); + } + Ok(plaintexts) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + // Byte offsets in a SHA-384 entry, as `Tcg2EventEntry::to_le_bytes` lays it out. + const EVENT_TYPE: usize = 4; + const DIGEST_COUNT: usize = 8; + const ALGORITHM: usize = 12; + const TAG: usize = 66; + const TEXT_LEN: usize = 70; + + fn entry(content: &str) -> Vec { + let event = Event::new("domain", "operation", content).unwrap(); + let (entry, _) = Tcg2EventEntry::from(event) + .with_target_measurement_register(4) + .digest(HashAlgorithm::Sha384); + entry.to_le_bytes() + } + + fn set_u32(entry: &mut [u8], at: usize, value: u32) { + entry[at..at + 4].copy_from_slice(&value.to_le_bytes()); + } + + #[test] + fn test_aael_plaintexts_reads_every_entry() { + let log = [entry("one"), entry("two")].concat(); + assert_eq!( + aael_plaintexts(&log).unwrap(), + ["domain operation one", "domain operation two"] + ); + } + + #[rstest] + #[case::truncated(|e: &mut Vec| { e.pop(); })] + #[case::event_type(|e: &mut Vec| set_u32(e, EVENT_TYPE, 1))] + #[case::digest_count(|e: &mut Vec| set_u32(e, DIGEST_COUNT, 2))] + #[case::algorithm(|e: &mut Vec| e[ALGORITHM..ALGORITHM + 2].copy_from_slice(&4u16.to_le_bytes()))] + #[case::tag(|e: &mut Vec| set_u32(e, TAG, 0))] + #[case::text_len(|e: &mut Vec| set_u32(e, TEXT_LEN, 4))] + #[case::not_utf8(|e: &mut Vec| *e.last_mut().unwrap() = 0xff)] + fn test_aael_plaintexts_rejects_a_malformed_entry(#[case] corrupt: fn(&mut Vec)) { + let mut bad = entry("two"); + corrupt(&mut bad); + let log = [entry("one"), bad].concat(); + assert!(aael_plaintexts(&log).is_err()); + } +} diff --git a/attestation-agent/attestation-agent/src/lib.rs b/attestation-agent/attestation-agent/src/lib.rs index 456a547a7..7a38ad688 100644 --- a/attestation-agent/attestation-agent/src/lib.rs +++ b/attestation-agent/attestation-agent/src/lib.rs @@ -3,10 +3,10 @@ // SPDX-License-Identifier: Apache-2.0 // -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use async_trait::async_trait; use attester::{BoxedAttester, detect_attestable_devices, detect_tee_type}; -use kbs_types::Tee; +use kbs_types::{HashAlgorithm, Tee}; use std::{collections::HashMap, str::FromStr, sync::Arc}; use tokio::sync::{Mutex, RwLock}; @@ -25,6 +25,9 @@ use tracing::{debug, info}; use crate::{config::Config, eventlog::Event}; +const INITDATA_EVENT_DOMAIN: &str = "github.com/confidential-containers"; +const INITDATA_EVENT_OPERATION: &str = "InitData"; + pub enum RuntimeMeasurement { /// The runtime measurement is extended successfully. Ok, @@ -108,11 +111,7 @@ impl AttestationAgent { pub async fn init(&mut self) -> Result<()> { let config = self.config.read().await; if config.eventlog_config.enable_eventlog { - let eventlog = EventLog::new( - self.primary_attester.clone(), - config.eventlog_config.init_pcr, - ) - .await?; + let eventlog = EventLog::new(self.primary_attester.clone()).await?; self.eventlog = Some(Mutex::new(eventlog)); } @@ -120,6 +119,24 @@ impl AttestationAgent { Ok(()) } + /// Bind the initdata digest through an `InitData` event in the eventlog's default register + /// (RTMR3 on TDX), for when [`AttestationAPIs::bind_init_data`] returns `NotBound`. Needs + /// the eventlog to be enabled. + pub async fn measure_init_data(&self, alg: HashAlgorithm, digest: &[u8]) -> Result<()> { + let Some(eventlog) = &self.eventlog else { + bail!( + "initdata is not bound: enable the eventlog to bind it through a runtime measurement" + ); + }; + if !self.primary_attester.supports_runtime_measurement() { + bail!("initdata is not bound and this platform cannot extend runtime measurements"); + } + let pcr = self.config.read().await.eventlog_config.init_pcr; + let extended = extend_init_data_once(&mut *eventlog.lock().await, pcr, alg, digest).await?; + info!("Initdata measured into the eventlog (extended: {extended})."); + Ok(()) + } + /// Create a new instance of [AttestationAgent]. pub fn new(config: Config) -> Result { let config = RwLock::new(config); @@ -260,7 +277,8 @@ impl AttestationAPIs for AttestationAgent { } /// Perform the initdata binding. If current platform does not support initdata - /// binding, return `InitdataResult::Unsupported`. + /// binding, return `InitdataResult::Unsupported`; if it does but the register for it is + /// unset, return `InitDataResult::NotBound`. async fn bind_init_data(&self, init_data: &[u8]) -> Result { self.primary_attester.bind_init_data(init_data).await } @@ -275,3 +293,121 @@ impl AttestationAPIs for AttestationAgent { self.additional_attesters.keys().cloned().collect() } } + +/// Extend and log the initdata digest once per boot. Returns false when an earlier AA run +/// already recorded the same digest; a different recorded digest is an error. +async fn extend_init_data_once( + eventlog: &mut EventLog, + pcr: u64, + alg: HashAlgorithm, + digest: &[u8], +) -> Result { + // Canonical JSON (RFC 8785), as the CoCo eventlog spec requires for event content. + let content = format!(r#"{{"digest":"{alg}:{}"}}"#, hex::encode(digest)); + match eventlog + .logged_contents(INITDATA_EVENT_DOMAIN, INITDATA_EVENT_OPERATION)? + .as_slice() + { + [] => { + let event = Event::new(INITDATA_EVENT_DOMAIN, INITDATA_EVENT_OPERATION, &content)?; + eventlog.extend_entry(event, pcr).await?; + Ok(true) + } + [logged] if *logged == content => Ok(false), + logged => bail!("eventlog already records initdata {logged:?}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use attester::test_utils::FakeTdx; + use std::collections::HashMap; + use std::path::Path; + use std::sync::Mutex as StdMutex; + + type Registers = Arc>>>; + + const PCR: u64 = 17; + + /// Open the eventlog in `dir` the way a fresh AA process would, sharing `registers` + /// so they outlive the instance like real RTMRs across an AA restart. + async fn open(dir: &Path, registers: &Registers, crash_after_extend: bool) -> Result { + let fake = FakeTdx { + registers: registers.clone(), + crash_after_extend, + }; + let attester: BoxedAttester = Box::new(fake); + EventLog::open(Arc::new(attester), &dir.join("eventlog"), dir.join("wal")).await + } + + async fn record(dir: &Path, registers: &Registers, digest: &[u8]) -> Result { + let mut eventlog = open(dir, registers, false).await?; + extend_init_data_once(&mut eventlog, PCR, HashAlgorithm::Sha384, digest).await + } + + fn rtmr3(registers: &Registers) -> Option> { + registers.lock().unwrap().get(&PCR).cloned() + } + + #[tokio::test] + async fn test_extend_init_data_once_once_across_restarts() { + let tmp = tempfile::tempdir().unwrap(); + let registers = Registers::default(); + + assert!(record(tmp.path(), ®isters, &[1; 48]).await.unwrap()); + let after_first = rtmr3(®isters).expect("RTMR3 extended"); + + assert!(!record(tmp.path(), ®isters, &[1; 48]).await.unwrap()); + assert_eq!(rtmr3(®isters).unwrap(), after_first); + + let eventlog = open(tmp.path(), ®isters, false).await.unwrap(); + let logged = eventlog + .logged_contents(INITDATA_EVENT_DOMAIN, INITDATA_EVENT_OPERATION) + .unwrap(); + assert_eq!( + logged, + [format!(r#"{{"digest":"sha384:{}"}}"#, "01".repeat(48))] + ); + } + + #[tokio::test] + async fn test_extend_init_data_once_rejects_a_different_digest() { + let tmp = tempfile::tempdir().unwrap(); + let registers = Registers::default(); + + record(tmp.path(), ®isters, &[1; 48]).await.unwrap(); + let after_first = rtmr3(®isters); + + assert!(record(tmp.path(), ®isters, &[2; 48]).await.is_err()); + assert_eq!(rtmr3(®isters), after_first); + } + + #[tokio::test] + async fn test_extend_init_data_once_rejects_a_malformed_log() { + let tmp = tempfile::tempdir().unwrap(); + let registers = Registers::default(); + std::fs::write(tmp.path().join("eventlog"), b"junk").unwrap(); + + assert!(record(tmp.path(), ®isters, &[1; 48]).await.is_err()); + assert_eq!(rtmr3(®isters), None); + } + + #[tokio::test] + async fn test_extend_init_data_once_after_a_crash_recovers_first() { + let tmp = tempfile::tempdir().unwrap(); + let registers = Registers::default(); + + // Crash after the extend lands but before the entry is written. + let mut eventlog = open(tmp.path(), ®isters, true).await.unwrap(); + let crashed = + extend_init_data_once(&mut eventlog, PCR, HashAlgorithm::Sha384, &[1; 48]).await; + assert!(crashed.is_err()); + drop(eventlog); + let after_crash = rtmr3(®isters).expect("RTMR3 extended before the crash"); + + // WAL recovery writes the entry, so the restart finds it and does not extend again. + assert!(!record(tmp.path(), ®isters, &[1; 48]).await.unwrap()); + assert_eq!(rtmr3(®isters).unwrap(), after_crash); + } +} diff --git a/attestation-agent/attester/Cargo.toml b/attestation-agent/attester/Cargo.toml index 7c2e5118b..b4e9a40aa 100644 --- a/attestation-agent/attester/Cargo.toml +++ b/attestation-agent/attester/Cargo.toml @@ -115,4 +115,7 @@ se-attester = ["pv"] tpm-attester = ["tss-esapi", "picky-asn1", "picky-asn1-x509", "picky-asn1-der"] +# Fake attesters for other crates' tests. +test-utils = [] + bin = ["clap", "tracing-subscriber"] diff --git a/attestation-agent/attester/src/lib.rs b/attestation-agent/attester/src/lib.rs index ef7dd409e..2512e40e4 100644 --- a/attestation-agent/attester/src/lib.rs +++ b/attestation-agent/attester/src/lib.rs @@ -8,6 +8,8 @@ use kbs_types::{HashAlgorithm, Tee}; pub mod sample; pub mod sample_device; +#[cfg(any(test, feature = "test-utils"))] +pub mod test_utils; pub mod utils; #[cfg(feature = "az-snp-vtpm-attester")] @@ -199,6 +201,9 @@ impl TryFrom for BoxedAttester { pub enum InitDataResult { Ok, Unsupported, + /// The platform supports initdata binding, but the register meant to carry + /// the digest was left unset, so the digest is not bound yet. + NotBound, } pub type TeeEvidence = serde_json::Value; diff --git a/attestation-agent/attester/src/tdx/mod.rs b/attestation-agent/attester/src/tdx/mod.rs index a421e52d8..e76106365 100644 --- a/attestation-agent/attester/src/tdx/mod.rs +++ b/attestation-agent/attester/src/tdx/mod.rs @@ -186,13 +186,7 @@ impl Attester for TdxAttester { } }; - let mut init_data = init_data_digest.to_vec(); - init_data.resize(TDX_REGISTER_LENGTH, 0); - if init_data != mr_configid { - bail!("Init data does not match!"); - } - - Ok(InitDataResult::Ok) + check_mr_configid(init_data_digest, &mr_configid) } async fn get_runtime_measurement(&self, pcr_index: u64) -> Result> { @@ -225,9 +219,27 @@ impl Attester for TdxAttester { } } +/// An all-zero MRCONFIGID means the host did not set it, which is the case on +/// CSPs that don't expose it. Any other value must match the digest. +fn check_mr_configid(init_data_digest: &[u8], mr_configid: &[u8]) -> Result { + if mr_configid.iter().all(|b| *b == 0) { + return Ok(InitDataResult::NotBound); + } + + let mut init_data = init_data_digest.to_vec(); + init_data.resize(TDX_REGISTER_LENGTH, 0); + if init_data != mr_configid { + bail!("Init data does not match!"); + } + + Ok(InitDataResult::Ok) +} + #[cfg(test)] mod tests { use super::*; + use rstest::rstest; + use std::result::Result::Ok; #[ignore] #[tokio::test] @@ -246,4 +258,22 @@ mod tests { let report = attester.ioctl_get_report(); assert!(report.is_ok()); } + + fn outcome(result: Result) -> &'static str { + match result { + Ok(InitDataResult::Ok) => "bound", + Ok(InitDataResult::NotBound) => "not bound", + Ok(InitDataResult::Unsupported) => "unsupported", + Err(_) => "error", + } + } + + #[rstest] + #[case::matches([7u8; 48], "bound")] + #[case::unset([0u8; 48], "not bound")] + #[case::mismatch([9u8; 48], "error")] + fn test_check_mr_configid(#[case] mr_configid: [u8; 48], #[case] expected: &str) { + let digest = [7u8; 48]; + assert_eq!(outcome(check_mr_configid(&digest, &mr_configid)), expected); + } } diff --git a/attestation-agent/attester/src/test_utils.rs b/attestation-agent/attester/src/test_utils.rs new file mode 100644 index 000000000..91aef9467 --- /dev/null +++ b/attestation-agent/attester/src/test_utils.rs @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Confidential Containers Authors +// +// SPDX-License-Identifier: Apache-2.0 +// + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use anyhow::*; +use kbs_types::HashAlgorithm; + +use crate::{Attester, TeeEvidence}; + +/// A TDX-like attester with per-register software RTMRs. `crash_after_extend` fails the +/// call after the extend lands, as a crash right after extending would. +#[derive(Default)] +pub struct FakeTdx { + pub registers: Arc>>>, + pub crash_after_extend: bool, +} + +impl FakeTdx { + fn initial_value() -> Vec { + vec![0; HashAlgorithm::Sha384.digest_len()] + } +} + +#[async_trait::async_trait] +impl Attester for FakeTdx { + async fn get_evidence(&self, _: Vec) -> Result { + Ok(TeeEvidence::Null) + } + + fn supports_runtime_measurement(&self) -> bool { + true + } + + async fn extend_runtime_measurement(&self, digest: Vec, pcr: u64) -> Result<()> { + let mut registers = self.registers.lock().unwrap(); + let value = registers.entry(pcr).or_insert_with(Self::initial_value); + let mut buf = value.clone(); + buf.extend_from_slice(&digest); + *value = HashAlgorithm::Sha384.digest(&buf); + if self.crash_after_extend { + bail!("crash after extend"); + } + Ok(()) + } + + async fn get_runtime_measurement(&self, pcr: u64) -> Result> { + let registers = self.registers.lock().unwrap(); + Ok(registers + .get(&pcr) + .cloned() + .unwrap_or_else(Self::initial_value)) + } + + fn pcr_to_ccmr(&self, pcr: u64) -> u64 { + // Same mapping as the TDX attester. + match pcr { + 1 | 7 => 1, + 2..=6 => 2, + 8..=15 => 3, + _ => 4, + } + } + + fn ccel_hash_algorithm(&self) -> HashAlgorithm { + HashAlgorithm::Sha384 + } +}