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>,
#[serde(default)]
#[cfg_attr(not(feature = "tee"), allow(dead_code))]
pub claim_signature: 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 = "TEE first-boot unavailable, or caller is not the configured bootstrap claim DID"),
(status = 410, description = "Carve-out already used — this VTA already has an admin"),
),
)]
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")]
verify_claim_proof(&req)?;
#[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_sink,
"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;
authorize_claimant(
state.config.read().await.tee.bootstrap_claim_did.as_deref(),
client_ed25519_pub,
)?;
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(carve_out_closed_error());
}
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(carve_out_closed_error());
}
state.keys_ks.persist().await?;
vti_common::integrity::reseal_if_active().await?;
info!("TEE first-boot carve-out consumed — closed for good");
Ok(bundle)
}
#[cfg(feature = "tee")]
fn carve_out_closed_error() -> AppError {
AppError::Gone("TEE first-boot carve-out has already been used".into())
}
#[cfg(feature = "tee")]
fn verify_claim_proof(req: &BootstrapRequestBody) -> Result<(), AppError> {
vta_sdk::sealed_transfer::BootstrapRequest {
version: req.version,
client_did: req.client_did.clone(),
nonce: req.nonce.clone(),
label: None,
claim_signature: req.claim_signature.clone(),
}
.verify_claim_signature()
.map_err(|e| {
AppError::Forbidden(format!(
"bootstrap claim must be signed by the client_did's key: {e}"
))
})
}
#[cfg(feature = "tee")]
fn authorize_claimant(
configured: Option<&str>,
client_ed25519_pub: &[u8; 32],
) -> Result<(), AppError> {
let configured = configured.ok_or_else(|| {
AppError::Forbidden(
"this VTA has no tee.bootstrap_claim_did configured, so no first-boot admin claim \
is accepted"
.into(),
)
})?;
let authorized = affinidi_crypto::did_key::did_key_to_ed25519_pub(configured).map_err(|e| {
AppError::Internal(format!(
"configured tee.bootstrap_claim_did is not an Ed25519 did:key: {e}"
))
})?;
if &authorized != client_ed25519_pub {
return Err(AppError::Forbidden(
"client_did is not this VTA's bootstrap claim DID".into(),
));
}
Ok(())
}
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"
);
}
async fn claim_state(claim_pub: Option<&[u8; 32]>) -> AppState {
use crate::config::{TeeConfig, TeeMode};
use crate::server::TeeContext;
let (_app, ctx) = crate::test_support::build_test_app().await;
let tee_state = crate::tee::init_tee(&TeeConfig {
mode: TeeMode::Simulated,
..Default::default()
})
.expect("init simulated tee")
.expect("simulated tee state present");
let mut state = ctx.state.clone();
state.tee = Some(TeeContext {
state: tee_state,
mnemonic_guard: None,
});
state.config.write().await.tee.bootstrap_claim_did =
claim_pub.map(affinidi_crypto::did_key::ed25519_pub_to_did_key);
state
}
async fn assert_nothing_minted(state: &AppState) {
use crate::tee::admin_bootstrap::BOOTSTRAP_CARVEOUT_CLOSED_KEY;
assert!(
state
.keys_ks
.get_raw(BOOTSTRAP_CARVEOUT_CLOSED_KEY)
.await
.unwrap()
.is_none(),
"the carve-out sentinel must not be written for a refused claim"
);
}
#[tokio::test]
async fn consumed_carveout_returns_410_gone_not_403() {
let (_seed, claim_pub) = generate_ed25519_keypair();
let state = claim_state(Some(&claim_pub)).await;
let now = now_epoch();
mint_mode_b(&state, &claim_pub, [1u8; 16], now)
.await
.expect("first bootstrap mints an admin");
let err = mint_mode_b(&state, &claim_pub, [2u8; 16], now)
.await
.expect_err("second bootstrap must be refused");
assert!(
matches!(err, AppError::Gone(_)),
"consumed carve-out must map to AppError::Gone, got {err:?}"
);
assert_eq!(
err.into_response().status(),
axum::http::StatusCode::GONE,
"consumed carve-out must render as HTTP 410, not 403/409"
);
}
#[tokio::test]
async fn caller_without_the_claim_did_is_refused_without_minting() {
let (_, claim_pub) = generate_ed25519_keypair();
let (_, stranger_pub) = generate_ed25519_keypair();
let state = claim_state(Some(&claim_pub)).await;
let err = mint_mode_b(&state, &stranger_pub, [1u8; 16], now_epoch())
.await
.expect_err("a stranger must not claim admin");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
assert_nothing_minted(&state).await;
}
#[tokio::test]
async fn no_configured_claim_did_admits_nobody() {
let (_, caller_pub) = generate_ed25519_keypair();
let state = claim_state(None).await;
let err = mint_mode_b(&state, &caller_pub, [1u8; 16], now_epoch())
.await
.expect_err("without a claim DID no one may claim");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
assert_nothing_minted(&state).await;
}
#[tokio::test]
async fn missing_sentinel_does_not_admit_a_stranger() {
use crate::tee::admin_bootstrap::BOOTSTRAP_CARVEOUT_CLOSED_KEY;
let (_, claim_pub) = generate_ed25519_keypair();
let (_, stranger_pub) = generate_ed25519_keypair();
let state = claim_state(Some(&claim_pub)).await;
mint_mode_b(&state, &claim_pub, [1u8; 16], now_epoch())
.await
.expect("first bootstrap mints an admin");
state
.keys_ks
.remove(BOOTSTRAP_CARVEOUT_CLOSED_KEY)
.await
.unwrap();
let err = mint_mode_b(&state, &stranger_pub, [2u8; 16], now_epoch())
.await
.expect_err("a stranger must not walk through a reopened window");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
fn body_from(req: vta_sdk::sealed_transfer::BootstrapRequest) -> BootstrapRequestBody {
BootstrapRequestBody {
version: req.version,
client_did: req.client_did,
nonce: req.nonce,
label: req.label,
claim_signature: req.claim_signature,
}
}
#[test]
fn claim_proof_requires_a_signature_by_client_did() {
use vta_sdk::sealed_transfer::BootstrapRequest;
let (seed, pubkey) = generate_ed25519_keypair();
verify_claim_proof(&body_from(BootstrapRequest::new_signed_claim(
&seed, [1u8; 16], None,
)))
.expect("signed claim verifies");
let unsigned = body_from(BootstrapRequest::new(pubkey, [1u8; 16], None));
assert!(matches!(
verify_claim_proof(&unsigned),
Err(AppError::Forbidden(_))
));
let (other_seed, _) = generate_ed25519_keypair();
let mut forged = body_from(BootstrapRequest::new_signed_claim(
&other_seed,
[1u8; 16],
None,
));
forged.client_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&pubkey);
assert!(matches!(
verify_claim_proof(&forged),
Err(AppError::Forbidden(_))
));
}
}