use std::fs;
use std::io::Write;
use std::path::PathBuf;
use serde::Deserialize;
use vta_sdk::attestation::verify_nitro_assertion;
use vta_sdk::sealed_transfer::{
BootstrapRequest, SealedPayloadV1, armor, bundle_digest, ed25519_seed_to_x25519_secret,
generate_ed25519_keypair, open_bundle,
};
use crate::auth;
use crate::config;
use vta_sdk::hex::lower as hex_lower;
pub async fn run_request(
out: PathBuf,
label: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let config_dir = config::config_dir()?;
let created = vta_cli_common::sealed_consumer::create_bootstrap_request(&config_dir, label)?;
let json = serde_json::to_string_pretty(&created.request)?;
fs::write(&out, json.as_bytes())?;
println!("Bootstrap request written to {}", out.display());
println!();
println!(" Bundle-Id: {}", created.bundle_id_hex);
println!(" Client DID: {}", created.request.client_did);
println!(" Seed saved: {}", created.secret_path.display());
println!();
println!("Hand the request to the producer. They will return an armored bundle.");
println!("Verify the SHA-256 digest they print to you out-of-band, then run:");
println!(" pnm bootstrap open --bundle <file> --expect-digest <hex>");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn run_provision_request(
template: String,
vars: Vec<String>,
context_hint: Option<String>,
admin_template: Option<String>,
validity_hours: f64,
label: Option<String>,
out: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
use vta_sdk::provision_integration::ProvisionRequestBuilder;
if !validity_hours.is_finite() || validity_hours <= 0.0 {
return Err(format!(
"--validity-hours must be a positive finite number, got {validity_hours}"
)
.into());
}
let validity = chrono::Duration::seconds((validity_hours * 3600.0) as i64);
let mut builder = ProvisionRequestBuilder::new(template).validity(validity);
for raw in &vars {
let (k, v) = parse_var(raw)?;
builder = builder.var(k, v);
}
if let Some(ctx) = context_hint {
builder = builder.context_hint(ctx);
}
if let Some(admin) = admin_template {
builder = builder.admin_template(admin);
}
if let Some(l) = label {
builder = builder.label(l);
}
let config_dir = config::config_dir()?;
let created =
vta_cli_common::sealed_consumer::create_provision_request(&config_dir, builder).await?;
let json = serde_json::to_string_pretty(&created.request)?;
fs::write(&out, json.as_bytes())?;
println!("Provision bootstrap request written to {}", out.display());
println!();
println!(" Bundle-Id: {}", created.bundle_id_hex);
println!(" Client DID: {}", created.client_did);
println!(" Seed saved: {}", created.secret_path.display());
println!();
println!("Hand the request to the VTA operator. They will run:");
println!(" vta bootstrap provision-integration --request <file> --out <bundle>");
println!("(or `pnm bootstrap provision-integration` over REST against a live VTA).");
println!();
println!("Verify the returned digest out-of-band, then:");
println!(" pnm bootstrap open --bundle <file> --expect-digest <hex>");
Ok(())
}
fn parse_var(raw: &str) -> Result<(String, serde_json::Value), Box<dyn std::error::Error>> {
let (key, value) = raw
.split_once('=')
.ok_or_else(|| format!("invalid --var '{raw}': expected KEY=VALUE"))?;
if key.is_empty() {
return Err(format!("invalid --var '{raw}': empty key").into());
}
let parsed = serde_json::from_str::<serde_json::Value>(value)
.unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
Ok((key.to_string(), parsed))
}
pub async fn run_open(
bundle_path: PathBuf,
out: Option<PathBuf>,
expect_digest: Option<String>,
no_verify_digest: bool,
expect_vta_did: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let config_dir = config::config_dir()?;
let opened = vta_cli_common::sealed_consumer::open_armored_bundle(
&bundle_path,
&config_dir,
expect_digest.as_deref(),
no_verify_digest,
)?;
println!("Sealed bundle opened.");
println!();
println!(" Bundle-Id: {}", opened.bundle_id_hex);
println!(" Digest (sha256): {}", opened.digest);
println!(" Producer DID: {}", opened.producer.producer_did);
println!(" Producer proof: {:?}", opened.producer.proof);
println!();
match &opened.payload {
SealedPayloadV1::AdminCredential(c) => {
println!("Payload: AdminCredential");
println!(" DID: {}", c.did);
println!(" VTA DID: {}", c.vta_did);
if let Some(ref u) = c.vta_url {
println!(" VTA URL: {u}");
}
if out.is_none() {
println!();
println!("Nothing was written. To install this credential either:");
println!(" - re-open with --out <path> for a file-based consumer, or");
println!(
" - use the online flow: pnm bootstrap connect --vta-url <url> \
[--expect-digest <sha256>] [--expect-pcr0 <hex>] [--expect-pcr8 <hex>]"
);
}
}
SealedPayloadV1::ContextProvision(p) => {
println!("Payload: ContextProvision");
println!(" Context: {} ({})", p.context_id, p.context_name);
println!(" Admin DID: {}", p.admin_did);
if out.is_none() {
println!();
println!(
"Nothing was written — this payload carries an admin credential. \
Re-open with --out <path> to write it as JSON."
);
}
}
SealedPayloadV1::DidSecrets(s) => {
println!("Payload: DidSecrets");
println!(" DID: {}", s.did);
println!(" Secrets: {}", s.secrets.len());
}
SealedPayloadV1::AdminKeySet(keys) => {
println!("Payload: AdminKeySet ({} keys)", keys.len());
for k in keys {
println!(" - {}", k.label);
}
}
SealedPayloadV1::RawPrivateKey(k) => {
println!("Payload: RawPrivateKey ({})", k.key_type);
}
SealedPayloadV1::TemplateBootstrap(p) => {
println!("Payload: TemplateBootstrap");
println!(" Template: {}", p.config.template_name);
println!(" Kind: {}", p.config.template_kind);
println!(" Secrets for: {} DID(s)", p.secrets.len());
println!(" Outputs: {}", p.config.outputs.len());
if let Some(ref u) = p.config.vta_url {
println!(" VTA URL: {u}");
}
match expect_vta_did.as_deref() {
Some(pinned) => {
use vta_sdk::provision_integration::template_verify::verify_template_bootstrap;
verify_template_bootstrap((**p).clone(), pinned, chrono::Duration::minutes(5))?;
println!();
println!(" \x1b[1;32m✓ VC verified against pinned VTA DID\x1b[0m");
}
None => {
println!();
println!(" \x1b[1;33m⚠ VC NOT verified — digest-only trust anchor.\x1b[0m");
println!(
" Re-run with --expect-vta-did <did> to verify the authorization VC."
);
}
}
println!();
println!("Install via the provision-integration flow on the integration host.");
}
SealedPayloadV1::AdminRotation(p) => {
println!("Payload: AdminRotation");
println!(" Admin DID: {}", p.admin.did);
println!(" VTA DID: {}", p.vta_trust.vta_did);
if let Some(ref u) = p.vta_url {
println!(" VTA URL: {u}");
}
println!();
println!(
" \x1b[1;33m⚠ VC verification not yet wired for AdminRotation — digest-only.\x1b[0m"
);
println!();
println!("Install via the provision-integration flow on the integration host.");
}
SealedPayloadV1::IssuedCredential(c) => {
println!("Payload: IssuedCredential");
println!(" Issuer DID: {}", c.issuer_did);
if let Some(ref label) = c.label {
println!(" Label: {label}");
}
let kind = if c.credential.is_string() {
"SD-JWT-VC (compact)"
} else {
"W3C Data-Integrity VC"
};
println!(" Format: {kind}");
println!();
println!(
"Receive this credential into the holder vault via the credential-exchange flow."
);
}
SealedPayloadV1::MessagingBridgeCredentials(b) => {
println!("Payload: MessagingBridgeCredentials");
println!(" Platform: {}", b.platform);
println!(" Fields: {}", b.fields.len());
println!();
println!(
"Load these platform secrets into the messaging-bridge connector's secret store."
);
}
}
if let Some(path) = out {
let bundle = vta_cli_common::sealed_consumer::extract_admin_credential(opened.payload)?;
write_credential_bundle(&path, &bundle)?;
println!();
println!("Credential written to {} (0600).", path.display());
}
Ok(())
}
fn write_credential_bundle(
path: &std::path::Path,
bundle: &vta_sdk::credentials::CredentialBundle,
) -> Result<(), Box<dyn std::error::Error>> {
let json = serde_json::to_vec_pretty(bundle)?;
let mut opts = fs::OpenOptions::new();
opts.create(true).write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(path)?;
file.write_all(&json)?;
file.write_all(b"\n")?;
drop(file);
if let Err(e) = vta_cli_common::secure_file::restrict_file_to_owner(path) {
eprintln!(
"warning: could not restrict {} to owner ({e}) — the private key may be \
readable by other local users",
path.display()
);
}
Ok(())
}
#[derive(Debug, Deserialize)]
struct BootstrapResponseWire {
bundle: String,
digest: String,
}
#[allow(clippy::too_many_arguments)]
pub async fn run_connect(
vta_url: String,
expect_digest: Option<String>,
no_verify_digest: bool,
expect_pcr0: Option<String>,
expect_pcr8: Option<String>,
vta_slug: Option<String>,
pnm_config: &mut crate::config::PnmConfig,
) -> Result<(), Box<dyn std::error::Error>> {
vta_cli_common::sealed_consumer::validate_digest_flags(
expect_digest.as_deref(),
no_verify_digest,
)?;
let (ed_seed, ed_pub) = generate_ed25519_keypair();
let nonce: [u8; 16] = rand::random();
let bundle_id_hex = hex_lower(&nonce);
let body = BootstrapRequest::new(ed_pub, nonce, None);
let url = format!("{}/bootstrap/request", vta_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = client.post(&url).json(&body).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("bootstrap request failed ({status}): {body}").into());
}
let wire: BootstrapResponseWire = resp.json().await?;
if let Some(expected) = &expect_digest
&& !expected.eq_ignore_ascii_case(&wire.digest)
{
return Err(format!(
"server-reported digest {} does not match expected {}",
wire.digest, expected
)
.into());
}
let bundles = armor::decode(&wire.bundle)?;
if bundles.len() != 1 {
return Err(format!(
"expected exactly one armored bundle from server, got {}",
bundles.len()
)
.into());
}
let bundle = &bundles[0];
if hex_lower(&bundle.bundle_id) != bundle_id_hex {
return Err("server-returned bundle_id does not match our nonce".into());
}
let x_secret = ed25519_seed_to_x25519_secret(&ed_seed);
let opened = open_bundle(&x_secret, bundle, expect_digest.as_deref())?;
let attest = verify_nitro_assertion(&opened.producer, &ed_pub, &nonce)?;
attest
.check_pcrs(expect_pcr0.as_deref(), expect_pcr8.as_deref())
.map_err(|e| {
format!(
"{e}. The attestation is cryptographically valid but the enclave does not \
match the pinned measurement — refusing to bootstrap. Confirm the expected \
PCR against the deployed EIF / KMS key policy."
)
})?;
println!("TEE attestation verified.");
println!(" Enclave module: {}", attest.module_id);
if !attest.pcr0_hex.is_empty() {
let pinned = if expect_pcr0.is_some() {
" (pinned ✓)"
} else {
""
};
println!(" PCR0: {}{pinned}", attest.pcr0_hex);
}
if !attest.pcr8_hex.is_empty() {
let pinned = if expect_pcr8.is_some() {
" (pinned ✓)"
} else {
""
};
println!(" PCR8: {}{pinned}", attest.pcr8_hex);
}
let credential = match opened.payload {
SealedPayloadV1::AdminCredential(c) => c,
other => {
return Err(format!(
"expected AdminCredential payload from online bootstrap, got {}",
variant_name(&other)
)
.into());
}
};
let slug = vta_slug.unwrap_or_else(|| default_slug(&credential.vta_did));
pnm_config.vtas.insert(
slug.clone(),
crate::config::VtaConfig {
name: slug.clone(),
vta_did: Some(credential.vta_did.clone()),
url: None,
mediator_did: None,
},
);
if pnm_config.default_vta.is_none() {
pnm_config.default_vta = Some(slug.clone());
}
crate::config::save_config(pnm_config)?;
let keyring_key = crate::config::vta_keyring_key(&slug);
auth::store_session(
&keyring_key,
&credential.did,
&credential.private_key_multibase,
&credential.vta_did,
)?;
auth::ensure_authenticated(&vta_url, &keyring_key).await?;
println!();
println!("Bootstrap complete.");
println!(" VTA slug: {slug}");
println!(" Client DID: {}", credential.did);
println!(" VTA DID: {}", credential.vta_did);
println!(" Digest: {}", bundle_digest(bundle));
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn run_provision_integration(
client: &vta_sdk::client::VtaClient,
request: PathBuf,
context: Option<String>,
assertion: String,
vc_validity_seconds: Option<i64>,
out: PathBuf,
create_context: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use vta_sdk::provision_integration::http::{
AssertionMode as WireAssertionMode, ProvisionIntegrationRequest,
};
let request_json =
fs::read_to_string(&request).map_err(|e| format!("read {}: {e}", request.display()))?;
let vp: vta_sdk::provision_integration::BootstrapRequest = serde_json::from_str(&request_json)
.map_err(|e| format!("parse BootstrapRequest (VP): {e}"))?;
let target_context = resolve_target_context_wire(&vp, context)?;
let assertion_mode = match assertion.as_str() {
"did-signed" | "didsigned" | "did_signed" => WireAssertionMode::DidSigned,
"pinned-only" | "pinnedonly" | "pinned_only" | "pinned" => WireAssertionMode::PinnedOnly,
other => {
return Err(format!(
"invalid --assertion value '{other}' — use 'did-signed' or 'pinned-only'"
)
.into());
}
};
let resp = client
.provision_integration(ProvisionIntegrationRequest {
request: vp,
context: Some(target_context.clone()),
assertion: Some(assertion_mode),
vc_validity_seconds,
create_context,
})
.await?;
fs::write(&out, resp.bundle.as_bytes()).map_err(|e| format!("write {}: {e}", out.display()))?;
eprintln!(
"Integration provisioned via {} — sealed bundle written to {}",
client.endpoint_label(),
out.display()
);
eprintln!();
eprintln!(" Bundle-Id: {}", resp.summary.bundle_id_hex);
if resp.summary.context_created {
eprintln!(" Context: {target_context} (created inline via --create-context)");
} else if create_context {
eprintln!(
" Context: {target_context} (already existed; --create-context was a no-op)"
);
} else {
eprintln!(" Context: {target_context}");
}
eprintln!(" Client DID: {}", resp.summary.client_did);
if resp.summary.admin_rolled_over {
eprintln!(
" Admin DID: {} (VTA-minted, rolled over from client)",
resp.summary.admin_did
);
if let Some(ref admin_tpl) = resp.summary.admin_template_name {
eprintln!(" Admin template: {admin_tpl}");
}
} else {
eprintln!(" Admin DID: {} (== client)", resp.summary.admin_did);
}
if let Some(ref integration_did) = resp.summary.integration_did {
eprintln!(" Integration DID: {integration_did}");
} else {
eprintln!(" Integration DID: (none — admin-rotation only)");
}
if let (Some(name), Some(kind)) = (
resp.summary.template_name.as_deref(),
resp.summary.template_kind.as_deref(),
) {
eprintln!(" Template: {name} ({kind})");
}
eprintln!(" Secrets: {}", resp.summary.secret_count);
eprintln!(" Outputs: {}", resp.summary.output_count);
eprintln!(" SHA-256 digest: {}", resp.digest);
eprintln!();
eprintln!(
"Communicate the digest to the integration's operator out-of-band so they can\n \
verify the bundle on first boot:\n \
pnm bootstrap open --bundle <file> --expect-digest {}",
resp.digest
);
Ok(())
}
fn resolve_target_context_wire(
request: &vta_sdk::provision_integration::BootstrapRequest,
explicit: Option<String>,
) -> Result<String, Box<dyn std::error::Error>> {
use vta_sdk::provision_integration::BootstrapAsk;
let hint = match &request.ask {
BootstrapAsk::TemplateBootstrap(ask) => ask.context_hint.clone(),
BootstrapAsk::AdminRotation(ask) => ask.context_hint.clone(),
};
match (explicit, hint) {
(Some(explicit), Some(hint)) if explicit != hint => Err(format!(
"--context '{explicit}' does not match request contextHint '{hint}' — \
operator and integration must agree on the context before provisioning"
)
.into()),
(Some(explicit), _) => Ok(explicit),
(None, Some(hint)) => Ok(hint),
(None, None) => Err(
"no context specified — pass --context <id> or have the integration's \
BootstrapRequest include a contextHint"
.into(),
),
}
}
fn variant_name(p: &SealedPayloadV1) -> &'static str {
match p {
SealedPayloadV1::AdminCredential(_) => "AdminCredential",
SealedPayloadV1::ContextProvision(_) => "ContextProvision",
SealedPayloadV1::DidSecrets(_) => "DidSecrets",
SealedPayloadV1::AdminKeySet(_) => "AdminKeySet",
SealedPayloadV1::RawPrivateKey(_) => "RawPrivateKey",
SealedPayloadV1::TemplateBootstrap(_) => "TemplateBootstrap",
SealedPayloadV1::AdminRotation(_) => "AdminRotation",
SealedPayloadV1::IssuedCredential(_) => "IssuedCredential",
SealedPayloadV1::MessagingBridgeCredentials(_) => "MessagingBridgeCredentials",
}
}
fn default_slug(vta_did: &str) -> String {
vta_did.rsplit(':').next().unwrap_or("vta").to_string()
}
#[cfg(test)]
mod tests {
use super::parse_var;
use serde_json::Value;
#[test]
fn parse_var_plain_string() {
let (k, v) = parse_var("URL=https://mediator.example.com").unwrap();
assert_eq!(k, "URL");
assert_eq!(v, Value::String("https://mediator.example.com".into()));
}
#[test]
fn parse_var_json_types_round_trip() {
assert_eq!(parse_var("N=42").unwrap().1, Value::Number(42.into()));
assert_eq!(parse_var("B=true").unwrap().1, Value::Bool(true));
assert!(parse_var(r#"A=[1,2]"#).unwrap().1.is_array());
}
#[test]
fn parse_var_value_may_contain_equals() {
let (_, v) = parse_var("URL=https://m.example.com?x=1").unwrap();
assert_eq!(v.as_str(), Some("https://m.example.com?x=1"));
}
#[test]
fn parse_var_missing_equals_errors() {
assert!(parse_var("LONELY").is_err());
}
#[test]
fn parse_var_empty_key_errors() {
assert!(parse_var("=value").is_err());
}
fn scratch_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("pnm-open-out-{name}-{}.json", std::process::id()));
p
}
fn sample_bundle() -> vta_sdk::credentials::CredentialBundle {
vta_sdk::credentials::CredentialBundle {
did: "did:key:z6MkTest".into(),
private_key_multibase: "z3SecretTest".into(),
vta_did: "did:webvh:QmTest:vta.example.com:vta".into(),
vta_url: Some("https://vta.example.com".into()),
}
}
#[test]
fn written_credential_uses_wire_field_names() {
let path = scratch_path("fields");
super::write_credential_bundle(&path, &sample_bundle()).unwrap();
let v: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(v["did"], "did:key:z6MkTest");
assert_eq!(v["privateKeyMultibase"], "z3SecretTest");
assert_eq!(v["vtaDid"], "did:webvh:QmTest:vta.example.com:vta");
assert_eq!(v["vtaUrl"], "https://vta.example.com");
std::fs::remove_file(&path).ok();
}
#[test]
fn written_credential_omits_absent_vta_url() {
let path = scratch_path("nourl");
let mut bundle = sample_bundle();
bundle.vta_url = None;
super::write_credential_bundle(&path, &bundle).unwrap();
let v: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert!(v.get("vtaUrl").is_none());
std::fs::remove_file(&path).ok();
}
#[cfg(unix)]
#[test]
fn written_credential_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let path = scratch_path("perms");
super::write_credential_bundle(&path, &sample_bundle()).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"credential file must not be group/world readable"
);
std::fs::remove_file(&path).ok();
}
#[cfg(unix)]
#[test]
fn written_credential_truncates_existing_file() {
let path = scratch_path("truncate");
std::fs::write(&path, vec![b'x'; 4096]).unwrap();
super::write_credential_bundle(&path, &sample_bundle()).unwrap();
let v: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(v["did"], "did:key:z6MkTest");
std::fs::remove_file(&path).ok();
}
}