use crate::configs::ProfileConfig;
use serde_json::Value;
pub fn build_public_jwk(jwk: &affinidi_tdk::affinidi_crypto::JWK) -> serde_json::Value {
match &jwk.params {
affinidi_tdk::affinidi_crypto::Params::EC(params) => {
let mut jwk_obj = serde_json::json!({
"kty": "EC",
"crv": params.curve,
"x": params.x,
"y": params.y,
});
if let Some(kid) = &jwk.key_id {
jwk_obj["kid"] = serde_json::json!(kid);
}
jwk_obj
}
affinidi_tdk::affinidi_crypto::Params::OKP(params) => {
let mut jwk_obj = serde_json::json!({
"kty": "OKP",
"crv": params.curve,
"x": params.x,
});
if let Some(kid) = &jwk.key_id {
jwk_obj["kid"] = serde_json::json!(kid);
}
jwk_obj
}
_ => serde_json::json!({}),
}
}
pub fn build_verification_methods(profile_config: &ProfileConfig) -> Vec<serde_json::Value> {
profile_config
.secrets
.iter()
.enumerate()
.map(|(index, secret)| {
let public_jwk = match &secret.secret_material {
affinidi_tdk::secrets_resolver::secrets::SecretMaterial::JWK(jwk) => {
build_public_jwk(jwk)
}
_ => serde_json::json!({}),
};
serde_json::json!({
"id": format!("{}#key-{}", profile_config.did, index),
"type": "JsonWebKey2020",
"controller": profile_config.did,
"publicKeyJwk": public_jwk,
})
})
.collect()
}
pub const REST_SERVICE_TYPE: &str = "TRQPRest";
pub const DIDCOMM_SERVICE_TYPE: &str = "DIDCommMessaging";
pub const DIDCOMM_SERVICE_FRAGMENT: &str = "#didcomm";
pub const REST_SERVICE_FRAGMENT: &str = "#rest";
pub const TRUST_REGISTRY_SERVICE_TYPE: &str = "TrustRegistry";
pub const TRUST_REGISTRY_SERVICE_FRAGMENT: &str = "#trust-registry";
pub const TRQP_PROFILE_URI: &str = "https://trustoverip.org/profiles/trqp/v2";
pub const TSP_SERVICE_TYPE: &str = "TSPTransport";
pub const TSP_SERVICE_FRAGMENT: &str = "#tsp";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TransportFlags {
pub rest: bool,
pub didcomm: bool,
pub tsp: bool,
}
impl Default for TransportFlags {
fn default() -> Self {
Self {
rest: true,
didcomm: true,
tsp: false,
}
}
}
impl TransportFlags {
pub fn validate(&self) -> Result<(), String> {
if !self.rest && !self.didcomm && !self.tsp {
return Err(
"at least one transport must be enabled: set ENABLE_REST, ENABLE_DIDCOMM \
or ENABLE_TSP to 'true' (a registry with no transport can serve nobody)"
.to_string(),
);
}
if self.tsp && !self.didcomm {
return Err(
"ENABLE_TSP=true requires ENABLE_DIDCOMM=true: TSP frames are multiplexed \
on the DIDComm mediator socket and cannot be received without it"
.to_string(),
);
}
if self.tsp && !cfg!(feature = "tsp") {
return Err(
"ENABLE_TSP=true requires a binary built with `--features tsp`; this build \
cannot serve TSP and must not advertise it"
.to_string(),
);
}
Ok(())
}
}
pub fn validate_public_url(url: &str) -> Result<(), String> {
if url.starts_with("https://") {
return Ok(());
}
if let Some(rest) = url.strip_prefix("http://") {
if let Some(after) = rest.strip_prefix('[') {
return match after.split_once(']') {
Some((host, tail))
if host == "::1" && (tail.is_empty() || tail.starts_with([':', '/', '?'])) =>
{
Ok(())
}
_ => Err(cleartext_rejection(url)),
};
}
let host = rest.split(['/', ':', '?']).next().unwrap_or("");
if host == "localhost" || host == "127.0.0.1" {
return Ok(());
}
}
Err(cleartext_rejection(url))
}
fn cleartext_rejection(url: &str) -> String {
format!(
"TR_PUBLIC_URL must be https:// (got '{url}'); cleartext TRQP is spoofable by an \
on-path attacker. http:// is allowed only to loopback (localhost, 127.0.0.1, \
[::1]) for local dev."
)
}
pub fn build_services(
did: &str,
mediator_did: &str,
public_url: Option<&str>,
flags: TransportFlags,
) -> Vec<Value> {
let mut services = Vec::new();
if flags.didcomm {
services.push(serde_json::json!({
"id": format!("{did}{DIDCOMM_SERVICE_FRAGMENT}"),
"type": DIDCOMM_SERVICE_TYPE,
"serviceEndpoint": {
"uri": mediator_did,
"accept": ["didcomm/v2"],
"routingKeys": []
}
}));
}
if flags.rest
&& let Some(url) = public_url.map(str::trim).filter(|u| !u.is_empty())
{
let url = url.trim_end_matches('/');
services.push(serde_json::json!({
"id": format!("{did}{REST_SERVICE_FRAGMENT}"),
"type": REST_SERVICE_TYPE,
"serviceEndpoint": url,
}));
services.push(serde_json::json!({
"id": format!("{did}{TRUST_REGISTRY_SERVICE_FRAGMENT}"),
"type": TRUST_REGISTRY_SERVICE_TYPE,
"serviceEndpoint": {
"uri": url,
"profile": TRQP_PROFILE_URI,
},
}));
}
if flags.tsp {
services.push(serde_json::json!({
"id": format!("{did}{TSP_SERVICE_FRAGMENT}"),
"type": TSP_SERVICE_TYPE,
"serviceEndpoint": mediator_did,
}));
}
services
}
pub fn build_did_document(
profile_config: &ProfileConfig,
mediator_did: &str,
public_url: Option<&str>,
flags: TransportFlags,
) -> String {
let verification_methods = build_verification_methods(profile_config);
let key_refs: Vec<String> = (0..profile_config.secrets.len())
.map(|index| format!("{}#key-{}", profile_config.did, index))
.collect();
serde_json::json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/jws-2020/v1"
],
"id": profile_config.did,
"verificationMethod": verification_methods,
"authentication": key_refs,
"assertionMethod": key_refs,
"keyAgreement": key_refs,
"service": build_services(&profile_config.did, mediator_did, public_url, flags)
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_tdk::{affinidi_crypto::JWK, secrets_resolver::secrets::Secret};
use serde_json::json;
#[test]
fn test_build_public_jwk_ec() {
let jwk: JWK = serde_json::from_value(json!({
"crv": "P-256",
"kty": "EC",
"x": "DEtsdJXfi7IuqaZFkRW_aBwHHpID1jQjPqN_Y46zlZM",
"y": "LQs6Q-gGqgtrUW2iEfb9YRyvPAuNALceHqGYs4sNwh4",
"d": "private part"
}))
.unwrap();
let result = build_public_jwk(&jwk);
assert_eq!(result["kty"], "EC");
assert!(result.get("x").is_some());
assert!(result.get("y").is_some());
assert!(result.get("d").is_none()); }
#[test]
fn test_build_public_jwk_okp() {
let jwk: JWK = serde_json::from_value(json!({
"crv": "Ed25519",
"kty": "OKP",
"x": "DfRiO5mCASvWyPxr20GQEfzOmFFh50spyP7KHMjvGQo",
"d": "private part"
}))
.unwrap();
let result = build_public_jwk(&jwk);
assert_eq!(result["kty"], "OKP");
assert!(result.get("x").is_some());
assert!(result.get("d").is_none()); }
#[test]
fn test_build_verification_methods_single_key() {
let secret: Secret = serde_json::from_value(json!({
"id": "did:web:example.com#key-0",
"type": "JsonWebKey2020",
"privateKeyJwk": {
"crv": "P-256",
"d": "ctKLNB9cXUO3yD-jMCaRi680RmHOFuS30nVogmEhkx4",
"kty": "EC",
"x": "ctKLNB9cXUO3yD-jMCaRi680RmHOFuS30nVogmEhkx4",
"y": "1GDFw4zkTPdVWwqxRhSnEVCdkZyfmViJR8Nq5ad2V9w"
}
}))
.unwrap();
let profile = ProfileConfig {
did: "did:web:example.com".to_string(),
alias: "test".to_string(),
secrets: vec![secret],
};
let methods = build_verification_methods(&profile);
assert_eq!(methods.len(), 1);
assert_eq!(methods[0]["id"], "did:web:example.com#key-0");
assert_eq!(methods[0]["type"], "JsonWebKey2020");
assert_eq!(methods[0]["controller"], "did:web:example.com");
assert_eq!(methods[0]["publicKeyJwk"]["kty"], "EC");
assert_eq!(methods[0]["publicKeyJwk"]["crv"], "P-256");
assert!(methods[0]["publicKeyJwk"].get("d").is_none());
}
#[test]
fn test_build_verification_methods_multiple_keys() {
let secret1: Secret = serde_json::from_value(json!({
"id": "did:web:example.com#key-0",
"type": "JsonWebKey2020",
"privateKeyJwk": {
"crv": "P-256",
"d": "ctKLNB9cXUO3yD-jMCaRi680RmHOFuS30nVogmEhkx4",
"kty": "EC",
"x": "ctKLNB9cXUO3yD-jMCaRi680RmHOFuS30nVogmEhkx4",
"y": "1GDFw4zkTPdVWwqxRhSnEVCdkZyfmViJR8Nq5ad2V9w"
}
}))
.unwrap();
let secret2: Secret = serde_json::from_value(json!({
"id": "did:web:example.com#key-1",
"type": "JsonWebKey2020",
"privateKeyJwk": {
"crv": "secp256k1",
"d": "rJcdID8WLUt3Fby5ZsVgyVtrkaEXv050hISLxwY5RrI",
"kty": "EC",
"x": "rJcdID8WLUt3Fby5ZsVgyVtrkaEXv050hISLxwY5RrI",
"y": "eKiDGeJExattkEmEBbOBOBuzvCB9YnfFaZ6xMzYpIMM"
}
}))
.unwrap();
let secret3: Secret = serde_json::from_value(json!({
"id": "did:web:example.com#key-2",
"type": "JsonWebKey2020",
"privateKeyJwk": {
"crv": "Ed25519",
"d": "DfRiO5mCASvWyPxr20GQEfzOmFFh50spyP7KHMjvGQo",
"kty": "OKP",
"x": "DfRiO5mCASvWyPxr20GQEfzOmFFh50spyP7KHMjvGQo"
}
}))
.unwrap();
let profile = ProfileConfig {
did: "did:web:example.com".to_string(),
alias: "test".to_string(),
secrets: vec![secret1, secret2, secret3],
};
let methods = build_verification_methods(&profile);
assert_eq!(methods.len(), 3);
assert_eq!(methods[0]["id"], "did:web:example.com#key-0");
assert_eq!(methods[1]["id"], "did:web:example.com#key-1");
assert_eq!(methods[2]["id"], "did:web:example.com#key-2");
assert_eq!(methods[0]["type"], "JsonWebKey2020");
assert_eq!(methods[1]["type"], "JsonWebKey2020");
assert_eq!(methods[2]["type"], "JsonWebKey2020");
assert_eq!(methods[0]["controller"], "did:web:example.com");
assert_eq!(methods[1]["controller"], "did:web:example.com");
assert_eq!(methods[2]["controller"], "did:web:example.com");
assert!(methods[0]["publicKeyJwk"].get("d").is_none());
assert!(methods[1]["publicKeyJwk"].get("d").is_none());
assert!(methods[2]["publicKeyJwk"].get("d").is_none());
}
#[test]
fn test_build_did_document_structure() {
let profile = ProfileConfig {
did: "did:web:localhost%3A3232".to_string(),
alias: "local-test".to_string(),
secrets: vec![],
};
let doc = build_did_document(
&profile,
"did:web:mediator.example.com",
None,
TransportFlags::default(),
);
let parsed: serde_json::Value = serde_json::from_str(&doc).unwrap();
assert_eq!(parsed["id"], "did:web:localhost%3A3232");
assert!(parsed["@context"].is_array());
assert!(parsed["verificationMethod"].is_array());
assert!(parsed["authentication"].is_array());
assert!(parsed["assertionMethod"].is_array());
assert!(parsed["keyAgreement"].is_array());
assert!(parsed["service"].is_array());
}
#[test]
fn test_did_document_didcomm_service() {
let profile = ProfileConfig {
did: "did:web:example.com".to_string(),
alias: "test".to_string(),
secrets: vec![],
};
let doc = build_did_document(
&profile,
"did:web:mediator.com",
None,
TransportFlags::default(),
);
let parsed: serde_json::Value = serde_json::from_str(&doc).unwrap();
let service = &parsed["service"][0];
assert_eq!(service["type"], "DIDCommMessaging");
assert_eq!(service["serviceEndpoint"]["uri"], "did:web:mediator.com");
assert_eq!(service["serviceEndpoint"]["accept"][0], "didcomm/v2");
}
const DID: &str = "did:web:registry.example";
const MEDIATOR: &str = "did:web:mediator.example";
fn rest_entry(services: &[Value]) -> Option<&Value> {
services.iter().find(|s| s["type"] == REST_SERVICE_TYPE)
}
#[test]
fn no_public_url_advertises_didcomm_only() {
let services = build_services(DID, MEDIATOR, None, TransportFlags::default());
assert_eq!(services.len(), 1);
assert_eq!(services[0]["type"], DIDCOMM_SERVICE_TYPE);
assert!(rest_entry(&services).is_none());
}
#[test]
fn blank_public_url_is_treated_as_absent() {
for blank in ["", " ", "\t\n"] {
let services = build_services(DID, MEDIATOR, Some(blank), TransportFlags::default());
assert!(
rest_entry(&services).is_none(),
"blank {blank:?} must not advertise REST"
);
}
}
#[test]
fn public_url_adds_a_trqp_rest_entry() {
let services = build_services(
DID,
MEDIATOR,
Some("https://registry.example"),
TransportFlags::default(),
);
assert_eq!(services.len(), 3, "didcomm + rest + trust-registry");
let rest = rest_entry(&services).expect("REST entry");
assert_eq!(rest["type"], "TRQPRest");
assert_ne!(rest["type"], "VTARest");
assert_eq!(rest["id"], format!("{DID}#rest"));
assert_eq!(
rest["serviceEndpoint"],
Value::String("https://registry.example".into()),
"REST endpoint must be a plain string, not the DIDComm object form"
);
}
#[test]
fn trust_registry_entry_is_additive_and_leaves_rest_untouched() {
let services = build_services(
DID,
MEDIATOR,
Some("https://registry.example/"),
TransportFlags::default(),
);
let rest = rest_entry(&services).expect("REST entry");
assert!(
rest["type"].is_string(),
"#rest keeps its string type: {}",
rest["type"]
);
assert!(
rest["serviceEndpoint"].is_string(),
"#rest keeps its string endpoint: {}",
rest["serviceEndpoint"]
);
let profile = services
.iter()
.find(|s| s["type"] == TRUST_REGISTRY_SERVICE_TYPE)
.expect("TrustRegistry entry");
assert_eq!(profile["id"], format!("{DID}#trust-registry"));
assert_eq!(
profile["serviceEndpoint"]["uri"], "https://registry.example",
"same surface as #rest, trailing slash trimmed alike"
);
assert_eq!(profile["serviceEndpoint"]["profile"], TRQP_PROFILE_URI);
}
#[test]
fn the_trust_registry_entry_is_an_endpoint_not_a_referral() {
let services = build_services(
DID,
MEDIATOR,
Some("https://registry.example"),
TransportFlags::default(),
);
let profile = services
.iter()
.find(|s| s["type"] == TRUST_REGISTRY_SERVICE_TYPE)
.expect("TrustRegistry entry");
let uri = profile["serviceEndpoint"]["uri"].as_str().unwrap();
assert!(
!uri.starts_with("did:"),
"a registry advertises where it serves, never another DID: {uri}"
);
}
#[test]
fn public_url_trailing_slash_is_trimmed() {
let services = build_services(
DID,
MEDIATOR,
Some("https://registry.example/"),
TransportFlags::default(),
);
assert_eq!(
rest_entry(&services).unwrap()["serviceEndpoint"],
Value::String("https://registry.example".into())
);
}
#[test]
fn didcomm_fragment_is_stable() {
let services = build_services(DID, MEDIATOR, None, TransportFlags::default());
assert_eq!(services[0]["id"], format!("{DID}#didcomm"));
}
#[test]
fn cleartext_public_url_is_rejected() {
assert!(validate_public_url("http://registry.example").is_err());
assert!(validate_public_url("ftp://registry.example").is_err());
assert!(validate_public_url("registry.example").is_err());
}
#[test]
fn https_and_loopback_public_urls_are_accepted() {
assert!(validate_public_url("https://registry.example").is_ok());
assert!(validate_public_url("http://localhost:3232").is_ok());
assert!(validate_public_url("http://127.0.0.1:3232").is_ok());
assert!(validate_public_url("http://[::1]:3232").is_ok());
}
#[test]
fn loopback_exception_does_not_leak_to_lookalike_hosts() {
for url in [
"http://localhost.evil.com",
"http://localhostevil.com",
"http://127.0.0.1.evil.com",
"http://[::1].evil.com",
"http://[::1]evil.com",
"http://[::1]@evil.com",
"http://[::2]:3232",
"http://[::1",
] {
assert!(
validate_public_url(url).is_err(),
"a lookalike host must not inherit the loopback exception: {url}"
);
}
}
#[test]
fn every_loopback_form_stays_accepted() {
for url in [
"http://localhost",
"http://localhost:3232",
"http://localhost/trust-tasks",
"http://127.0.0.1:3232",
"http://[::1]",
"http://[::1]:3232",
"http://[::1]/trust-tasks",
] {
assert!(
validate_public_url(url).is_ok(),
"local dev must stay usable: {url}"
);
}
}
fn types_of(services: &[Value]) -> Vec<&str> {
services.iter().filter_map(|s| s["type"].as_str()).collect()
}
#[test]
fn rest_only_advertises_rest_only() {
let flags = TransportFlags {
rest: true,
didcomm: false,
tsp: false,
};
let services = build_services(DID, MEDIATOR, Some("https://registry.example"), flags);
assert_eq!(
types_of(&services),
vec![REST_SERVICE_TYPE, TRUST_REGISTRY_SERVICE_TYPE]
);
}
#[test]
fn rest_disabled_suppresses_entry_despite_public_url() {
let flags = TransportFlags {
rest: false,
didcomm: true,
tsp: false,
};
let services = build_services(DID, MEDIATOR, Some("https://registry.example"), flags);
assert_eq!(types_of(&services), vec![DIDCOMM_SERVICE_TYPE]);
}
#[test]
fn tsp_enabled_advertises_tsp_at_the_mediator_did() {
let flags = TransportFlags {
rest: false,
didcomm: true,
tsp: true,
};
let services = build_services(DID, MEDIATOR, None, flags);
let tsp = services
.iter()
.find(|s| s["type"] == TSP_SERVICE_TYPE)
.expect("TSP entry");
assert_eq!(tsp["id"], format!("{DID}#tsp"));
assert_eq!(
tsp["serviceEndpoint"],
Value::String(MEDIATOR.into()),
"TSP endpoint is the mediator DID, mirroring DIDComm's indirection"
);
}
#[test]
fn default_is_rest_and_didcomm_without_tsp() {
let flags = TransportFlags::default();
assert!(flags.rest && flags.didcomm && !flags.tsp);
assert!(flags.validate().is_ok());
}
#[test]
fn no_transport_enabled_is_rejected() {
let flags = TransportFlags {
rest: false,
didcomm: false,
tsp: false,
};
assert!(flags.validate().is_err());
}
#[test]
fn tsp_without_didcomm_is_rejected() {
let flags = TransportFlags {
rest: true,
didcomm: false,
tsp: true,
};
let err = flags.validate().expect_err("TSP requires DIDComm");
assert!(err.contains("ENABLE_DIDCOMM"), "unhelpful error: {err}");
}
#[test]
fn tsp_requires_the_tsp_build_feature() {
let flags = TransportFlags {
rest: true,
didcomm: true,
tsp: true,
};
if cfg!(feature = "tsp") {
assert!(flags.validate().is_ok());
} else {
let err = flags.validate().expect_err("no tsp feature compiled in");
assert!(err.contains("--features tsp"), "unhelpful error: {err}");
}
}
}