use axum::Json;
use axum::extract::State;
use axum::response::IntoResponse;
#[cfg(feature = "tee")]
use base64::Engine;
#[cfg(feature = "tee")]
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
use serde::{Deserialize, Serialize};
#[cfg(feature = "tee")]
use sha2::{Digest, Sha256};
#[cfg(feature = "tee")]
use tracing::info;
#[cfg(feature = "tee")]
use vta_sdk::credentials::CredentialBundle;
#[cfg(feature = "tee")]
use vta_sdk::sealed_transfer::{
AssertionProof, AttestationQuoteAssertion, ProducerAssertion, SealedPayloadV1, armor,
bundle_digest, generate_ed25519_keypair, seal_payload,
};
#[cfg(feature = "tee")]
use crate::acl::delete_acl_entry;
#[cfg(feature = "tee")]
use crate::acl::store_acl_entry;
#[cfg(feature = "tee")]
use crate::acl::{AclEntry, Role};
#[cfg(feature = "tee")]
use crate::audit::audit;
use crate::auth::session::now_epoch;
use crate::error::AppError;
#[cfg(feature = "tee")]
use crate::sealed_nonce_store::PersistentNonceStore;
use crate::server::AppState;
const MAX_LABEL_LEN: usize = 256;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[derive(utoipa::ToSchema)]
pub struct BootstrapRequestBody {
pub version: u8,
pub client_did: String,
pub nonce: String,
#[serde(default, deserialize_with = "deserialize_bounded_label")]
#[cfg_attr(not(feature = "tee"), allow(dead_code))]
pub label: Option<String>,
}
fn deserialize_bounded_label<'de, D>(de: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<String> = Option::deserialize(de)?;
if let Some(label) = &s
&& label.len() > MAX_LABEL_LEN
{
return Err(serde::de::Error::custom(format!(
"label exceeds {MAX_LABEL_LEN} bytes"
)));
}
Ok(s)
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct BootstrapResponseBody {
pub bundle: String,
pub digest: String,
}
#[utoipa::path(
post, path = "/bootstrap/request", tag = "bootstrap",
request_body = BootstrapRequestBody,
responses(
(status = 200, description = "Armored sealed admin bundle + digest", body = BootstrapResponseBody),
(status = 400, description = "Unsupported version or malformed client_did/nonce"),
(status = 403, description = "Carve-out already used or TEE first-boot unavailable"),
),
)]
pub async fn request(
State(state): State<AppState>,
Json(req): Json<BootstrapRequestBody>,
) -> Result<Json<BootstrapResponseBody>, AppError> {
if req.version != 1 {
return Err(AppError::Validation(format!(
"unsupported bootstrap request version: {}",
req.version
)));
}
let client_ed25519_pub = decode_client_did(&req.client_did)?;
let bundle_id = decode_nonce(&req.nonce)?;
let now = now_epoch();
#[cfg(feature = "tee")]
let bundle = mint_mode_b(&state, &client_ed25519_pub, bundle_id, now).await?;
#[cfg(not(feature = "tee"))]
{
let _ = (state, client_ed25519_pub, bundle_id, now);
Err(AppError::Forbidden(
"bootstrap request requires TEE first-boot attestation, which is not available on \
this VTA build. Non-TEE VTAs use the `pnm setup` temp-did:key + ACL flow instead."
.into(),
))
}
#[cfg(feature = "tee")]
{
let digest = bundle_digest(&bundle);
let armored = armor::encode(&bundle);
info!(
client_label_hash = %label_hash_prefix(req.label.as_deref()),
"TEE first-boot bootstrap completed"
);
audit!(
"bootstrap.swap",
actor = "bootstrap-endpoint",
resource = "bootstrap",
outcome = "success"
);
let _ = crate::audit::record(
&state.audit_ks,
"bootstrap.swap",
"bootstrap-endpoint",
None,
"success",
Some("rest"),
None,
)
.await;
Ok(Json(BootstrapResponseBody {
bundle: armored,
digest,
}))
}
}
#[cfg(feature = "tee")]
static MODE_B_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[cfg(feature = "tee")]
async fn mint_mode_b(
state: &AppState,
client_ed25519_pub: &[u8; 32],
bundle_id: [u8; 16],
now: u64,
) -> Result<vta_sdk::sealed_transfer::SealedBundle, AppError> {
use crate::tee::admin_bootstrap::{BOOTSTRAP_CARVEOUT_CLOSED_KEY, LEGACY_ADMIN_CREDENTIAL_KEY};
let tee_state =
state.tee.as_ref().map(|tc| &tc.state).ok_or_else(|| {
AppError::Forbidden("TEE first-boot is not available on this VTA".into())
})?;
let _carve_out_guard = MODE_B_LOCK.lock().await;
if state
.keys_ks
.get_raw(BOOTSTRAP_CARVEOUT_CLOSED_KEY)
.await?
.is_some()
|| state
.keys_ks
.get_raw(LEGACY_ADMIN_CREDENTIAL_KEY)
.await?
.is_some()
{
return Err(AppError::Forbidden(
"TEE first-boot carve-out has already been used".into(),
));
}
let cfg = state.config.read().await;
let vta_did = cfg
.vta_did
.as_ref()
.ok_or_else(|| AppError::Internal("VTA DID not configured".into()))?
.clone();
let vta_url = cfg.public_url.clone();
drop(cfg);
let (_producer_seed, producer_ed_pub) = generate_ed25519_keypair();
let producer_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&producer_ed_pub);
let mut hasher = Sha256::new();
hasher.update(client_ed25519_pub);
hasher.update(bundle_id);
hasher.update(producer_ed_pub);
let user_data = hasher.finalize();
let report = tee_state
.provider
.attest(user_data.as_slice(), &bundle_id)
.map_err(|e| AppError::Internal(format!("tee attest failed: {e}")))?;
let (did, private_key_multibase) = crate::auth::credentials::generate_did_key();
let credential = CredentialBundle {
did: did.clone(),
private_key_multibase,
vta_did,
vta_url,
};
let assertion = ProducerAssertion {
producer_did,
proof: AssertionProof::Attested(AttestationQuoteAssertion {
format: format!("{}", report.tee_type),
quote_b64: report.evidence,
}),
};
let client_x25519_pub =
affinidi_crypto::did_key::ed25519_pub_to_x25519_bytes(client_ed25519_pub)
.map_err(|e| AppError::Internal(format!("client_did X25519 derivation: {e}")))?;
let nonce_store = PersistentNonceStore::new(state.sealed_nonces_ks.clone());
let payload = SealedPayloadV1::AdminCredential(Box::new(credential));
let bundle = seal_payload(
&client_x25519_pub,
bundle_id,
assertion,
&payload,
&nonce_store,
)
.await
.map_err(|e| AppError::Internal(format!("sealed-transfer seal failed: {e}")))?;
let entry = AclEntry::new(did.clone(), Role::Admin, "tee:mode-b")
.with_label(Some("TEE first-boot admin".to_string()))
.with_created_at(now);
store_acl_entry(&state.acl_ks, &entry).await?;
if !state
.keys_ks
.insert_raw_if_absent(BOOTSTRAP_CARVEOUT_CLOSED_KEY, did.as_bytes().to_vec())
.await?
{
let _ = delete_acl_entry(&state.acl_ks, &did).await;
return Err(AppError::Forbidden(
"TEE first-boot carve-out has already been used".into(),
));
}
state.keys_ks.persist().await?;
vti_common::integrity::reseal_if_active().await?;
info!("TEE first-boot carve-out consumed — closed for good");
Ok(bundle)
}
fn decode_client_did(did: &str) -> Result<[u8; 32], AppError> {
affinidi_crypto::did_key::did_key_to_ed25519_pub(did)
.map_err(|e| AppError::Validation(format!("invalid client_did: {e}")))
}
#[cfg(feature = "tee")]
fn label_hash_prefix(label: Option<&str>) -> String {
match label {
Some(s) => {
let digest = Sha256::digest(s.as_bytes());
let mut hex = String::with_capacity(16);
for b in &digest[..8] {
hex.push_str(&format!("{b:02x}"));
}
hex
}
None => "none".to_string(),
}
}
#[cfg(feature = "tee")]
fn decode_nonce(s: &str) -> Result<[u8; 16], AppError> {
let raw = B64URL
.decode(s)
.map_err(|e| AppError::Validation(format!("invalid nonce base64: {e}")))?;
raw.try_into()
.map_err(|_| AppError::Validation("nonce must be 16 bytes".into()))
}
#[cfg(not(feature = "tee"))]
fn decode_nonce(_s: &str) -> Result<[u8; 16], AppError> {
Err(AppError::Forbidden(
"bootstrap request requires TEE first-boot attestation".into(),
))
}
impl IntoResponse for BootstrapResponseBody {
fn into_response(self) -> axum::response::Response {
Json(self).into_response()
}
}
#[cfg(all(test, feature = "tee"))]
mod tests {
use super::*;
#[test]
fn label_hash_prefix_is_stable_and_truncated() {
let a1 = label_hash_prefix(Some("alice@example.com"));
let a2 = label_hash_prefix(Some("alice@example.com"));
let b = label_hash_prefix(Some("bob@example.com"));
assert_eq!(a1, a2, "same label produces same hash");
assert_ne!(a1, b, "different labels produce different hashes");
assert_eq!(a1.len(), 16, "prefix is 16 hex chars (64 bits)");
assert!(
a1.chars().all(|c| c.is_ascii_hexdigit()),
"hash prefix is hex"
);
assert_eq!(label_hash_prefix(None), "none");
}
#[test]
fn label_hash_prefix_does_not_leak_raw_label() {
let raw = "glenn's iphone";
let hashed = label_hash_prefix(Some(raw));
for substr_len in 3..=raw.len() {
for start in 0..=(raw.len() - substr_len) {
let needle = &raw[start..start + substr_len];
assert!(
!hashed.contains(needle),
"hashed output '{hashed}' must not contain raw substring '{needle}'"
);
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn carveout_claim_admits_exactly_one_concurrent_minter() {
use crate::tee::admin_bootstrap::BOOTSTRAP_CARVEOUT_CLOSED_KEY;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
let dir = tempfile::tempdir().expect("tempdir");
let store_config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = Store::open(&store_config).expect("open store");
let keys_ks = store.keyspace(crate::keyspaces::KEYS).expect("keyspace");
let n_tasks: usize = 16;
let successes = std::sync::Arc::new(AtomicUsize::new(0));
let mut handles = Vec::with_capacity(n_tasks);
for i in 0..n_tasks {
let ks = keys_ks.clone();
let successes = std::sync::Arc::clone(&successes);
handles.push(tokio::spawn(async move {
let _guard = MODE_B_LOCK.lock().await;
tokio::time::sleep(Duration::from_millis(2)).await;
let claimed = ks
.insert_raw_if_absent(
BOOTSTRAP_CARVEOUT_CLOSED_KEY,
format!("admin-{i}").into_bytes(),
)
.await
.expect("claim sentinel");
if claimed {
ks.persist().await.expect("persist carve-out");
successes.fetch_add(1, Ordering::SeqCst);
}
}));
}
for h in handles {
h.await.expect("task joined");
}
assert_eq!(
successes.load(Ordering::SeqCst),
1,
"exactly one task may claim the carve-out sentinel; got {} successes",
successes.load(Ordering::SeqCst),
);
assert!(
keys_ks
.get_raw(BOOTSTRAP_CARVEOUT_CLOSED_KEY)
.await
.unwrap()
.is_some(),
"sentinel must be set after the run"
);
}
}
#[cfg(feature = "webvh")]
#[cfg(feature = "webvh")]
pub use provision::{__path_provision_integration, provision_integration};
#[cfg(feature = "webvh")]
mod provision {
use axum::Json;
use axum::extract::State;
use serde::{Deserialize, Serialize};
use crate::auth::AdminAuth;
use crate::error::AppError;
use crate::operations::provision_integration::{
AssertionMode, ProvisionIntegrationParams,
provision_integration as provision_integration_lib,
};
use crate::server::AppState;
use vta_sdk::provision_integration::BootstrapRequest;
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ProvisionIntegrationRequestBody {
pub request: BootstrapRequest,
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub assertion: Option<AssertionModeWire>,
#[serde(default)]
pub vc_validity_seconds: Option<i64>,
#[serde(default)]
pub create_context: bool,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(utoipa::ToSchema)]
pub enum AssertionModeWire {
DidSigned,
PinnedOnly,
}
impl From<AssertionModeWire> for AssertionMode {
fn from(m: AssertionModeWire) -> Self {
match m {
AssertionModeWire::DidSigned => AssertionMode::DidSigned,
AssertionModeWire::PinnedOnly => AssertionMode::PinnedOnly,
}
}
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ProvisionIntegrationResponseBody {
pub bundle: String,
pub digest: String,
pub summary: ProvisionSummaryWire,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ProvisionSummaryWire {
pub client_did: String,
pub admin_did: String,
pub admin_rolled_over: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub integration_did: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin_template_name: Option<String>,
pub bundle_id_hex: String,
pub secret_count: usize,
pub output_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub webvh_server_id: Option<String>,
#[serde(default)]
pub context_created: bool,
}
#[utoipa::path(
post, path = "/bootstrap/provision-integration", tag = "bootstrap",
security(("bearer_jwt" = [])),
request_body = ProvisionIntegrationRequestBody,
responses(
(status = 200, description = "Sealed provisioned bundle (content-negotiated)"),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not an admin in the target context"),
),
)]
pub async fn provision_integration(
auth: AdminAuth,
State(state): State<AppState>,
Json(req): Json<ProvisionIntegrationRequestBody>,
) -> Result<Json<ProvisionIntegrationResponseBody>, AppError> {
let verified = req
.request
.verify()
.map_err(|e| AppError::Validation(format!("verify BootstrapRequest: {e}")))?;
let assertion_mode = req.assertion.map(AssertionMode::from).unwrap_or_default();
let vc_validity = req.vc_validity_seconds.map(chrono::Duration::seconds);
let deps = crate::operations::provision_integration::ProvisionIntegrationDeps::from(&state);
use crate::operations::provision_integration::ResolveContextError;
let (context, context_created) =
match crate::operations::provision_integration::resolve_target_context(
&auth.0,
&deps.contexts_ks,
req.context,
req.create_context,
)
.await
{
Ok(v) => v,
Err(ResolveContextError::Ambiguous(
crate::operations::provision_integration::AmbiguousContext {
candidates,
message,
},
)) => {
return Err(AppError::Validation(format!(
"{message} (candidates: {})",
candidates.join(", "),
)));
}
Err(ResolveContextError::Op(e)) => return Err(e),
};
let output = provision_integration_lib(
&deps,
&auth.0,
ProvisionIntegrationParams {
request: verified,
context,
assertion_mode,
vc_validity,
},
)
.await?;
Ok(Json(ProvisionIntegrationResponseBody {
bundle: output.armored,
digest: output.digest,
summary: ProvisionSummaryWire {
client_did: output.summary.client_did,
admin_did: output.summary.admin_did,
admin_rolled_over: output.summary.admin_rolled_over,
integration_did: output.summary.integration_did,
template_name: output.summary.template_name,
template_kind: output.summary.template_kind,
admin_template_name: output.summary.admin_template_name,
bundle_id_hex: output.summary.bundle_id_hex,
secret_count: output.summary.secret_count,
output_count: output.summary.output_count,
webvh_server_id: output.summary.webvh_server_id,
context_created,
},
}))
}
}