use std::sync::Arc;
use affinidi_tdk::dids::{DID, KeyType};
use affinidi_tdk::secrets_resolver::secrets::Secret;
use didwebvh_rs::DIDWebVHState;
use didwebvh_rs::parameters::Parameters;
use serde_json::json;
use crate::error::{Result, WebVHError};
pub fn generate_ed25519_identity() -> Result<(String, Secret)> {
DID::generate_did_key(KeyType::Ed25519)
.map_err(|e| WebVHError::DIDComm(format!("failed to generate did:key: {e}")))
}
pub fn encode_host(server_url: &str) -> Result<String> {
let parsed = url::Url::parse(server_url)
.map_err(|e| WebVHError::DIDComm(format!("invalid server URL: {e}")))?;
let host_str = parsed
.host_str()
.ok_or_else(|| WebVHError::DIDComm("server URL has no host".into()))?;
Ok(match parsed.port() {
Some(port) => format!("{host_str}%3A{port}"),
None => host_str.to_string(),
})
}
pub fn build_did_web_id(server_url: &str, mnemonic: &str) -> Result<String> {
let host = encode_host(server_url)?;
if mnemonic == ".well-known" {
Ok(format!("did:web:{host}"))
} else {
let path = mnemonic.replace('/', ":");
Ok(format!("did:web:{host}:{path}"))
}
}
#[derive(Default)]
pub struct DidDocumentOptions<'a> {
pub key_agreement_multibase: Option<&'a str>,
pub mediator_endpoint: Option<&'a str>,
pub tsp_endpoint: Option<&'a str>,
}
pub fn add_webvh_hosting_service(doc: &mut serde_json::Value, uri: &str) {
let did_id = doc["id"].as_str().unwrap_or_default().to_string();
let entry = json!({
"id": format!("{did_id}#webvh-hosting"),
"type": "WebVHHosting",
"serviceEndpoint": { "uri": uri },
});
match doc.get_mut("service").and_then(|s| s.as_array_mut()) {
Some(services) => services.push(entry),
None => doc["service"] = json!([entry]),
}
}
pub fn add_trust_task_https_service(doc: &mut serde_json::Value, base_uri: &str) {
let did_id = doc["id"].as_str().unwrap_or_default().to_string();
let entry = json!({
"id": format!("{did_id}#trust-tasks"),
"type": "TrustTaskHTTPS",
"serviceEndpoint": base_uri,
});
match doc.get_mut("service").and_then(|s| s.as_array_mut()) {
Some(services) => services.push(entry),
None => doc["service"] = json!([entry]),
}
}
pub fn add_sign_in_portal_service(doc: &mut serde_json::Value, endpoint: &str) {
let did_id = doc["id"].as_str().unwrap_or_default().to_string();
let entry = json!({
"id": format!("{did_id}#sign-in-portal"),
"type": SERVICE_TYPE_SIGN_IN_PORTAL,
"serviceEndpoint": endpoint,
});
match doc.get_mut("service").and_then(|s| s.as_array_mut()) {
Some(services) => services.push(entry),
None => doc["service"] = json!([entry]),
}
}
pub fn is_sign_in_portal_url(uri: &str) -> bool {
url::Url::parse(uri).is_ok_and(|url| {
url.scheme() == "https"
&& url.host().is_some()
&& url.username().is_empty()
&& url.password().is_none()
})
}
pub fn is_https_or_loopback(uri: &str) -> bool {
let Ok(url) = url::Url::parse(uri) else {
return false;
};
if !url.username().is_empty() || url.password().is_some() {
return false;
}
match url.scheme() {
"https" => url.host().is_some(),
"http" => match url.host() {
Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
},
_ => false,
}
}
pub fn build_did_document(
host: &str,
mnemonic: &str,
public_key_multibase: &str,
opts: &DidDocumentOptions<'_>,
) -> serde_json::Value {
let did_path = if mnemonic == ".well-known" {
String::new()
} else {
mnemonic.replace('/', ":")
};
let did_id = if did_path.is_empty() {
format!("did:webvh:{{SCID}}:{host}")
} else {
format!("did:webvh:{{SCID}}:{host}:{did_path}")
};
let mut vm = vec![json!({
"id": format!("{did_id}#key-0"),
"type": "Multikey",
"controller": &did_id,
"publicKeyMultibase": public_key_multibase,
})];
let mut doc = json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://www.w3.org/ns/cid/v1",
],
"id": did_id,
"authentication": [format!("{did_id}#key-0")],
"assertionMethod": [format!("{did_id}#key-0")],
});
if let Some(ka_key) = opts.key_agreement_multibase {
vm.push(json!({
"id": format!("{did_id}#key-1"),
"type": "Multikey",
"controller": &did_id,
"publicKeyMultibase": ka_key,
}));
doc["keyAgreement"] = json!([format!("{did_id}#key-1")]);
}
doc["verificationMethod"] = json!(vm);
let mut services = vec![];
if let Some(tsp) = opts.tsp_endpoint {
services.push(json!({
"id": format!("{did_id}#tsp"),
"type": "TSPTransport",
"serviceEndpoint": tsp,
}));
}
if let Some(mediator) = opts.mediator_endpoint {
services.push(json!({
"id": format!("{did_id}#vta-didcomm"),
"type": "DIDCommMessaging",
"serviceEndpoint": [{
"accept": ["didcomm/v2"],
"uri": mediator,
}],
}));
}
if !services.is_empty() {
doc["service"] = json!(services);
}
doc
}
pub const SERVICE_TYPE_WEBVH_HOSTING: &str = "WebVHHosting";
pub const SERVICE_TYPE_WEBVH_HOSTING_LEGACY: &str = "WebVHHostingService";
pub const SERVICE_TYPE_TSP: &str = "TSPTransport";
pub const SERVICE_TYPE_DIDCOMM: &str = "DIDCommMessaging";
pub const SERVICE_TYPE_TRUST_TASK_HTTPS: &str = "TrustTaskHTTPS";
pub const SERVICE_TYPE_SIGN_IN_PORTAL: &str = "SignInPortal";
pub fn is_implicit_webvh_service(service_id: &str) -> bool {
service_id.ends_with("#whois") || service_id.ends_with("#files")
}
pub fn service_types_from_doc(doc: &serde_json::Value) -> Vec<String> {
let Some(services) = doc.get("service").and_then(|s| s.as_array()) else {
return Vec::new();
};
let mut out: Vec<String> = Vec::new();
let mut push = |t: &str| {
if !t.is_empty() && !out.iter().any(|seen| seen == t) {
out.push(t.to_string());
}
};
for svc in services {
let id = svc.get("id").and_then(|v| v.as_str()).unwrap_or("");
if is_implicit_webvh_service(id) {
continue;
}
match svc.get("type") {
Some(serde_json::Value::String(t)) => push(t),
Some(serde_json::Value::Array(types)) => {
for t in types.iter().filter_map(|t| t.as_str()) {
push(t);
}
}
_ => {}
}
}
out
}
pub fn advertises_tsp(types: &[String]) -> bool {
types.iter().any(|t| t == SERVICE_TYPE_TSP)
}
pub fn advertises_didcomm(types: &[String]) -> bool {
types.iter().any(|t| t == SERVICE_TYPE_DIDCOMM)
}
pub async fn create_log_entry(
did_document: &serde_json::Value,
secret: &Secret,
) -> Result<(String, String)> {
let public_key_multibase = secret
.get_public_keymultibase()
.map_err(|e| WebVHError::DIDComm(format!("failed to get public key multibase: {e}")))?;
let mut signing_key = secret.clone();
if !signing_key.id.contains('#') {
signing_key.id = format!("did:key:{public_key_multibase}#{public_key_multibase}");
}
let mut state = DIDWebVHState::default();
let params = Parameters {
update_keys: Some(Arc::new(vec![public_key_multibase.into()])),
..Default::default()
};
state
.create_log_entry(None, did_document, ¶ms, &signing_key)
.await
.map_err(|e| WebVHError::DIDComm(format!("failed to create WebVH log entry: {e}")))?;
let scid = state.scid().to_string();
let lines: Vec<String> = state
.log_entries()
.iter()
.map(|e| {
serde_json::to_string(&e.log_entry)
.map_err(|e| WebVHError::DIDComm(format!("failed to serialize log entry: {e}")))
})
.collect::<Result<Vec<_>>>()?;
let jsonl = lines.join("\n");
Ok((scid, jsonl))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosting_is_advertised_on_a_document_with_no_services() {
let mut doc =
build_did_document("example.com", ".well-known", "z6MkKey", &Default::default());
assert!(
doc.get("service").is_none(),
"precondition: no transport, no services"
);
add_webvh_hosting_service(&mut doc, "https://example.com");
let services = doc["service"]
.as_array()
.expect("a service array now exists");
assert_eq!(services.len(), 1);
assert_eq!(services[0]["type"], "WebVHHosting");
assert_eq!(
services[0]["serviceEndpoint"],
json!({ "uri": "https://example.com" })
);
assert_eq!(
services[0]["id"],
json!(format!("{}#webvh-hosting", doc["id"].as_str().unwrap()))
);
}
#[test]
fn hosting_goes_after_the_transport_services() {
let mut doc = build_did_document(
"example.com",
".well-known",
"z6MkKey",
&DidDocumentOptions {
key_agreement_multibase: Some("z6LSKa"),
mediator_endpoint: Some("did:web:mediator.example"),
tsp_endpoint: Some("did:web:mediator.example"),
},
);
add_webvh_hosting_service(&mut doc, "https://example.com");
let types: Vec<&str> = doc["service"]
.as_array()
.unwrap()
.iter()
.map(|s| s["type"].as_str().unwrap())
.collect();
assert_eq!(types, ["TSPTransport", "DIDCommMessaging", "WebVHHosting"]);
}
#[test]
fn trust_task_https_is_advertised_with_the_api_base_as_endpoint() {
let mut doc =
build_did_document("example.com", ".well-known", "z6MkKey", &Default::default());
add_webvh_hosting_service(&mut doc, "https://example.com");
add_trust_task_https_service(&mut doc, "https://example.com/api");
let services = doc["service"].as_array().expect("service array");
assert_eq!(services.len(), 2);
assert_eq!(services[1]["type"], "TrustTaskHTTPS");
assert_eq!(services[1]["serviceEndpoint"], "https://example.com/api");
assert_eq!(
services[1]["id"],
json!(format!("{}#trust-tasks", doc["id"].as_str().unwrap()))
);
}
#[test]
fn sign_in_portal_is_advertised_with_the_login_page_as_endpoint() {
let mut doc =
build_did_document("example.com", ".well-known", "z6MkKey", &Default::default());
add_trust_task_https_service(&mut doc, "https://example.com/api");
add_sign_in_portal_service(&mut doc, "https://example.com/login");
let services = doc["service"].as_array().expect("service array");
assert_eq!(services[1]["type"], SERVICE_TYPE_SIGN_IN_PORTAL);
assert_eq!(services[1]["serviceEndpoint"], "https://example.com/login");
assert_eq!(
services[1]["id"],
json!(format!("{}#sign-in-portal", doc["id"].as_str().unwrap()))
);
}
#[test]
fn only_https_qualifies_for_a_sign_in_portal() {
assert!(is_sign_in_portal_url("https://example.com/login"));
assert!(is_sign_in_portal_url("https://example.com:8443/login"));
assert!(!is_sign_in_portal_url("http://localhost:8534/login"));
assert!(!is_sign_in_portal_url("http://127.0.0.1:8534/login"));
assert!(!is_sign_in_portal_url("https://user@example.com/login"));
assert!(!is_sign_in_portal_url("not a url"));
}
#[test]
fn https_and_loopback_http_qualify_for_trust_task_https() {
assert!(is_https_or_loopback("https://example.com/api"));
assert!(is_https_or_loopback("http://localhost:8534/api"));
assert!(is_https_or_loopback("http://127.0.0.1:8534/api"));
assert!(is_https_or_loopback("http://[::1]:8534/api"));
}
#[test]
fn plaintext_http_to_a_real_host_does_not_qualify() {
assert!(!is_https_or_loopback("http://example.com/api"));
assert!(!is_https_or_loopback("http://webvh.example.com/api"));
assert!(!is_https_or_loopback("http://localhost.evil.example/api"));
assert!(!is_https_or_loopback("http://127.0.0.1.evil.example/api"));
assert!(!is_https_or_loopback("http://localhostevil.example/api"));
assert!(!is_https_or_loopback("http://user@localhost/api"));
assert!(!is_https_or_loopback("http://evil.example@127.0.0.1/api"));
assert!(!is_https_or_loopback("ftp://localhost/api"));
assert!(!is_https_or_loopback("not a url"));
assert!(is_https_or_loopback("http://127.0.0.2:8534/api"));
assert!(is_https_or_loopback("HTTP://LOCALHOST:8534/api"));
}
#[test]
fn encode_host_with_port() {
let result = encode_host("http://localhost:8085").unwrap();
assert_eq!(result, "localhost%3A8085");
}
#[test]
fn encode_host_without_port() {
let result = encode_host("https://example.com").unwrap();
assert_eq!(result, "example.com");
}
#[test]
fn encode_host_invalid_url() {
assert!(encode_host("not-a-url").is_err());
}
#[test]
fn build_did_web_id_simple() {
assert_eq!(
build_did_web_id("https://example.com", "my-did").unwrap(),
"did:web:example.com:my-did"
);
}
#[test]
fn build_did_web_id_with_port() {
assert_eq!(
build_did_web_id("http://localhost:8530", "my-did").unwrap(),
"did:web:localhost%3A8530:my-did"
);
}
#[test]
fn build_did_web_id_nested_path() {
assert_eq!(
build_did_web_id("https://example.com", "people/staff").unwrap(),
"did:web:example.com:people:staff"
);
}
#[test]
fn build_did_web_id_well_known() {
assert_eq!(
build_did_web_id("https://example.com", ".well-known").unwrap(),
"did:web:example.com"
);
}
#[test]
fn build_did_web_id_invalid_url() {
assert!(build_did_web_id("not-a-url", "test").is_err());
}
#[test]
fn build_did_document_correct_did_id() {
let doc = build_did_document(
"example.com%3A8085",
"mypath",
"z6Mk...",
&Default::default(),
);
let id = doc["id"].as_str().unwrap();
assert!(id.starts_with("did:webvh:{SCID}:example.com%3A8085:"));
assert!(id.ends_with(":mypath"));
}
#[test]
fn build_did_document_nested_path() {
let doc = build_did_document(
"example.com",
"people/staff/glenn",
"z6Mk...",
&Default::default(),
);
let id = doc["id"].as_str().unwrap();
assert!(id.contains(":people:staff:glenn"));
assert!(!id.contains('/'));
}
#[test]
fn build_did_document_structure() {
let doc = build_did_document("example.com", "test", "z6MkPubKey", &Default::default());
let context = doc["@context"].as_array().expect("@context is array");
assert_eq!(
context,
&vec![
serde_json::Value::String("https://www.w3.org/ns/did/v1".to_string()),
serde_json::Value::String("https://www.w3.org/ns/cid/v1".to_string()),
]
);
assert!(doc["authentication"].is_array());
assert!(doc["verificationMethod"].is_array());
let vm = &doc["verificationMethod"][0];
assert_eq!(vm["type"], "Multikey");
assert_eq!(vm["publicKeyMultibase"], "z6MkPubKey");
assert!(doc.get("service").is_none());
}
#[test]
fn build_did_document_with_vta_didcomm_service() {
let doc = build_did_document(
"example.com",
"test",
"z6MkPubKey",
&DidDocumentOptions {
mediator_endpoint: Some("did:example:mediator"),
..Default::default()
},
);
let service = &doc["service"];
assert!(service.is_array());
let svc = &service[0];
assert!(svc["id"].as_str().unwrap().ends_with("#vta-didcomm"));
assert_eq!(svc["type"], "DIDCommMessaging");
assert_eq!(svc["serviceEndpoint"][0]["uri"], "did:example:mediator");
assert_eq!(svc["serviceEndpoint"][0]["accept"][0], "didcomm/v2");
}
#[test]
fn build_did_document_matches_webvh_service_template() {
let doc = build_did_document(
"example.com",
"control",
"z6MkSigning",
&DidDocumentOptions {
key_agreement_multibase: Some("z6LSKA"),
mediator_endpoint: Some("did:webvh:QmMED:mediator.example.com"),
..Default::default()
},
);
let did_id = doc["id"].as_str().unwrap();
assert_eq!(
doc["@context"][0], "https://www.w3.org/ns/did/v1",
"first context entry"
);
assert_eq!(
doc["@context"][1], "https://www.w3.org/ns/cid/v1",
"second context entry (cid v1)"
);
assert_eq!(
doc["@context"].as_array().unwrap().len(),
2,
"no extra contexts"
);
let vm = doc["verificationMethod"].as_array().unwrap();
assert_eq!(vm.len(), 2);
assert_eq!(vm[0]["id"], format!("{did_id}#key-0"));
assert_eq!(vm[0]["type"], "Multikey");
assert_eq!(vm[0]["controller"], did_id);
assert_eq!(vm[1]["id"], format!("{did_id}#key-1"));
assert_eq!(vm[1]["type"], "Multikey");
assert_eq!(doc["assertionMethod"][0], format!("{did_id}#key-0"));
assert_eq!(doc["authentication"][0], format!("{did_id}#key-0"));
assert_eq!(doc["keyAgreement"][0], format!("{did_id}#key-1"));
let service = doc["service"].as_array().unwrap();
assert_eq!(service.len(), 1, "exactly one service entry");
assert_eq!(service[0]["id"], format!("{did_id}#vta-didcomm"));
assert_eq!(service[0]["type"], "DIDCommMessaging");
assert_eq!(
service[0]["serviceEndpoint"][0]["uri"],
"did:webvh:QmMED:mediator.example.com"
);
assert_eq!(service[0]["serviceEndpoint"][0]["accept"][0], "didcomm/v2");
}
#[test]
fn build_did_document_matches_webvh_tsp_template_ordering() {
let doc = build_did_document(
"example.com",
"control",
"z6MkSigning",
&DidDocumentOptions {
key_agreement_multibase: Some("z6LSKA"),
mediator_endpoint: Some("did:webvh:QmMED:mediator.example.com"),
tsp_endpoint: Some("did:webvh:QmMED:mediator.example.com"),
},
);
let did_id = doc["id"].as_str().unwrap();
let service = doc["service"].as_array().unwrap();
assert_eq!(service.len(), 2, "TSP + DIDComm service entries");
assert_eq!(service[0]["id"], format!("{did_id}#tsp"));
assert_eq!(service[0]["type"], "TSPTransport");
assert_eq!(
service[0]["serviceEndpoint"], "did:webvh:QmMED:mediator.example.com",
"TSP serviceEndpoint is a bare string, not an array-of-objects"
);
assert_eq!(service[1]["id"], format!("{did_id}#vta-didcomm"));
assert_eq!(service[1]["type"], "DIDCommMessaging");
assert_eq!(
service[1]["serviceEndpoint"][0]["uri"],
"did:webvh:QmMED:mediator.example.com"
);
}
#[test]
fn service_types_reads_bare_string_and_array_type_shapes() {
let doc = serde_json::json!({
"service": [
{ "id": "#webvh-hosting", "type": "WebVHHosting" },
{ "id": "#auth", "type": ["Authentication"] },
]
});
assert_eq!(
service_types_from_doc(&doc),
vec!["WebVHHosting".to_string(), "Authentication".to_string()]
);
}
#[test]
fn service_types_preserves_document_order_and_dedupes() {
let doc = serde_json::json!({
"service": [
{ "type": "TSPTransport" },
{ "type": "DIDCommMessaging" },
{ "type": "TSPTransport" },
]
});
assert_eq!(
service_types_from_doc(&doc),
vec!["TSPTransport".to_string(), "DIDCommMessaging".to_string()]
);
}
#[test]
fn service_types_handles_multi_type_service() {
let doc = serde_json::json!({
"service": [{ "type": ["TSPTransport", "DIDCommMessaging"] }]
});
let types = service_types_from_doc(&doc);
assert!(advertises_tsp(&types));
assert!(advertises_didcomm(&types));
}
#[test]
fn service_types_absent_or_malformed_yields_empty() {
assert!(service_types_from_doc(&serde_json::json!({})).is_empty());
assert!(service_types_from_doc(&serde_json::json!({ "service": "nope" })).is_empty());
let doc =
serde_json::json!({ "service": [{ "id": "#x" }, { "type": 42 }, { "type": "" }] });
assert!(service_types_from_doc(&doc).is_empty());
}
#[test]
fn service_types_round_trips_build_did_document() {
let doc = build_did_document(
"example.com",
"alice",
"z6MkTest",
&DidDocumentOptions {
key_agreement_multibase: None,
mediator_endpoint: Some("did:webvh:QmMED:mediator.example.com"),
tsp_endpoint: Some("did:webvh:QmMED:mediator.example.com"),
},
);
let types = service_types_from_doc(&doc);
assert_eq!(
types,
vec!["TSPTransport".to_string(), "DIDCommMessaging".to_string()]
);
assert!(advertises_tsp(&types));
assert!(advertises_didcomm(&types));
}
#[test]
fn service_types_skips_resolver_injected_implicit_services() {
let did = "did:webvh:QmSCID:webvh.example.com:server1";
let doc = serde_json::json!({
"service": [
{
"id": format!("{did}#webvh-hosting"),
"type": "WebVHHosting",
"serviceEndpoint": { "uri": "https://webvh.example.com/server1" }
},
{
"id": format!("{did}#whois"),
"type": "LinkedVerifiablePresentation",
"serviceEndpoint": "https://webvh.example.com/server1/whois.vp"
},
{
"id": format!("{did}#files"),
"type": "relativeRef",
"serviceEndpoint": "https://webvh.example.com/server1/files"
},
]
});
assert_eq!(
service_types_from_doc(&doc),
vec!["WebVHHosting".to_string()],
"only the operator-declared service should surface"
);
}
#[test]
fn service_types_keeps_operator_declared_linked_vp() {
let did = "did:webvh:QmSCID:example.com:x";
let doc = serde_json::json!({
"service": [{
"id": format!("{did}#my-presentation"),
"type": "LinkedVerifiablePresentation",
"serviceEndpoint": "https://example.com/vp"
}]
});
assert_eq!(
service_types_from_doc(&doc),
vec!["LinkedVerifiablePresentation".to_string()]
);
}
#[test]
fn implicit_service_predicate_matches_only_whois_and_files() {
assert!(is_implicit_webvh_service("did:webvh:Q:h:p#whois"));
assert!(is_implicit_webvh_service("did:webvh:Q:h:p#files"));
assert!(!is_implicit_webvh_service("did:webvh:Q:h:p#tsp"));
assert!(!is_implicit_webvh_service("did:webvh:Q:h:p#vta-didcomm"));
assert!(!is_implicit_webvh_service("did:webvh:Q:h:p#webvh-hosting"));
assert!(!is_implicit_webvh_service("did:webvh:Q:h:p#whois-extra"));
assert!(!is_implicit_webvh_service(""));
}
#[test]
fn implicit_predicate_matches_url_parsed_service_ids() {
let base =
"did:webvh:QmRUN4vrMp6cS1xqWSH46bCipf9W95VrFDyyFBm8XXcZ1E:webvh.storm.ws:webvh-server1";
for frag in ["whois", "files"] {
let parsed = url::Url::parse(&format!("{base}#{frag}")).expect("parse service id");
assert!(
is_implicit_webvh_service(parsed.as_str()),
"Url::as_str() must preserve the #{frag} fragment; got {}",
parsed.as_str()
);
}
let hosting = url::Url::parse(&format!("{base}#webvh-hosting")).expect("parse");
assert!(!is_implicit_webvh_service(hosting.as_str()));
}
#[test]
fn service_types_empty_when_document_advertises_none() {
let doc = build_did_document(
"example.com",
"alice",
"z6MkTest",
&DidDocumentOptions {
key_agreement_multibase: None,
mediator_endpoint: None,
tsp_endpoint: None,
},
);
assert!(service_types_from_doc(&doc).is_empty());
assert!(!advertises_tsp(&service_types_from_doc(&doc)));
}
}