#![cfg(feature = "intel-tiber")]
use std::time::Duration;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as B64_STANDARD;
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tenzro_types::tee::{AttestationResult, Measurement, TeeVendor};
use crate::error::{Result, TeeError};
pub const TIBER_API_URL_US: &str = "https://api.trustauthority.intel.com";
pub const TIBER_API_URL_EU: &str = "https://api.eu.trustauthority.intel.com";
pub const NONCE_PATH: &str = "/appraisal/v2/nonce";
pub const ATTEST_PATH: &str = "/appraisal/v2/attest";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_JWKS_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenSigningAlg {
Ps384,
Rs256,
}
impl TokenSigningAlg {
fn as_str(self) -> &'static str {
match self {
TokenSigningAlg::Ps384 => "PS384",
TokenSigningAlg::Rs256 => "RS256",
}
}
fn jwt(self) -> Algorithm {
match self {
TokenSigningAlg::Ps384 => Algorithm::PS384,
TokenSigningAlg::Rs256 => Algorithm::RS256,
}
}
}
#[derive(Debug, Clone, Default)]
pub enum TiberJwksPin {
#[default]
Any,
AllowedHosts(Vec<String>),
Override(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifierNonce {
#[serde(with = "b64_bytes")]
pub val: Vec<u8>,
#[serde(with = "b64_bytes")]
pub iat: Vec<u8>,
#[serde(with = "b64_bytes")]
pub signature: Vec<u8>,
}
mod b64_bytes {
use super::*;
use serde::{Deserializer, Serializer, de::Error};
pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&B64_STANDARD.encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Vec<u8>, D::Error> {
let s = String::deserialize(d)?;
B64_STANDARD.decode(s.as_bytes()).map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AttestRequest {
#[serde(with = "b64_bytes")]
pub quote: Vec<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub verifier_nonce: Option<VerifierNonce>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "b64_opt")]
pub runtime_data: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub policy_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "b64_opt")]
pub user_data: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "b64_opt")]
pub event_log: Option<Vec<u8>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_signing_alg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_must_match: Option<bool>,
}
mod b64_opt {
use super::*;
use serde::Serializer;
pub fn serialize<S: Serializer>(
bytes: &Option<Vec<u8>>,
s: S,
) -> std::result::Result<S::Ok, S::Error> {
match bytes {
Some(b) => s.serialize_str(&B64_STANDARD.encode(b)),
None => s.serialize_none(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
struct AttestResponse {
token: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TiberClaims {
pub iss: Option<String>,
pub exp: Option<i64>,
pub iat: Option<i64>,
pub nbf: Option<i64>,
pub jti: Option<String>,
pub eat_profile: Option<String>,
pub dbgstat: Option<String>,
pub attester_tcb_status: Option<String>,
pub attester_tcb_date: Option<String>,
pub attester_advisory_ids: Option<Vec<String>>,
pub tdx_mrtd: Option<String>,
pub tdx_rtmr0: Option<String>,
pub tdx_rtmr1: Option<String>,
pub tdx_rtmr2: Option<String>,
pub tdx_rtmr3: Option<String>,
pub tdx_mrsignerseam: Option<String>,
pub tdx_seamsvn: Option<i64>,
#[serde(flatten)]
pub extras: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct IntelTiberClient {
base_url: String,
api_key: String,
http: Client,
jwks_pin: TiberJwksPin,
expected_iss: Option<String>,
}
impl IntelTiberClient {
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
let mut base_url = base_url.into();
if base_url.is_empty() {
return Err(TeeError::ConfigurationError(
"Intel Tiber base URL is empty".to_string(),
));
}
while base_url.ends_with('/') {
base_url.pop();
}
let api_key = api_key.into();
if api_key.is_empty() {
return Err(TeeError::ConfigurationError(
"Intel Tiber API key is empty".to_string(),
));
}
let http = Client::builder()
.timeout(DEFAULT_TIMEOUT)
.build()
.map_err(|e| TeeError::ConfigurationError(format!("HTTP client build failed: {e}")))?;
Ok(Self {
base_url,
api_key,
http,
jwks_pin: TiberJwksPin::default(),
expected_iss: None,
})
}
pub fn with_jwks_pin(mut self, pin: TiberJwksPin) -> Self {
self.jwks_pin = pin;
self
}
pub fn with_expected_issuer(mut self, iss: impl Into<String>) -> Self {
self.expected_iss = Some(iss.into());
self
}
pub async fn get_nonce(&self) -> Result<VerifierNonce> {
let url = format!("{}{}", self.base_url, NONCE_PATH);
let resp = self
.http
.get(&url)
.header("x-api-key", &self.api_key)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| TeeError::AttestationVerificationFailed(format!("ITA nonce GET failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(TeeError::AttestationVerificationFailed(format!(
"ITA nonce returned HTTP {status}: {body}"
)));
}
resp.json::<VerifierNonce>().await.map_err(|e| {
TeeError::AttestationVerificationFailed(format!("ITA nonce body parse failed: {e}"))
})
}
pub async fn attest(&self, request: &AttestRequest) -> Result<String> {
let url = format!("{}{}", self.base_url, ATTEST_PATH);
let resp = self
.http
.post(&url)
.header("x-api-key", &self.api_key)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.json(request)
.send()
.await
.map_err(|e| {
TeeError::AttestationVerificationFailed(format!("ITA attest POST failed: {e}"))
})?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(TeeError::AttestationVerificationFailed(format!(
"ITA attest returned HTTP {status}: {body}"
)));
}
let parsed: AttestResponse = resp.json().await.map_err(|e| {
TeeError::AttestationVerificationFailed(format!("ITA attest body parse failed: {e}"))
})?;
Ok(parsed.token)
}
pub async fn get_token_with_nonce(
&self,
quote: Vec<u8>,
nonce: VerifierNonce,
token_signing_alg: TokenSigningAlg,
) -> Result<String> {
let req = AttestRequest {
quote,
verifier_nonce: Some(nonce),
runtime_data: None,
policy_ids: Vec::new(),
user_data: None,
event_log: None,
token_signing_alg: Some(token_signing_alg.as_str().to_string()),
policy_must_match: None,
};
self.attest(&req).await
}
pub async fn verify_token(&self, token: &str) -> Result<TiberClaims> {
let header = jsonwebtoken::decode_header(token).map_err(|e| {
TeeError::AttestationVerificationFailed(format!("ITA JWT header decode failed: {e}"))
})?;
let alg = match header.alg {
Algorithm::PS384 => TokenSigningAlg::Ps384,
Algorithm::RS256 => TokenSigningAlg::Rs256,
other => {
return Err(TeeError::AttestationVerificationFailed(format!(
"ITA token uses unsupported alg {other:?} (only PS384/RS256 issued)"
)));
}
};
let kid = header.kid.ok_or_else(|| {
TeeError::AttestationVerificationFailed("ITA JWT header missing kid".to_string())
})?;
let jwks_url = match &self.jwks_pin {
TiberJwksPin::Override(u) => u.clone(),
TiberJwksPin::Any => header.jku.ok_or_else(|| {
TeeError::AttestationVerificationFailed("ITA JWT header missing jku".to_string())
})?,
TiberJwksPin::AllowedHosts(hosts) => {
let jku = header.jku.ok_or_else(|| {
TeeError::AttestationVerificationFailed(
"ITA JWT header missing jku".to_string(),
)
})?;
let parsed = reqwest::Url::parse(&jku).map_err(|e| {
TeeError::AttestationVerificationFailed(format!(
"ITA jku is not a valid URL: {e}"
))
})?;
let host = parsed.host_str().ok_or_else(|| {
TeeError::AttestationVerificationFailed("ITA jku has no host".to_string())
})?;
if !hosts.iter().any(|h| h == host) {
return Err(TeeError::AttestationVerificationFailed(format!(
"ITA jku host '{host}' not in allowed list"
)));
}
jku
}
};
let jwks_resp = self
.http
.get(&jwks_url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| {
TeeError::AttestationVerificationFailed(format!("ITA JWKS fetch failed: {e}"))
})?;
if !jwks_resp.status().is_success() {
return Err(TeeError::AttestationVerificationFailed(format!(
"ITA JWKS fetch returned HTTP {}",
jwks_resp.status()
)));
}
let body = jwks_resp.bytes().await.map_err(|e| {
TeeError::AttestationVerificationFailed(format!("ITA JWKS body read failed: {e}"))
})?;
if body.len() > MAX_JWKS_BYTES {
return Err(TeeError::AttestationVerificationFailed(format!(
"ITA JWKS body too large: {} > {MAX_JWKS_BYTES}",
body.len()
)));
}
let jwks: Jwks = serde_json::from_slice(&body).map_err(|e| {
TeeError::AttestationVerificationFailed(format!("ITA JWKS parse failed: {e}"))
})?;
let jwk = jwks
.keys
.iter()
.find(|k| k.kid.as_deref() == Some(kid.as_str()))
.ok_or_else(|| {
TeeError::AttestationVerificationFailed(format!(
"ITA JWKS has no key with kid={kid}"
))
})?;
if jwk.kty != "RSA" {
return Err(TeeError::AttestationVerificationFailed(format!(
"ITA JWK has unsupported kty={} (only RSA accepted)",
jwk.kty
)));
}
let n = jwk.n.as_deref().ok_or_else(|| {
TeeError::AttestationVerificationFailed("ITA JWK missing n parameter".to_string())
})?;
let e = jwk.e.as_deref().ok_or_else(|| {
TeeError::AttestationVerificationFailed("ITA JWK missing e parameter".to_string())
})?;
let decoding_key = DecodingKey::from_rsa_components(n, e).map_err(|err| {
TeeError::AttestationVerificationFailed(format!(
"ITA JWK -> DecodingKey conversion failed: {err}"
))
})?;
let mut validation = Validation::new(alg.jwt());
validation.validate_exp = true;
validation.validate_nbf = true;
if let Some(iss) = &self.expected_iss {
validation.set_issuer(&[iss]);
}
let token_data = jsonwebtoken::decode::<TiberClaims>(token, &decoding_key, &validation)
.map_err(|e| {
TeeError::AttestationVerificationFailed(format!(
"ITA JWT signature verification failed: {e}"
))
})?;
Ok(token_data.claims)
}
pub async fn verify_quote(
&self,
quote: Vec<u8>,
nonce: Option<VerifierNonce>,
token_signing_alg: TokenSigningAlg,
) -> Result<(AttestationResult, TiberClaims)> {
let req = AttestRequest {
quote,
verifier_nonce: nonce,
runtime_data: None,
policy_ids: Vec::new(),
user_data: None,
event_log: None,
token_signing_alg: Some(token_signing_alg.as_str().to_string()),
policy_must_match: None,
};
let token = self.attest(&req).await?;
let claims = self.verify_token(&token).await?;
let result = claims_to_attestation_result(&claims)?;
Ok((result, claims))
}
}
pub fn claims_to_attestation_result(claims: &TiberClaims) -> Result<AttestationResult> {
let tcb_ok = claims
.attester_tcb_status
.as_deref()
.map(|s| s == "OK")
.unwrap_or(false);
let dbg_off = claims
.dbgstat
.as_deref()
.map(|s| s == "disabled")
.unwrap_or(false);
let valid = tcb_ok && dbg_off;
let mut measurements: Vec<Measurement> = Vec::new();
let mut push_hex = |index: u32, register: &str, hex_opt: &Option<String>| -> Result<()> {
if let Some(hex_str) = hex_opt {
let value = hex::decode(hex_str.trim_start_matches("0x")).map_err(|e| {
TeeError::AttestationVerificationFailed(format!(
"ITA claim {register} is not hex: {e}"
))
})?;
measurements.push(Measurement {
index,
algorithm: "SHA384".to_string(),
value,
register: register.to_string(),
description: None,
});
}
Ok(())
};
push_hex(0, "MRTD", &claims.tdx_mrtd)?;
push_hex(0, "RTMR0", &claims.tdx_rtmr0)?;
push_hex(1, "RTMR1", &claims.tdx_rtmr1)?;
push_hex(2, "RTMR2", &claims.tdx_rtmr2)?;
push_hex(3, "RTMR3", &claims.tdx_rtmr3)?;
push_hex(0, "MRSIGNERSEAM", &claims.tdx_mrsignerseam)?;
let mut details: HashMap<String, String> = HashMap::new();
details.insert(
"verification_method".to_string(),
"intel_tiber".to_string(),
);
if let Some(tcb) = &claims.attester_tcb_status {
details.insert("attester_tcb_status".to_string(), tcb.clone());
}
if let Some(date) = &claims.attester_tcb_date {
details.insert("attester_tcb_date".to_string(), date.clone());
}
if let Some(dbg) = &claims.dbgstat {
details.insert("dbgstat".to_string(), dbg.clone());
}
if let Some(eat) = &claims.eat_profile {
details.insert("eat_profile".to_string(), eat.clone());
}
if let Some(svn) = claims.tdx_seamsvn {
details.insert("tdx_seamsvn".to_string(), svn.to_string());
}
if let Some(advisories) = &claims.attester_advisory_ids {
if !advisories.is_empty() {
details.insert(
"attester_advisory_ids".to_string(),
advisories.join(","),
);
}
}
Ok(AttestationResult {
valid,
vendor: TeeVendor::IntelTdx,
tcb_version: claims
.attester_tcb_status
.clone()
.unwrap_or_default(),
measurements,
cert_chain_valid: valid,
details,
verified_at: tenzro_types::Timestamp::now(),
..Default::default()
})
}
#[derive(Debug, Deserialize)]
struct Jwks {
keys: Vec<Jwk>,
}
#[derive(Debug, Deserialize)]
struct Jwk {
kty: String,
kid: Option<String>,
n: Option<String>,
e: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_rejects_empty_url() {
let err = IntelTiberClient::new("", "key").unwrap_err();
assert!(matches!(err, TeeError::ConfigurationError(_)));
}
#[test]
fn new_rejects_empty_key() {
let err = IntelTiberClient::new(TIBER_API_URL_US, "").unwrap_err();
assert!(matches!(err, TeeError::ConfigurationError(_)));
}
#[test]
fn new_strips_trailing_slash() {
let c = IntelTiberClient::new("https://api.trustauthority.intel.com///", "k").unwrap();
assert_eq!(c.base_url, "https://api.trustauthority.intel.com");
}
#[test]
fn signing_alg_round_trip() {
assert_eq!(TokenSigningAlg::Ps384.as_str(), "PS384");
assert_eq!(TokenSigningAlg::Rs256.as_str(), "RS256");
assert_eq!(TokenSigningAlg::Ps384.jwt(), Algorithm::PS384);
assert_eq!(TokenSigningAlg::Rs256.jwt(), Algorithm::RS256);
}
#[test]
fn verifier_nonce_serde_round_trip() {
let n = VerifierNonce {
val: b"\x01\x02\x03".to_vec(),
iat: b"\x04\x05".to_vec(),
signature: b"\x06\x07\x08\x09".to_vec(),
};
let j = serde_json::to_string(&n).unwrap();
assert!(j.contains(r#""val":"AQID""#));
assert!(j.contains(r#""iat":"BAU=""#));
assert!(j.contains(r#""signature":"BgcICQ==""#));
let parsed: VerifierNonce = serde_json::from_str(&j).unwrap();
assert_eq!(parsed.val, n.val);
assert_eq!(parsed.iat, n.iat);
assert_eq!(parsed.signature, n.signature);
}
#[test]
fn attest_request_serializes_only_provided_fields() {
let req = AttestRequest {
quote: b"\xaa\xbb".to_vec(),
verifier_nonce: None,
runtime_data: None,
policy_ids: Vec::new(),
user_data: None,
event_log: None,
token_signing_alg: None,
policy_must_match: None,
};
let j = serde_json::to_string(&req).unwrap();
assert!(j.contains(r#""quote":"qrs=""#));
assert!(!j.contains("verifier_nonce"));
assert!(!j.contains("runtime_data"));
assert!(!j.contains("policy_ids"));
assert!(!j.contains("user_data"));
assert!(!j.contains("event_log"));
assert!(!j.contains("token_signing_alg"));
assert!(!j.contains("policy_must_match"));
}
#[test]
fn attest_request_serializes_full_payload() {
let req = AttestRequest {
quote: b"\x00\x01".to_vec(),
verifier_nonce: Some(VerifierNonce {
val: b"n".to_vec(),
iat: b"i".to_vec(),
signature: b"s".to_vec(),
}),
runtime_data: Some(b"r".to_vec()),
policy_ids: vec!["1d3f-…".to_string()],
user_data: Some(b"u".to_vec()),
event_log: Some(b"e".to_vec()),
token_signing_alg: Some("PS384".to_string()),
policy_must_match: Some(true),
};
let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
assert_eq!(v["quote"], "AAE=");
assert_eq!(v["verifier_nonce"]["val"], "bg==");
assert_eq!(v["runtime_data"], "cg==");
assert_eq!(v["policy_ids"][0], "1d3f-…");
assert_eq!(v["user_data"], "dQ==");
assert_eq!(v["event_log"], "ZQ==");
assert_eq!(v["token_signing_alg"], "PS384");
assert_eq!(v["policy_must_match"], true);
}
#[test]
fn jwks_pin_default_is_any() {
assert!(matches!(TiberJwksPin::default(), TiberJwksPin::Any));
}
fn claims_with(tcb: Option<&str>, dbg: Option<&str>) -> TiberClaims {
TiberClaims {
iss: None,
exp: None,
iat: None,
nbf: None,
jti: None,
eat_profile: Some(
"https://portal.trustauthority.intel.com/eat_profile.html".to_string(),
),
dbgstat: dbg.map(String::from),
attester_tcb_status: tcb.map(String::from),
attester_tcb_date: Some("2026-01-01T00:00:00Z".to_string()),
attester_advisory_ids: Some(vec!["INTEL-SA-12345".to_string()]),
tdx_mrtd: Some("00".repeat(48)),
tdx_rtmr0: Some("11".repeat(48)),
tdx_rtmr1: Some("22".repeat(48)),
tdx_rtmr2: Some("33".repeat(48)),
tdx_rtmr3: Some("44".repeat(48)),
tdx_mrsignerseam: Some("ff".repeat(48)),
tdx_seamsvn: Some(3),
extras: Default::default(),
}
}
#[test]
fn projection_marks_valid_when_tcb_ok_and_debug_off() {
let r = claims_to_attestation_result(&claims_with(Some("OK"), Some("disabled"))).unwrap();
assert!(r.valid);
assert!(r.cert_chain_valid);
assert_eq!(r.vendor, TeeVendor::IntelTdx);
assert_eq!(r.tcb_version, "OK");
assert_eq!(r.measurements.len(), 6);
assert_eq!(r.measurements[0].register, "MRTD");
assert_eq!(r.measurements[0].value.len(), 48);
assert_eq!(r.measurements[0].algorithm, "SHA384");
assert_eq!(r.details.get("verification_method").map(String::as_str), Some("intel_tiber"));
assert_eq!(r.details.get("dbgstat").map(String::as_str), Some("disabled"));
assert_eq!(r.details.get("attester_tcb_status").map(String::as_str), Some("OK"));
assert_eq!(r.details.get("tdx_seamsvn").map(String::as_str), Some("3"));
assert_eq!(
r.details.get("attester_advisory_ids").map(String::as_str),
Some("INTEL-SA-12345")
);
}
#[test]
fn projection_invalid_when_tcb_out_of_date() {
let r = claims_to_attestation_result(&claims_with(Some("OutOfDate"), Some("disabled")))
.unwrap();
assert!(!r.valid);
assert!(!r.cert_chain_valid);
}
#[test]
fn projection_invalid_when_debug_enabled() {
let r = claims_to_attestation_result(&claims_with(Some("OK"), Some("enabled"))).unwrap();
assert!(!r.valid);
}
#[test]
fn projection_rejects_non_hex_measurement() {
let mut c = claims_with(Some("OK"), Some("disabled"));
c.tdx_mrtd = Some("not-hex-bytes".to_string());
assert!(claims_to_attestation_result(&c).is_err());
}
#[tokio::test]
#[ignore = "requires TIBER_API_URL + TIBER_API_KEY and a reachable Tiber endpoint"]
async fn live_nonce() {
let url = std::env::var("TIBER_API_URL").expect("TIBER_API_URL");
let key = std::env::var("TIBER_API_KEY").expect("TIBER_API_KEY");
let client = IntelTiberClient::new(url, key).unwrap();
let nonce = client.get_nonce().await.expect("nonce fetch");
assert!(!nonce.val.is_empty());
assert!(!nonce.iat.is_empty());
assert!(!nonce.signature.is_empty());
}
}