use affinidi_messaging_didcomm::Message;
use affinidi_messaging_didcomm_service::{
DIDCommResponse, DIDCommServiceError, Extension, HandlerContext, MESSAGE_PICKUP_STATUS_TYPE,
MessagePolicy, MiddlewareResult, Next, Router, TRUST_PING_TYPE, handler_fn, ignore_handler,
middleware_fn, trust_ping_handler,
};
use did_hosting_common::didcomm_types::*;
use did_hosting_common::server::problem_report::log_problem_report;
use did_hosting_common::server::trust_tasks::ProofRule;
use serde_json::{Value, json};
use tracing::{debug, info, warn};
use crate::acl::check_acl;
use crate::server::AppState;
use crate::server_push;
pub fn build_control_router(state: AppState) -> Result<Router, DIDCommServiceError> {
Ok(Router::new()
.extension(state)
.route(TRUST_PING_TYPE, handler_fn(trust_ping_handler))?
.route(MESSAGE_PICKUP_STATUS_TYPE, handler_fn(ignore_handler))?
.route(
trust_tasks_didcomm::ENVELOPE_TYPE,
handler_fn(handle_trust_tasks_envelope),
)?
.fallback(handler_fn(handle_fallback))
.layer(
MessagePolicy::new()
.require_encrypted(true)
.require_sender_did(true),
)
.layer(middleware_fn(filtered_request_logging)))
}
async fn filtered_request_logging(
ctx: HandlerContext,
message: Message,
meta: affinidi_messaging_didcomm::UnpackMetadata,
next: Next,
) -> MiddlewareResult {
const QUIET: &[&str] = &[
MSG_HEALTH_PING,
MSG_HEALTH_PONG,
MSG_STATS_SYNC,
MSG_STATS_ACK,
MESSAGE_PICKUP_STATUS_TYPE,
];
let msg_type = message.typ.clone();
let result = next.run(ctx, message, meta).await;
if !QUIET.iter().any(|t| msg_type == *t) {
let status = match &result {
Ok(Some(_)) => "ok(response)",
Ok(None) => "ok(empty)",
Err(_) => "error",
};
info!(message_type = %msg_type, status, "DIDComm request processed");
}
result
}
pub(crate) fn spec_did_record_json(
record: &did_hosting_common::did_ops::DidRecord,
did_url: &str,
) -> Value {
let rfc3339 = |secs: u64| {
chrono::DateTime::<chrono::Utc>::from_timestamp(secs as i64, 0)
.unwrap_or_default()
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
};
let mut rec = serde_json::Map::new();
rec.insert("mnemonic".into(), json!(record.mnemonic));
rec.insert("owner".into(), json!(record.owner));
rec.insert("createdAt".into(), json!(rfc3339(record.created_at)));
rec.insert("updatedAt".into(), json!(rfc3339(record.updated_at)));
rec.insert("versionCount".into(), json!(record.version_count));
rec.insert("method".into(), json!(record.method));
rec.insert("disabled".into(), json!(record.disabled));
rec.insert("didUrl".into(), json!(did_url));
if !record.domain.is_empty() {
rec.insert("domain".into(), json!(record.domain));
}
if let Some(did_id) = record.did_id.as_ref().filter(|_| record.version_count > 0) {
rec.insert("didId".into(), json!(did_id));
}
if !record.agent_names.is_empty() {
rec.insert(
"ext".into(),
json!({
did_hosting_common::server::trust_tasks::ext::WEBVH_EXT_KEY: {
"agentNames": record.agent_names,
}
}),
);
}
Value::Object(rec)
}
static LAST_SEQ: std::sync::LazyLock<std::sync::RwLock<std::collections::HashMap<String, u64>>> =
std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
fn accept_seq(server_did: &str, seq: u64) -> bool {
if seq > 0
&& let Ok(map) = LAST_SEQ.read()
&& let Some(&last) = map.get(server_did)
&& seq <= last
{
return false;
}
if let Ok(mut map) = LAST_SEQ.write() {
map.insert(server_did.to_string(), seq);
}
true
}
pub(crate) async fn do_stats_sync(
state: &AppState,
signer: &str,
body: &Value,
) -> Result<Value, InfraRejection> {
if !matches!(
check_acl(&state.acl_ks, signer).await,
Ok(crate::acl::Role::Service)
) {
warn!(did = signer, "stats sync rejected: Service role required");
return Err(InfraRejection::new(
"e.p.stats.unauthorized",
"service role required",
));
}
let server_did = body
.get("server_did")
.and_then(|v| v.as_str())
.unwrap_or(signer);
if server_did != signer {
warn!(
did = signer,
server_did, "stats sync rejected: server_did does not match signer"
);
return Err(InfraRejection::new(
"e.p.stats.sender_mismatch",
"server_did does not match signer",
));
}
let seq = body.get("seq").and_then(|v| v.as_u64()).unwrap_or(0);
if !accept_seq(server_did, seq) {
debug!(server_did, seq, "stats sync: stale sequence");
return Ok(json!({ "status": "skipped", "reason": "stale_seq" }));
}
let deltas = body
.get("did_deltas")
.and_then(|v| v.as_array())
.map(Vec::as_slice)
.unwrap_or_default();
const MAX_DELTAS_PER_SYNC: usize = 10_000;
if deltas.len() > MAX_DELTAS_PER_SYNC {
warn!(
did = signer,
count = deltas.len(),
"stats sync rejected: too many deltas"
);
return Err(InfraRejection::new(
"e.p.stats.too-large",
"too many deltas in one sync",
));
}
for d in deltas {
let mnemonic = d.get("mnemonic").and_then(|v| v.as_str()).unwrap_or("");
if mnemonic.is_empty() {
continue;
}
let resolve_delta = d.get("resolve_delta").and_then(|v| v.as_u64()).unwrap_or(0);
let update_delta = d.get("update_delta").and_then(|v| v.as_u64()).unwrap_or(0);
let last_resolved_at = d.get("last_resolved_at").and_then(|v| v.as_u64());
let last_updated_at = d.get("last_updated_at").and_then(|v| v.as_u64());
state.stats_collector.record_deltas(
mnemonic,
resolve_delta,
update_delta,
last_resolved_at,
last_updated_at,
);
}
debug!(
server_did,
seq,
delta_count = deltas.len(),
"stats sync accepted"
);
Ok(json!({ "status": "accepted" }))
}
pub(crate) async fn do_health_pong(state: &AppState, signer: &str, body: &Value) {
use crate::registry::{self, ServiceStatus};
if !matches!(
check_acl(&state.acl_ks, signer).await,
Ok(crate::acl::Role::Service)
) {
warn!(did = signer, "health pong ignored: Service role required");
return;
}
let status = body
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let version = body
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
debug!(signer, status, version, "health pong received from server");
let instance_id = signer.replace(':', "_");
let now = crate::auth::session::now_epoch();
if let Err(e) = registry::update_instance_status(
&state.registry_ks,
&instance_id,
ServiceStatus::Active,
now,
)
.await
{
warn!(instance_id, error = %e, "failed to update instance status from health pong");
}
}
pub(crate) fn do_sync_ack(signer: &str, type_uri: &str, body: &Value) {
let status = body
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let mnemonic = body
.get("mnemonic")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
info!(
signer,
mnemonic, status, type_uri, "DID sync: server acknowledged"
);
}
pub(crate) async fn do_domain_ack(state: &AppState, signer: &str, type_uri: &str, body: &Value) {
let status = body
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let Some(domain) = body.get("domain").and_then(|v| v.as_str()) else {
return;
};
let op = match type_uri {
MSG_REPLICA_DOMAIN_ASSIGN_ACK => "assign",
MSG_REPLICA_DOMAIN_UNASSIGN_ACK => "unassign",
_ => "purge",
};
info!(
signer,
domain, status, op, "server acknowledged domain {op}"
);
match check_acl(&state.acl_ks, signer).await {
Ok(crate::acl::Role::Service) => {}
Ok(other) => {
warn!(did = signer, role = %other, domain, op, "domain ack rejected: Service role required");
return;
}
Err(_) => {
warn!(
did = signer,
domain, op, "domain ack rejected: DID not in ACL"
);
return;
}
}
let settled = match op {
"assign" => crate::server_push::DomainOp::Assign,
"unassign" => crate::server_push::DomainOp::Unassign,
_ => crate::server_push::DomainOp::Purge,
};
crate::server_push::settle_domain_intent(state, signer, domain, settled).await;
let instance_id = signer.replace(':', "_");
match crate::registry::get_instance(&state.registry_ks, &instance_id).await {
Ok(Some(mut instance)) => {
let add = op == "assign" && status == "applied";
let mutated = if add {
if !instance.served_domains.iter().any(|d| d == domain) {
instance.served_domains.push(domain.to_string());
instance.served_domains.sort();
true
} else {
false
}
} else {
let before = instance.served_domains.len();
instance.served_domains.retain(|d| d != domain);
instance.served_domains.len() != before
};
if mutated
&& let Err(e) =
crate::registry::register_instance(&state.registry_ks, &instance).await
{
warn!(instance_id, error = %e, "failed to update served_domains after domain ack");
}
}
Ok(None) => {
warn!(
instance_id,
"domain ack received for unregistered server — skipping registry update"
);
}
Err(e) => {
warn!(instance_id, error = %e, "failed to load instance for ack reconciliation");
}
}
}
pub(crate) struct InfraRejection {
pub code: &'static str,
pub comment: String,
}
impl InfraRejection {
fn new(code: &'static str, comment: impl Into<String>) -> Self {
Self {
code,
comment: comment.into(),
}
}
}
pub(crate) async fn do_server_register(
state: &AppState,
signer: &str,
body: &Value,
) -> Result<Value, InfraRejection> {
use crate::acl::check_acl;
use crate::registry::{self, ServiceInstance, ServiceStatus, ServiceType};
let role = match check_acl(&state.acl_ks, signer).await {
Ok(crate::acl::Role::Service) => crate::acl::Role::Service,
Ok(other) => {
warn!(
did = signer,
role = %other,
"server registration rejected: Service role required"
);
return Err(InfraRejection::new(
"e.p.registration.unauthorized",
"service role required to register as a server",
));
}
Err(_) => {
warn!(
did = signer,
"server registration rejected: DID not in ACL (requires pre-approval)"
);
return Err(InfraRejection::new(
"e.p.registration.unauthorized",
"server DID must be pre-approved in the ACL before registering",
));
}
};
let public_url = body
.get("public_url")
.and_then(|v| v.as_str())
.unwrap_or_default();
if let Err(e) =
registry::validate_registered_url(public_url, &state.config.registry.url_allowlist)
{
warn!(
did = signer,
requested = public_url,
"server registration rejected: URL host not in registry.url_allowlist",
);
return Err(InfraRejection::new(
"e.p.registration.unauthorized",
e.user_message(),
));
}
let label = body.get("label").and_then(|v| v.as_str()).map(String::from);
let enabled_methods: Vec<String> = body
.get("enabled_methods")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_else(|| vec!["webvh".to_string()]);
let claimed_served_domains: Vec<String> = body
.get("served_domains")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let served_domains: Vec<String> = if claimed_served_domains.is_empty() {
Vec::new()
} else {
let mut keep = Vec::with_capacity(claimed_served_domains.len());
for name in claimed_served_domains {
match did_hosting_common::server::domain::get_domain(&state.store, &name).await {
Ok(Some(_)) => keep.push(name),
Ok(None) => warn!(
did = signer,
domain = %name,
"register: dropping served_domains entry — control plane has no DomainEntry for this name"
),
Err(e) => warn!(
did = signer,
domain = %name,
error = %e,
"register: failed to look up DomainEntry during served_domains validation — dropping entry"
),
}
}
keep
};
let protocol_version = body
.get("protocol_version")
.and_then(|v| v.as_str())
.unwrap_or("1.0")
.to_string();
let trust_task_capable = body
.get("trust_task_capable")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let sync_batch_capable = body
.get("sync_batch")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let instance_id = signer.replace(':', "_");
let previous = registry::get_instance(&state.registry_ks, &instance_id)
.await
.ok()
.flatten();
let instance = ServiceInstance {
instance_id: instance_id.clone(),
service_type: ServiceType::Server,
label,
url: public_url.to_string(),
status: ServiceStatus::Active,
last_health_check: None,
registered_at: crate::auth::session::now_epoch(),
metadata: json!({ "did": signer }),
enabled_methods,
served_domains,
protocol_version,
advertised_services: previous
.as_ref()
.and_then(|p| p.advertised_services.clone()),
services_checked_at: previous.as_ref().and_then(|p| p.services_checked_at),
trust_task_capable,
sync_batch_capable,
last_inbound_transport: previous.as_ref().and_then(|p| p.last_inbound_transport),
last_inbound_at: previous.as_ref().and_then(|p| p.last_inbound_at),
last_outbound_transport: previous.as_ref().and_then(|p| p.last_outbound_transport),
last_outbound_at: previous.as_ref().and_then(|p| p.last_outbound_at),
last_ack_at: previous.as_ref().and_then(|p| p.last_ack_at),
last_reconcile_at: previous.as_ref().and_then(|p| p.last_reconcile_at),
};
if let Err(e) = registry::register_instance(&state.registry_ks, &instance).await {
warn!(did = signer, error = %e, "server registration failed");
return Err(InfraRejection::new(
"e.p.registration.internal-error",
e.to_string(),
));
}
info!(
did = signer,
instance_id = %instance_id,
public_url = public_url,
role = %role,
"server registered"
);
if let Err(e) = registry::refresh_advertised_services(
&state.registry_ks,
&instance_id,
state.did_resolver.as_ref(),
crate::auth::session::now_epoch(),
)
.await
{
warn!(
did = signer,
error = %e,
"failed to cache advertised services for registering server"
);
}
let reported = server_push::parse_reported(body);
server_push::sync_all_dids_to_server(state, signer.to_string(), reported);
server_push::sync_all_domains_to_server(state, signer.to_string());
Ok(json!({
"instance_id": instance_id,
"status": "registered",
}))
}
async fn handle_fallback(
ctx: HandlerContext,
message: Message,
) -> Result<Option<DIDCommResponse>, DIDCommServiceError> {
let sender = ctx.sender_did.as_deref();
if log_problem_report("control", sender, &message) {
return Ok(None);
}
warn!(
sender = sender.unwrap_or("unknown"),
msg_type = %message.typ,
"inbound DIDComm: unhandled message type — replying with a problem-report"
);
let (typ, body) = run_unsupported_task(&message.typ);
Ok(Some(
DIDCommResponse::new(typ, body).thid(message.id.clone()),
))
}
fn run_unsupported_task(msg_type: &str) -> (String, Value) {
(
MSG_PROBLEM_REPORT.to_string(),
json!({
"code": "e.p.msg.unsupported-task",
"comment": format!(
"this control plane has no handler for `{msg_type}` — it may have been retired, \
or this peer may be a version that predates it"
),
}),
)
}
fn require_sender(ctx: &HandlerContext) -> Result<&str, DIDCommServiceError> {
ctx.sender_did
.as_deref()
.map(|did| did.split('#').next().unwrap_or(did))
.ok_or_else(|| DIDCommServiceError::Internal("missing sender DID".into()))
}
async fn handle_trust_tasks_envelope(
ctx: HandlerContext,
message: Message,
Extension(state): Extension<AppState>,
) -> Result<Option<DIDCommResponse>, DIDCommServiceError> {
let sender = require_sender(&ctx)?;
info!(sender = sender, "inbound DIDComm: trust-tasks envelope");
match run_trust_tasks_envelope(&state, sender, &message).await? {
Some((response_type, response_body)) => Ok(Some(
DIDCommResponse::new(response_type, response_body).thid(message.id.clone()),
)),
None => Ok(None),
}
}
pub(crate) async fn run_trust_tasks_envelope(
state: &AppState,
sender: &str,
message: &Message,
) -> Result<Option<(String, Value)>, DIDCommServiceError> {
let body_bytes = serde_json::to_vec(&message.body).unwrap_or_default();
let large_doc_charge =
match did_hosting_common::server::trust_tasks::size::check_for_known_issuer(
&body_bytes,
&crate::control_tasks::SERVED_TRUST_TASK_URIS,
&state.acl_ks,
&state.large_document_budget,
&format!("vid:{sender}"),
did_hosting_common::server::auth::session::now_epoch(),
)
.await
{
Ok(charge) => charge,
Err(err) => {
let body =
serde_json::to_value(&err).expect("trust-task-error document serialises");
return Ok(Some((trust_tasks_didcomm::ENVELOPE_TYPE.to_string(), body)));
}
};
let doc: trust_tasks_rs::TrustTask<Value> = match serde_json::from_value(message.body.clone()) {
Ok(d) => d,
Err(e) => {
warn!(
sender = sender,
error = %e,
"trust-tasks envelope: inner body did not parse as TrustTask<Value>"
);
let err_doc = body_parse_error(&e.to_string());
let body =
serde_json::to_value(&err_doc).expect("trust-task-error document serialises");
return Ok(Some((trust_tasks_didcomm::ENVELOPE_TYPE.to_string(), body)));
}
};
let my_vid = state
.config
.server_did
.as_deref()
.ok_or_else(|| DIDCommServiceError::Internal("server_did not configured".into()))?;
let transport =
trust_tasks_didcomm::DidcommHandler::new(my_vid.to_string(), sender.to_string());
let verifier = require_verifier(state)?;
match dispatch_trust_task_doc(
state,
sender,
None,
None,
&transport,
doc,
verifier,
large_doc_charge,
)
.await?
.into_document()
{
Some(body) => Ok(Some((trust_tasks_didcomm::ENVELOPE_TYPE.to_string(), body))),
None => {
tracing::error!(
should_not_happen = true,
sender = sender,
"trust-tasks envelope: dispatch suppressed (identity_mismatch w/ no transport sender)"
);
Ok(None)
}
}
}
pub(crate) fn body_parse_error(reason: &str) -> trust_tasks_rs::ErrorResponse {
use trust_tasks_rs::{ErrorPayload, RejectReason, TrustTask};
let reject = RejectReason::MalformedRequest {
reason: format!("body did not parse as a Trust Task document: {reason}"),
};
let payload: ErrorPayload = reject.into();
TrustTask {
id: format!("urn:uuid:{}", uuid::Uuid::new_v4()),
thread_id: None,
parent_thread_id: None,
ceremony: None,
type_uri: did_hosting_common::server::trust_tasks::framework_error_type_uri(),
issuer: None,
recipient: None,
issued_at: Some(chrono::Utc::now()),
expires_at: None,
payload,
context: None,
proof: None,
extra: Default::default(),
}
}
pub(crate) enum RoutedReply {
Framework(Box<did_hosting_common::server::trust_tasks::DispatchOutcome>),
Document(Value),
Suppressed,
Sealed(crate::control_tasks::SealedReply),
}
impl RoutedReply {
pub(crate) fn into_document(self) -> Option<Value> {
use did_hosting_common::server::trust_tasks::DispatchOutcome;
match self {
RoutedReply::Framework(outcome) => match *outcome {
DispatchOutcome::Handled(doc) => {
Some(serde_json::to_value(&doc).expect("response document serialises"))
}
DispatchOutcome::Rejected(err) => {
Some(serde_json::to_value(&err).expect("error document serialises"))
}
DispatchOutcome::Suppressed => None,
},
RoutedReply::Document(value) => Some(value),
RoutedReply::Suppressed => None,
RoutedReply::Sealed(sealed) => sealed.into_document(),
}
}
}
pub(crate) struct HttpsFacts<'a> {
pub ip: Option<std::net::IpAddr>,
pub headers: &'a axum::http::HeaderMap,
pub body_len: usize,
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn dispatch_trust_task_doc(
state: &AppState,
sender: &str,
bearer: Option<&crate::auth::AuthClaims>,
https: Option<&HttpsFacts<'_>>,
transport: &(impl trust_tasks_rs::TransportHandler + Sync),
doc: trust_tasks_rs::TrustTask<Value>,
verifier: &did_hosting_common::server::trust_tasks::TransportBoundVerifier,
large_doc_charge: Option<did_hosting_common::server::trust_tasks::size::LargeDocumentCharge>,
) -> Result<RoutedReply, DIDCommServiceError> {
let secret = match state
.config
.server_did
.as_deref()
.ok_or_else(|| "server_did not configured".to_string())
.and_then(|did| {
crate::signing::control_signing_secret(state, did).map_err(|e| e.to_string())
}) {
Ok(secret) => secret,
Err(e) => {
tracing::error!(error = %e, "refusing trust task: this control plane cannot sign its replies");
return Ok(RoutedReply::Framework(Box::new(
did_hosting_common::server::trust_tasks::DispatchOutcome::Rejected(
doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
trust_tasks_rs::RejectReason::InternalError {
reason: "this service cannot sign its replies".into(),
},
),
),
)));
}
};
let reply = route_trust_task_doc(
state,
sender,
bearer,
https,
transport,
doc,
verifier,
large_doc_charge,
)
.await?;
Ok(seal_reply(state, &secret, sender, reply).await)
}
async fn seal_reply(
state: &AppState,
secret: &affinidi_tdk::secrets_resolver::secrets::Secret,
requester: &str,
reply: RoutedReply,
) -> RoutedReply {
use did_hosting_common::server::trust_tasks::DispatchOutcome;
match reply {
RoutedReply::Framework(outcome) => match *outcome {
DispatchOutcome::Handled(doc) => RoutedReply::Framework(Box::new(
DispatchOutcome::Handled(sign_reply_doc(state, secret, requester, doc).await),
)),
other => RoutedReply::Framework(Box::new(other)),
},
RoutedReply::Document(value) => {
let is_error = value
.get("type")
.and_then(Value::as_str)
.is_some_and(|t| t.starts_with(vta_sdk::inbound::TRUST_TASK_ERROR_PREFIX));
if is_error {
return RoutedReply::Document(value);
}
match serde_json::from_value::<trust_tasks_rs::TrustTask<Value>>(value.clone()) {
Ok(doc) => RoutedReply::Document(
serde_json::to_value(sign_reply_doc(state, secret, requester, doc).await)
.expect("signed reply serialises"),
),
Err(_) => RoutedReply::Document(value),
}
}
RoutedReply::Suppressed => RoutedReply::Suppressed,
sealed @ RoutedReply::Sealed(_) => sealed,
}
}
pub(crate) async fn sign_reply_doc(
state: &AppState,
secret: &affinidi_tdk::secrets_resolver::secrets::Secret,
requester: &str,
mut doc: trust_tasks_rs::TrustTask<Value>,
) -> trust_tasks_rs::TrustTask<Value> {
let my_vid = state.config.server_did.clone().unwrap_or_default();
doc.issuer = Some(my_vid);
if doc.recipient.is_none() && !requester.is_empty() {
doc.recipient = Some(requester.to_string());
}
doc.issued_at = Some(chrono::Utc::now());
doc.proof = None;
match did_hosting_common::server::trust_tasks::sign_document(&doc, secret).await {
Ok(signed) => signed,
Err(e) => {
tracing::error!(error = %e, type_uri = %doc.type_uri, "cannot sign trust-task reply; refusing it");
let mut err = doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
trust_tasks_rs::RejectReason::InternalError {
reason: "this service could not sign its reply".into(),
},
);
err.issuer = doc.issuer.clone();
err.recipient = Some(requester.to_string());
serde_json::from_value(serde_json::to_value(&err).expect("error serialises"))
.expect("error re-reads as a TrustTask")
}
}
}
#[allow(clippy::too_many_arguments)]
async fn route_trust_task_doc(
state: &AppState,
sender: &str,
bearer: Option<&crate::auth::AuthClaims>,
https: Option<&HttpsFacts<'_>>,
transport: &(impl trust_tasks_rs::TransportHandler + Sync),
doc: trust_tasks_rs::TrustTask<Value>,
verifier: &did_hosting_common::server::trust_tasks::TransportBoundVerifier,
large_doc_charge: Option<did_hosting_common::server::trust_tasks::size::LargeDocumentCharge>,
) -> Result<RoutedReply, DIDCommServiceError> {
use did_hosting_common::server::trust_tasks::{
DispatchOutcome, TransportBoundVerifier, TrustTaskContext, dispatch_inbound,
verify_bound_key, verify_sender_bound, verify_sender_bound_approval,
};
let my_vid = state
.config
.server_did
.as_deref()
.ok_or_else(|| DIDCommServiceError::Internal("server_did not configured".into()))?;
let type_uri = doc.type_uri.to_string();
if type_uri.starts_with(vta_sdk::inbound::TRUST_TASK_ERROR_PREFIX) {
let code = doc
.payload
.get("code")
.and_then(Value::as_str)
.unwrap_or("?");
let message = doc
.payload
.get("message")
.and_then(Value::as_str)
.unwrap_or("");
warn!(
sender,
%code,
%message,
"inbound trust-task error from a peer — terminal, not answered"
);
let retryable = doc
.payload
.get("retryable")
.and_then(Value::as_bool)
.unwrap_or(false);
if doc.proof.is_some()
&& !retryable
&& (crate::server_push::is_configured_watcher(state, sender)
|| matches!(
did_hosting_common::server::acl::get_acl_entry(&state.acl_ks, sender).await,
Ok(Some(_))
))
&& let Ok(signer) =
verify_sender_bound(&doc, None, Some(sender), my_vid, verifier).await
{
crate::trust_tasks_infra::acknowledge_outbox(state, &signer, &doc).await;
}
return Ok(RoutedReply::Suppressed);
}
let rule = proof_rule(&type_uri);
if let ProofRule::BoundKey(_) = rule {
let Some(https) = https else {
warn!(sender, %type_uri, "trust task refused: a bound-key exchange is served over HTTPS only");
return Ok(RoutedReply::Framework(Box::new(DispatchOutcome::Rejected(
doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
trust_tasks_rs::RejectReason::PermissionDenied {
reason: "this task is served over HTTPS only".into(),
},
),
))));
};
if let Err(refused) = crate::control_tasks::admit_bound(state, https, &doc).await {
return Ok(RoutedReply::Sealed(refused));
}
}
let signed = rule != ProofRule::Optional || doc.proof.is_some();
let needs_acl = matches!(rule, ProofRule::Authentication | ProofRule::AssertionMethod);
if rule == ProofRule::SessionKey
&& doc
.proof
.as_ref()
.is_some_and(|p| !p.verification_method.starts_with("did:key:"))
{
warn!(sender, %type_uri, "trust task refused: a session ceremony must be signed by a did:key");
return Ok(RoutedReply::Framework(Box::new(DispatchOutcome::Rejected(
doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
trust_tasks_rs::RejectReason::PermissionDenied {
reason:
"this ceremony must be signed by the did:key its session will be bound to"
.into(),
},
),
))));
}
let watcher_ack = matches!(
type_uri.as_str(),
MSG_SYNC_UPDATE_ACK | MSG_SYNC_DELETE_ACK | MSG_SYNC_BATCH_ACK
) && crate::server_push::is_configured_watcher(state, sender);
if needs_acl
&& !watcher_ack
&& !matches!(
did_hosting_common::server::acl::get_acl_entry(&state.acl_ks, sender).await,
Ok(Some(_))
)
{
warn!(sender, %type_uri, "trust task refused: sender has no ACL entry");
return Ok(RoutedReply::Framework(Box::new(DispatchOutcome::Rejected(
doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
trust_tasks_rs::RejectReason::PermissionDenied {
reason: "caller is not present in the maintainer's ACL".into(),
},
),
))));
}
let principal = if signed {
let bound = match rule {
ProofRule::AssertionMethod => {
verify_sender_bound_approval(&doc, Some(sender), my_vid, verifier).await
}
ProofRule::BoundKey(purposes) => match verify_bound_key(&doc, purposes).await {
Ok(issuer) => Ok(issuer),
Err(e) => {
did_hosting_common::server::trust_tasks::size::settle_large_document_charge(
&state.large_document_budget,
large_doc_charge.as_ref(),
None,
);
warn!(%type_uri, error = %e, "trust task refused: not signed by its bound key");
return Ok(RoutedReply::Sealed(crate::control_tasks::refuse_bound(
state, &doc, &e,
)));
}
},
_ => verify_sender_bound(&doc, None, Some(sender), my_vid, verifier).await,
};
did_hosting_common::server::trust_tasks::size::settle_large_document_charge(
&state.large_document_budget,
large_doc_charge.as_ref(),
bound.as_ref().ok().map(String::as_str),
);
match bound {
Ok(principal) => principal,
Err(e) => {
warn!(sender, %type_uri, error = %e, "trust task refused: not bound to its sender");
let reason = e.reject_reason();
return Ok(RoutedReply::Framework(Box::new(DispatchOutcome::Rejected(
doc.reject_with(format!("urn:uuid:{}", uuid::Uuid::new_v4()), reason),
))));
}
}
} else {
sender.to_string()
};
if signed
&& rule.uses_shared_replay_cache()
&& let Err(e) = state.replay_cache.check(&principal, &doc.id)
{
warn!(did = %principal, doc_id = %doc.id, %type_uri, ?e, "trust task refused: replay cache");
let reason = match e {
did_hosting_common::server::replay::ReplayError::Duplicate => {
trust_tasks_rs::RejectReason::IdConflict
}
did_hosting_common::server::replay::ReplayError::Full => {
trust_tasks_rs::RejectReason::Unavailable { retry_after: None }
}
};
return Ok(RoutedReply::Framework(Box::new(DispatchOutcome::Rejected(
doc.reject_with(format!("urn:uuid:{}", uuid::Uuid::new_v4()), reason),
))));
}
let policy: trust_tasks_rs::ProofPolicy<'_, TransportBoundVerifier> =
trust_tasks_rs::ProofPolicy::Verify(verifier);
if crate::trust_tasks_auth::owns(&type_uri) {
return Ok(RoutedReply::Document(
crate::trust_tasks_auth::dispatch(state, transport, policy, doc)
.await
.ok_or(DIDCommServiceError::Internal(
"auth dispatch produced no reply".into(),
))?,
));
}
if crate::trust_tasks_infra::owns(&type_uri) {
let via = did_hosting_common::server::didcomm_profile::ObservedTransport::from_binding_uri(
transport.binding_uri(),
);
return Ok(
match crate::trust_tasks_infra::dispatch(state, &principal, via, doc).await {
Some(value) => RoutedReply::Document(value),
None => RoutedReply::Suppressed,
},
);
}
let doc = match crate::control_tasks::route(
state,
bearer,
https,
transport,
trust_tasks_rs::ProofPolicy::<TransportBoundVerifier>::AcceptUnverified,
doc,
)
.await
{
Ok(reply) => return Ok(reply),
Err(unclaimed) => *unclaimed,
};
let ctx = TrustTaskContext {
acl_ks: &state.acl_ks,
acl_locks: &state.acl_locks,
my_vid,
};
Ok(RoutedReply::Framework(Box::new(
dispatch_inbound::<TransportBoundVerifier>(&ctx, transport, policy, doc).await,
)))
}
pub(crate) fn proof_rule(type_uri: &str) -> ProofRule {
use trust_tasks_rs::Payload;
if let Some(rule) = crate::control_tasks::proof_rule(type_uri) {
return rule;
}
if type_uri == trust_tasks_rs::specs::trust_task_discovery::v0_1::Payload::TYPE_URI
|| crate::trust_tasks_auth::owns(type_uri)
|| type_uri.starts_with(OOB_FAMILY_PREFIX)
{
return ProofRule::Optional;
}
ProofRule::Authentication
}
const OOB_FAMILY_PREFIX: &str = "https://trusttasks.org/spec/auth/oob/";
pub(crate) fn session_key_may_sign(type_uri: &str) -> bool {
proof_rule(type_uri) == ProofRule::Authentication && !crate::trust_tasks_auth::owns(type_uri)
}
pub(crate) fn require_verifier(
state: &AppState,
) -> Result<&did_hosting_common::server::trust_tasks::TransportBoundVerifier, DIDCommServiceError> {
state.trust_tasks_verifier.as_deref().ok_or_else(|| {
DIDCommServiceError::Internal(
"no trust-task proof verifier configured (a DID resolver is required)".into(),
)
})
}
#[cfg(test)]
mod tests {
use did_hosting_common::server::store::{
KS_ACL, KS_DIDS, KS_REGISTRY, KS_SESSIONS, KS_STATS, KS_TIMESERIES,
};
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use affinidi_messaging_didcomm::Message;
use did_hosting_common::did_ops::{DidRecord, did_key, owner_key};
use did_hosting_common::server::acl::{AclEntry, Role, store_acl_entry};
use did_hosting_common::server::config::{
AuthConfig, FeaturesConfig, LogConfig, SecretsConfig, ServerConfig, StoreConfig, VtaConfig,
};
use did_hosting_common::server::stats_collector::StatsCollector;
use did_hosting_common::server::store::Store;
use serde_json::json;
use crate::config::{AppConfig, RegistryConfig};
use crate::server::AppState;
use super::*;
async fn test_state() -> (AppState, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let store_config = StoreConfig {
data_dir: PathBuf::from(dir.path()),
..StoreConfig::default()
};
let store = Store::open(&store_config).await.expect("open store");
let sessions_ks = store.keyspace(KS_SESSIONS).expect("sessions ks");
let acl_ks = store.keyspace(KS_ACL).expect("acl ks");
let registry_ks = store.keyspace(KS_REGISTRY).expect("registry ks");
let dids_ks = store.keyspace(KS_DIDS).expect("dids ks");
let stats_ks = store.keyspace(KS_STATS).expect("stats ks");
let config = AppConfig {
features: FeaturesConfig::default(),
server_did: Some("did:webvh:test:control.example.com".into()),
mediator_did: None,
public_url: Some("http://control.test".into()),
did_hosting_url: Some("http://control.test".into()),
server: ServerConfig::default(),
log: LogConfig::default(),
store: store_config,
fjall: Default::default(),
auth: AuthConfig::default(),
secrets: SecretsConfig::default(),
vta: VtaConfig::default(),
registry: RegistryConfig::default(),
trust_tasks: Default::default(),
hosting: Default::default(),
identity: Default::default(),
config_path: PathBuf::new(),
};
let state = AppState {
store: store.clone(),
sessions_ks,
acl_ks,
registry_ks,
dids_ks,
config: Arc::new(config),
did_resolver: None,
secrets_resolver: None,
identity: Some(
did_hosting_common::server::identity::ServiceIdentity::generated_for(
"did:webvh:test:control.example.com",
)
.await
.unwrap(),
),
trust_tasks_verifier: None,
jwt_keys: None,
webauthn: None,
http_client: reqwest::Client::new(),
didcomm_service: Arc::new(OnceLock::new()),
stats_collector: Arc::new(StatsCollector::new()),
stats_ks: stats_ks.clone(),
timeseries_ks: store.keyspace(KS_TIMESERIES).expect("timeseries ks"),
signing_key_bytes: None,
replay_cache: Arc::new(crate::replay::ReplayCache::new()),
path_locks: crate::path_locks::PathLocks::new(),
acl_locks: did_hosting_common::server::path_locks::PathLocks::new(),
pending_challenges: Arc::new(crate::pending_challenges::PendingChallengeTracker::new()),
ip_rate_limiter: Arc::new(crate::rate_limit::IpRateLimiter::new()),
redeem_rate_limiter: Arc::new(crate::rate_limit::SourceRateLimiter::new()),
large_document_budget: Arc::new(
did_hosting_common::server::trust_tasks::size::LargeDocumentBudget::new(),
),
outbox_notify: Arc::new(tokio::sync::Notify::new()),
cache_invalidate: None,
oob: Default::default(),
};
(state, dir)
}
fn build_msg(typ: &str, body: serde_json::Value) -> Message {
Message::build("msg-id".to_string(), typ.to_string(), body).finalize()
}
async fn seed_did(state: &AppState, owner_did: &str, mnemonic: &str) {
let record = DidRecord {
services: None,
owner: owner_did.into(),
mnemonic: mnemonic.into(),
created_at: 1,
updated_at: 1,
version_count: 1,
did_id: Some(format!("did:webvh:abc:{mnemonic}")),
content_size: 42,
disabled: false,
deleted_at: None,
method: "webvh".to_string(),
domain: String::new(),
agent_names: Vec::new(),
};
state
.dids_ks
.insert(did_key(mnemonic), &record)
.await
.expect("seed did record");
state
.dids_ks
.insert_raw(owner_key(owner_did, mnemonic), mnemonic.as_bytes().to_vec())
.await
.expect("seed owner index");
}
#[tokio::test]
async fn check_acl_returns_role_for_seeded_did() {
use did_hosting_common::server::acl::check_acl;
let (state, _dir) = test_state().await;
let did = "did:example:owner-a";
store_acl_entry(
&state.acl_ks,
&AclEntry {
did: did.into(),
role: Role::Owner,
label: None,
created_at: 0,
max_total_size: None,
max_did_count: None,
domains: did_hosting_common::server::domain::DomainScope::All,
},
)
.await
.unwrap();
let role = check_acl(&state.acl_ks, did).await.unwrap();
assert_eq!(role, Role::Owner);
let err = check_acl(&state.acl_ks, "did:example:stranger")
.await
.unwrap_err();
assert!(matches!(err, crate::error::AppError::Forbidden(_)));
}
#[test]
fn unsupported_task_reply_names_the_type() {
let (typ, body) = run_unsupported_task("https://example.org/spec/made-up/0.1");
assert_eq!(typ, MSG_PROBLEM_REPORT);
assert_eq!(
body.get("code").and_then(|v| v.as_str()),
Some("e.p.msg.unsupported-task")
);
let comment = body.get("comment").and_then(|v| v.as_str()).unwrap();
assert!(
comment.contains("https://example.org/spec/made-up/0.1"),
"the refused type is the whole diagnostic; got {comment}"
);
}
#[test]
fn unsupported_task_reply_cannot_start_a_problem_report_loop() {
let (typ, _) = run_unsupported_task("https://example.org/spec/made-up/0.1");
assert!(
did_hosting_common::server::problem_report::is_problem_report(&typ),
"{typ} must be recognised as a problem-report so peers drop rather than reply"
);
}
#[test]
fn retired_tasks_are_refused_by_name() {
for retired in [
"https://trusttasks.org/spec/did-management/did/publish/0.1",
"https://trusttasks.org/spec/did-management/agent-name/set/0.1",
"https://trusttasks.org/spec/did-management/agent-name/enable/0.1",
"https://trusttasks.org/spec/did-management/agent-name/disable/0.1",
MSG_AUTHENTICATE,
MSG_DELETE,
MSG_SERVER_REGISTER,
] {
let (typ, body) = run_unsupported_task(retired);
assert_eq!(typ, MSG_PROBLEM_REPORT, "{retired}");
assert!(
body.get("comment")
.and_then(|v| v.as_str())
.is_some_and(|c| c.contains(retired)),
"{retired} must be named in the reply"
);
}
}
const CONTROL: &str = "did:webvh:test:control.example.com";
async fn signing_state() -> (AppState, tempfile::TempDir) {
let (mut state, dir) = test_state().await;
state.trust_tasks_verifier = Some(Arc::new(TransportBoundVerifier::with_resolver(
Arc::new(DidKeyResolver),
)));
(state, dir)
}
async fn seed_role(state: &AppState, did: &str, role: Role) {
store_acl_entry(
&state.acl_ks,
&AclEntry {
did: did.into(),
role,
label: None,
created_at: 1_700_000_000,
max_total_size: None,
max_did_count: None,
domains: did_hosting_common::server::domain::DomainScope::All,
},
)
.await
.unwrap();
}
fn request_doc(type_uri: &str, issuer: Option<&str>, payload: Value) -> Value {
let mut doc = json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": type_uri,
"recipient": CONTROL,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": payload,
});
if let Some(issuer) = issuer {
doc["issuer"] = json!(issuer);
}
doc
}
async fn sign(doc: Value, signer: &affinidi_tdk::secrets_resolver::secrets::Secret) -> Value {
crate::signing::test_util::sign_operational(doc, signer).await
}
fn envelope(body: Value) -> Message {
Message::build(
uuid::Uuid::new_v4().to_string(),
trust_tasks_didcomm::ENVELOPE_TYPE.to_string(),
body,
)
.finalize()
}
async fn dispatch_envelope(state: &AppState, sender: &str, body: Value) -> Value {
let (resp_type, resp_body) =
super::run_trust_tasks_envelope(state, sender, &envelope(body))
.await
.expect("dispatch returns Ok")
.expect("envelope produces a response");
assert_eq!(resp_type, trust_tasks_didcomm::ENVELOPE_TYPE);
resp_body
}
fn list_payload() -> Value {
json!({})
}
#[tokio::test]
async fn trust_tasks_envelope_happy_path_list_returns_handled_response() {
let (state, _dir) = signing_state().await;
let (admin, admin_key) = crate::signing::test_util::did_key_signer(&[21u8; 32]);
seed_role(&state, &admin, Role::Admin).await;
let doc = sign(
request_doc(
"https://trusttasks.org/spec/acl/list/0.1",
Some(&admin),
list_payload(),
),
&admin_key,
)
.await;
let resp = dispatch_envelope(&state, &admin, doc).await;
let inner_type = resp["type"].as_str().unwrap();
assert!(
inner_type.ends_with("/acl/list/0.1#response"),
"expected acl/list response type, got {inner_type}"
);
let entries = resp["payload"]["entries"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["subject"], admin.as_str());
}
#[tokio::test]
async fn trust_tasks_envelope_unsigned_acl_read_is_refused() {
let (state, _dir) = signing_state().await;
let (admin, _) = crate::signing::test_util::did_key_signer(&[21u8; 32]);
seed_role(&state, &admin, Role::Admin).await;
let doc = request_doc(
"https://trusttasks.org/spec/acl/list/0.1",
Some(&admin),
list_payload(),
);
let resp = dispatch_envelope(&state, &admin, doc).await;
assert_eq!(resp["payload"]["code"], "proofRequired", "{resp}");
}
#[tokio::test]
async fn trust_tasks_envelope_malformed_inner_body_returns_routed_error() {
let (state, _dir) = test_state().await;
let msg = build_msg(
trust_tasks_didcomm::ENVELOPE_TYPE,
json!({"not": "a-trust-task"}),
);
let (resp_type, resp_body) =
super::run_trust_tasks_envelope(&state, "did:example:admin", &msg)
.await
.expect("dispatch returns Ok")
.expect("malformed body emits an error doc");
assert_eq!(resp_type, trust_tasks_didcomm::ENVELOPE_TYPE);
assert_eq!(
resp_body["type"].as_str().unwrap(),
did_hosting_common::server::trust_tasks::framework_error_type_uri().to_string()
);
assert_eq!(resp_body["payload"]["code"], "malformedRequest");
}
#[tokio::test]
async fn trust_tasks_envelope_bridges_did_management() {
let (state, _dir) = signing_state().await;
let (admin, admin_key) = crate::signing::test_util::did_key_signer(&[22u8; 32]);
seed_role(&state, &admin, Role::Admin).await;
let doc = sign(
request_doc(
MSG_DID_REQUEST,
Some(&admin),
json!({ "path": "bob", "reserve": false }),
),
&admin_key,
)
.await;
let resp = dispatch_envelope(&state, &admin, doc).await;
assert_eq!(
resp["type"],
"https://trusttasks.org/spec/did-management/did/check-name/0.1#response"
);
assert_eq!(resp["payload"]["available"], true);
assert_eq!(resp["issuer"], CONTROL);
assert_eq!(resp["recipient"], admin.as_str());
}
#[tokio::test]
async fn trust_tasks_envelope_unsigned_did_management_is_refused() {
let (state, _dir) = signing_state().await;
let admin = "did:example:admin";
seed_role(&state, admin, Role::Admin).await;
seed_did(&state, admin, "victim").await;
let doc = request_doc(MSG_DELETE, Some(admin), json!({ "mnemonic": "victim" }));
let resp = dispatch_envelope(&state, admin, doc).await;
assert_eq!(resp["payload"]["code"], "proofRequired", "{resp}");
assert!(
state
.dids_ks
.get::<did_hosting_common::did_ops::DidRecord>(did_key("victim"))
.await
.unwrap()
.is_some(),
"nothing was deleted"
);
}
#[tokio::test]
async fn trust_tasks_envelope_proof_from_someone_else_is_refused() {
let (state, _dir) = signing_state().await;
let admin = "did:example:admin";
seed_role(&state, admin, Role::Admin).await;
seed_did(&state, admin, "victim").await;
let (attacker, attacker_key) = crate::signing::test_util::did_key_signer(&[66u8; 32]);
let doc = sign(
request_doc(MSG_DELETE, Some(&attacker), json!({ "mnemonic": "victim" })),
&attacker_key,
)
.await;
let resp = dispatch_envelope(&state, admin, doc).await;
assert_eq!(resp["payload"]["code"], "permissionDenied", "{resp}");
assert!(
state
.dids_ks
.get::<did_hosting_common::did_ops::DidRecord>(did_key("victim"))
.await
.unwrap()
.is_some(),
"nothing was deleted"
);
}
#[tokio::test]
async fn trust_tasks_envelope_issuerless_grant_under_admin_name_is_refused() {
let (state, _dir) = signing_state().await;
let admin = "did:example:admin";
seed_role(&state, admin, Role::Admin).await;
let (attacker, attacker_key) = crate::signing::test_util::did_key_signer(&[66u8; 32]);
let mut signed = sign(
request_doc(
"https://trusttasks.org/spec/acl/grant/0.1",
Some(&attacker),
json!({ "entry": { "subject": attacker, "role": "admin" } }),
),
&attacker_key,
)
.await;
signed.as_object_mut().unwrap().remove("issuer");
let resp = dispatch_envelope(&state, admin, signed).await;
assert_ne!(
resp["type"].as_str().unwrap_or(""),
"https://trusttasks.org/spec/acl/grant/0.1#response",
"{resp}"
);
assert!(
did_hosting_common::server::acl::get_acl_entry(&state.acl_ks, &attacker)
.await
.unwrap()
.is_none(),
"no ACL entry was created for the attacker"
);
}
#[tokio::test]
async fn trust_tasks_envelope_replay_is_refused() {
let (state, _dir) = signing_state().await;
let (admin, admin_key) = crate::signing::test_util::did_key_signer(&[23u8; 32]);
seed_role(&state, &admin, Role::Admin).await;
seed_did(&state, &admin, "replayed").await;
let doc = sign(
request_doc(MSG_DELETE, Some(&admin), json!({ "mnemonic": "replayed" })),
&admin_key,
)
.await;
let first = dispatch_envelope(&state, &admin, doc.clone()).await;
assert_eq!(
first["type"], MSG_DELETE_CONFIRM,
"the first delivery must be handled, not rejected: {first}"
);
let replayed = dispatch_envelope(&state, &admin, doc).await;
assert_eq!(replayed["payload"]["code"], "idConflict", "{replayed}");
}
#[tokio::test]
async fn envelope_replies_are_signed_by_the_control_plane() {
use did_hosting_common::server::identity::ServiceIdentity;
use did_hosting_common::server::trust_tasks::verify_sender_bound;
let (mut state, _dir) = signing_state().await;
let (control, control_key) = crate::signing::test_util::did_key_signer(&[71u8; 32]);
let mut cfg = (*state.config).clone();
cfg.server_did = Some(control.clone());
state.config = Arc::new(cfg);
state.identity = Some(
ServiceIdentity::from_signing_secret(&control, control_key)
.await
.unwrap(),
);
let (admin, admin_key) = crate::signing::test_util::did_key_signer(&[72u8; 32]);
seed_role(&state, &admin, Role::Admin).await;
let mut doc = request_doc(MSG_DID_REQUEST, Some(&admin), json!({ "path": "bob" }));
doc["recipient"] = json!(control);
let reply = dispatch_envelope(&state, &admin, sign(doc, &admin_key).await).await;
let reply: trust_tasks_rs::TrustTask<Value> = serde_json::from_value(reply).unwrap();
assert!(
reply
.type_uri
.to_string()
.ends_with("check-name/0.1#response")
);
verify_sender_bound(
&reply,
Some(&control),
None,
&admin,
state.trust_tasks_verifier.as_deref().unwrap(),
)
.await
.expect("reply is signed by the control plane for the requester");
let mut unsigned = request_doc(MSG_DID_REQUEST, Some(&admin), json!({ "path": "bob" }));
unsigned["recipient"] = json!(control);
let err = dispatch_envelope(&state, &admin, unsigned).await;
assert!(err["type"].as_str().unwrap().contains("/trust-task-error/"));
assert!(err.get("proof").is_none());
}
#[tokio::test]
async fn an_outbox_entry_is_settled_by_its_targets_signed_ack() {
let (state, _dir) = signing_state().await;
let (edge, edge_key) = crate::signing::test_util::did_key_signer(&[81u8; 32]);
let (other, other_key) = crate::signing::test_util::did_key_signer(&[82u8; 32]);
seed_role(&state, &edge, Role::Service).await;
seed_role(&state, &other, Role::Service).await;
crate::outbox::enqueue(
&state.store,
&edge,
MSG_SYNC_UPDATE,
json!({"mnemonic": "alice"}),
)
.await
.unwrap();
let (key, entry) = crate::outbox::list_pending_for_target(&state.store, &edge)
.await
.unwrap()
.remove(0);
crate::outbox::record_sent(&state.store, key, &entry, "urn:uuid:sent-1")
.await
.unwrap();
let ack = |issuer: &str| {
let mut doc = request_doc(
MSG_SYNC_UPDATE_ACK,
Some(issuer),
json!({"mnemonic": "alice", "status": "applied"}),
);
doc["threadId"] = json!("urn:uuid:sent-1");
doc
};
super::run_trust_tasks_envelope(&state, &edge, &envelope(ack(&edge)))
.await
.unwrap();
super::run_trust_tasks_envelope(
&state,
&other,
&envelope(sign(ack(&other), &other_key).await),
)
.await
.unwrap();
assert_eq!(
crate::outbox::list_pending_for_target(&state.store, &edge)
.await
.unwrap()
.len(),
1
);
super::run_trust_tasks_envelope(
&state,
&edge,
&envelope(sign(ack(&edge), &edge_key).await),
)
.await
.unwrap();
assert!(
crate::outbox::list_pending_for_target(&state.store, &edge)
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn an_unauthorised_signer_does_not_populate_the_replay_cache() {
let (state, _dir) = signing_state().await;
let (stranger, key) = crate::signing::test_util::did_key_signer(&[83u8; 32]);
let doc = sign(
request_doc(MSG_DID_REQUEST, Some(&stranger), json!({ "path": "alice" })),
&key,
)
.await;
for _ in 0..2 {
let reply = dispatch_envelope(&state, &stranger, doc.clone()).await;
assert_eq!(reply["payload"]["code"], "permissionDenied", "{reply}");
}
assert!(state.replay_cache.is_empty());
}
#[tokio::test]
async fn a_control_plane_without_a_signing_identity_refuses_every_trust_task() {
let (mut state, _dir) = signing_state().await;
state.identity = None;
let (admin, admin_key) = crate::signing::test_util::did_key_signer(&[95u8; 32]);
seed_role(&state, &admin, Role::Admin).await;
let signed = sign(
request_doc(
MSG_DID_REQUEST,
Some(&admin),
json!({ "path": "bob", "reserve": true }),
),
&admin_key,
)
.await;
let challenge = request_doc(
"https://trusttasks.org/spec/auth/challenge/0.1",
Some(&admin),
json!({ "purpose": "login" }),
);
for doc in [signed, challenge] {
let reply = dispatch_envelope(&state, &admin, doc).await;
assert_eq!(reply["payload"]["code"], "internalError", "{reply}");
assert!(reply.get("proof").is_none());
}
assert!(
state
.dids_ks
.get::<did_hosting_common::did_ops::DidRecord>(did_key("bob"))
.await
.unwrap()
.is_none(),
"nothing was reserved"
);
}
fn register_payload() -> Value {
json!({
"public_url": "https://edge.example",
"label": "edge",
"trust_task_capable": true,
})
}
#[tokio::test]
async fn unsigned_server_registration_is_refused() {
let (state, _dir) = signing_state().await;
let edge = "did:example:edge";
seed_role(&state, edge, Role::Service).await;
let doc = request_doc(MSG_SERVER_REGISTER, Some(edge), register_payload());
let resp = dispatch_envelope(&state, edge, doc).await;
assert_eq!(resp["payload"]["code"], "proofRequired", "{resp}");
assert!(
crate::registry::get_instance(&state.registry_ks, &edge.replace(':', "_"))
.await
.unwrap()
.is_none(),
"no instance was registered"
);
}
#[tokio::test]
async fn signed_server_registration_is_accepted() {
let (state, _dir) = signing_state().await;
let (edge, edge_key) = crate::signing::test_util::did_key_signer(&[41u8; 32]);
seed_role(&state, &edge, Role::Service).await;
let doc = sign(
request_doc(MSG_SERVER_REGISTER, Some(&edge), register_payload()),
&edge_key,
)
.await;
let resp = dispatch_envelope(&state, &edge, doc).await;
assert_eq!(resp["type"], MSG_SERVER_REGISTER_ACK, "{resp}");
assert!(
crate::registry::get_instance(&state.registry_ks, &edge.replace(':', "_"))
.await
.unwrap()
.is_some(),
"the signing edge was registered"
);
}
#[tokio::test]
async fn health_pong_from_a_non_service_signer_is_ignored() {
use crate::registry::{ServiceInstance, ServiceStatus, ServiceType};
let (state, _dir) = signing_state().await;
let (edge, edge_key) = crate::signing::test_util::did_key_signer(&[42u8; 32]);
seed_role(&state, &edge, Role::Owner).await;
let instance_id = edge.replace(':', "_");
crate::registry::register_instance(
&state.registry_ks,
&ServiceInstance {
instance_id: instance_id.clone(),
service_type: ServiceType::Server,
label: None,
url: "https://edge.example".into(),
status: ServiceStatus::Unreachable,
last_health_check: None,
registered_at: 0,
metadata: json!({ "did": edge }),
enabled_methods: vec![],
served_domains: vec![],
protocol_version: "1.0".into(),
advertised_services: None,
services_checked_at: None,
trust_task_capable: true,
sync_batch_capable: false,
last_inbound_transport: None,
last_inbound_at: None,
last_outbound_transport: None,
last_outbound_at: None,
last_ack_at: None,
last_reconcile_at: None,
},
)
.await
.unwrap();
let doc = sign(
request_doc(MSG_HEALTH_PONG, Some(&edge), json!({ "status": "ok" })),
&edge_key,
)
.await;
let out = super::run_trust_tasks_envelope(&state, &edge, &envelope(doc))
.await
.expect("dispatch ok");
assert!(out.is_none(), "a pong is terminal");
let inst = crate::registry::get_instance(&state.registry_ks, &instance_id)
.await
.unwrap()
.unwrap();
assert_eq!(inst.status, ServiceStatus::Unreachable, "status unchanged");
}
#[tokio::test]
async fn trust_tasks_envelope_returns_none_when_server_did_unconfigured() {
let (mut state, _dir) = test_state().await;
let cfg = AppConfig {
features: state.config.features.clone(),
server_did: None,
mediator_did: None,
public_url: state.config.public_url.clone(),
did_hosting_url: state.config.did_hosting_url.clone(),
server: state.config.server.clone(),
log: state.config.log.clone(),
store: state.config.store.clone(),
fjall: state.config.fjall,
auth: state.config.auth.clone(),
secrets: state.config.secrets.clone(),
vta: state.config.vta.clone(),
registry: state.config.registry.clone(),
trust_tasks: state.config.trust_tasks.clone(),
hosting: state.config.hosting.clone(),
identity: Default::default(),
config_path: state.config.config_path.clone(),
};
state.config = Arc::new(cfg);
let msg = envelope(request_doc(
"https://trusttasks.org/spec/acl/list/0.1",
Some("did:example:admin"),
list_payload(),
));
let err = super::run_trust_tasks_envelope(&state, "did:example:admin", &msg)
.await
.expect_err("missing server_did should fail");
match err {
DIDCommServiceError::Internal(msg) => {
assert!(msg.contains("server_did"), "operator-actionable: {msg}");
}
other => panic!("expected Internal, got {other:?}"),
}
}
use affinidi_data_integrity::DidKeyResolver;
use did_hosting_common::server::trust_tasks::TransportBoundVerifier;
use crate::signing::test_util::did_key_signer;
fn unsigned_grant_doc(admin_did: &str, subject: &str) -> Value {
use trust_tasks_rs::specs::acl::grant::v0_1 as grant;
let entry: grant::AclEntry = grant::AclEntry::builder()
.subject(subject)
.role("owner")
.scopes(vec![])
.label(Some(
"signed e2e grant"
.parse::<grant::AclEntryLabel>()
.expect("label"),
))
.ext(
serde_json::from_value::<Option<grant::Ext>>(json!({
"vnd.affinidi.webvh": { "domains": { "kind": "all" } }
}))
.expect("webvh ext parses"),
)
.try_into()
.expect("e2e ACL entry is well formed");
let payload: grant::Payload = grant::Payload::builder()
.entry(entry)
.reason(Some(
"signed end-to-end grant"
.parse::<grant::PayloadReason>()
.expect("reason"),
))
.try_into()
.expect("e2e grant payload is well formed");
let mut doc = trust_tasks_rs::TrustTask::for_payload(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
payload,
);
doc.issuer = Some(admin_did.into());
doc.recipient = Some("did:webvh:test:control.example.com".into());
doc.issued_at = Some(chrono::Utc::now());
serde_json::to_value(&doc).expect("grant document serialises")
}
async fn grant_state(admin_did: &str) -> (AppState, tempfile::TempDir) {
let (mut state, dir) = test_state().await;
state.trust_tasks_verifier = Some(Arc::new(TransportBoundVerifier::with_resolver(
Arc::new(DidKeyResolver),
)));
assert!(
state.config.trust_tasks.enforce_proofs,
"strict proofs are the default"
);
store_acl_entry(
&state.acl_ks,
&AclEntry {
did: admin_did.into(),
role: Role::Admin,
label: None,
created_at: 1_700_000_000,
max_total_size: None,
max_did_count: None,
domains: did_hosting_common::server::domain::DomainScope::All,
},
)
.await
.expect("seed admin");
(state, dir)
}
#[tokio::test]
async fn trust_tasks_envelope_signed_acl_grant_verifies_end_to_end() {
let (admin_did, admin_signer) = did_key_signer(&[41u8; 32]);
let (state, _dir) = grant_state(&admin_did).await;
let subject = "did:example:grantee";
let signed = crate::signing::test_util::sign_operational(
unsigned_grant_doc(&admin_did, subject),
&admin_signer,
)
.await;
let msg = build_msg(trust_tasks_didcomm::ENVELOPE_TYPE, signed);
let (resp_type, resp_body) = super::run_trust_tasks_envelope(&state, &admin_did, &msg)
.await
.expect("dispatch ok")
.expect("a response is emitted");
assert_eq!(resp_type, trust_tasks_didcomm::ENVELOPE_TYPE);
let inner = resp_body["type"].as_str().unwrap();
assert!(
inner.ends_with("/acl/grant/0.1#response"),
"expected grant response, got {inner}"
);
let stored = did_hosting_common::server::acl::get_acl_entry(&state.acl_ks, subject)
.await
.expect("acl read")
.expect("granted entry stored");
assert!(
matches!(stored.role, Role::Owner),
"granted role is owner, got {:?}",
stored.role
);
}
#[tokio::test]
async fn trust_tasks_envelope_signed_acl_grant_tampered_is_rejected() {
let (admin_did, admin_signer) = did_key_signer(&[42u8; 32]);
let (state, _dir) = grant_state(&admin_did).await;
let mut signed = crate::signing::test_util::sign_operational(
unsigned_grant_doc(&admin_did, "did:example:grantee"),
&admin_signer,
)
.await;
signed["payload"]["entry"]["subject"] = json!("did:example:mallory");
let msg = build_msg(trust_tasks_didcomm::ENVELOPE_TYPE, signed);
let (_, resp_body) = super::run_trust_tasks_envelope(&state, &admin_did, &msg)
.await
.expect("dispatch ok")
.expect("an error document is emitted");
assert_eq!(
resp_body["type"],
did_hosting_common::server::trust_tasks::framework_error_type_uri().to_string()
);
assert_eq!(resp_body["payload"]["code"], "proofInvalid");
for did in ["did:example:mallory", "did:example:grantee"] {
assert!(
did_hosting_common::server::acl::get_acl_entry(&state.acl_ks, did)
.await
.expect("acl read")
.is_none(),
"{did} must not be granted"
);
}
}
#[tokio::test]
async fn trust_tasks_envelope_unsigned_acl_grant_is_rejected() {
let (admin_did, _admin_signer) = did_key_signer(&[43u8; 32]);
let (state, _dir) = grant_state(&admin_did).await;
let msg = build_msg(
trust_tasks_didcomm::ENVELOPE_TYPE,
unsigned_grant_doc(&admin_did, "did:example:grantee"),
);
let (_, resp_body) = super::run_trust_tasks_envelope(&state, &admin_did, &msg)
.await
.expect("dispatch ok")
.expect("an error document is emitted");
assert_eq!(
resp_body["type"],
did_hosting_common::server::trust_tasks::framework_error_type_uri().to_string()
);
assert_eq!(resp_body["payload"]["code"], "proofRequired");
assert!(
did_hosting_common::server::acl::get_acl_entry(&state.acl_ks, "did:example:grantee")
.await
.expect("acl read")
.is_none(),
"nothing must be granted"
);
}
fn record_at(mnemonic: &str, did_id: &str, version_count: u64) -> DidRecord {
DidRecord {
services: None,
owner: "did:example:owner".into(),
mnemonic: mnemonic.into(),
created_at: 1,
updated_at: 1,
version_count,
did_id: Some(did_id.into()),
content_size: 1,
disabled: false,
deleted_at: None,
method: "webvh".into(),
domain: String::new(),
agent_names: Vec::new(),
}
}
#[test]
fn an_edge_is_current_only_for_the_same_did_at_the_same_version() {
use crate::server_push::{ReportedDid, edge_is_current};
let record = record_at("alice", "did:webvh:QmNew:host:alice", 2);
let have = |did_id: Option<&str>, version_count| ReportedDid {
did_id: did_id.map(String::from),
version_count,
disabled: None,
};
assert!(edge_is_current(
&record,
Some(&have(Some("did:webvh:QmNew:host:alice"), 2))
));
assert!(!edge_is_current(&record, None), "absent → push");
assert!(
!edge_is_current(&record, Some(&have(Some("did:webvh:QmNew:host:alice"), 1))),
"behind → push"
);
assert!(
!edge_is_current(&record, Some(&have(Some("did:webvh:QmOld:host:alice"), 2))),
"a different DID at the slot → push"
);
assert!(
!edge_is_current(&record, Some(&have(None, 5))),
"an edge that does not say which DID it holds → push"
);
let reported = |disabled| ReportedDid {
disabled: Some(disabled),
..have(Some("did:webvh:QmNew:host:alice"), 2)
};
assert!(edge_is_current(&record, Some(&reported(false))));
assert!(
!edge_is_current(&record, Some(&reported(true))),
"a different disabled state → push"
);
}
#[test]
fn preloaded_dids_carry_the_did_identity() {
let reported = crate::server_push::parse_reported(&json!({
"preloaded_dids": [
{ "mnemonic": "alice", "did_id": "did:webvh:QmA:h:alice", "version_count": 3 },
{ "mnemonic": "bob", "version_count": 1 },
]
}));
assert_eq!(
reported["alice"].did_id.as_deref(),
Some("did:webvh:QmA:h:alice")
);
assert_eq!(reported["alice"].version_count, 3);
assert_eq!(reported["bob"].did_id, None);
}
#[tokio::test]
async fn resync_pushes_a_re_created_did_at_the_same_version() {
let (state, _dir) = test_state().await;
let record = record_at("alice", "did:webvh:QmNew:host:alice", 1);
state
.dids_ks
.insert(did_key("alice"), &record)
.await
.unwrap();
state
.dids_ks
.insert_raw(
did_hosting_common::did_ops::content_log_key("alice"),
b"{}".to_vec(),
)
.await
.unwrap();
let edge = "did:example:edge-resync";
let reported = crate::server_push::parse_reported(&json!({
"preloaded_dids": [
{ "mnemonic": "alice", "did_id": "did:webvh:QmOld:host:alice", "version_count": 1 },
]
}));
crate::server_push::sync_all_dids_to_server(&state, edge.to_string(), reported);
let mut rows = Vec::new();
for _ in 0..100 {
rows = crate::outbox::list_pending_for_target(&state.store, edge)
.await
.unwrap();
if !rows.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert_eq!(rows.len(), 1, "the new DID is pushed");
assert_eq!(rows[0].1.msg_type, MSG_SYNC_UPDATE);
assert_eq!(rows[0].1.body["didId"], "did:webvh:QmNew:host:alice");
assert_eq!(rows[0].1.body["disabled"], false);
}
async fn seed_service(state: &AppState, did: &str) {
store_acl_entry(
&state.acl_ks,
&AclEntry {
did: did.into(),
role: Role::Service,
label: None,
created_at: 1,
max_total_size: None,
max_did_count: None,
domains: did_hosting_common::server::domain::DomainScope::All,
},
)
.await
.unwrap();
}
async fn pending_ops(state: &AppState, edge: &str) -> Vec<(String, String)> {
crate::outbox::list_pending_for_target(&state.store, edge)
.await
.unwrap()
.into_iter()
.map(|(_, e)| {
(
e.msg_type,
e.body["domain"].as_str().unwrap_or_default().to_string(),
)
})
.collect()
}
async fn clear_outbox(state: &AppState, edge: &str) {
for (k, _) in crate::outbox::list_pending_for_target(&state.store, edge)
.await
.unwrap()
{
crate::outbox::remove(&state.store, k).await.unwrap();
}
}
#[tokio::test]
async fn unsettled_domain_ops_are_re_sent_on_registration() {
use crate::server_push::{
DomainOp, domain_intents, resend_domain_intents, send_domain_assign, send_domain_purge,
send_domain_unassign,
};
let (state, _dir) = test_state().await;
let edge = "did:example:edge-domains";
seed_service(&state, edge).await;
send_domain_assign(&state, edge, "kept.example")
.await
.unwrap();
send_domain_unassign(&state, edge, "gone.example")
.await
.unwrap();
send_domain_purge(&state, edge, "wiped.example")
.await
.unwrap();
clear_outbox(&state, edge).await;
resend_domain_intents(&state, edge).await;
let mut ops = pending_ops(&state, edge).await;
ops.sort();
let mut want = vec![
(
MSG_REPLICA_DOMAIN_ASSIGN.to_string(),
"kept.example".to_string(),
),
(
MSG_REPLICA_DOMAIN_UNASSIGN.to_string(),
"gone.example".to_string(),
),
(
MSG_REPLICA_DOMAIN_UNASSIGN.to_string(),
"wiped.example".to_string(),
),
(
MSG_REPLICA_DOMAIN_PURGE.to_string(),
"wiped.example".to_string(),
),
];
want.sort();
assert_eq!(ops, want);
do_domain_ack(
&state,
edge,
MSG_REPLICA_DOMAIN_UNASSIGN_ACK,
&json!({ "domain": "gone.example", "status": "scheduled", "purgeAt": "2026-01-01T00:00:00Z" }),
)
.await;
do_domain_ack(
&state,
edge,
MSG_REPLICA_DOMAIN_PURGE_ACK,
&json!({ "domain": "wiped.example", "status": "purged", "removed": 0 }),
)
.await;
do_domain_ack(
&state,
edge,
MSG_REPLICA_DOMAIN_ASSIGN_ACK,
&json!({ "domain": "kept.example", "status": "applied" }),
)
.await;
let intents = domain_intents(&state, edge).await.unwrap();
assert_eq!(intents.len(), 1, "{intents:?}");
assert_eq!(intents[0].domain, "kept.example");
assert_eq!(intents[0].op, DomainOp::Assign);
}
#[tokio::test]
async fn a_stale_or_foreign_ack_does_not_settle_a_domain_op() {
use crate::server_push::{
DomainOp, domain_intents, send_domain_assign, send_domain_unassign,
};
let (state, _dir) = test_state().await;
let edge = "did:example:edge-stale";
seed_service(&state, edge).await;
send_domain_unassign(&state, edge, "a.example")
.await
.unwrap();
send_domain_assign(&state, edge, "a.example").await.unwrap();
do_domain_ack(
&state,
edge,
MSG_REPLICA_DOMAIN_UNASSIGN_ACK,
&json!({ "domain": "a.example", "status": "scheduled", "purgeAt": "2026-01-01T00:00:00Z" }),
)
.await;
let intents = domain_intents(&state, edge).await.unwrap();
assert_eq!(intents[0].op, DomainOp::Assign, "the newer assign stands");
send_domain_unassign(&state, edge, "a.example")
.await
.unwrap();
do_domain_ack(
&state,
"did:example:not-a-service",
MSG_REPLICA_DOMAIN_UNASSIGN_ACK,
&json!({ "domain": "a.example", "status": "scheduled", "purgeAt": "2026-01-01T00:00:00Z" }),
)
.await;
assert_eq!(
domain_intents(&state, edge).await.unwrap()[0].op,
DomainOp::Unassign,
"another DID's ack settles nothing"
);
}
}