mod mint;
mod preconditions;
mod seal;
mod templates;
mod vta_keys;
mod webvh;
pub use preconditions::{AmbiguousContext, ensure_target_context_or_create, infer_target_context};
use std::collections::BTreeMap;
use std::sync::Arc;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use chrono::Duration;
use serde_json::Value;
use tokio::sync::RwLock;
use tracing::info;
use crate::acl::{Role, delete_acl_entry, get_acl_entry};
use crate::audit::{self, audit};
use crate::auth::AuthClaims;
use crate::config::AppConfig;
use crate::didcomm_bridge::DIDCommBridge;
use crate::error::AppError;
use crate::keys::seed_store::SeedStore;
use crate::server::AppState;
use crate::store::KeyspaceHandle;
use vta_sdk::provision_integration::{
AdminOfClaim, OperatorOfClaim, VerifiedBootstrapRequest, VtaAuthorizationClaim,
credential::{VtaAuthorizationParams, issue_vta_authorization_credential},
};
use vta_sdk::sealed_transfer::{
SealedPayloadV1,
template_bootstrap::{
DidKeyMaterial, KeyPair, TemplateBootstrapConfig, TemplateBootstrapPayload, TemplateOutput,
},
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AssertionMode {
#[default]
DidSigned,
PinnedOnly,
}
#[derive(Clone)]
pub struct ProvisionIntegrationDeps {
pub keys_ks: KeyspaceHandle,
pub acl_ks: KeyspaceHandle,
pub audit_ks: KeyspaceHandle,
pub contexts_ks: KeyspaceHandle,
pub did_templates_ks: KeyspaceHandle,
pub imported_ks: KeyspaceHandle,
pub webvh_ks: KeyspaceHandle,
pub sealed_nonces_ks: KeyspaceHandle,
pub seed_store: Arc<dyn SeedStore>,
pub config: Arc<RwLock<AppConfig>>,
pub did_resolver: Option<DIDCacheClient>,
pub didcomm_bridge: Arc<DIDCommBridge>,
}
impl From<&AppState> for ProvisionIntegrationDeps {
fn from(state: &AppState) -> Self {
Self {
keys_ks: state.keys_ks.clone(),
acl_ks: state.acl_ks.clone(),
audit_ks: state.audit_ks.clone(),
contexts_ks: state.contexts_ks.clone(),
did_templates_ks: state.did_templates_ks.clone(),
imported_ks: state.imported_ks.clone(),
webvh_ks: state.webvh_ks.clone(),
sealed_nonces_ks: state.sealed_nonces_ks.clone(),
seed_store: state.seed_store.clone(),
config: state.config.clone(),
did_resolver: state.did_resolver.clone(),
didcomm_bridge: state.didcomm_bridge.clone(),
}
}
}
pub struct ProvisionIntegrationParams {
pub request: VerifiedBootstrapRequest,
pub context: String,
pub assertion_mode: AssertionMode,
pub vc_validity: Option<Duration>,
}
pub struct ProvisionIntegrationOutput {
pub armored: String,
pub digest: String,
pub summary: ProvisionSummary,
}
#[derive(Debug)]
pub struct ProvisionSummary {
pub client_did: String,
pub admin_did: String,
pub admin_rolled_over: bool,
pub integration_did: Option<String>,
pub template_name: Option<String>,
pub template_kind: Option<String>,
pub admin_template_name: Option<String>,
pub bundle_id_hex: String,
pub secret_count: usize,
pub output_count: usize,
pub webvh_server_id: Option<String>,
}
pub async fn provision_integration(
state: &ProvisionIntegrationDeps,
auth: &AuthClaims,
params: ProvisionIntegrationParams,
) -> Result<ProvisionIntegrationOutput, AppError> {
let ProvisionIntegrationParams {
request,
context,
assertion_mode,
vc_validity,
} = params;
let client_did = request.holder().to_string();
let bundle_id = request
.decode_nonce()
.map_err(|e| AppError::Validation(format!("bootstrap request nonce decode: {e}")))?;
let client_x25519_pub = request
.decode_client_x25519_pub()
.map_err(|e| AppError::Validation(format!("bootstrap request X25519 derivation: {e}")))?;
preconditions::preconditions(state, auth, &context, &request).await?;
if matches!(
request.ask(),
vta_sdk::provision_integration::BootstrapAsk::AdminRotation(_)
) {
return provision_admin_rotation(
state,
auth,
&request,
&context,
assertion_mode,
vc_validity,
bundle_id,
&client_did,
&client_x25519_pub,
)
.await;
}
let (template_name, mut template_vars) = preconditions::extract_template(request.ask())?
.expect("TemplateBootstrap ask must yield an integration template");
let admin_template_ref = preconditions::extract_admin_template(request.ask());
let integration_url = template_vars
.get("URL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let webvh_server_id = webvh::resolve_webvh_server(&template_vars, &state.webvh_ks).await?;
let webvh_path = webvh::take_webvh_path(&mut template_vars)?;
let webvh_domain = webvh::take_webvh_domain(&mut template_vars)?;
let ctx_before_mint = crate::contexts::get_context(&state.contexts_ks, &context)
.await?
.ok_or_else(|| {
AppError::Internal(format!(
"context '{context}' disappeared between precondition check and DID mint"
))
})?;
let set_primary = ctx_before_mint.did.is_none();
let integration_template = templates::resolve_template_by_name(state, &context, &template_name)
.await
.map_err(|e| match e {
AppError::NotFound(_) => AppError::Validation(format!(
"integration template '{template_name}' is not registered on this VTA. \
Register it via 'pnm did-templates create {template_name} --file <path>' \
then retry."
)),
other => other,
})?;
let use_did_key = templates::template_targets_did_key_only(&integration_template);
let mut did_key_material: Option<DidKeyMaterial> = None;
let (integration_did, signing_key_id, ka_key_id, did_document, did_log) = if use_did_key {
let (did, skid, kkid, doc, log, material) = mint::mint_integration_via_did_key_template(
state,
&context,
&client_did,
&template_name,
&template_vars,
)
.await?;
did_key_material = Some(material);
(did, skid, kkid, doc, log)
} else {
let (params_server_id, params_url) = match &webvh_server_id {
Some(id) => (Some(id.clone()), None),
None => {
let url = integration_url.clone().ok_or_else(|| {
AppError::Validation(format!(
"webvh DIDs need a publication target. Template '{template_name}' \
resolved without a 'URL' or 'WEBVH_SERVER' template var. Pass either \
`--var URL=https://...` (serverless mode — you publish did.jsonl \
yourself) or `--var WEBVH_SERVER=<id>` (route through a webvh \
hosting server registered with `vta webvh add-server`). At least one \
is required for any webvh-method built-in (did-hosting-control, \
did-hosting-daemon, did-hosting-server)."
))
})?;
(None, Some(url))
}
};
let template_vars_hashmap: std::collections::HashMap<String, Value> =
template_vars.clone().into_iter().collect();
let create_result = super::did_webvh::create_did_webvh(
&state.keys_ks,
&state.imported_ks,
&state.contexts_ks,
&state.webvh_ks,
&state.did_templates_ks,
&*state.seed_store,
&*state.config.read().await,
auth,
super::did_webvh::CreateDidWebvhParams {
context_id: context.clone(),
server_id: params_server_id,
url: params_url,
path: webvh_path,
domain: webvh_domain,
label: Some(client_did.clone()),
portable: true,
add_mediator_service: false,
additional_services: None,
pre_rotation_count: 0,
did_document: None,
did_log: None,
set_primary,
signing_key_id: None,
ka_key_id: None,
template: Some(template_name.clone()),
template_context: None,
template_vars: template_vars_hashmap,
is_vta_identity: false,
},
state
.did_resolver
.as_ref()
.ok_or_else(|| AppError::Internal("DID resolver not initialized".into()))?,
&state.didcomm_bridge,
"provision-integration",
)
.await?;
let did_document = create_result.did_document.clone().ok_or_else(|| {
AppError::Internal("create_did_webvh did not return did_document".into())
})?;
(
create_result.did.clone(),
create_result.signing_key_id.clone(),
create_result.ka_key_id.clone(),
did_document,
create_result.log_entry.clone(),
)
};
if use_did_key && set_primary {
let mut ctx = ctx_before_mint.clone();
ctx.did = Some(integration_did.clone());
ctx.updated_at = chrono::Utc::now();
crate::contexts::store_context(&state.contexts_ks, &ctx)
.await
.map_err(|e| {
AppError::Internal(format!("set integration did:key as context primary: {e}"))
})?;
}
let mut secrets = BTreeMap::new();
if let Some(material) = did_key_material {
secrets.insert(material.did.clone(), material);
} else {
let signing_secret_resp = super::keys::get_key_secret(
&state.keys_ks,
&state.imported_ks,
&state.seed_store,
&state.audit_ks,
auth,
&signing_key_id,
"provision-integration",
)
.await?;
let ka_secret_resp = super::keys::get_key_secret(
&state.keys_ks,
&state.imported_ks,
&state.seed_store,
&state.audit_ks,
auth,
&ka_key_id,
"provision-integration",
)
.await?;
let signing_kid =
published_kid_for(&did_document, &signing_secret_resp.public_key_multibase)
.ok_or_else(|| {
AppError::Internal(format!(
"rendered DID document for '{integration_did}' has no \
verificationMethod matching the minted signing publicKeyMultibase \
— template '{template_name}' likely references a different \
SIGNING_KEY_MB binding"
))
})?;
let ka_kid = published_kid_for(&did_document, &ka_secret_resp.public_key_multibase)
.ok_or_else(|| {
AppError::Internal(format!(
"rendered DID document for '{integration_did}' has no \
verificationMethod matching the minted key-agreement \
publicKeyMultibase — template '{template_name}' likely \
references a different KA_KEY_MB binding"
))
})?;
secrets.insert(
integration_did.clone(),
DidKeyMaterial {
did: integration_did.clone(),
signing_key: KeyPair {
key_id: signing_kid,
public_key_multibase: signing_secret_resp.public_key_multibase.clone(),
private_key_multibase: signing_secret_resp.private_key_multibase.clone(),
},
ka_key: KeyPair {
key_id: ka_kid,
public_key_multibase: ka_secret_resp.public_key_multibase.clone(),
private_key_multibase: ka_secret_resp.private_key_multibase.clone(),
},
},
);
}
let admin_did = if let Some(ref admin_ref) = admin_template_ref {
let minted = mint::mint_admin_via_template(state, &context, admin_ref).await?;
secrets.insert(minted.material.did.clone(), minted.material.clone());
minted.material.did
} else {
client_did.clone()
};
match super::acl::create_acl(
&state.acl_ks,
&state.audit_ks,
&state.contexts_ks,
auth,
&admin_did,
Role::Admin,
request.label().map(str::to_string),
vec![context.clone()],
None,
"provision-integration",
)
.await
{
Ok(_) => {}
Err(AppError::Conflict(_)) => {
info!(
admin_did = %admin_did,
context = %context,
"ACL row already exists — reusing for provision-integration"
);
}
Err(e) => return Err(e),
}
retire_ephemeral_after_rollover(state, &client_did, &admin_did, &context).await?;
let config = state.config.read().await;
let vta_did = config
.vta_did
.as_ref()
.ok_or_else(|| AppError::Internal("VTA DID not configured".into()))?
.clone();
drop(config);
let template_kind =
templates::resolve_template_kind(&state.did_templates_ks, &template_name, &context)
.await
.unwrap_or_else(|_| "integration".to_string());
let claim = VtaAuthorizationClaim {
id: admin_did.clone(),
admin_of: AdminOfClaim {
vta: vta_did.clone(),
context: context.clone(),
role: "admin".into(),
},
operator_of: Some(OperatorOfClaim {
did: integration_did.clone(),
template: template_name.clone(),
}),
};
let mut vc_params = VtaAuthorizationParams::new(claim);
if let Some(validity) = vc_validity {
vc_params = vc_params.with_validity(validity);
}
let vc_issuer_secret = vta_keys::load_vta_vc_issuance_secret(state, &vta_did).await?;
let vc = issue_vta_authorization_credential(&vc_issuer_secret, vc_params)
.await
.map_err(|e| AppError::Internal(format!("issue VTA authorization VC: {e}")))?;
let vc_value =
serde_json::to_value(&vc).map_err(|e| AppError::Internal(format!("serialize VC: {e}")))?;
let vta_trust = vta_keys::load_vta_trust_bundle(state, &vta_did).await?;
let mut outputs = Vec::new();
if let Some(log) = did_log {
outputs.push(TemplateOutput::WebvhLog {
did: integration_did.clone(),
log,
});
}
let secret_count = secrets.len();
let output_count = outputs.len();
let payload = TemplateBootstrapPayload {
authorization: vc_value,
secrets,
config: TemplateBootstrapConfig {
template_name: template_name.clone(),
template_kind: template_kind.clone(),
did_document,
outputs,
vta_url: state.config.read().await.public_url.clone(),
vta_trust,
},
};
let seal::SealedProvisionBundle { armored, digest } = seal::seal_provision_payload(
state,
&vta_did,
assertion_mode,
bundle_id,
&client_x25519_pub,
SealedPayloadV1::TemplateBootstrap(Box::new(payload)),
)
.await?;
let bundle_id_hex = hex_lower(&bundle_id);
let admin_rolled_over = admin_template_ref.is_some();
let admin_template_name = admin_template_ref.as_ref().map(|r| r.name.clone());
info!(
client_did = %client_did,
admin_did = %admin_did,
admin_rolled_over,
integration_did = %integration_did,
context = %context,
template = %template_name,
admin_template = ?admin_template_name,
bundle_id = %bundle_id_hex,
"provision-integration bundle sealed"
);
Ok(ProvisionIntegrationOutput {
armored,
digest,
summary: ProvisionSummary {
client_did,
admin_did,
admin_rolled_over,
integration_did: Some(integration_did),
template_name: Some(template_name),
template_kind: Some(template_kind),
admin_template_name,
bundle_id_hex,
secret_count,
output_count,
webvh_server_id,
},
})
}
use vta_sdk::hex::lower as hex_lower;
async fn retire_ephemeral_after_rollover(
state: &ProvisionIntegrationDeps,
client_did: &str,
admin_did: &str,
context: &str,
) -> Result<(), AppError> {
if admin_did == client_did {
return Ok(());
}
if get_acl_entry(&state.acl_ks, client_did).await?.is_none() {
return Ok(());
}
delete_acl_entry(&state.acl_ks, client_did).await?;
info!(
from = %client_did,
to = %admin_did,
context = %context,
"provision-integration retired ephemeral ACL row after admin rollover"
);
audit!(
"acl.swap",
actor = client_did,
resource = admin_did,
outcome = "success"
);
let _ = audit::record(
&state.audit_ks,
"acl.swap",
client_did,
Some(admin_did),
"success",
Some("provision-integration"),
Some(context),
)
.await;
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn provision_admin_rotation(
state: &ProvisionIntegrationDeps,
auth: &AuthClaims,
request: &VerifiedBootstrapRequest,
context: &str,
assertion_mode: AssertionMode,
vc_validity: Option<Duration>,
bundle_id: [u8; 16],
client_did: &str,
client_x25519_pub: &[u8; 32],
) -> Result<ProvisionIntegrationOutput, AppError> {
let admin_template_ref =
preconditions::extract_admin_template(request.ask()).ok_or_else(|| {
AppError::Internal(
"AdminRotation ask reached provision_admin_rotation without an admin template — \
wiring bug; the wire shape requires it"
.into(),
)
})?;
let minted = mint::mint_admin_via_template(state, context, &admin_template_ref).await?;
let admin_did = minted.material.did.clone();
let admin_template_name = admin_template_ref.name.clone();
match super::acl::create_acl(
&state.acl_ks,
&state.audit_ks,
&state.contexts_ks,
auth,
&admin_did,
Role::Admin,
request.label().map(str::to_string),
vec![context.to_string()],
None,
"provision-integration",
)
.await
{
Ok(_) => {}
Err(AppError::Conflict(_)) => {
info!(
admin_did = %admin_did,
context = %context,
"ACL row already exists — reusing for provision-integration (admin rotation)"
);
}
Err(e) => return Err(e),
}
retire_ephemeral_after_rollover(state, client_did, &admin_did, context).await?;
let config = state.config.read().await;
let vta_did = config
.vta_did
.as_ref()
.ok_or_else(|| AppError::Internal("VTA DID not configured".into()))?
.clone();
drop(config);
let claim = VtaAuthorizationClaim {
id: admin_did.clone(),
admin_of: AdminOfClaim {
vta: vta_did.clone(),
context: context.to_string(),
role: "admin".into(),
},
operator_of: None,
};
let mut vc_params = VtaAuthorizationParams::new(claim);
if let Some(validity) = vc_validity {
vc_params = vc_params.with_validity(validity);
}
let vc_issuer_secret = vta_keys::load_vta_vc_issuance_secret(state, &vta_did).await?;
let vc = issue_vta_authorization_credential(&vc_issuer_secret, vc_params)
.await
.map_err(|e| AppError::Internal(format!("issue VTA authorization VC: {e}")))?;
let vc_value =
serde_json::to_value(&vc).map_err(|e| AppError::Internal(format!("serialize VC: {e}")))?;
let vta_trust = vta_keys::load_vta_trust_bundle(state, &vta_did).await?;
let vta_url = state.config.read().await.public_url.clone();
let payload = vta_sdk::sealed_transfer::AdminRotationPayload {
authorization: vc_value,
admin: minted.material.clone(),
vta_url,
vta_trust,
};
let seal::SealedProvisionBundle { armored, digest } = seal::seal_provision_payload(
state,
&vta_did,
assertion_mode,
bundle_id,
client_x25519_pub,
SealedPayloadV1::AdminRotation(Box::new(payload)),
)
.await?;
let bundle_id_hex = hex_lower(&bundle_id);
info!(
client_did = %client_did,
admin_did = %admin_did,
context = %context,
admin_template = %admin_template_name,
bundle_id = %bundle_id_hex,
"provision-integration AdminRotation bundle sealed"
);
Ok(ProvisionIntegrationOutput {
armored,
digest,
summary: ProvisionSummary {
client_did: client_did.to_string(),
admin_did,
admin_rolled_over: true,
integration_did: None,
template_name: None,
template_kind: None,
admin_template_name: Some(admin_template_name),
bundle_id_hex,
secret_count: 1,
output_count: 0,
webvh_server_id: None,
},
})
}
fn published_kid_for(doc: &Value, target_mb: &str) -> Option<String> {
doc.get("verificationMethod")?
.as_array()?
.iter()
.find(|vm| {
vm.get("publicKeyMultibase")
.and_then(Value::as_str)
.is_some_and(|mb| mb == target_mb)
})
.and_then(|vm| vm.get("id").and_then(Value::as_str))
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::preconditions::{extract_admin_template, extract_template, preconditions};
use super::templates::resolve_template_kind;
use super::webvh::{resolve_webvh_server, take_webvh_path};
use super::*;
use vta_sdk::provision_integration::{BootstrapAsk, DidTemplateRef, TemplateBootstrapAsk};
fn sample_ask(template_name: &str, with_url: bool) -> BootstrapAsk {
let mut vars = BTreeMap::new();
if with_url {
vars.insert(
"URL".to_string(),
Value::String("https://mediator.example.com".into()),
);
}
BootstrapAsk::TemplateBootstrap(TemplateBootstrapAsk {
context_hint: Some("prod-mediator".into()),
template: DidTemplateRef {
name: template_name.into(),
vars,
},
admin_template: None,
note: None,
})
}
fn sample_ask_with_admin(template_name: &str, admin_template_name: &str) -> BootstrapAsk {
let mut vars = BTreeMap::new();
vars.insert(
"URL".to_string(),
Value::String("https://mediator.example.com".into()),
);
BootstrapAsk::TemplateBootstrap(TemplateBootstrapAsk {
context_hint: Some("prod-mediator".into()),
template: DidTemplateRef {
name: template_name.into(),
vars,
},
admin_template: Some(DidTemplateRef {
name: admin_template_name.into(),
vars: BTreeMap::new(),
}),
note: None,
})
}
#[test]
fn extract_template_pulls_name_and_vars() {
let ask = sample_ask("didcomm-mediator", true);
let (name, vars) = extract_template(&ask)
.unwrap()
.expect("TemplateBootstrap ask should yield Some");
assert_eq!(name, "didcomm-mediator");
assert_eq!(
vars.get("URL").and_then(|v| v.as_str()),
Some("https://mediator.example.com")
);
}
#[test]
fn extract_admin_template_returns_none_when_absent() {
let ask = sample_ask("didcomm-mediator", true);
assert!(extract_admin_template(&ask).is_none());
}
#[test]
fn extract_admin_template_returns_some_when_present() {
let ask = sample_ask_with_admin("didcomm-mediator", "vta-admin");
let admin = extract_admin_template(&ask).expect("admin template");
assert_eq!(admin.name, "vta-admin");
}
#[test]
fn assertion_mode_default_is_did_signed() {
assert_eq!(AssertionMode::default(), AssertionMode::DidSigned);
}
use crate::config::StoreConfig;
use crate::store::Store;
use crate::test_support::{
bootstrap_test_vta, open_test_store, signed_request, signed_request_with_vars,
super_admin_claims, test_deps,
};
use chrono::Utc;
use vta_sdk::webvh::WebvhServerRecord;
async fn fresh_webvh_keyspace() -> (tempfile::TempDir, Store, crate::store::KeyspaceHandle) {
let dir = tempfile::tempdir().expect("temp dir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
let ks = store.keyspace("webvh").expect("open webvh ks");
(dir, store, ks)
}
fn sample_server_record(id: &str) -> WebvhServerRecord {
WebvhServerRecord {
id: id.into(),
did: format!("did:webvh:{id}"),
label: None,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[tokio::test]
async fn resolve_webvh_server_absent_returns_none() {
let (_dir, _store, ks) = fresh_webvh_keyspace().await;
let vars = BTreeMap::new();
assert_eq!(resolve_webvh_server(&vars, &ks).await.unwrap(), None);
}
#[tokio::test]
async fn resolve_webvh_server_null_returns_none() {
let (_dir, _store, ks) = fresh_webvh_keyspace().await;
let mut vars = BTreeMap::new();
vars.insert("WEBVH_SERVER".into(), Value::Null);
assert_eq!(resolve_webvh_server(&vars, &ks).await.unwrap(), None);
}
#[tokio::test]
async fn resolve_webvh_server_empty_string_returns_none() {
let (_dir, _store, ks) = fresh_webvh_keyspace().await;
let mut vars = BTreeMap::new();
vars.insert("WEBVH_SERVER".into(), Value::String(" ".into()));
assert_eq!(resolve_webvh_server(&vars, &ks).await.unwrap(), None);
}
#[tokio::test]
async fn resolve_webvh_server_unknown_id_is_not_found() {
let (_dir, _store, ks) = fresh_webvh_keyspace().await;
let mut vars = BTreeMap::new();
vars.insert(
"WEBVH_SERVER".into(),
Value::String("never-registered".into()),
);
let err = resolve_webvh_server(&vars, &ks).await.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "got: {err:?}");
let msg = err.to_string();
assert!(msg.contains("never-registered"), "got: {msg}");
assert!(msg.contains("vta webvh add-server"), "got: {msg}");
}
#[tokio::test]
async fn resolve_webvh_server_registered_id_returns_some() {
let (_dir, _store, ks) = fresh_webvh_keyspace().await;
crate::webvh_store::store_server(&ks, &sample_server_record("hosted-edge-1"))
.await
.unwrap();
let mut vars = BTreeMap::new();
vars.insert("WEBVH_SERVER".into(), Value::String("hosted-edge-1".into()));
assert_eq!(
resolve_webvh_server(&vars, &ks).await.unwrap(),
Some("hosted-edge-1".into())
);
}
#[tokio::test]
async fn resolve_webvh_server_trims_whitespace() {
let (_dir, _store, ks) = fresh_webvh_keyspace().await;
crate::webvh_store::store_server(&ks, &sample_server_record("hosted-edge-1"))
.await
.unwrap();
let mut vars = BTreeMap::new();
vars.insert(
"WEBVH_SERVER".into(),
Value::String(" hosted-edge-1 ".into()),
);
assert_eq!(
resolve_webvh_server(&vars, &ks).await.unwrap(),
Some("hosted-edge-1".into())
);
}
#[tokio::test]
async fn resolve_webvh_server_wrong_type_is_validation_error() {
let (_dir, _store, ks) = fresh_webvh_keyspace().await;
let mut vars = BTreeMap::new();
vars.insert("WEBVH_SERVER".into(), Value::Bool(true));
let err = resolve_webvh_server(&vars, &ks).await.unwrap_err();
assert!(matches!(err, AppError::Validation(_)), "got: {err:?}");
assert!(err.to_string().contains("bool"), "got: {err}");
}
#[test]
fn take_webvh_path_absent_returns_none() {
let mut vars = BTreeMap::new();
vars.insert("URL".into(), Value::String("https://a".into()));
assert_eq!(take_webvh_path(&mut vars).unwrap(), None);
assert!(vars.contains_key("URL"), "unrelated keys must survive");
}
#[test]
fn take_webvh_path_null_returns_none_and_removes_key() {
let mut vars = BTreeMap::new();
vars.insert("WEBVH_PATH".into(), Value::Null);
assert_eq!(take_webvh_path(&mut vars).unwrap(), None);
assert!(
!vars.contains_key("WEBVH_PATH"),
"null WEBVH_PATH must still be removed so the renderer never sees it"
);
}
#[test]
fn take_webvh_path_string_returns_some_and_removes_key() {
let mut vars = BTreeMap::new();
vars.insert("URL".into(), Value::String("https://a".into()));
vars.insert("WEBVH_PATH".into(), Value::String("team/mediator".into()));
assert_eq!(
take_webvh_path(&mut vars).unwrap(),
Some("team/mediator".into())
);
assert!(
!vars.contains_key("WEBVH_PATH"),
"WEBVH_PATH must be removed so it can't reach the renderer"
);
assert!(vars.contains_key("URL"), "unrelated keys must survive");
}
#[test]
fn take_webvh_path_trims_whitespace() {
let mut vars = BTreeMap::new();
vars.insert(
"WEBVH_PATH".into(),
Value::String(" team/mediator ".into()),
);
assert_eq!(
take_webvh_path(&mut vars).unwrap(),
Some("team/mediator".into())
);
}
#[test]
fn take_webvh_path_empty_string_is_validation_error() {
let mut vars = BTreeMap::new();
vars.insert("WEBVH_PATH".into(), Value::String(String::new()));
let err = take_webvh_path(&mut vars).unwrap_err();
assert!(matches!(err, AppError::Validation(_)), "got: {err:?}");
assert!(
err.to_string().contains("WEBVH_PATH"),
"error must name the offending var: {err}"
);
}
#[test]
fn take_webvh_path_whitespace_only_is_validation_error() {
let mut vars = BTreeMap::new();
vars.insert("WEBVH_PATH".into(), Value::String(" ".into()));
let err = take_webvh_path(&mut vars).unwrap_err();
assert!(matches!(err, AppError::Validation(_)), "got: {err:?}");
}
#[test]
fn take_webvh_path_non_string_is_validation_error() {
let mut vars = BTreeMap::new();
vars.insert("WEBVH_PATH".into(), Value::Bool(true));
let err = take_webvh_path(&mut vars).unwrap_err();
assert!(matches!(err, AppError::Validation(_)), "got: {err:?}");
let mut vars = BTreeMap::new();
vars.insert("WEBVH_PATH".into(), Value::Number(42.into()));
let err = take_webvh_path(&mut vars).unwrap_err();
assert!(matches!(err, AppError::Validation(_)), "got: {err:?}");
}
use vta_sdk::did_templates::{DidTemplate, DidTemplateRecord, Scope};
fn mediator_template_vars() -> BTreeMap<String, Value> {
let mut vars = BTreeMap::new();
vars.insert("URL".into(), Value::String("https://mediator.test".into()));
vars.insert(
"WS_URL".into(),
Value::String("wss://mediator.test/ws".into()),
);
vars.insert("ROUTING_KEYS".into(), Value::Array(vec![]));
vars
}
#[tokio::test]
async fn preconditions_accepts_builtin_integration_template() {
let ts = open_test_store().await;
crate::contexts::create_context(&ts.contexts_ks, "prod-mediator", "Prod Mediator")
.await
.expect("create context");
let deps = test_deps(&ts);
let auth = super_admin_claims();
let request = signed_request("didcomm-mediator", "prod-mediator").await;
preconditions(&deps, &auth, "prod-mediator", &request)
.await
.expect("built-in didcomm-mediator should satisfy preconditions");
}
#[tokio::test]
async fn preconditions_rejects_unknown_template() {
let ts = open_test_store().await;
crate::contexts::create_context(&ts.contexts_ks, "prod-mediator", "Prod Mediator")
.await
.expect("create context");
let deps = test_deps(&ts);
let auth = super_admin_claims();
let request = signed_request("never-registered", "prod-mediator").await;
let err = preconditions(&deps, &auth, "prod-mediator", &request)
.await
.expect_err("unknown template must be rejected");
assert!(matches!(err, AppError::Validation(_)), "got: {err:?}");
let msg = err.to_string();
assert!(msg.contains("never-registered"), "got: {msg}");
assert!(msg.contains("is not registered on this VTA"), "got: {msg}");
}
#[tokio::test]
async fn resolve_template_kind_resolves_builtin_when_no_stored_record() {
let ts = open_test_store().await;
let kind = resolve_template_kind(&ts.did_templates_ks, "didcomm-mediator", "prod-mediator")
.await
.expect("built-in kind lookup should succeed");
let expected = vta_sdk::did_templates::load_embedded("didcomm-mediator")
.expect("built-in template available")
.kind;
assert_eq!(kind, expected);
}
#[tokio::test]
async fn resolve_template_kind_prefers_stored_record_over_builtin() {
let ts = open_test_store().await;
let mut tpl: DidTemplate =
vta_sdk::did_templates::load_embedded("didcomm-mediator").expect("built-in available");
"shadowed-kind".clone_into(&mut tpl.kind);
let record = DidTemplateRecord {
template: tpl,
scope: Scope::Context {
context_id: "prod-mediator".into(),
},
created_at: 0,
updated_at: 0,
created_by: "test".into(),
};
crate::did_templates::store_context_template(
&ts.did_templates_ks,
"prod-mediator",
&record,
)
.await
.expect("store context template");
let kind = resolve_template_kind(&ts.did_templates_ks, "didcomm-mediator", "prod-mediator")
.await
.expect("stored record resolves");
assert_eq!(kind, "shadowed-kind");
}
#[tokio::test]
async fn provision_integration_binds_minted_did_when_context_has_none() {
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
crate::contexts::create_context(&ts.contexts_ks, "prod-mediator", "Prod Mediator")
.await
.expect("create context");
let ctx_before = crate::contexts::get_context(&ts.contexts_ks, "prod-mediator")
.await
.unwrap()
.unwrap();
assert!(
ctx_before.did.is_none(),
"precondition: fresh context has no DID"
);
let auth = super_admin_claims();
let request = signed_request_with_vars(
"didcomm-mediator",
"prod-mediator",
mediator_template_vars(),
)
.await;
let output = provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request,
context: "prod-mediator".into(),
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await
.expect("provision_integration");
let ctx_after = crate::contexts::get_context(&ts.contexts_ks, "prod-mediator")
.await
.unwrap()
.unwrap();
assert!(
ctx_after.did.is_some(),
"context DID must be populated after provisioning a fresh context"
);
assert_eq!(
ctx_after.did.as_deref(),
output.summary.integration_did.as_deref(),
"bound DID must match the minted integration DID returned in the summary"
);
}
#[tokio::test]
async fn provision_integration_preserves_existing_context_did() {
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
let mut ctx = crate::contexts::create_context(&ts.contexts_ks, "prod-mediator", "Prod")
.await
.expect("create context");
let pre_existing_did = "did:webvh:pre-existing.example".to_string();
ctx.did = Some(pre_existing_did.clone());
crate::contexts::store_context(&ts.contexts_ks, &ctx)
.await
.expect("pre-populate context DID");
let auth = super_admin_claims();
let request = signed_request_with_vars(
"didcomm-mediator",
"prod-mediator",
mediator_template_vars(),
)
.await;
provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request,
context: "prod-mediator".into(),
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await
.expect("provision_integration");
let ctx_after = crate::contexts::get_context(&ts.contexts_ks, "prod-mediator")
.await
.unwrap()
.unwrap();
assert_eq!(
ctx_after.did.as_deref(),
Some(pre_existing_did.as_str()),
"existing primary DID must not be displaced by a later integration"
);
}
#[tokio::test]
async fn provision_integration_summary_counts_match_payload() {
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
crate::contexts::create_context(&ts.contexts_ks, "prod-mediator", "Prod")
.await
.expect("create context");
let auth = super_admin_claims();
let request = signed_request_with_vars(
"didcomm-mediator",
"prod-mediator",
mediator_template_vars(),
)
.await;
let output = provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request,
context: "prod-mediator".into(),
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await
.expect("provision_integration");
assert!(
!output.summary.admin_rolled_over,
"no admin rollover requested"
);
assert_eq!(
output.summary.secret_count, 1,
"exactly one minted integration DID should be in the payload's secrets map"
);
assert_eq!(
output.summary.output_count, 1,
"exactly one webvh log output"
);
assert!(!output.armored.is_empty(), "armored bundle populated");
assert_eq!(
output.digest.len(),
64,
"SHA-256 digest is 32 bytes hex-encoded"
);
}
#[tokio::test]
async fn provision_integration_mints_did_key_when_template_methods_is_key() {
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
crate::contexts::create_context(&ts.contexts_ks, "signer-ctx", "Local signers")
.await
.expect("create context");
let tpl_json = serde_json::json!({
"schemaVersion": 1,
"name": "local-signer",
"kind": "signer",
"description": "Test: did:key integration template",
"methods": ["key"],
"requiredVars": [],
"optionalVars": {},
"defaults": {},
"document": {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1"
],
"id": "{DID}",
"verificationMethod": [{
"id": "{DID}#{SIGNING_KEY_MB}",
"type": "Multikey",
"controller": "{DID}",
"publicKeyMultibase": "{SIGNING_KEY_MB}"
}],
"authentication": ["{DID}#{SIGNING_KEY_MB}"],
"assertionMethod": ["{DID}#{SIGNING_KEY_MB}"]
}
});
let tpl = DidTemplate::from_json(tpl_json).expect("valid template");
let record = DidTemplateRecord {
template: tpl,
scope: Scope::Context {
context_id: "signer-ctx".into(),
},
created_at: 0,
updated_at: 0,
created_by: "test".into(),
};
crate::did_templates::store_context_template(&ts.did_templates_ks, "signer-ctx", &record)
.await
.expect("store context template");
let auth = super_admin_claims();
let request = signed_request_with_vars("local-signer", "signer-ctx", BTreeMap::new()).await;
let output = provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request,
context: "signer-ctx".into(),
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await
.expect("provision_integration");
let integration_did = output
.summary
.integration_did
.as_deref()
.expect("TemplateBootstrap path must yield Some(integration_did)");
assert!(
integration_did.starts_with("did:key:"),
"integration DID must be a did:key for templates with methods=[\"key\"], got {integration_did}"
);
assert_eq!(
output.summary.output_count, 0,
"did:key path emits no webvh log — outputs should be empty"
);
assert_eq!(
output.summary.secret_count, 1,
"one minted integration DID in secrets (signing + KA keys for that DID)"
);
let ctx_after = crate::contexts::get_context(&ts.contexts_ks, "signer-ctx")
.await
.unwrap()
.unwrap();
assert_eq!(
ctx_after.did.as_deref(),
Some(integration_did),
"did:key path must set context primary when ctx.did was None"
);
}
#[tokio::test]
async fn provision_integration_bundle_kids_match_published_did_document() {
use super::sealed_transfer_open::open_for_test;
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
crate::contexts::create_context(&ts.contexts_ks, "prod-mediator", "Prod")
.await
.expect("create context");
let auth = super_admin_claims();
let request = signed_request_with_vars(
"didcomm-mediator",
"prod-mediator",
mediator_template_vars(),
)
.await;
let output = provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request,
context: "prod-mediator".into(),
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await
.expect("provision_integration");
let payload = open_for_test(&output.armored, &output.digest, &[7u8; 32]);
let integration_did = output
.summary
.integration_did
.as_deref()
.expect("TemplateBootstrap path must yield Some(integration_did)");
let material = payload
.secrets
.get(integration_did)
.expect("integration DID secrets present");
let expected_signing_kid = format!("{integration_did}#key-0");
let expected_ka_kid = format!("{integration_did}#key-1");
assert_eq!(
material.signing_key.key_id, expected_signing_kid,
"signing kid must be the canonical didcomm-mediator template's \
`#key-0` to match the published DID doc"
);
assert_eq!(
material.ka_key.key_id, expected_ka_kid,
"key-agreement kid must be the canonical didcomm-mediator \
template's `#key-1` to match the published DID doc"
);
let doc = &payload.config.did_document;
let vm_ids: Vec<String> = doc["verificationMethod"]
.as_array()
.expect("verificationMethod array")
.iter()
.filter_map(|vm| vm["id"].as_str().map(str::to_string))
.collect();
assert!(
vm_ids.contains(&material.signing_key.key_id),
"signing kid {} not in published verificationMethod ids {:?}",
material.signing_key.key_id,
vm_ids
);
assert!(
vm_ids.contains(&material.ka_key.key_id),
"key-agreement kid {} not in published verificationMethod ids {:?}",
material.ka_key.key_id,
vm_ids
);
}
use crate::test_support::signed_admin_rotation_request;
#[tokio::test]
async fn provision_integration_admin_rotation_mints_fresh_admin_no_integration() {
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
crate::contexts::create_context(&ts.contexts_ks, "ctx-1", "Test ctx")
.await
.expect("create context");
let auth = super_admin_claims();
let request = signed_admin_rotation_request("vta-admin", "ctx-1").await;
let client_did = request.holder().to_string();
let output = provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request,
context: "ctx-1".into(),
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await
.expect("provision_integration AdminRotation");
assert!(
output.summary.integration_did.is_none(),
"AdminRotation must not produce an integration DID"
);
assert!(
output.summary.template_name.is_none(),
"AdminRotation has no integration template"
);
assert!(
output.summary.template_kind.is_none(),
"AdminRotation has no integration template kind"
);
assert!(output.summary.admin_rolled_over);
assert_eq!(output.summary.secret_count, 1, "admin DID only");
assert_eq!(output.summary.output_count, 0, "no template outputs");
assert_eq!(
output.summary.admin_template_name.as_deref(),
Some("vta-admin")
);
assert_ne!(
output.summary.admin_did, client_did,
"AdminRotation must mint a fresh admin DID, not echo client_did"
);
let payload_bytes = output.armored.clone();
let bundles =
vta_sdk::sealed_transfer::armor::decode(&payload_bytes).expect("armor decode");
assert_eq!(bundles.len(), 1, "single bundle");
let x_secret = vta_sdk::sealed_transfer::ed25519_seed_to_x25519_secret(&[7u8; 32]);
let opened =
vta_sdk::sealed_transfer::open_bundle(&x_secret, &bundles[0], Some(&output.digest))
.expect("open AdminRotation bundle");
let rotation_payload = match opened.payload {
vta_sdk::sealed_transfer::SealedPayloadV1::AdminRotation(boxed) => *boxed,
other => panic!("expected AdminRotation, got {other:?}"),
};
assert_eq!(rotation_payload.admin.did, output.summary.admin_did);
assert!(
rotation_payload
.admin
.signing_key
.private_key_multibase
.starts_with('z')
);
let acl_entry = crate::acl::get_acl_entry(&deps.acl_ks, &output.summary.admin_did)
.await
.expect("ACL lookup")
.expect("ACL row exists for rotated admin DID");
assert_eq!(acl_entry.role, crate::acl::Role::Admin);
assert!(
acl_entry.allowed_contexts.iter().any(|c| c == "ctx-1"),
"ACL row contexts must include ctx-1, got {:?}",
acl_entry.allowed_contexts
);
}
#[tokio::test]
async fn provision_integration_admin_rotation_retires_ephemeral_acl_and_emits_swap_audit() {
use crate::acl::{AclEntry, Role, store_acl_entry};
use vta_sdk::protocols::audit_management::list::AuditLogEntry;
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
crate::contexts::create_context(&ts.contexts_ks, "ctx-swap", "Swap-audit ctx")
.await
.expect("create context");
let request = signed_admin_rotation_request("vta-admin", "ctx-swap").await;
let client_did = request.holder().to_string();
let ephemeral_row = AclEntry {
did: client_did.clone(),
role: Role::Admin,
label: Some("ephemeral".into()),
allowed_contexts: vec!["ctx-swap".into()],
created_at: 0,
created_by: "test".into(),
expires_at: None,
kind: Default::default(),
capabilities: vec![],
device: None,
version: 0,
};
store_acl_entry(&deps.acl_ks, &ephemeral_row)
.await
.expect("seed ephemeral ACL row");
let auth = super_admin_claims();
let output = provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request,
context: "ctx-swap".into(),
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await
.expect("provision_integration AdminRotation");
assert!(
crate::acl::get_acl_entry(&deps.acl_ks, &client_did)
.await
.expect("acl get ephemeral")
.is_none(),
"ephemeral ACL row must be retired after admin rollover"
);
assert!(
crate::acl::get_acl_entry(&deps.acl_ks, &output.summary.admin_did)
.await
.expect("acl get admin")
.is_some(),
"rotated admin ACL row must exist"
);
let entries: Vec<AuditLogEntry> = {
let raw = deps
.audit_ks
.prefix_iter_raw("log:")
.await
.expect("audit prefix scan");
raw.iter()
.filter_map(|(_, v)| serde_json::from_slice(v).ok())
.collect()
};
let swap_entries: Vec<&AuditLogEntry> =
entries.iter().filter(|e| e.action == "acl.swap").collect();
assert_eq!(
swap_entries.len(),
1,
"expected exactly one acl.swap audit entry, got: {:#?}",
swap_entries
);
let swap = swap_entries[0];
assert_eq!(swap.actor, client_did, "actor = swapped-from (ephemeral)");
assert_eq!(
swap.resource.as_deref(),
Some(output.summary.admin_did.as_str()),
"resource = swapped-to (rotated long-term admin)"
);
assert_eq!(swap.outcome, "success");
assert_eq!(swap.channel.as_deref(), Some("provision-integration"));
assert_eq!(swap.context_id.as_deref(), Some("ctx-swap"));
}
#[tokio::test]
async fn provision_integration_admin_rotation_swap_audit_skipped_when_no_ephemeral_row() {
use vta_sdk::protocols::audit_management::list::AuditLogEntry;
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
crate::contexts::create_context(&ts.contexts_ks, "ctx-relayer", "Relayer ctx")
.await
.expect("create context");
let auth = super_admin_claims();
let request = signed_admin_rotation_request("vta-admin", "ctx-relayer").await;
let client_did = request.holder().to_string();
let output = provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request,
context: "ctx-relayer".into(),
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await
.expect("provision_integration AdminRotation (relayer mode)");
assert_ne!(output.summary.admin_did, client_did);
assert!(
crate::acl::get_acl_entry(&deps.acl_ks, &output.summary.admin_did)
.await
.expect("acl get admin")
.is_some()
);
let entries: Vec<AuditLogEntry> = {
let raw = deps
.audit_ks
.prefix_iter_raw("log:")
.await
.expect("audit prefix scan");
raw.iter()
.filter_map(|(_, v)| serde_json::from_slice(v).ok())
.collect()
};
let swap_count = entries.iter().filter(|e| e.action == "acl.swap").count();
assert_eq!(
swap_count, 0,
"no acl.swap entry expected when ephemeral has no ACL row"
);
}
#[tokio::test]
async fn provision_integration_admin_rotation_rejects_wrong_kind_template() {
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
crate::contexts::create_context(&ts.contexts_ks, "ctx-2", "Test ctx 2")
.await
.expect("create context");
let auth = super_admin_claims();
let request = signed_admin_rotation_request("didcomm-mediator", "ctx-2").await;
let result = provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request,
context: "ctx-2".into(),
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await;
let err = match result {
Ok(_) => panic!("non-admin template must be rejected"),
Err(e) => e,
};
assert!(matches!(err, AppError::Validation(_)), "got: {err:?}");
let msg = err.to_string();
assert!(
msg.contains("kind") && msg.contains("admin"),
"error must explain admin-kind requirement, got: {msg}"
);
}
}
#[cfg(test)]
mod sealed_transfer_open {
use vta_sdk::sealed_transfer::template_bootstrap::TemplateBootstrapPayload;
use vta_sdk::sealed_transfer::{
SealedPayloadV1, armor, ed25519_seed_to_x25519_secret, open_bundle,
};
pub fn open_for_test(
armored: &str,
digest: &str,
holder_seed: &[u8; 32],
) -> TemplateBootstrapPayload {
let bundles = armor::decode(armored).expect("armor decode");
assert_eq!(bundles.len(), 1, "expected single bundle");
let x_secret = ed25519_seed_to_x25519_secret(holder_seed);
let opened = open_bundle(&x_secret, &bundles[0], Some(digest)).expect("open bundle");
match opened.payload {
SealedPayloadV1::TemplateBootstrap(boxed) => *boxed,
other => panic!("expected TemplateBootstrap, got {other:?}"),
}
}
}