mod mint;
mod preconditions;
mod seal;
mod templates;
mod vta_keys;
mod webvh;
pub use preconditions::{
AmbiguousContext, ResolveContextError, ensure_target_context_or_create, infer_target_context,
resolve_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, warn};
use crate::acl::{Capability, Role, delete_acl_entry, get_acl_entry, store_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},
http::AdminScope,
};
use vta_sdk::sealed_transfer::{
SealedPayloadV1,
template_bootstrap::{
DidKeyMaterial, DidKeyMaterialV2, KeyPair, SlotKeyPair, TemplateBootstrapConfig,
TemplateBootstrapPayload, TemplateBootstrapPayloadV2, 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: vta_audit::SharedAuditSink,
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>,
pub webvh_auth_locks: crate::operations::did_webvh::WebvhAuthLocks,
}
impl From<&AppState> for ProvisionIntegrationDeps {
fn from(state: &AppState) -> Self {
Self {
keys_ks: state.keys_ks.clone(),
acl_ks: state.acl_ks.clone(),
audit: std::sync::Arc::clone(&state.audit_sink),
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(),
#[cfg(any(feature = "didcomm", feature = "tsp"))]
didcomm_bridge: state.didcomm_bridge.clone(),
#[cfg(not(any(feature = "didcomm", feature = "tsp")))]
didcomm_bridge: std::sync::Arc::new(crate::didcomm_bridge::DIDCommBridge::placeholder()),
webvh_auth_locks: state.webvh_auth_locks.clone(),
}
}
}
pub struct ProvisionIntegrationParams {
pub request: VerifiedBootstrapRequest,
pub context: String,
pub admin_scope: AdminScope,
pub assertion_mode: AssertionMode,
pub vc_validity: Option<Duration>,
}
pub struct ProvisionIntegrationOutput {
pub armored: String,
pub digest: String,
pub digest_multibase: 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 context: String,
pub admin_scope: AdminScope,
}
fn allowed_contexts_for(scope: AdminScope, context: &str) -> Vec<String> {
match scope {
AdminScope::Context => vec![context.to_string()],
AdminScope::Unrestricted => Vec::new(),
}
}
pub async fn provision_integration(
state: &ProvisionIntegrationDeps,
auth: &AuthClaims,
params: ProvisionIntegrationParams,
) -> Result<ProvisionIntegrationOutput, AppError> {
let ProvisionIntegrationParams {
request,
context,
admin_scope,
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, admin_scope, &request).await?;
if matches!(
request.ask(),
vta_sdk::provision_integration::BootstrapAsk::AdminRotation(_)
) {
return provision_admin_rotation(
state,
auth,
&request,
&context,
admin_scope,
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_peer = templates::template_targets_did_peer(&integration_template);
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, additional_key_ids, did_document, did_log) =
if use_did_peer {
let (did, skid, kkid, doc, material) =
provision_did_peer(state, &template_vars).await?;
did_key_material = Some(material);
(
did,
skid,
kkid,
std::collections::BTreeMap::new(),
doc,
None,
)
} else 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, std::collections::BTreeMap::new(), doc, log)
} else {
let path_mode =
vta_sdk::protocols::did_management::create::WebvhPathMode::from(webvh_path);
webvh::reject_root_on_shared_server(&path_mode, webvh_server_id.is_some())?;
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 config = state.config.read().await;
let did_resolver = state
.did_resolver
.as_ref()
.ok_or_else(|| AppError::Internal("DID resolver not initialized".into()))?;
let create_deps = super::did_webvh::CreateDidWebvhDeps {
keys_ks: &state.keys_ks,
imported_ks: &state.imported_ks,
contexts_ks: &state.contexts_ks,
webvh_ks: &state.webvh_ks,
did_templates_ks: &state.did_templates_ks,
audit: &state.audit,
seed_store: &*state.seed_store,
config: &config,
did_resolver,
didcomm_bridge: &state.didcomm_bridge,
auth_locks: &state.webvh_auth_locks,
#[cfg(feature = "tsp")]
tsp: None,
};
let create_result = super::did_webvh::create_did_webvh(
&create_deps,
auth,
super::did_webvh::CreateDidWebvhParams {
context_id: context.clone(),
server_id: params_server_id,
url: params_url,
path_mode,
domain: webvh_domain,
label: Some(client_did.clone()),
portable: true,
add_mediator_service: false,
add_tsp_service: false,
additional_services: None,
pre_rotation_count: 0,
did_document: None,
did_log: None,
set_primary,
pre_derived: None,
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,
},
"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(),
create_result.additional_key_ids.clone(),
did_document,
create_result.log_entry.clone(),
)
};
if (use_did_key || use_did_peer) && 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();
let mut additional_material: Vec<SlotKeyPair> = Vec::new();
let mut primary_slots: Option<(SlotKeyPair, SlotKeyPair)> = None;
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,
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,
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"
))
})?;
for (slot, key_id) in &additional_key_ids {
let resp = super::keys::get_key_secret(
&state.keys_ks,
&state.imported_ks,
&state.seed_store,
&state.audit,
auth,
key_id,
"provision-integration",
)
.await?;
additional_material.push(SlotKeyPair {
slot: slot.clone(),
key_type: resp.key_type.clone(),
key_id: key_id.clone(),
public_key_multibase: resp.public_key_multibase.clone(),
private_key_multibase: resp.private_key_multibase.clone(),
});
}
primary_slots = Some((
SlotKeyPair {
slot: vta_sdk::did_templates::SLOT_SIGNING.to_string(),
key_type: signing_secret_resp.key_type.clone(),
key_id: signing_kid.clone(),
public_key_multibase: signing_secret_resp.public_key_multibase.clone(),
private_key_multibase: signing_secret_resp.private_key_multibase.clone(),
},
SlotKeyPair {
slot: vta_sdk::did_templates::SLOT_KA.to_string(),
key_type: ka_secret_resp.key_type.clone(),
key_id: ka_kid.clone(),
public_key_multibase: ka_secret_resp.public_key_multibase.clone(),
private_key_multibase: ka_secret_resp.private_key_multibase.clone(),
},
));
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,
&state.contexts_ks,
auth,
super::acl::CreateAclParams {
did: admin_did.clone(),
role: Role::Admin,
label: request.label().map(str::to_string),
allowed_contexts: allowed_contexts_for(admin_scope, &context),
..Default::default()
},
"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 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 payload = match (additional_material.is_empty(), primary_slots) {
(false, Some((signing_key, ka_key))) => {
let mut v2_secrets = BTreeMap::new();
v2_secrets.insert(
integration_did.clone(),
DidKeyMaterialV2 {
did: integration_did.clone(),
signing_key,
ka_key,
additional_signing_keys: additional_material,
},
);
for (did, material) in &secrets {
if did == &integration_did {
continue;
}
v2_secrets.insert(did.clone(), DidKeyMaterialV2::from_v1(material));
}
SealedPayloadV1::TemplateBootstrapV2(Box::new(TemplateBootstrapPayloadV2 {
authorization: vc_value,
secrets: v2_secrets,
config,
}))
}
_ => SealedPayloadV1::TemplateBootstrap(Box::new(TemplateBootstrapPayload {
authorization: vc_value,
secrets,
config,
})),
};
let seal::SealedProvisionBundle {
armored,
digest,
digest_multibase,
} = seal::seal_provision_payload(
state,
&vta_did,
assertion_mode,
bundle_id,
&client_x25519_pub,
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,
digest_multibase,
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,
context,
admin_scope,
},
})
}
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(());
}
let Some(ephemeral) = get_acl_entry(&state.acl_ks, client_did).await? else {
return Ok(());
};
carry_additive_capabilities(state, &ephemeral, admin_did, context).await?;
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"
);
audit::record_best_effort(
&state.audit,
"acl.swap",
client_did,
Some(admin_did),
"success",
Some("provision-integration"),
Some(context),
)
.await;
Ok(())
}
fn additive_to_carry(ephemeral: &[Capability], successor: &[Capability]) -> Vec<Capability> {
ephemeral
.iter()
.copied()
.filter(|cap| vti_common::acl::is_additive(*cap))
.filter(|cap| !successor.contains(cap))
.collect()
}
async fn carry_additive_capabilities(
state: &ProvisionIntegrationDeps,
ephemeral: &vti_common::acl::AclEntry,
admin_did: &str,
context: &str,
) -> Result<(), AppError> {
if !ephemeral
.capabilities
.iter()
.any(|cap| vti_common::acl::is_additive(*cap))
{
return Ok(());
}
let Some(mut successor) = get_acl_entry(&state.acl_ks, admin_did).await? else {
warn!(
admin_did = %admin_did,
"rollover successor has no ACL row — additive capabilities not carried"
);
return Ok(());
};
let added = additive_to_carry(&ephemeral.capabilities, &successor.capabilities);
if added.is_empty() {
return Ok(());
}
successor.capabilities.extend(added.iter().copied());
store_acl_entry(&state.acl_ks, &successor).await?;
info!(
from = %ephemeral.did,
to = %admin_did,
context = %context,
carried = ?added,
"carried additive capabilities to the rollover successor"
);
audit!(
"acl.capabilities.carried",
actor = &ephemeral.did,
resource = admin_did,
outcome = "success"
);
audit::record_best_effort(
&state.audit,
"acl.capabilities.carried",
&ephemeral.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,
admin_scope: AdminScope,
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,
&state.contexts_ks,
auth,
super::acl::CreateAclParams {
did: admin_did.clone(),
role: Role::Admin,
label: request.label().map(str::to_string),
allowed_contexts: allowed_contexts_for(admin_scope, context),
..Default::default()
},
"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,
digest_multibase,
} = 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,
digest_multibase,
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,
context: context.to_string(),
admin_scope,
},
})
}
async fn provision_did_peer(
state: &ProvisionIntegrationDeps,
template_vars: &BTreeMap<String, Value>,
) -> Result<(String, String, String, Value, DidKeyMaterial), AppError> {
use crate::operations::did_peer::{
mediator_did_didcomm_service, mint_did_peer_with_services, peer_secrets_to_did_key_material,
};
let mediator_did = template_vars
.get("MEDIATOR_DID")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AppError::Validation(
"did:peer agent template requires a non-empty 'MEDIATOR_DID' var — it becomes \
the agent's DIDCommMessaging service endpoint. Pass `--var MEDIATOR_DID=<did>`."
.into(),
)
})?
.to_string();
for forbidden in ["WEBVH_SERVER", "URL", "WEBVH_PATH", "WEBVH_DOMAIN"] {
if template_vars.get(forbidden).is_some_and(|v| !v.is_null()) {
return Err(AppError::Validation(format!(
"did:peer agent template does not accept '{forbidden}' — a did:peer is \
self-contained (no WebVH hosting, no did.jsonl, no publish). Remove it, or \
use the did:webvh 'ai-agent' template if you need hosting."
)));
}
}
let accept =
string_list_var(template_vars, "ACCEPT").unwrap_or_else(|| vec!["didcomm/v2".into()]);
let routing_keys = string_list_var(template_vars, "ROUTING_KEYS").unwrap_or_default();
let services = mediator_did_didcomm_service(&mediator_did, accept, routing_keys);
let (did, secrets) = mint_did_peer_with_services(services)
.map_err(|e| AppError::Internal(format!("mint did:peer: {e}")))?;
let material = peer_secrets_to_did_key_material(&did, &secrets)
.map_err(|e| AppError::Internal(format!("map did:peer secrets: {e}")))?;
let resolver = state
.did_resolver
.as_ref()
.ok_or_else(|| AppError::Internal("DID resolver not initialized".into()))?;
let resolved = resolver
.resolve(&did)
.await
.map_err(|e| AppError::Internal(format!("resolve minted did:peer '{did}': {e}")))?;
let did_document = serde_json::to_value(&resolved.doc)
.map_err(|e| AppError::Internal(format!("serialize did:peer document: {e}")))?;
info!(
did = %did,
mediator_did = %mediator_did,
"minted did:peer agent via provision-integration"
);
Ok((
did,
material.signing_key.key_id.clone(),
material.ka_key.key_id.clone(),
did_document,
material,
))
}
fn string_list_var(vars: &BTreeMap<String, Value>, key: &str) -> Option<Vec<String>> {
match vars.get(key) {
Some(Value::Array(items)) => Some(
items
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
),
Some(Value::String(s)) => Some(vec![s.clone()]),
_ => 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};
#[test]
fn an_additive_grant_survives_the_rollover() {
assert_eq!(
additive_to_carry(&[Capability::PersonaHolder], &[]),
vec![Capability::PersonaHolder]
);
}
#[test]
fn a_narrowing_capability_is_left_where_it_is() {
let carried = additive_to_carry(&[Capability::VaultRead, Capability::PersonaHolder], &[]);
assert_eq!(carried, vec![Capability::PersonaHolder]);
assert!(
!carried.contains(&Capability::VaultRead),
"the role already implies it"
);
}
#[test]
fn a_grant_the_successor_already_has_is_not_carried_twice() {
assert!(
additive_to_carry(&[Capability::PersonaHolder], &[Capability::PersonaHolder])
.is_empty()
);
}
#[test]
fn nothing_is_carried_when_there_was_nothing_additive() {
assert!(additive_to_carry(&[], &[]).is_empty());
assert!(additive_to_carry(&[Capability::VaultRead], &[]).is_empty());
}
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(crate::keyspaces::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
}
#[test]
fn allowed_contexts_follows_the_scope() {
assert_eq!(
allowed_contexts_for(AdminScope::Context, "ctx-a"),
vec!["ctx-a".to_string()]
);
assert!(
allowed_contexts_for(AdminScope::Unrestricted, "ctx-a").is_empty(),
"an unrestricted admin names no context — including not the one it was \
provisioned into, which would silently scope it"
);
}
#[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", AdminScope::Context, &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", AdminScope::Context, &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(),
admin_scope: AdminScope::Context,
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(),
admin_scope: AdminScope::Context,
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(),
admin_scope: AdminScope::Context,
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(),
admin_scope: AdminScope::Context,
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_mints_did_peer_when_template_methods_is_peer() {
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, "agents", "Agents")
.await
.expect("create context");
let mediator_did = "did:peer:2.Ez6LSbysY2xFMRpGMhb7tFTLMpeuPRaqaWM8m1jhSw9UZTQY.Vz6MkqRYqQ";
let mut vars = BTreeMap::new();
vars.insert(
"MEDIATOR_DID".into(),
Value::String(mediator_did.to_string()),
);
let auth = super_admin_claims();
let request = signed_request_with_vars("ai-agent-peer", "agents", vars).await;
let client_did = request.holder().to_string();
let output = provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request,
context: "agents".into(),
admin_scope: AdminScope::Context,
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await
.expect("provision_integration did:peer");
let integration_did = output
.summary
.integration_did
.as_deref()
.expect("TemplateBootstrap path must yield Some(integration_did)");
assert!(
integration_did.starts_with("did:peer:2"),
"integration DID must be a did:peer:2, got {integration_did}"
);
assert_eq!(
output.summary.output_count, 0,
"did:peer path emits no webvh log — outputs must be empty"
);
let payload = open_for_test(&output.armored, &output.digest, &[7u8; 32]);
assert_eq!(output.summary.secret_count, 1, "one minted did:peer");
let material = payload
.secrets
.get(integration_did)
.expect("did:peer secrets present in bundle");
assert_eq!(material.did, integration_did);
assert!(
material.signing_key.key_id.contains("#key-1"),
"signing key id: {}",
material.signing_key.key_id
);
assert!(!material.signing_key.private_key_multibase.is_empty());
assert!(
material.ka_key.key_id.contains("#key-2"),
"ka key id: {}",
material.ka_key.key_id
);
assert!(!material.ka_key.private_key_multibase.is_empty());
let doc = &payload.config.did_document;
let services = doc["service"].as_array().expect("service array");
let didcomm = services
.iter()
.find(|s| {
let t = &s["type"];
t == "DIDCommMessaging"
|| t.as_array()
.is_some_and(|a| a.iter().any(|v| v == "DIDCommMessaging"))
})
.expect("DIDCommMessaging service present");
let endpoint_str = serde_json::to_string(&didcomm["serviceEndpoint"]).unwrap();
assert!(
endpoint_str.contains(mediator_did),
"DIDCommMessaging endpoint must route through MEDIATOR_DID; got {endpoint_str}"
);
assert!(!output.summary.admin_rolled_over);
assert_eq!(output.summary.admin_did, client_did);
let acl = crate::acl::get_acl_entry(&ts.acl_ks, &client_did)
.await
.unwrap()
.expect("holder ACL entry created");
assert_eq!(acl.role, Role::Admin);
assert!(acl.allowed_contexts.contains(&"agents".to_string()));
let ctx_after = crate::contexts::get_context(&ts.contexts_ks, "agents")
.await
.unwrap()
.unwrap();
assert_eq!(ctx_after.did.as_deref(), Some(integration_did));
}
#[tokio::test]
async fn provision_integration_did_peer_rejects_webvh_vars() {
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
crate::contexts::create_context(&ts.contexts_ks, "agents", "Agents")
.await
.expect("create context");
let mut vars = BTreeMap::new();
vars.insert(
"MEDIATOR_DID".into(),
Value::String("did:peer:2.Ez6LSm".into()),
);
vars.insert(
"URL".into(),
Value::String("https://should-not-be-here.example".into()),
);
let auth = super_admin_claims();
let request = signed_request_with_vars("ai-agent-peer", "agents", vars).await;
let result = provision_integration(
&deps,
&auth,
ProvisionIntegrationParams {
request,
context: "agents".into(),
admin_scope: AdminScope::Context,
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await;
let err = match result {
Ok(_) => panic!("did:peer must reject URL"),
Err(e) => e,
};
assert!(matches!(err, AppError::Validation(_)), "got: {err:?}");
assert!(err.to_string().contains("URL"), "got: {err}");
}
fn hybrid_template() -> vta_sdk::did_templates::DidTemplate {
vta_sdk::did_templates::DidTemplate::from_json(serde_json::json!({
"schemaVersion": 2,
"name": "hybrid-host",
"kind": "vtc-host",
"methods": ["webvh"],
"requiredVars": ["URL"],
"keys": {
"signing": { "purpose": "signing", "algorithms": ["ed25519"] },
"ka": { "purpose": "keyAgreement", "algorithms": ["x25519"] },
"pq-signing": { "purpose": "signing", "algorithms": ["mldsa44"] },
},
"document": {
"@context": ["https://www.w3.org/ns/did/v1"],
"id": "{DID}",
"verificationMethod": [
{ "id": "{DID}#key-0", "type": "Multikey", "controller": "{DID}",
"publicKeyMultibase": "{SIGNING_KEY_MB}" },
{ "id": "{DID}#key-1", "type": "Multikey", "controller": "{DID}",
"publicKeyMultibase": "{KA_KEY_MB}" },
{ "id": "{DID}#key-2", "type": "Multikey", "controller": "{DID}",
"publicKeyMultibase": "{PQ_SIGNING_KEY_MB}" },
],
"assertionMethod": ["{DID}#key-0", "{DID}#key-2"],
"authentication": ["{DID}#key-0"],
"keyAgreement": ["{DID}#key-1"],
"service": [{ "id": "{DID}#rest", "type": "VTCRest", "serviceEndpoint": "{URL}" }],
}
}))
.expect("the hybrid template is valid")
}
#[tokio::test]
async fn a_template_with_an_extra_slot_seals_the_v2_variant() {
use super::sealed_transfer_open::open_raw_for_test;
use vta_sdk::sealed_transfer::SealedPayloadV1;
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
crate::contexts::create_context(&ts.contexts_ks, "hybrid", "Hybrid")
.await
.expect("create context");
vta_support::did_templates::store_global_template(
&ts.did_templates_ks,
&vta_sdk::did_templates::DidTemplateRecord {
template: hybrid_template(),
scope: vta_sdk::did_templates::Scope::Global,
created_at: 0,
updated_at: 0,
created_by: "test".into(),
},
)
.await
.expect("store template");
let mut vars = BTreeMap::new();
vars.insert("URL".into(), Value::String("https://vtc.test".into()));
let request = signed_request_with_vars("hybrid-host", "hybrid", vars).await;
let output = provision_integration(
&deps,
&super_admin_claims(),
ProvisionIntegrationParams {
request,
context: "hybrid".into(),
admin_scope: AdminScope::Context,
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await
.expect("provision_integration");
let payload = open_raw_for_test(&output.armored, &output.digest, &[7u8; 32]);
let SealedPayloadV1::TemplateBootstrapV2(p) = payload else {
panic!("a template with an extra key slot must seal TemplateBootstrapV2");
};
let integration_did = output
.summary
.integration_did
.as_deref()
.expect("integration DID");
let material = p.secrets.get(integration_did).expect("integration secrets");
assert_eq!(
material.additional_signing_keys.len(),
1,
"the bundle must carry the post-quantum key the template asked for"
);
let pq = &material.additional_signing_keys[0];
assert_eq!(pq.slot, "pq-signing", "the slot names what asked for it");
assert_eq!(
pq.key_type,
vta_sdk::keys::KeyType::MlDsa44,
"carried from the stored record, not inferred"
);
let published: Vec<&str> = p.config.did_document["verificationMethod"]
.as_array()
.expect("verificationMethod array")
.iter()
.filter_map(|vm| vm["id"].as_str())
.collect();
assert!(
published.contains(&pq.key_id.as_str()),
"the post-quantum kid {} is not published in {published:?}",
pq.key_id
);
let (_b, priv_bytes) =
multibase::decode(&pq.private_key_multibase).expect("private multibase");
assert_eq!(
&priv_bytes[..2],
&[0x9a, 0x26],
"the private half must carry the ML-DSA-44 seed multicodec"
);
assert_eq!(material.signing_key.slot, "signing");
assert_eq!(
material.signing_key.key_type,
vta_sdk::keys::KeyType::Ed25519
);
assert_eq!(material.ka_key.key_type, vta_sdk::keys::KeyType::X25519);
}
#[tokio::test]
async fn a_v1_template_still_seals_the_v1_variant() {
use super::sealed_transfer_open::open_raw_for_test;
use vta_sdk::sealed_transfer::SealedPayloadV1;
let ts = open_test_store().await;
let (_vta_did, deps) = bootstrap_test_vta(&ts).await;
crate::contexts::create_context(&ts.contexts_ks, "classic", "Classic")
.await
.expect("create context");
let request =
signed_request_with_vars("didcomm-mediator", "classic", mediator_template_vars()).await;
let output = provision_integration(
&deps,
&super_admin_claims(),
ProvisionIntegrationParams {
request,
context: "classic".into(),
admin_scope: AdminScope::Context,
assertion_mode: AssertionMode::PinnedOnly,
vc_validity: None,
},
)
.await
.expect("provision_integration");
let payload = open_raw_for_test(&output.armored, &output.digest, &[7u8; 32]);
assert!(
matches!(payload, SealedPayloadV1::TemplateBootstrap(_)),
"a v1 template must keep sealing TemplateBootstrap, or every existing \
opener breaks on its next provisioning run"
);
}
#[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(),
admin_scope: AdminScope::Context,
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(),
admin_scope: AdminScope::Context,
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::new(client_did.clone(), Role::Admin, "test")
.with_label(Some("ephemeral".into()))
.with_contexts(vec!["ctx-swap".into()])
.with_created_at(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(),
admin_scope: AdminScope::Context,
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 = ts
.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(),
admin_scope: AdminScope::Context,
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 = ts
.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(),
admin_scope: AdminScope::Context,
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:?}"),
}
}
pub fn open_raw_for_test(
armored: &str,
digest: &str,
holder_seed: &[u8; 32],
) -> SealedPayloadV1 {
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);
open_bundle(&x_secret, &bundles[0], Some(digest))
.expect("open bundle")
.payload
}
}