Skip to content

Commit 100c0b6

Browse files
committed
Challenge RA: impl attestation-oneshot-client
Signed-off-by: Jiale Zhang <[email protected]>
1 parent 71f2573 commit 100c0b6

File tree

4 files changed

+482
-0
lines changed

4 files changed

+482
-0
lines changed
Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
1+
//! A lightweight CLI for challenge-mode remote attestation.
2+
//! It talks to the guest `api-server-rest` to fetch evidence, and
3+
//! then verifies it locally with the attestation-service library.
4+
5+
use anyhow::{anyhow, bail, Context, Result};
6+
use attestation_service::config::Config;
7+
use attestation_service::rvps::{RvpsConfig, RvpsCrateConfig};
8+
use attestation_service::token::{ear_broker, AttestationTokenConfig};
9+
use attestation_service::{
10+
AttestationService, HashAlgorithm, InitDataInput, RuntimeData, VerificationRequest,
11+
};
12+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13+
use base64::Engine;
14+
use clap::{ArgGroup, Parser, Subcommand};
15+
use kbs_types::Tee;
16+
use reference_value_provider_service::storage::{local_json, ReferenceValueStorageConfig};
17+
use serde_json::Value;
18+
use std::fs;
19+
use std::path::{Path, PathBuf};
20+
use std::str::FromStr;
21+
22+
const DEFAULT_WORK_DIR: &str = "/var/lib/attestation";
23+
24+
#[derive(Parser, Debug)]
25+
#[command(
26+
name = "attestation-challenge-client",
27+
about = "Fetch attestation evidence and verify it locally."
28+
)]
29+
struct Cli {
30+
#[command(subcommand)]
31+
command: Commands,
32+
}
33+
34+
#[derive(Subcommand, Debug)]
35+
enum Commands {
36+
#[command(
37+
about = "Call guest api-server-rest /aa/evidence to fetch hardware evidence",
38+
group(
39+
ArgGroup::new("runtime_source")
40+
.args(["runtime_data", "runtime_data_file"])
41+
.multiple(false)
42+
)
43+
)]
44+
GetEvidence {
45+
/// Base URL of the guest api-server-rest, for example https://host:8006
46+
#[arg(long = "aa-url")]
47+
aa_url: String,
48+
/// Runtime data string passed to attestation-agent (defaults to empty)
49+
#[arg(long)]
50+
runtime_data: Option<String>,
51+
/// Read runtime data from file (must be UTF-8)
52+
#[arg(long)]
53+
runtime_data_file: Option<PathBuf>,
54+
/// Write evidence to file instead of stdout
55+
#[arg(long)]
56+
output: Option<PathBuf>,
57+
},
58+
59+
#[command(
60+
about = "Verify evidence and print an EAR token or its payload",
61+
group(
62+
ArgGroup::new("runtime_input")
63+
.args(["runtime_raw", "runtime_raw_file", "runtime_json", "runtime_json_file"])
64+
.multiple(false)
65+
),
66+
group(
67+
ArgGroup::new("init_input")
68+
.args(["init_data_digest", "init_data_toml"])
69+
.multiple(false)
70+
)
71+
)]
72+
Verify {
73+
/// Path to evidence file produced by get-evidence
74+
#[arg(long)]
75+
evidence: PathBuf,
76+
/// TEE type (e.g. tdx, sgx, snp, csv, azsnpvtpm, sample, system)
77+
#[arg(long)]
78+
tee: String,
79+
/// Use raw runtime data bytes from this string (UTF-8)
80+
#[arg(long)]
81+
runtime_raw: Option<String>,
82+
/// Use raw runtime data bytes from file
83+
#[arg(long)]
84+
runtime_raw_file: Option<PathBuf>,
85+
/// Use structured runtime data from JSON string
86+
#[arg(long)]
87+
runtime_json: Option<String>,
88+
/// Use structured runtime data from JSON file
89+
#[arg(long)]
90+
runtime_json_file: Option<PathBuf>,
91+
/// Hash algorithm for runtime data binding
92+
#[arg(long, default_value = "sha384")]
93+
runtime_hash_alg: String,
94+
/// Hex-encoded init data digest
95+
#[arg(long)]
96+
init_data_digest: Option<String>,
97+
/// Path to init data TOML file
98+
#[arg(long)]
99+
init_data_toml: Option<PathBuf>,
100+
/// Policy IDs to use (default: default)
101+
#[arg(long = "policy")]
102+
policies: Vec<String>,
103+
/// Print token payload as formatted JSON
104+
#[arg(long)]
105+
claims: bool,
106+
},
107+
}
108+
109+
#[tokio::main]
110+
async fn main() -> Result<()> {
111+
init_logger();
112+
let cli = Cli::parse();
113+
114+
match cli.command {
115+
Commands::GetEvidence {
116+
aa_url,
117+
runtime_data,
118+
runtime_data_file,
119+
output,
120+
} => handle_get_evidence(&aa_url, runtime_data, runtime_data_file, output).await?,
121+
Commands::Verify {
122+
evidence,
123+
tee,
124+
runtime_raw,
125+
runtime_raw_file,
126+
runtime_json,
127+
runtime_json_file,
128+
runtime_hash_alg,
129+
init_data_digest,
130+
init_data_toml,
131+
policies,
132+
claims,
133+
} => {
134+
handle_verify(
135+
evidence,
136+
tee,
137+
runtime_raw,
138+
runtime_raw_file,
139+
runtime_json,
140+
runtime_json_file,
141+
runtime_hash_alg,
142+
init_data_digest,
143+
init_data_toml,
144+
policies,
145+
claims,
146+
)
147+
.await?
148+
}
149+
}
150+
151+
Ok(())
152+
}
153+
154+
async fn handle_get_evidence(
155+
aa_url: &str,
156+
runtime_data: Option<String>,
157+
runtime_data_file: Option<PathBuf>,
158+
output: Option<PathBuf>,
159+
) -> Result<()> {
160+
let runtime_data = load_runtime_data_for_fetch(runtime_data, runtime_data_file)?;
161+
let base = aa_url.trim_end_matches('/');
162+
let url = format!("{}/aa/evidence", base);
163+
164+
// The api-server-rest only accepts GET with runtime_data as query parameter.
165+
let client = reqwest::Client::new();
166+
let resp = client
167+
.get(url)
168+
.query(&[("runtime_data", runtime_data)])
169+
.send()
170+
.await
171+
.context("send request to api-server-rest")?;
172+
173+
if !resp.status().is_success() {
174+
bail!("request failed with status {}", resp.status());
175+
}
176+
177+
let body = resp.text().await.context("read evidence body")?;
178+
179+
if let Some(path) = output {
180+
fs::write(&path, &body).with_context(|| format!("write evidence to {}", path.display()))?;
181+
println!("evidence saved to {}", path.display());
182+
} else {
183+
println!("{body}");
184+
}
185+
186+
Ok(())
187+
}
188+
189+
#[allow(clippy::too_many_arguments)]
190+
async fn handle_verify(
191+
evidence_path: PathBuf,
192+
tee_text: String,
193+
runtime_raw: Option<String>,
194+
runtime_raw_file: Option<PathBuf>,
195+
runtime_json: Option<String>,
196+
runtime_json_file: Option<PathBuf>,
197+
runtime_hash_alg: String,
198+
init_data_digest: Option<String>,
199+
init_data_toml: Option<PathBuf>,
200+
policies: Vec<String>,
201+
claims: bool,
202+
) -> Result<()> {
203+
let work_dir = PathBuf::from(DEFAULT_WORK_DIR);
204+
let config = build_default_config(&work_dir)?;
205+
206+
// Initialize service with the enforced defaults.
207+
let attestation_service = AttestationService::new(config)
208+
.await
209+
.context("initialize attestation service")?;
210+
211+
let evidence = read_evidence(&evidence_path)?;
212+
let tee = parse_tee(&tee_text)?;
213+
let runtime_hash_algorithm = HashAlgorithm::from_str(&runtime_hash_alg)
214+
.map_err(|e| anyhow!("invalid runtime hash algorithm: {e}"))?;
215+
let runtime_data = load_runtime_data_for_verify(
216+
runtime_raw,
217+
runtime_raw_file,
218+
runtime_json,
219+
runtime_json_file,
220+
)?;
221+
let init_data = load_init_data(init_data_digest, init_data_toml)?;
222+
let policy_ids = if policies.is_empty() {
223+
vec!["default".into()]
224+
} else {
225+
policies
226+
};
227+
228+
let request = VerificationRequest {
229+
evidence,
230+
tee,
231+
runtime_data,
232+
runtime_data_hash_algorithm: runtime_hash_algorithm,
233+
init_data,
234+
};
235+
236+
let token = attestation_service
237+
.evaluate(vec![request], policy_ids)
238+
.await
239+
.context("verify evidence")?;
240+
241+
println!("{token}");
242+
243+
if claims {
244+
let payload = decode_jwt_payload(&token).context("decode token payload")?;
245+
let pretty = serde_json::to_string_pretty(&payload)?;
246+
println!("{pretty}");
247+
}
248+
249+
Ok(())
250+
}
251+
252+
fn build_default_config(work_dir: &Path) -> Result<Config> {
253+
// Keep everything under the given work dir as requested.
254+
let rvps_config = RvpsConfig::BuiltIn(RvpsCrateConfig {
255+
storage: ReferenceValueStorageConfig::LocalJson(local_json::Config {
256+
file_path: work_dir
257+
.join("reference_values.json")
258+
.to_string_lossy()
259+
.to_string(),
260+
}),
261+
});
262+
263+
let policy_dir = work_dir.join("token/ear/policies");
264+
// Ensure the policy directory exists before OPA loads the default rego.
265+
fs::create_dir_all(&policy_dir)
266+
.with_context(|| format!("create policy dir {}", policy_dir.display()))?;
267+
268+
let ear_cfg = ear_broker::Configuration {
269+
policy_dir: policy_dir.to_string_lossy().to_string(),
270+
..ear_broker::Configuration::default()
271+
};
272+
273+
Ok(Config {
274+
work_dir: work_dir.to_path_buf(),
275+
rvps_config,
276+
attestation_token_broker: AttestationTokenConfig::Ear(ear_cfg),
277+
})
278+
}
279+
280+
fn load_runtime_data_for_fetch(
281+
runtime_data: Option<String>,
282+
runtime_data_file: Option<PathBuf>,
283+
) -> Result<String> {
284+
if let Some(path) = runtime_data_file {
285+
let content = fs::read_to_string(&path)
286+
.with_context(|| format!("read runtime data file {}", path.display()))?;
287+
return Ok(content);
288+
}
289+
290+
Ok(runtime_data.unwrap_or_default())
291+
}
292+
293+
fn load_runtime_data_for_verify(
294+
runtime_raw: Option<String>,
295+
runtime_raw_file: Option<PathBuf>,
296+
runtime_json: Option<String>,
297+
runtime_json_file: Option<PathBuf>,
298+
) -> Result<Option<RuntimeData>> {
299+
if let Some(raw) = runtime_raw {
300+
return Ok(Some(RuntimeData::Raw(raw.into_bytes())));
301+
}
302+
303+
if let Some(path) = runtime_raw_file {
304+
let bytes = fs::read(&path)
305+
.with_context(|| format!("read runtime data file {}", path.display()))?;
306+
return Ok(Some(RuntimeData::Raw(bytes)));
307+
}
308+
309+
if let Some(json_str) = runtime_json {
310+
let value: Value =
311+
serde_json::from_str(&json_str).context("parse runtime data JSON from string")?;
312+
return Ok(Some(RuntimeData::Structured(value)));
313+
}
314+
315+
if let Some(path) = runtime_json_file {
316+
let content = fs::read_to_string(&path)
317+
.with_context(|| format!("read runtime data JSON file {}", path.display()))?;
318+
let value: Value =
319+
serde_json::from_str(&content).context("parse runtime data JSON from file")?;
320+
return Ok(Some(RuntimeData::Structured(value)));
321+
}
322+
323+
Ok(None)
324+
}
325+
326+
fn load_init_data(
327+
init_data_digest: Option<String>,
328+
init_data_toml: Option<PathBuf>,
329+
) -> Result<Option<InitDataInput>> {
330+
if let Some(digest_hex) = init_data_digest {
331+
let bytes = hex::decode(&digest_hex)
332+
.with_context(|| format!("decode init data digest hex from `{digest_hex}`"))?;
333+
return Ok(Some(InitDataInput::Digest(bytes)));
334+
}
335+
336+
if let Some(path) = init_data_toml {
337+
let content = fs::read_to_string(&path)
338+
.with_context(|| format!("read init data toml {}", path.display()))?;
339+
return Ok(Some(InitDataInput::Toml(content)));
340+
}
341+
342+
Ok(None)
343+
}
344+
345+
fn read_evidence(path: &Path) -> Result<Value> {
346+
let content = fs::read_to_string(path)
347+
.with_context(|| format!("read evidence file {}", path.display()))?;
348+
let evidence: Value = serde_json::from_str(&content).context("parse evidence as JSON value")?;
349+
Ok(evidence)
350+
}
351+
352+
fn parse_tee(text: &str) -> Result<Tee> {
353+
// Follow the same mapping used by the RESTful service.
354+
match text.to_lowercase().as_str() {
355+
"azsnpvtpm" => Ok(Tee::AzSnpVtpm),
356+
"sev" => Ok(Tee::Sev),
357+
"sgx" => Ok(Tee::Sgx),
358+
"snp" => Ok(Tee::Snp),
359+
"tdx" => Ok(Tee::Tdx),
360+
"csv" => Ok(Tee::Csv),
361+
"sample" => Ok(Tee::Sample),
362+
"sampledevice" => Ok(Tee::SampleDevice),
363+
"aztdxvtpm" => Ok(Tee::AzTdxVtpm),
364+
"system" => Ok(Tee::System),
365+
"se" => Ok(Tee::Se),
366+
"tpm" => Ok(Tee::Tpm),
367+
"hygondcu" => Ok(Tee::HygonDcu),
368+
other => bail!("unsupported tee `{other}`"),
369+
}
370+
}
371+
372+
fn decode_jwt_payload(token: &str) -> Result<Value> {
373+
// Manual, signature-agnostic decode for display only.
374+
let parts: Vec<&str> = token.split('.').collect();
375+
if parts.len() < 2 {
376+
bail!("invalid JWT format");
377+
}
378+
379+
let payload_b64 = parts[1];
380+
let payload_bytes = URL_SAFE_NO_PAD
381+
.decode(payload_b64)
382+
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload_b64))
383+
.context("decode jwt payload base64")?;
384+
let payload: Value =
385+
serde_json::from_slice(&payload_bytes).context("parse jwt payload json")?;
386+
Ok(payload)
387+
}
388+
389+
fn init_logger() {
390+
// Prefer user-provided RUST_LOG, otherwise keep info noise low.
391+
let env = env_logger::Env::default().default_filter_or("info");
392+
let _ = env_logger::Builder::from_env(env).try_init();
393+
}

0 commit comments

Comments
 (0)