use serde_json::json;
use crate::config::AppConfig;
use crate::keys::{self};
use vta_sdk::protocol::matching::DIDCOMM_SERVICE_TYPE;
use crate::error::AppError;
use crate::operations::protocol::document::{TSP_SERVICE_FRAGMENT, TSP_SERVICE_TYPE};
pub(crate) fn with_tsp_service(
add_tsp_service: bool,
config: &AppConfig,
additional: Option<Vec<serde_json::Value>>,
) -> Option<Vec<serde_json::Value>> {
if !add_tsp_service || !config.services.tsp {
return additional;
}
let Some(mediator_did) = config
.messaging
.as_ref()
.map(|m| m.mediator_did.trim())
.filter(|did| !did.is_empty())
else {
return additional;
};
let mut services = additional.unwrap_or_default();
if services.iter().any(is_tsp_service) {
return Some(services);
}
services.push(json!({
"id": format!("{{DID}}{TSP_SERVICE_FRAGMENT}"),
"type": TSP_SERVICE_TYPE,
"serviceEndpoint": mediator_did,
}));
Some(services)
}
fn is_tsp_service(service: &serde_json::Value) -> bool {
match service.get("type") {
Some(serde_json::Value::String(t)) => t == TSP_SERVICE_TYPE,
Some(serde_json::Value::Array(types)) => {
types.iter().any(|t| t.as_str() == Some(TSP_SERVICE_TYPE))
}
_ => false,
}
}
pub(crate) fn with_tsp_in_rendered_document(
add_tsp_service: bool,
document: &mut serde_json::Value,
) -> Result<(), AppError> {
if !add_tsp_service {
return Ok(());
}
let services = document
.get("service")
.and_then(serde_json::Value::as_array);
if services.is_some_and(|s| s.iter().any(is_tsp_service)) {
return Ok(());
}
let Some(mediator_did) = services.and_then(|s| s.iter().find_map(didcomm_mediator)) else {
return Err(AppError::Validation(
"addTspService: this document names no DIDComm mediator, and a TSP entry \
advertises the same mediator DIDComm uses — there is nothing to point it at"
.into(),
));
};
let entry = vta_sdk::did_templates::tsp_service(&mediator_did)
.map_err(|e| AppError::Validation(format!("addTspService: {e}")))?;
if let Some(services) = document
.get_mut("service")
.and_then(serde_json::Value::as_array_mut)
{
services.push(entry);
}
crate::operations::protocol::document::sort_services_canonical(document);
Ok(())
}
fn didcomm_mediator(service: &serde_json::Value) -> Option<String> {
let is_didcomm = match service.get("type") {
Some(serde_json::Value::String(t)) => t == DIDCOMM_SERVICE_TYPE,
Some(serde_json::Value::Array(types)) => types
.iter()
.any(|t| t.as_str() == Some(DIDCOMM_SERVICE_TYPE)),
_ => false,
};
if !is_didcomm {
return None;
}
let uri = match service.get("serviceEndpoint")? {
serde_json::Value::Array(items) => items.iter().find_map(endpoint_uri),
other => endpoint_uri(other),
};
uri.map(str::trim)
.filter(|u| u.starts_with("did:"))
.map(str::to_owned)
}
fn endpoint_uri(endpoint: &serde_json::Value) -> Option<&str> {
match endpoint {
serde_json::Value::String(s) => Some(s),
serde_json::Value::Object(o) => o.get("uri").and_then(serde_json::Value::as_str),
_ => None,
}
}
pub fn build_did_document(
derived: &keys::DerivedEntityKeys,
config: &AppConfig,
add_mediator_service: bool,
additional_services: &Option<Vec<serde_json::Value>>,
) -> serde_json::Value {
build_did_document_inner(
derived,
None,
config,
true,
add_mediator_service,
additional_services,
)
}
pub fn build_vta_did_document_with_sealed_transfer(
derived: &keys::DerivedEntityKeys,
sealed_transfer: &keys::DerivedSealedTransferKey,
config: &AppConfig,
add_mediator_service: bool,
additional_services: &Option<Vec<serde_json::Value>>,
) -> serde_json::Value {
build_did_document_inner(
derived,
Some(sealed_transfer),
config,
true,
add_mediator_service,
additional_services,
)
}
pub(crate) fn build_did_document_with_options(
derived: &keys::DerivedEntityKeys,
config: &AppConfig,
include_ka: bool,
add_mediator_service: bool,
additional_services: &Option<Vec<serde_json::Value>>,
) -> serde_json::Value {
build_did_document_inner(
derived,
None,
config,
include_ka,
add_mediator_service,
additional_services,
)
}
fn build_did_document_inner(
derived: &keys::DerivedEntityKeys,
sealed_transfer: Option<&keys::DerivedSealedTransferKey>,
config: &AppConfig,
include_ka: bool,
add_mediator_service: bool,
additional_services: &Option<Vec<serde_json::Value>>,
) -> serde_json::Value {
let mut vm = vec![json!({
"id": "{DID}#key-0",
"type": "Multikey",
"controller": "{DID}",
"publicKeyMultibase": &derived.signing_pub
})];
let mut assertion_method = vec![json!("{DID}#key-0")];
let mut did_document = json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://www.w3.org/ns/cid/v1"
],
"id": "{DID}",
"authentication": ["{DID}#key-0"]
});
if include_ka {
vm.push(json!({
"id": "{DID}#key-1",
"type": "Multikey",
"controller": "{DID}",
"publicKeyMultibase": &derived.ka_pub
}));
did_document["keyAgreement"] = json!(["{DID}#key-1"]);
}
if let Some(st) = sealed_transfer {
vm.push(json!({
"id": "{DID}#sealed-transfer-0",
"type": "Multikey",
"controller": "{DID}",
"publicKeyMultibase": &st.public_key
}));
assertion_method.push(json!("{DID}#sealed-transfer-0"));
}
did_document["assertionMethod"] = json!(assertion_method);
did_document["verificationMethod"] = json!(vm);
if add_mediator_service && let Some(ref msg) = config.messaging {
let services = did_document
.as_object_mut()
.unwrap()
.entry("service")
.or_insert_with(|| json!([]));
services.as_array_mut().unwrap().push(json!({
"id": "{DID}#vta-didcomm",
"type": "DIDCommMessaging",
"serviceEndpoint": [{
"accept": ["didcomm/v2"],
"uri": msg.mediator_did
}]
}));
}
if let Some(svcs) = additional_services {
let services = did_document
.as_object_mut()
.unwrap()
.entry("service")
.or_insert_with(|| json!([]));
for svc in svcs {
services.as_array_mut().unwrap().push(svc.clone());
}
}
#[cfg(feature = "tee")]
if config.tee.embed_in_did
&& let Some(ref public_url) = config.public_url
{
let services = did_document
.as_object_mut()
.unwrap()
.entry("service")
.or_insert_with(|| json!([]));
services.as_array_mut().unwrap().push(json!({
"id": "{DID}#tee-attestation",
"type": "TeeAttestation",
"serviceEndpoint": format!("{}/attestation/report", public_url.trim_end_matches('/'))
}));
}
crate::operations::protocol::document::sort_services_canonical(&mut did_document);
did_document
}
pub(crate) fn highest_key_fragment(document: &serde_json::Value) -> Option<u32> {
document
.get("verificationMethod")
.and_then(serde_json::Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter_map(|vm| vm.get("id").and_then(serde_json::Value::as_str))
.filter_map(|id| id.rsplit_once("#key-"))
.filter_map(|(_, n)| n.parse::<u32>().ok())
.max()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MintedVmIds {
pub signing: String,
pub key_agreement: Option<String>,
pub next_fragment_id: u32,
pub additional_signing: std::collections::BTreeMap<String, String>,
}
pub(crate) fn minted_vm_ids(
document: &serde_json::Value,
did: &str,
signing_pub: &str,
ka_pub: Option<&str>,
additional_signing: &[(String, String)],
) -> MintedVmIds {
let methods = document
.get("verificationMethod")
.and_then(serde_json::Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let id_carrying = |public_key: &str| -> Option<String> {
methods
.iter()
.find(|vm| {
vm.get("publicKeyMultibase")
.and_then(serde_json::Value::as_str)
== Some(public_key)
})
.and_then(|vm| vm.get("id"))
.and_then(serde_json::Value::as_str)
.map(|id| id.replace("{DID}", did))
};
let highest = highest_key_fragment(document);
MintedVmIds {
signing: id_carrying(signing_pub).unwrap_or_else(|| format!("{did}#key-0")),
key_agreement: ka_pub.and_then(id_carrying),
next_fragment_id: highest.map_or(2, |n| n + 1),
additional_signing: additional_signing
.iter()
.filter_map(|(slot, public_key)| id_carrying(public_key).map(|id| (slot.clone(), id)))
.collect(),
}
}
#[cfg(test)]
mod tests {
use affinidi_tdk::secrets_resolver::secrets::Secret;
use super::*;
use crate::config::MessagingConfig;
fn fake_keys() -> keys::DerivedEntityKeys {
let signing_secret = Secret::generate_ed25519(None, Some(&[7u8; 32]));
let ka_secret = Secret::generate_ed25519(None, Some(&[9u8; 32]))
.to_x25519()
.expect("x25519 conversion");
keys::DerivedEntityKeys {
signing_pub: signing_secret.get_public_keymultibase().unwrap(),
signing_secret,
signing_path: "m/26'/2'/0'/0'".into(),
signing_priv: String::new(),
signing_label: "signing".into(),
ka_pub: ka_secret.get_public_keymultibase().unwrap(),
ka_secret,
ka_path: "m/26'/2'/0'/1'".into(),
ka_priv: String::new(),
ka_label: "ka".into(),
signing_key_type: vta_sdk::keys::KeyType::Ed25519,
ka_key_type: vta_sdk::keys::KeyType::X25519,
additional_signing: Vec::new(),
}
}
#[test]
fn services_are_published_in_canonical_transport_order() {
let mut config = crate::test_support::test_app_config(std::path::PathBuf::from("/tmp/x"));
config.messaging = Some(MessagingConfig {
mediator_url: "https://mediator.example.com".into(),
mediator_did: "did:webvh:mediator.example.com:mediator".into(),
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
let additional = Some(vec![
json!({
"id": "{DID}#tsp",
"type": "TSPTransport",
"serviceEndpoint": "did:webvh:mediator.example.com:mediator",
}),
json!({
"id": "{DID}#vta-rest",
"type": "VTARest",
"serviceEndpoint": "https://vta.example.com",
}),
]);
let doc = build_did_document(&fake_keys(), &config, true, &additional);
let types: Vec<&str> = doc["service"]
.as_array()
.expect("service array")
.iter()
.map(|s| s["type"].as_str().unwrap())
.collect();
assert_eq!(types, ["TSPTransport", "DIDCommMessaging", "VTARest"]);
}
const MEDIATOR: &str = "did:webvh:mediator.example.com:mediator";
fn config_with(tsp: bool, mediator: Option<&str>) -> crate::config::AppConfig {
let mut config = crate::test_support::test_app_config(std::path::PathBuf::from("/tmp/x"));
config.services.tsp = tsp;
config.messaging = mediator.map(|did| MessagingConfig {
mediator_url: "https://mediator.example.com".into(),
mediator_did: did.into(),
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
config
}
fn tsp_endpoints(services: &Option<Vec<serde_json::Value>>) -> Vec<&str> {
services
.as_deref()
.unwrap_or_default()
.iter()
.filter(|s| super::is_tsp_service(s))
.map(|s| s["serviceEndpoint"].as_str().unwrap())
.collect()
}
#[test]
fn tsp_is_added_at_the_didcomm_mediator_when_asked() {
let out = with_tsp_service(true, &config_with(true, Some(MEDIATOR)), None);
assert_eq!(tsp_endpoints(&out), [MEDIATOR]);
}
#[test]
fn tsp_is_absent_unless_the_caller_asks() {
let out = with_tsp_service(false, &config_with(true, Some(MEDIATOR)), None);
assert!(out.is_none());
}
#[test]
fn a_vta_without_tsp_enabled_never_advertises_it() {
let out = with_tsp_service(true, &config_with(false, Some(MEDIATOR)), None);
assert!(out.is_none(), "services.tsp = false must veto the entry");
}
#[test]
fn no_mediator_means_no_tsp_entry() {
let out = with_tsp_service(true, &config_with(true, None), None);
assert!(out.is_none());
}
#[test]
fn a_caller_supplied_tsp_service_is_not_duplicated() {
let caller = json!({
"id": "{DID}#tsp-transport",
"type": "TSPTransport",
"serviceEndpoint": "did:webvh:other.example:mediator",
});
let out = with_tsp_service(true, &config_with(true, Some(MEDIATOR)), Some(vec![caller]));
assert_eq!(
tsp_endpoints(&out),
["did:webvh:other.example:mediator"],
"the caller's entry must survive, and must be the only one"
);
}
#[test]
fn other_additional_services_are_preserved() {
let rest = json!({
"id": "{DID}#vta-rest",
"type": "VTARest",
"serviceEndpoint": "https://vta.example.com",
});
let out = with_tsp_service(true, &config_with(true, Some(MEDIATOR)), Some(vec![rest]))
.expect("services");
assert_eq!(out.len(), 2);
assert_eq!(out[0]["type"], "VTARest");
assert_eq!(tsp_endpoints(&Some(out)), [MEDIATOR]);
}
const ROOM_MEDIATOR: &str = "did:webvh:QmRoomMediator:mediator.example.com";
fn render_builtin(name: &str, extra: &[(&str, &str)]) -> serde_json::Value {
let template = vta_sdk::did_templates::load_embedded(name).expect("builtin template");
let mut vars = vta_sdk::did_templates::TemplateVars::new();
vars.insert_string("DID", "{DID}");
vars.insert_string("SIGNING_KEY_MB", "z6MkSigningExample");
vars.insert_string("KA_KEY_MB", "z6LSkaExample");
for (k, v) in extra {
vars.insert_string(*k, *v);
}
template.render(&vars).expect("render")
}
fn service_types(doc: &serde_json::Value) -> Vec<&str> {
doc["service"]
.as_array()
.expect("service array")
.iter()
.map(|s| s["type"].as_str().expect("type"))
.collect()
}
#[test]
fn a_templated_room_advertises_tsp_at_its_own_mediator_when_asked() {
let mut doc = render_builtin(
"room",
&[("WEBVH_SERVER", "prod"), ("MEDIATOR_DID", ROOM_MEDIATOR)],
);
with_tsp_in_rendered_document(true, &mut doc).expect("tsp added");
assert_eq!(service_types(&doc), ["TSPTransport", "DIDCommMessaging"]);
assert_eq!(doc["service"][0]["id"], "{DID}#tsp");
assert_eq!(doc["service"][0]["serviceEndpoint"], ROOM_MEDIATOR);
}
#[test]
fn a_templated_room_host_keeps_its_rest_entry_in_canonical_order() {
let mut doc = render_builtin(
"room-host",
&[
("WEBVH_SERVER", "prod"),
("URL", "https://rooms.example.com"),
("MEDIATOR_DID", ROOM_MEDIATOR),
],
);
with_tsp_in_rendered_document(true, &mut doc).expect("tsp added");
assert_eq!(
service_types(&doc),
["TSPTransport", "DIDCommMessaging", "VTARest"]
);
assert_eq!(doc["service"][0]["serviceEndpoint"], ROOM_MEDIATOR);
}
#[test]
fn a_rendered_document_is_untouched_unless_the_caller_asks() {
let rendered = render_builtin(
"room",
&[("WEBVH_SERVER", "prod"), ("MEDIATOR_DID", ROOM_MEDIATOR)],
);
let mut doc = rendered.clone();
with_tsp_in_rendered_document(false, &mut doc).expect("no-op");
assert_eq!(doc, rendered);
}
#[test]
fn a_template_that_already_advertises_tsp_is_not_duplicated() {
let mut doc = render_builtin("ai-agent", &[("MEDIATOR_DID", ROOM_MEDIATOR)]);
with_tsp_in_rendered_document(true, &mut doc).expect("left as is");
let tsp = service_types(&doc)
.into_iter()
.filter(|t| *t == "TSPTransport")
.count();
assert_eq!(tsp, 1);
}
#[test]
fn asking_for_tsp_on_a_document_with_no_mediator_is_refused() {
let mut doc = render_builtin("did-host-http", &[("URL", "https://host.example.com")]);
let err = with_tsp_in_rendered_document(true, &mut doc).expect_err("refused");
assert!(matches!(err, AppError::Validation(_)), "got {err:?}");
}
#[test]
fn the_didcomm_mediator_is_found_in_every_endpoint_shape() {
for endpoint in [
json!(ROOM_MEDIATOR),
json!({ "uri": ROOM_MEDIATOR }),
json!([{ "uri": ROOM_MEDIATOR, "accept": ["didcomm/v2"] }]),
] {
let svc = json!({ "type": "DIDCommMessaging", "serviceEndpoint": endpoint });
assert_eq!(didcomm_mediator(&svc).as_deref(), Some(ROOM_MEDIATOR));
}
let url = json!({ "type": "DIDCommMessaging", "serviceEndpoint": "https://m.example.com" });
assert_eq!(didcomm_mediator(&url), None);
}
const DID: &str = "did:webvh:QmScid:example.com:rooms:northwind";
fn rendered(template: &str, derived: &keys::DerivedEntityKeys) -> serde_json::Value {
let tpl = vta_sdk::did_templates::load_embedded(template)
.unwrap_or_else(|e| panic!("built-in `{template}` failed to load: {e}"));
let mut vars = vta_sdk::did_templates::TemplateVars::new();
vars.insert_string("DID", "{DID}");
vars.insert_string("SIGNING_KEY_MB", derived.signing_pub.clone());
vars.insert_string("KA_KEY_MB", derived.ka_pub.clone());
vars.insert_string("WEBVH_SERVER", "https://webvh.example.com");
vars.insert_string("URL", "https://rooms.example.com/");
vars.insert_string("MEDIATOR_DID", "did:webvh:QmMed:example.com:mediator");
vars.insert_string("VTA_DID", "did:webvh:QmVta:example.com");
vars.insert_string("VTA_URL", "https://vta.example.com");
vars.insert_string("CONTEXT_ID", "rooms");
vars.insert_string("NOW", "2026-09-14T00:00:00Z");
tpl.render(&vars)
.unwrap_or_else(|e| panic!("built-in `{template}` failed to render: {e}"))
}
#[test]
fn the_builders_own_document_still_names_key_0_and_key_1() {
let derived = fake_keys();
let config = crate::test_support::test_app_config(std::path::PathBuf::from("/tmp/x"));
let doc = build_did_document(&derived, &config, false, &None);
let ids = minted_vm_ids(&doc, DID, &derived.signing_pub, Some(&derived.ka_pub), &[]);
assert_eq!(ids.signing, format!("{DID}#key-0"));
assert_eq!(ids.key_agreement.as_deref(), Some(&*format!("{DID}#key-1")));
assert_eq!(ids.next_fragment_id, 2);
}
#[test]
fn a_template_numbering_its_methods_from_one_is_followed() {
let derived = fake_keys();
for template in ["room", "room-host"] {
let doc = rendered(template, &derived);
let ids = minted_vm_ids(&doc, DID, &derived.signing_pub, Some(&derived.ka_pub), &[]);
let published = |public_key: &str| -> String {
doc["verificationMethod"]
.as_array()
.unwrap()
.iter()
.find(|vm| vm["publicKeyMultibase"] == public_key)
.map(|vm| vm["id"].as_str().unwrap().replace("{DID}", DID))
.unwrap_or_else(|| panic!("`{template}` publishes no method for that key"))
};
assert_eq!(ids.signing, published(&derived.signing_pub), "{template}");
assert_eq!(
ids.key_agreement.as_deref(),
Some(&*published(&derived.ka_pub)),
"{template}",
);
assert_ne!(
ids.signing,
ids.key_agreement.clone().unwrap(),
"{template}"
);
}
}
#[test]
fn next_fragment_id_clears_every_method_the_document_published() {
let derived = fake_keys();
let doc = rendered("room-host", &derived);
let ids = minted_vm_ids(&doc, DID, &derived.signing_pub, Some(&derived.ka_pub), &[]);
let highest = doc["verificationMethod"]
.as_array()
.unwrap()
.iter()
.filter_map(|vm| vm["id"].as_str())
.filter_map(|id| id.rsplit_once("#key-"))
.filter_map(|(_, n)| n.parse::<u32>().ok())
.max()
.expect("room-host numbers its methods");
assert!(
ids.next_fragment_id > highest,
"a rotation would allocate #key-{} over a published method",
ids.next_fragment_id,
);
}
#[test]
fn a_method_named_by_its_own_key_is_still_found() {
let derived = fake_keys();
let doc = rendered("vta-admin", &derived);
let ids = minted_vm_ids(&doc, DID, &derived.signing_pub, None, &[]);
assert_eq!(ids.signing, format!("{DID}#{}", derived.signing_pub));
assert_eq!(
ids.key_agreement, None,
"vta-admin publishes no keyAgreement"
);
assert!(
!ids.signing.contains("{DID}"),
"the sentinel was not stamped"
);
}
#[test]
fn a_document_that_names_neither_key_falls_back_to_the_old_pair() {
let derived = fake_keys();
let doc = json!({ "id": "{DID}", "verificationMethod": [] });
let ids = minted_vm_ids(&doc, DID, &derived.signing_pub, Some(&derived.ka_pub), &[]);
assert_eq!(ids.signing, format!("{DID}#key-0"));
assert_eq!(ids.key_agreement, None);
assert_eq!(ids.next_fragment_id, 2);
}
}