diff --git a/src/cli.rs b/src/cli.rs index 9d336ec..a04b7a6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,6 +5,7 @@ use std::convert::TryFrom; use anyhow::{anyhow, bail, Error, Result}; +use base64::Engine; use serde::{Deserialize, Serialize}; use std::io::IsTerminal; use tpm2_policy::TPMPolicyStep; @@ -35,8 +36,9 @@ impl TryFrom<&TPM2Config> for TPMPolicyStep { match (&cfg.pcr_ids, &cfg.policy_pubkey_path) { (Some(_), Some(pubkey_path)) => Ok(TPMPolicyStep::Or([ Box::new(TPMPolicyStep::PCRs( - cfg.get_pcr_hash_alg(), - cfg.get_pcr_ids().unwrap(), + cfg.get_pcr_hash_alg()?, + cfg.get_pcr_ids()? + .ok_or_else(|| anyhow!("pcr_ids unexpectedly empty"))?, Box::new(TPMPolicyStep::NoStep), )), Box::new(get_authorized_policy_step( @@ -52,8 +54,9 @@ impl TryFrom<&TPM2Config> for TPMPolicyStep { Box::new(TPMPolicyStep::NoStep), ])), (Some(_), None) => Ok(TPMPolicyStep::PCRs( - cfg.get_pcr_hash_alg(), - cfg.get_pcr_ids().unwrap(), + cfg.get_pcr_hash_alg()?, + cfg.get_pcr_ids()? + .ok_or_else(|| anyhow!("pcr_ids unexpectedly empty"))?, Box::new(TPMPolicyStep::NoStep), )), (None, Some(pubkey_path)) => { @@ -71,36 +74,48 @@ pub(crate) const DEFAULT_POLICY_REF: &str = ""; impl TPM2Config { pub(super) fn get_pcr_hash_alg( &self, - ) -> tss_esapi::interface_types::algorithm::HashingAlgorithm { + ) -> anyhow::Result { crate::utils::get_hash_alg_from_name(self.pcr_bank.as_ref()) } pub(super) fn get_name_hash_alg( &self, - ) -> tss_esapi::interface_types::algorithm::HashingAlgorithm { + ) -> anyhow::Result { crate::utils::get_hash_alg_from_name(self.hash.as_ref()) } - pub(super) fn get_pcr_ids(&self) -> Option> { + pub(super) fn get_pcr_ids(&self) -> Result>> { match &self.pcr_ids { - None => None, + None => Ok(None), Some(serde_json::Value::Array(vals)) => { - Some(vals.iter().map(|x| x.as_u64().unwrap()).collect()) + let ids: Result> = vals + .iter() + .map(|x| { + x.as_u64() + .ok_or_else(|| anyhow!("non-u64 value in pcr_ids")) + }) + .collect(); + Ok(Some(ids?)) } - _ => panic!("Unexpected type found for pcr_ids"), + _ => bail!("Unexpected type found for pcr_ids"), } } - pub(super) fn get_pcr_ids_str(&self) -> Option { + pub(super) fn get_pcr_ids_str(&self) -> Result> { match &self.pcr_ids { - None => None, - Some(serde_json::Value::Array(vals)) => Some( - vals.iter() - .map(|x| x.as_u64().unwrap().to_string()) - .collect::>() - .join(","), - ), - _ => panic!("Unexpected type found for pcr_ids"), + None => Ok(None), + Some(serde_json::Value::Array(vals)) => { + let strs: Result> = vals + .iter() + .map(|x| { + x.as_u64() + .map(|v| v.to_string()) + .ok_or_else(|| anyhow!("non-u64 value in pcr_ids")) + }) + .collect(); + Ok(Some(strs?.join(","))) + } + _ => bail!("Unexpected type found for pcr_ids"), } } @@ -109,6 +124,53 @@ impl TPM2Config { if self.pcr_ids.is_some() && self.pcr_bank.is_none() { self.pcr_bank = Some("sha256".to_string()); } + if let Some(ref hash) = self.hash { + crate::utils::get_hash_alg_from_name(Some(hash))?; + } + if let Some(ref bank) = self.pcr_bank { + crate::utils::get_hash_alg_from_name(Some(bank))?; + } + // tpm2-policy 0.6.0 hardcodes SHA-256 for policy sessions on the + // decrypt path, so non-SHA-256 name hash with PCR binding would + // produce tokens that encrypt successfully but can never be unsealed. + if self.pcr_ids.is_some() { + if let Some(ref hash) = self.hash { + if hash.to_lowercase() != "sha256" { + bail!( + "non-SHA-256 hash is not supported with PCR binding \ + (tpm2-policy hardcodes SHA-256 for policy sessions)" + ); + } + } + } + if self.pcr_digest.is_some() && self.pcr_ids.is_none() { + bail!("pcr_digest requires pcr_ids"); + } + if let Some(ref digest) = self.pcr_digest { + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(digest) + .map_err(|e| anyhow!("invalid pcr_digest base64: {}", e))?; + if decoded.is_empty() { + bail!("pcr_digest must not be empty"); + } + if let Some(ref pcr_ids) = self.pcr_ids { + let num_pcrs = match pcr_ids { + serde_json::Value::Array(v) => v.len(), + _ => bail!("pcr_ids has unexpected type (expected array)"), + }; + let hash_size = crate::utils::hash_digest_size(self.pcr_bank.as_ref())?; + let expected = num_pcrs * hash_size; + if decoded.len() != expected { + bail!( + "pcr_digest length {} does not match expected {} ({} PCRs * {} bytes)", + decoded.len(), + expected, + num_pcrs, + hash_size + ); + } + } + } // Make use of the defaults if not specified if self.use_policy.is_some() && self.use_policy.unwrap() { if self.policy_path.is_none() { @@ -126,6 +188,9 @@ impl TPM2Config { { eprintln!("To use a policy, please specifiy use_policy: true. Not specifying this will be a fatal error in a next release"); } + if self.pcr_digest.is_some() && self.policy_pubkey_path.is_some() { + bail!("pcr_digest cannot be combined with authorized policy"); + } if (self.policy_pubkey_path.is_some() || self.policy_path.is_some() || self.policy_ref.is_some()) @@ -168,6 +233,10 @@ impl TPM2Config { if !new.is_u64() { bail!("Non-positive string int"); } + let v = new.as_u64().unwrap(); + if v > 23 { + bail!("PCR ID {} out of valid range (0-23)", v); + } Ok(new) } Err(_) => Err(anyhow!("Unparseable string int")), @@ -178,6 +247,10 @@ impl TPM2Config { if !new.is_u64() { return Err(anyhow!("Non-positive int")); } + let v = new.as_u64().unwrap(); + if v > 23 { + bail!("PCR ID {} out of valid range (0-23)", v); + } Ok(new) } _ => Err(anyhow!("Invalid value in pcr_ids")), @@ -186,9 +259,12 @@ impl TPM2Config { self.pcr_ids = Some(serde_json::Value::Array(newvals?)); } + if let Some(serde_json::Value::Array(ref mut vals)) = self.pcr_ids { + vals.sort_by_key(|v| v.as_u64().unwrap_or(0)); + } + match &self.pcr_ids { None => Ok(()), - // The normalization above would've caught any non-ints Some(serde_json::Value::Array(_)) => Ok(()), _ => Err(anyhow!("Invalid type")), } @@ -271,4 +347,167 @@ mod tests { let result = serde_json::from_str::(config_str); assert!(result.is_ok()); } + + #[test] + fn test_pcr_digest_with_policy_rejected() { + let config_str = r#"{"pcr_ids": [23], "pcr_digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "use_policy": true}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("pcr_digest cannot be combined")); + } + + #[test] + fn test_pcr_digest_empty_rejected() { + let config_str = r#"{"pcr_ids": [23], "pcr_digest": ""}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("must not be empty")); + } + + #[test] + fn test_pcr_digest_invalid_base64_rejected() { + let config_str = r#"{"pcr_ids": [23], "pcr_digest": "not!valid!base64"}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid pcr_digest")); + } + + #[test] + fn test_pcr_digest_wrong_length_rejected() { + // 27 A's = 20 bytes (SHA-1 size), but pcr_bank defaults to sha256 (32 bytes) + let config_str = r#"{"pcr_ids": [23], "pcr_digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("does not match expected")); + } + + #[test] + fn test_pcr_digest_correct_length_accepted() { + // 43 A's = 32 bytes, matching 1 PCR with default sha256 bank + let config_str = + r#"{"pcr_ids": [23], "pcr_digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_ok()); + } + + #[test] + fn test_pcr_digest_unsupported_bank_rejected() { + let config_str = + r#"{"pcr_ids": [23], "pcr_bank": "md5", "pcr_digest": "AAAAAAAAAAAAAAAA"}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unsupported")); + } + + #[test] + fn test_unsupported_hash_rejected() { + let config_str = r#"{"hash": "md5"}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unsupported")); + } + + #[test] + fn test_pcr_id_out_of_range_rejected() { + let config_str = r#"{"pcr_ids": [24]}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("out of valid range")); + } + + #[test] + fn test_pcr_id_large_value_rejected() { + let config_str = r#"{"pcr_ids": [4294967296]}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("out of valid range")); + } + + #[test] + fn test_pcr_digest_without_pcr_ids_rejected() { + let config_str = r#"{"pcr_digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("pcr_digest requires pcr_ids")); + } + + #[test] + fn test_non_sha256_hash_with_pcr_ids_rejected() { + let config_str = r#"{"hash": "sha384", "pcr_ids": [7]}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("non-SHA-256 hash")); + } + + #[test] + fn test_non_sha256_hash_with_pcr_digest_rejected() { + let config_str = + r#"{"hash": "sha384", "pcr_ids": [7], "pcr_digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("non-SHA-256 hash")); + } + + #[test] + fn test_non_sha256_hash_without_pcr_ids_accepted() { + let config_str = r#"{"hash": "sha384"}"#; + let result = serde_json::from_str::(config_str) + .unwrap() + .normalize(); + assert!(result.is_ok()); + } + + #[test] + fn test_pcr_ids_sorted_after_normalize() { + let config_str = r#"{"pcr_ids": [23, 7, 0]}"#; + let cfg = serde_json::from_str::(config_str) + .unwrap() + .normalize() + .unwrap(); + let ids = cfg.get_pcr_ids().unwrap().unwrap(); + assert_eq!(ids, vec![0, 7, 23]); + } } diff --git a/src/main.rs b/src/main.rs index d55d12a..c859833 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,10 +8,14 @@ use std::env; use std::io::{self, Read, Write}; use anyhow::{bail, Context, Error, Result}; +use base64::Engine; use josekit::jwe::{alg::direct::DirectJweAlgorithm::Dir, enc::A256GCM}; use serde::{Deserialize, Serialize}; use tpm2_policy::TPMPolicyStep; -use tss_esapi::structures::SensitiveData; +use tss_esapi::interface_types::algorithm::HashingAlgorithm; +use tss_esapi::interface_types::resource_handles::Hierarchy; +use tss_esapi::interface_types::session_handles::AuthSession; +use tss_esapi::structures::{MaxBuffer, PcrSelectionListBuilder, PcrSlot, SensitiveData}; mod cli; mod tpm_objects; @@ -19,12 +23,86 @@ mod utils; use cli::TPM2Config; +/// Compute a policy digest using caller-supplied PCR values instead of +/// reading them from the TPM. This is a workaround until tpm2-policy +/// supports pcr_digest natively. +/// +/// The pcr_digest is the base64url-no-padding encoding of the concatenated +/// raw PCR values (same format as tpm2_pcrread -o output / jose b64 enc). +fn compute_policy_digest_with_pcr_digest( + ctx: &mut tss_esapi::Context, + pcr_digest_b64: &str, + pcr_ids: &[u64], + pcr_hash_alg: HashingAlgorithm, + name_hash_alg: HashingAlgorithm, +) -> Result<(Option, Option)> { + use tss_esapi::constants::SessionType; + use tss_esapi::structures::SymmetricDefinition; + + // Decode the base64url-no-padding pcr_digest + let concatenated_pcr_values = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(pcr_digest_b64) + .context("Error decoding pcr_digest base64")?; + let concatenated_pcr_values = MaxBuffer::try_from(concatenated_pcr_values) + .context("Error converting pcr_digest to MaxBuffer")?; + + // Build PCR selection + // PcrSlot values are bitmask positions (Slot7 = 0x80 = 1 << 7), + // so we need to convert PCR IDs to bitmask form. + let pcr_slots: Result> = pcr_ids + .iter() + .map(|id| { + if *id > 23 { + anyhow::bail!("PCR ID {} out of valid range (0-23)", id); + } + let bitmask = 1u32 << (*id as u32); + PcrSlot::try_from(bitmask).map_err(|e| anyhow::anyhow!("Invalid PCR ID {}: {}", id, e)) + }) + .collect(); + let pcr_sel = PcrSelectionListBuilder::new() + .with_selection(pcr_hash_alg, &pcr_slots?) + .build() + .context("Error building PCR selection")?; + + // Hash the concatenated PCR values using the session hash algorithm + // (per TPM 2.0 Part 3, Section 23.7: pcrDigest uses the session hash, + // not the PCR bank hash). + let (hashed_data, _ticket) = ctx.execute_without_session(|context| { + context.hash(concatenated_pcr_values, name_hash_alg, Hierarchy::Owner) + })?; + + // Create a trial policy session + let trial_session = ctx + .start_auth_session( + None, + None, + None, + SessionType::Trial, + SymmetricDefinition::AES_128_CFB, + name_hash_alg, + )? + .ok_or_else(|| anyhow::anyhow!("Failed to create trial session"))?; + + // Apply the PCR policy and retrieve the digest. The closure ensures + // the trial session is always flushed, even on error. + let result = (|| -> Result { + ctx.policy_pcr(trial_session.try_into()?, hashed_data, pcr_sel)?; + Ok(ctx.policy_get_digest(trial_session.try_into()?)?) + })(); + + let session_handle: tss_esapi::handles::SessionHandle = trial_session.into(); + let _ = ctx.flush_context(session_handle.into()); + + Ok((None, Some(result?))) +} + fn perform_encrypt(cfg: TPM2Config, input: Vec) -> Result<()> { let key_type = match &cfg.key { None => "ecc", Some(key_type) => key_type, }; - let key_public = tpm_objects::get_key_public(key_type, cfg.get_name_hash_alg())?; + let name_hash_alg = cfg.get_name_hash_alg()?; + let key_public = tpm_objects::get_key_public(key_type, name_hash_alg)?; let mut ctx = utils::get_tpm2_ctx()?; let key_handle = utils::get_tpm2_primary_key(&mut ctx, key_public)?; @@ -37,13 +115,30 @@ fn perform_encrypt(cfg: TPM2Config, input: Vec) -> Result<()> { _ => "tpm2plus", }; - let (_, policy_digest) = policy_runner.send_policy(&mut ctx, true)?; + // If pcr_digest is provided, bypass tpm2-policy's PCR reading and + // manually construct the trial policy with the caller-supplied digest. + // This enables sealing to predicted/future PCR values. + let (_, policy_digest) = if let (Some(ref pcr_digest_b64), Some(ref pcr_ids)) = + (&cfg.pcr_digest, &cfg.get_pcr_ids()?) + { + let pcr_hash_alg = cfg.get_pcr_hash_alg()?; + compute_policy_digest_with_pcr_digest( + &mut ctx, + pcr_digest_b64, + pcr_ids, + pcr_hash_alg, + name_hash_alg, + )? + } else { + policy_runner.send_policy(&mut ctx, true)? + }; let mut jwk = josekit::jwk::Jwk::generate_oct_key(32).context("Error generating random JWK")?; jwk.set_key_operations(vec!["encrypt", "decrypt"]); let jwk_str = serde_json::to_string(&jwk.as_ref())?; - let public = tpm_objects::create_tpm2b_public_sealed_object(policy_digest)?.try_into()?; + let public = + tpm_objects::create_tpm2b_public_sealed_object(policy_digest, name_hash_alg)?.try_into()?; let jwk_str = SensitiveData::try_from(jwk_str.as_bytes().to_vec())?; let jwk_result = ctx.execute_with_nullauth_session(|ctx| { ctx.create(key_handle, public, None, Some(jwk_str), None, None) @@ -61,7 +156,7 @@ fn perform_encrypt(cfg: TPM2Config, input: Vec) -> Result<()> { jwk_pub, jwk_priv, pcr_bank: cfg.pcr_bank.clone(), - pcr_ids: cfg.get_pcr_ids_str(), + pcr_ids: cfg.get_pcr_ids_str()?, policy_pubkey_path: cfg.policy_pubkey_path, policy_ref: cfg.policy_ref, policy_path: cfg.policy_path, @@ -119,14 +214,21 @@ struct Tpm2Inner { } impl Tpm2Inner { - fn get_pcr_ids(&self) -> Option> { - Some( - self.pcr_ids - .as_ref()? - .split(',') - .map(|x| x.parse::().unwrap()) - .collect(), - ) + fn get_pcr_ids(&self) -> Result>> { + match &self.pcr_ids { + None => Ok(None), + Some(ids) => { + let parsed: Result> = ids + .split(',') + .map(|x| { + x.trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid PCR ID '{}': {}", x, e)) + }) + .collect(); + Ok(Some(parsed?)) + } + } } } @@ -137,8 +239,9 @@ impl TryFrom<&Tpm2Inner> for TPMPolicyStep { match (&cfg.pcr_ids, &cfg.policy_pubkey_path) { (Some(_), Some(pubkey_path)) => Ok(TPMPolicyStep::Or([ Box::new(TPMPolicyStep::PCRs( - utils::get_hash_alg_from_name(cfg.pcr_bank.as_ref()), - cfg.get_pcr_ids().unwrap(), + utils::get_hash_alg_from_name(cfg.pcr_bank.as_ref())?, + cfg.get_pcr_ids()? + .ok_or_else(|| anyhow::anyhow!("pcr_ids unexpectedly empty"))?, Box::new(TPMPolicyStep::NoStep), )), Box::new(utils::get_authorized_policy_step( @@ -154,8 +257,9 @@ impl TryFrom<&Tpm2Inner> for TPMPolicyStep { Box::new(TPMPolicyStep::NoStep), ])), (Some(_), None) => Ok(TPMPolicyStep::PCRs( - utils::get_hash_alg_from_name(cfg.pcr_bank.as_ref()), - cfg.get_pcr_ids().unwrap(), + utils::get_hash_alg_from_name(cfg.pcr_bank.as_ref())?, + cfg.get_pcr_ids()? + .ok_or_else(|| anyhow::anyhow!("pcr_ids unexpectedly empty"))?, Box::new(TPMPolicyStep::NoStep), )), (None, Some(pubkey_path)) => { @@ -191,7 +295,7 @@ fn perform_decrypt(input: Vec) -> Result<()> { let policy = TPMPolicyStep::try_from(&hdr_clevis.tpm2)?; - let name_alg = crate::utils::get_hash_alg_from_name(Some(&hdr_clevis.tpm2.hash)); + let name_alg = crate::utils::get_hash_alg_from_name(Some(&hdr_clevis.tpm2.hash))?; let key_public = tpm_objects::get_key_public(hdr_clevis.tpm2.key.as_str(), name_alg)?; let mut ctx = utils::get_tpm2_ctx()?; @@ -241,6 +345,10 @@ This command uses the following configuration properties: pcr_ids: PCR list used for policy. If not present, no PCR policy is used + pcr_digest: base64url-no-pad encoded concatenation of PCR values to seal + against, in ascending PCR index order. Requires pcr_ids. When + provided, uses the given values instead of reading live PCR state + use_policy: Whether to use a policy policy_ref: Reference to search for in signed policy file (default: {}) diff --git a/src/tpm_objects.rs b/src/tpm_objects.rs index a94f8e0..9aa221d 100644 --- a/src/tpm_objects.rs +++ b/src/tpm_objects.rs @@ -65,7 +65,15 @@ pub(super) fn get_key_public(key_type: &str, name_alg: HashingAlgorithm) -> Resu pub(super) fn create_tpm2b_public_sealed_object( policy: Option, + name_hash_alg: HashingAlgorithm, ) -> Result { + let name_alg = match name_hash_alg { + HashingAlgorithm::Sha1 => tss_constants::TPM2_ALG_SHA1, + HashingAlgorithm::Sha256 => tss_constants::TPM2_ALG_SHA256, + HashingAlgorithm::Sha384 => tss_constants::TPM2_ALG_SHA384, + HashingAlgorithm::Sha512 => tss_constants::TPM2_ALG_SHA512, + _ => bail!("Unsupported hash algorithm for sealed object"), + }; let mut object_attributes = ObjectAttributesBuilder::new() .with_fixed_tpm(true) .with_fixed_parent(true) @@ -87,7 +95,7 @@ pub(super) fn create_tpm2b_public_sealed_object( size: std::mem::size_of::() as u16, publicArea: tss_esapi::tss2_esys::TPMT_PUBLIC { type_: tss_constants::TPM2_ALG_KEYEDHASH, - nameAlg: tss_constants::TPM2_ALG_SHA256, + nameAlg: name_alg, objectAttributes: object_attributes.build()?.0, authPolicy: tss_esapi::tss2_esys::TPM2B_DIGEST::from(policy), parameters: params, @@ -173,3 +181,20 @@ pub(super) fn build_tpm2b_public(val: &[u8]) -> Result) -> HashingAlgorithm { +pub(crate) fn get_hash_alg_from_name(name: Option<&String>) -> Result { match name { - None => HashingAlgorithm::Sha256, + None => Ok(HashingAlgorithm::Sha256), Some(val) => match val.to_lowercase().as_str() { - "sha1" => HashingAlgorithm::Sha1, - "sha256" => HashingAlgorithm::Sha256, - "sha384" => HashingAlgorithm::Sha384, - "sha512" => HashingAlgorithm::Sha512, - _ => panic!("Unsupported hash algo: {:?}", name), + "sha1" => Ok(HashingAlgorithm::Sha1), + "sha256" => Ok(HashingAlgorithm::Sha256), + "sha384" => Ok(HashingAlgorithm::Sha384), + "sha512" => Ok(HashingAlgorithm::Sha512), + _ => bail!("Unsupported hash algorithm: {:?}", name), }, } } +pub(crate) fn hash_digest_size(name: Option<&String>) -> Result { + match get_hash_alg_from_name(name)? { + HashingAlgorithm::Sha1 => Ok(20), + HashingAlgorithm::Sha256 => Ok(32), + HashingAlgorithm::Sha384 => Ok(48), + HashingAlgorithm::Sha512 => Ok(64), + _ => bail!("Unsupported hash algorithm"), + } +} + pub(crate) fn serialize_as_base64_url_no_pad( bytes: &[u8], serializer: S, diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 2ee47b6..e34988e 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -5,6 +5,7 @@ use std::{io::Write, os::unix::process::CommandExt, process::Command}; use anyhow::{bail, Context, Result}; +use base64::Engine; type CheckFunction = dyn Fn(&str) -> Result<()>; struct EncryptFunc { @@ -20,6 +21,14 @@ struct DecryptFunc { const EXENAME: &str = env!("CARGO_BIN_EXE_clevis-pin-tpm2"); +// An arbitrary non-zero 32-byte value that will not match PCR 23's +// initial all-zeros state. The specific byte values do not matter. +const PCR23_SHA256_WRONG_DIGEST: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"; + +// An arbitrary non-zero 64-byte value for multi-PCR mismatch testing. +const PCR16_23_SHA256_WRONG_DIGEST: &str = + "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ"; + const CONFIG_STRINGS: &[(&str, &CheckFunction)] = &[ // No sealing (r#"{}"#, &always_success), @@ -31,6 +40,21 @@ const CONFIG_STRINGS: &[(&str, &CheckFunction)] = &[ (r#"{"pcr_ids": [23]}"#, &always_success), // sealed against SHA1 PCR23 (r#"{"pcr_bank": "sha1", "pcr_ids": [23]}"#, &always_success), + // Sealed against PCR23 with caller-supplied pcr_digest matching swtpm state + ( + r#"{"pcr_ids": [23], "pcr_digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#, + &check_no_pcr_digest_in_token, + ), + // Multi-PCR: sealed against PCR 16+23 with caller-supplied pcr_digest + ( + r#"{"pcr_ids": [16, 23], "pcr_digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#, + &check_no_pcr_digest_in_token, + ), + // SHA-1 bank: sealed against PCR 23 with caller-supplied pcr_digest + ( + r#"{"pcr_bank": "sha1", "pcr_ids": [23], "pcr_digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#, + &check_no_pcr_digest_in_token, + ), ]; // Check functions @@ -38,6 +62,28 @@ fn always_success(_token: &str) -> Result<()> { Ok(()) } +// Regression guard: verify pcr_digest is not persisted in the JWE token. +fn check_no_pcr_digest_in_token(token: &str) -> Result<()> { + let parts: Vec<&str> = token.trim().split('.').collect(); + if parts.len() != 5 { + bail!("JWE token does not have 5 parts (got {})", parts.len()); + } + let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[0]) + .context("Failed to decode JWE protected header")?; + let header: serde_json::Value = + serde_json::from_slice(&header_bytes).context("Failed to parse JWE header as JSON")?; + if let Some(tpm2) = header.get("clevis").and_then(|c| c.get("tpm2")) { + if tpm2.get("pcr_digest").is_some() { + bail!("pcr_digest was leaked into the JWE token header"); + } + } + if header.get("pcr_digest").is_some() { + bail!("pcr_digest was leaked into the top-level JWE header"); + } + Ok(()) +} + fn call_cmd_and_get_output(cmd: &mut Command, input: &str) -> Result { if let Ok(val) = std::env::var("TCTI") { cmd.env("TCTI", &val); @@ -193,4 +239,51 @@ fn pcr_tests() { if failed != 0 { panic!("{} tests failed", failed); } + + // Negative test: sealing with a pcr_digest that does not match the + // live PCR values must encrypt successfully but fail to decrypt (the + // TPM refuses to unseal). This is the primary regression guard against + // the original bug where pcr_digest was silently ignored. + let mismatch_cases: &[(&str, &str)] = &[ + ( + &format!( + r#"{{"pcr_ids": [23], "pcr_digest": "{}"}}"#, + PCR23_SHA256_WRONG_DIGEST + ), + "single-PCR", + ), + ( + &format!( + r#"{{"pcr_ids": [16, 23], "pcr_digest": "{}"}}"#, + PCR16_23_SHA256_WRONG_DIGEST + ), + "multi-PCR", + ), + ]; + + let encrypt_fn = generate_encrypt_us(false); + let decrypt_fn = generate_decrypt_us(false); + + for (config, label) in mismatch_cases { + eprintln!("pcr_digest_mismatch ({label}): encrypting with non-matching digest"); + let encrypted = (encrypt_fn.func)(INPUT, config) + .expect("encrypt with mismatched pcr_digest should succeed"); + + let parts: Vec<&str> = encrypted.trim().split('.').collect(); + assert_eq!( + parts.len(), + 5, + "encrypted output is not a valid JWE (expected 5 parts, got {})", + parts.len() + ); + + eprintln!("pcr_digest_mismatch ({label}): decrypting (should fail)"); + let result = (decrypt_fn.func)(&encrypted); + assert!( + result.is_err(), + "decrypt should fail when pcr_digest does not match live PCR values ({})", + label + ); + eprintln!("pcr_digest_mismatch ({label}): PASSED"); + } }