use affinidi_tdk::{
did_common::{
Document,
builder::{ServiceBuilder, VerificationMethodBuilder},
service::Endpoint,
verification_method::VerificationRelationship,
},
secrets_resolver::secrets::Secret,
};
use didwebvh_rs::{
DIDWebVHError,
create::{CreateDIDConfig, create_did},
log_entry::LogEntryMethods,
parameters::Parameters,
url::WebVHURL,
};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::Path;
use url::Url;
use vta_sdk::protocols::did_management::create::WebvhPathMode;
use crate::{config::PersonaDIDKeys, errors::OpenVTCError};
pub fn mediator_from_document(doc: &Document) -> Option<String> {
doc.service
.iter()
.find(|s| s.type_.iter().any(|t| t == "DIDCommMessaging"))
.and_then(|s| match &s.service_endpoint {
Endpoint::Url(url) => Some(url.to_string()),
Endpoint::Map(value) => {
let obj = match value {
Value::Array(items) => items.first()?,
other => other,
};
obj.get("uri").and_then(Value::as_str).map(str::to_owned)
}
_ => None,
})
.filter(|m| !m.is_empty())
}
pub fn advertises_tsp(document: &Document) -> bool {
document.service.iter().any(|s| {
s.type_
.iter()
.any(|t| t == vta_sdk::protocol::matching::TSP_SERVICE_TYPE)
})
}
pub fn tsp_advertisement_warning(document: &Document) -> Option<String> {
if advertises_tsp(document) {
return None;
}
Some(
"Note: this persona advertises DIDComm only — the VTA did not add a TSP \
service. It cannot join a TSP-only community, and the DID cannot gain \
the service later. Enable `[services] tsp` on the VTA (with a mediator \
configured) and mint a new persona if you need one."
.to_string(),
)
}
const RESERVED_FIRST_SEGMENTS: &[&str] = &[
".well-known",
"api",
"auth",
"dids",
"stats",
"acl",
"health",
];
pub fn validate_custom_path(path: &str) -> Result<(), String> {
if path.is_empty() {
return Err("Enter a path, or choose a server-assigned one.".to_string());
}
if path.len() > 255 {
return Err("A path must be at most 255 characters.".to_string());
}
if path.starts_with('/') || path.ends_with('/') {
return Err("A path must not start or end with '/'.".to_string());
}
for (i, segment) in path.split('/').enumerate() {
if segment.is_empty() {
return Err("A path must not contain empty segments (//).".to_string());
}
validate_path_segment(segment)?;
if i == 0 && RESERVED_FIRST_SEGMENTS.contains(&segment) {
return Err(format!(
"'{segment}' is reserved by the hosting server and cannot start a path."
));
}
}
Ok(())
}
fn validate_path_segment(segment: &str) -> Result<(), String> {
if segment.len() < 2 || segment.len() > 63 {
return Err(format!(
"'{segment}': each path segment must be 2 to 63 characters."
));
}
if !segment
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(format!(
"'{segment}': a path may use only lowercase letters, digits and hyphens."
));
}
let bytes = segment.as_bytes();
if !bytes[0].is_ascii_alphanumeric() || !bytes[segment.len() - 1].is_ascii_alphanumeric() {
return Err(format!(
"'{segment}': each path segment must start and end with a letter or digit."
));
}
Ok(())
}
pub fn explicit_path_mode(typed: &str) -> Result<WebvhPathMode, String> {
match WebvhPathMode::from(typed.trim().to_string()) {
WebvhPathMode::AutoAssign => Err("Enter a path, or choose a server-assigned one.".into()),
WebvhPathMode::WellKnown => Err(
"'.well-known' is the hosting server's own root DID, not a persona path — \
choose another name."
.into(),
),
WebvhPathMode::Explicit(path) => {
validate_custom_path(&path)?;
Ok(WebvhPathMode::Explicit(path))
}
}
}
pub async fn create_initial_webvh_did(
raw_url: &str,
keys: &mut PersonaDIDKeys,
mediator_did: &str,
update_secret: Secret,
next_update_secret: Secret,
did_log_path: &Path,
) -> Result<(String, Document), OpenVTCError> {
let normalized_url = normalize_webvh_url(raw_url)?;
let parsed_url = Url::parse(&normalized_url)
.map_err(|e| OpenVTCError::Config(format!("Invalid URL ({normalized_url}): {e}")))?;
let webvh_url = WebVHURL::parse_url(&parsed_url)
.map_err(|e| OpenVTCError::Config(format!("Invalid WebVH URL: {e}")))?;
let placeholder_did = webvh_url.to_did_base();
let mut did_document = Document::new(&placeholder_did)
.map_err(|e| OpenVTCError::Config(format!("Invalid DID URL: {e}")))?;
let mut property_set: HashMap<String, Value> = HashMap::new();
property_set.insert(
"publicKeyMultibase".to_string(),
Value::String(keys.signing.secret.get_public_keymultibase().map_err(|e| {
DIDWebVHError::InvalidMethodIdentifier(format!(
"Couldn't set signing verificationMethod publicKeybase: {e}"
))
})?),
);
let key_id = Url::parse(&[&placeholder_did, "#key-1"].concat()).map_err(|e| {
DIDWebVHError::InvalidMethodIdentifier(format!(
"Couldn't set verificationMethod Key ID for #key-1: {e}"
))
})?;
did_document.verification_method.push(
VerificationMethodBuilder::from_urls(
key_id.clone(),
"Multikey".to_string(),
did_document.id.clone(),
)
.properties(property_set.clone())
.build(),
);
did_document
.assertion_method
.push(VerificationRelationship::Reference(key_id.to_string()));
property_set.insert(
"publicKeyMultibase".to_string(),
Value::String(
keys.authentication
.secret
.get_public_keymultibase()
.map_err(|e| {
DIDWebVHError::InvalidMethodIdentifier(format!(
"Couldn't set authentication verificationMethod publicKeybase: {e}"
))
})?,
),
);
let key_id = Url::parse(&[&placeholder_did, "#key-2"].concat()).map_err(|e| {
DIDWebVHError::InvalidMethodIdentifier(format!(
"Couldn't set verificationMethod key ID for #key-2: {e}"
))
})?;
did_document.verification_method.push(
VerificationMethodBuilder::from_urls(
key_id.clone(),
"Multikey".to_string(),
did_document.id.clone(),
)
.properties(property_set.clone())
.build(),
);
did_document
.authentication
.push(VerificationRelationship::Reference(key_id.to_string()));
property_set.insert(
"publicKeyMultibase".to_string(),
Value::String(
keys.decryption
.secret
.get_public_keymultibase()
.map_err(|e| {
DIDWebVHError::InvalidMethodIdentifier(format!(
"Couldn't set decryption verificationMethod publicKeybase: {e}"
))
})?,
),
);
let key_id = Url::parse(&[&placeholder_did, "#key-3"].concat()).map_err(|e| {
DIDWebVHError::InvalidMethodIdentifier(format!(
"Couldn't set verificationMethod key ID for #key-3: {e}"
))
})?;
did_document.verification_method.push(
VerificationMethodBuilder::from_urls(
key_id.clone(),
"Multikey".to_string(),
did_document.id.clone(),
)
.properties(property_set.clone())
.build(),
);
did_document
.key_agreement
.push(VerificationRelationship::Reference(key_id.to_string()));
let endpoint = Endpoint::Map(json!([{"accept": ["didcomm/v2"], "uri": mediator_did}]));
let service_id = Url::parse(&[&placeholder_did, "#public-didcomm"].concat()).map_err(|e| {
DIDWebVHError::InvalidMethodIdentifier(format!(
"Couldn't set Service Endpoint for #public-didcomm: {e}"
))
})?;
did_document.service.push(
ServiceBuilder::new("DIDCommMessaging", endpoint)
.id_url(service_id)
.build(),
);
let mut update_secret = update_secret;
update_secret.id = [
"did:key:",
&update_secret.get_public_keymultibase().map_err(|e| {
OpenVTCError::Secret(format!(
"update Secret Key was missing public key information! {e}"
))
})?,
"#",
&update_secret.get_public_keymultibase().map_err(|e| {
OpenVTCError::Secret(format!(
"update Secret Key was missing public key information! {e}"
))
})?,
]
.concat();
let parameters = Parameters::new()
.with_key_pre_rotation(true)
.with_update_keys(vec![update_secret.get_public_keymultibase().map_err(
|e| {
OpenVTCError::Secret(format!(
"update Secret Key was missing public key information! {e}"
))
},
)?])
.with_next_key_hashes(vec![
next_update_secret
.get_public_keymultibase_hash()
.map_err(|e| {
OpenVTCError::Secret(format!(
"next_update Secret Key was missing public key information! {e}"
))
})?,
])
.with_portable(true)
.build();
let config = CreateDIDConfig::builder()
.address(&normalized_url)
.authorization_key(update_secret)
.did_document(serde_json::to_value(&did_document)?)
.parameters(parameters)
.build()?;
let result = create_did(config).await?;
let did_id = result.did();
keys.signing.secret.id = [did_id, "#key-1"].concat();
keys.authentication.secret.id = [did_id, "#key-2"].concat();
keys.decryption.secret.id = [did_id, "#key-3"].concat();
if let Some(parent) = did_log_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|e| {
OpenVTCError::Config(format!(
"couldn't create DID log directory {}: {e}",
parent.display()
))
})?;
}
let did_log_path_str = did_log_path
.to_str()
.ok_or_else(|| OpenVTCError::Config("DID log path contains invalid UTF-8".to_string()))?;
result.log_entry().save_to_file(did_log_path_str)?;
Ok((
did_id.to_string(),
serde_json::from_value(result.log_entry().get_did_document()?)?,
))
}
pub fn normalize_webvh_url(raw_url: &str) -> Result<String, OpenVTCError> {
let trimmed = raw_url.trim();
if trimmed.is_empty() {
return Err(OpenVTCError::Config(
"WebVH URL is empty. Expected e.g. https://example.com or https://example.com/path"
.to_string(),
));
}
let with_scheme = if let Some(scheme_end) = trimmed.find("://") {
let scheme = &trimmed[..scheme_end];
if scheme != "http" && scheme != "https" {
return Err(OpenVTCError::Config(format!(
"WebVH URL must use http or https (got {scheme}://)"
)));
}
trimmed.to_string()
} else {
format!("https://{trimmed}")
};
let after_scheme = with_scheme
.strip_prefix("https://")
.or_else(|| with_scheme.strip_prefix("http://"))
.unwrap_or(with_scheme.as_str());
if after_scheme.contains("//") {
return Err(OpenVTCError::Config(format!(
"WebVH URL path contains an empty segment (consecutive slashes): {raw_url}"
)));
}
let url = Url::parse(&with_scheme)
.map_err(|e| OpenVTCError::Config(format!("Invalid URL ({raw_url}): {e}")))?;
if url.scheme() != "http" && url.scheme() != "https" {
return Err(OpenVTCError::Config(format!(
"WebVH URL must use http or https (got {}://)",
url.scheme()
)));
}
if url.host_str().is_none_or(|h| h.is_empty()) {
return Err(OpenVTCError::Config(format!(
"WebVH URL is missing a host: {raw_url}"
)));
}
if url.query().is_some() {
return Err(OpenVTCError::Config(format!(
"WebVH URL must not contain a query string: {raw_url}"
)));
}
if url.fragment().is_some() {
return Err(OpenVTCError::Config(format!(
"WebVH URL must not contain a fragment: {raw_url}"
)));
}
let path = url.path();
let stripped = path.trim_start_matches('/').trim_end_matches('/');
if !stripped.is_empty() {
for segment in stripped.split('/') {
if segment.is_empty() {
return Err(OpenVTCError::Config(format!(
"WebVH URL path contains an empty segment (consecutive slashes): {raw_url}"
)));
}
if segment.contains(':') || segment.chars().any(|c| c.is_whitespace()) {
return Err(OpenVTCError::Config(format!(
"WebVH URL path segment '{segment}' contains invalid characters \
(':' or whitespace): {raw_url}"
)));
}
}
}
let host = url.host_str().unwrap();
let scheme = url.scheme();
let mut out = format!("{scheme}://{host}");
if let Some(port) = url.port() {
out.push_str(&format!(":{port}"));
}
if stripped.is_empty() {
out.push('/');
} else {
out.push('/');
out.push_str(stripped);
out.push('/');
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn placeholder_did_for(raw_url: &str) -> String {
let normalized = normalize_webvh_url(raw_url).expect("normalize");
let parsed = Url::parse(&normalized).expect("parse url");
let webvh = WebVHURL::parse_url(&parsed).expect("webvh parse");
webvh.to_did_base()
}
fn doc_with_services(types: &[&str]) -> Document {
let did = "did:webvh:QmScid:example.com:persona";
let mut document = Document::new(did).expect("new document");
for (i, type_) in types.iter().enumerate() {
let endpoint = Endpoint::Map(json!([{
"accept": ["didcomm/v2"],
"uri": "did:webvh:QmScid:example.com:mediator",
}]));
let id = Url::parse(&format!("{did}#svc-{i}")).expect("service id");
document
.service
.push(ServiceBuilder::new(*type_, endpoint).id_url(id).build());
}
document
}
#[test]
fn tsp_is_detected_by_type_not_id() {
assert!(advertises_tsp(&doc_with_services(&["TSPTransport"])));
assert!(advertises_tsp(&doc_with_services(&[
"DIDCommMessaging",
"TSPTransport"
])));
}
#[test]
fn a_didcomm_only_mint_is_warned_about() {
let document = doc_with_services(&["DIDCommMessaging"]);
assert!(!advertises_tsp(&document));
let warning = tsp_advertisement_warning(&document).expect("must warn");
assert!(
warning.contains("TSP-only community"),
"the consequence must be named, not just the missing service: {warning}"
);
assert!(
warning.contains("cannot gain the service later"),
"a persona does not recover on its own — the document is written at \
mint time and never revisited, and an operator who thinks it will \
heal is the reason this warning exists: {warning}"
);
assert!(
warning.contains("[services] tsp"),
"the fix is a VTA setting; naming it is what makes this actionable: {warning}"
);
}
#[test]
fn a_tsp_capable_mint_says_nothing() {
let document = doc_with_services(&["DIDCommMessaging", "TSPTransport"]);
assert_eq!(tsp_advertisement_warning(&document), None);
}
#[test]
fn a_serviceless_document_is_warned_about_too() {
assert!(tsp_advertisement_warning(&doc_with_services(&[])).is_some());
}
#[test]
fn normalize_adds_https_when_missing() {
assert_eq!(
normalize_webvh_url("example.com").unwrap(),
"https://example.com/"
);
}
#[test]
fn normalize_preserves_explicit_scheme_and_port() {
assert_eq!(
normalize_webvh_url("http://localhost:8080/path").unwrap(),
"http://localhost:8080/path/"
);
}
#[test]
fn normalize_adds_trailing_slash() {
assert_eq!(
normalize_webvh_url("https://example.com/vincent").unwrap(),
"https://example.com/vincent/"
);
}
#[test]
fn normalize_collapses_leading_slash_only_paths() {
assert_eq!(
normalize_webvh_url("https://example.com/").unwrap(),
"https://example.com/"
);
}
#[test]
fn normalize_rejects_empty() {
assert!(normalize_webvh_url(" ").is_err());
}
#[test]
fn normalize_rejects_double_slash_path() {
let err = normalize_webvh_url("https://example.com//vincent").unwrap_err();
assert!(err.to_string().contains("empty segment"), "got: {err}");
}
#[test]
fn normalize_rejects_non_http_scheme() {
assert!(normalize_webvh_url("ftp://example.com/").is_err());
}
#[test]
fn normalize_rejects_query_and_fragment() {
assert!(normalize_webvh_url("https://example.com/?x=1").is_err());
assert!(normalize_webvh_url("https://example.com/#frag").is_err());
}
#[test]
fn normalize_rejects_colon_in_path_segment() {
assert!(normalize_webvh_url("https://example.com/foo:bar").is_err());
}
#[test]
fn placeholder_did_converts_path_slash_to_colon() {
assert_eq!(
placeholder_did_for("https://r2.ic3.dev/vincent"),
"did:webvh:{SCID}:r2.ic3.dev:vincent"
);
}
#[test]
fn placeholder_did_handles_multiple_path_segments() {
assert_eq!(
placeholder_did_for("https://example.com/foo/bar"),
"did:webvh:{SCID}:example.com:foo:bar"
);
}
#[test]
fn placeholder_did_handles_no_path() {
assert_eq!(
placeholder_did_for("https://example.com/"),
"did:webvh:{SCID}:example.com"
);
}
#[test]
fn placeholder_did_encodes_port() {
assert_eq!(
placeholder_did_for("http://localhost:8080/test"),
"did:webvh:{SCID}:localhost%3A8080:test"
);
}
#[test]
fn a_plain_name_is_a_valid_path() {
assert_eq!(validate_custom_path("alice"), Ok(()));
assert_eq!(validate_custom_path("alice-2"), Ok(()));
assert_eq!(validate_custom_path("a1"), Ok(()));
assert_eq!(validate_custom_path("team/alice"), Ok(()));
}
#[test]
fn each_refusal_names_the_offending_segment() {
let err = validate_custom_path("team/Alice").unwrap_err();
assert!(err.contains("Alice"), "got: {err}");
assert!(err.contains("lowercase"), "got: {err}");
let err = validate_custom_path("team/a").unwrap_err();
assert!(err.contains("'a'"), "got: {err}");
assert!(err.contains("2 to 63"), "got: {err}");
let err = validate_custom_path("-alice").unwrap_err();
assert!(err.contains("start and end"), "got: {err}");
assert!(validate_custom_path("alice-").is_err());
}
#[test]
fn empty_bounding_and_double_slashes_are_refused() {
assert!(validate_custom_path("").is_err());
assert!(validate_custom_path("/alice").is_err());
assert!(validate_custom_path("alice/").is_err());
assert!(validate_custom_path("team//alice").is_err());
assert!(validate_custom_path(&"a".repeat(256)).is_err());
assert!(validate_custom_path("alice_bob").is_err(), "no underscores");
assert!(validate_custom_path("alice.bob").is_err(), "no dots");
}
#[test]
fn a_reserved_name_is_refused_only_in_first_position() {
let err = validate_custom_path("api").unwrap_err();
assert!(err.contains("reserved"), "got: {err}");
assert!(validate_custom_path("health").is_err());
assert_eq!(validate_custom_path("team/api"), Ok(()));
}
#[test]
fn a_typed_path_resolves_to_an_explicit_mode() {
assert_eq!(
explicit_path_mode(" alice "),
Ok(WebvhPathMode::Explicit("alice".to_string())),
"surrounding whitespace is trimmed, not sent"
);
let err = explicit_path_mode(" ").unwrap_err();
assert!(err.contains("server-assigned"), "got: {err}");
let err = explicit_path_mode(".well-known").unwrap_err();
assert!(err.contains("root DID"), "got: {err}");
}
}