use serde::de::DeserializeOwned;
use serde_json::Value;
use tracing::warn;
use trust_tasks_rs::{
DeclaredErrorCode, Dispatcher, ErrorPayload, ErrorResponse, ProofPolicy, ProofVerifier,
RequestPayload, ResolvedParties, StandardCode, TransportHandler, TrustTask,
};
use did_hosting_common::did_ops::DidRecord;
use did_hosting_common::server::error::{AgentNameError, ValidationKind};
use did_hosting_common::server::trust_tasks::{DispatchOutcome, ProofRule, run_pipeline};
use crate::acl::check_acl;
use crate::auth::AuthClaims;
use crate::error::AppError;
use crate::server::AppState;
pub(crate) mod agent_name;
pub(crate) mod auth;
pub(crate) mod did;
pub(crate) mod domain;
pub(crate) mod enrol;
#[cfg(test)]
mod enrol_tests;
#[cfg(test)]
pub(crate) mod harness;
pub(crate) mod passkey;
pub(crate) mod queries;
pub(crate) mod registry;
pub(crate) mod server;
#[cfg(test)]
pub(crate) mod soft_passkey;
#[cfg(test)]
mod tests;
use trust_tasks_rs::specs::{
auth::{
oob as tt_oob,
passkey::{
admin_list as tt_pk_admin_list, enroll as tt_enroll, enroll::invite as tt_invite,
list as tt_pk_list, login as tt_login, revoke as tt_pk_revoke,
},
revoke_session as tt_revoke_session, step_up as tt_step_up,
},
did_management::{
agent_name as tt_agent_name, did as tt_did, domain as tt_domain, identity as tt_identity,
me as tt_me, registry as tt_registry, server as tt_server, stats as tt_stats,
},
webvh::witness as tt_witness,
};
macro_rules! control_tasks {
(
$( $($seg:ident)::+ => $rule:ident, $handler:path; )*
@bound_key($($fam:ident)::+) {
$( $($bseg:ident)::+ => $purposes:expr, $bhandler:path; )*
}
) => {
pub(crate) const TASKS: &[(&str, ProofRule)] = &[
$( (<$($seg)::+::Payload as trust_tasks_rs::Payload>::TYPE_URI, ProofRule::$rule), )*
$( (<$($bseg)::+::Payload as trust_tasks_rs::Payload>::TYPE_URI, ProofRule::BoundKey($purposes)), )*
];
pub(crate) async fn route<V>(
state: &AppState,
bearer: Option<&AuthClaims>,
https: Option<&crate::messaging::HttpsFacts<'_>>,
transport: &(impl TransportHandler + Sync),
policy: ProofPolicy<'_, V>,
doc: TrustTask<Value>,
) -> Result<crate::messaging::RoutedReply, Box<TrustTask<Value>>>
where
V: ProofVerifier + ?Sized,
{
let type_uri = doc.type_uri.to_string();
$(
if type_uri == <$($seg)::+::Payload as trust_tasks_rs::Payload>::TYPE_URI {
return Ok(crate::messaging::RoutedReply::Framework(Box::new(
serve::<$($seg)::+::Payload, _>(
state,
bearer,
transport,
policy,
doc,
$($seg)::+::ERROR_CODES,
$handler,
)
.await,
)));
}
)*
$(
if type_uri == <$($bseg)::+::Payload as trust_tasks_rs::Payload>::TYPE_URI {
let Some(https) = https else {
return Err(Box::new(doc));
};
return Ok(crate::messaging::RoutedReply::Sealed(
$bhandler(state, https, doc).await,
));
}
)*
Err(Box::new(doc))
}
pub(crate) async fn admit_bound(
state: &AppState,
https: &crate::messaging::HttpsFacts<'_>,
doc: &TrustTask<Value>,
) -> Result<(), SealedReply> {
$($fam)::+::admit(state, https, doc).await
}
pub(crate) fn refuse_bound(
state: &AppState,
doc: &TrustTask<Value>,
error: &did_hosting_common::server::trust_tasks::BoundKeyError,
) -> SealedReply {
$($fam)::+::refuse(state, doc, error)
}
};
}
#[derive(Debug)]
pub(crate) enum SealedReply {
Document { status: u16, body: Value },
Unavailable(&'static str),
}
impl SealedReply {
pub(crate) fn into_document(self) -> Option<Value> {
match self {
SealedReply::Document { body, .. } => Some(body),
SealedReply::Unavailable(_) => None,
}
}
}
control_tasks! {
tt_did::check_name::v0_1 => Authentication, did::check_name;
tt_did::register::v0_1 => Authentication, did::register;
tt_did::info::v0_1 => Authentication, did::info;
tt_did::list::v0_1 => Authentication, did::list;
tt_did::delete::v0_1 => Authentication, did::delete;
tt_did::change_owner::v0_1 => Authentication, did::change_owner;
tt_did::set_state::v0_1 => Authentication, did::set_state;
tt_did::rollback::v0_1 => Authentication, did::rollback;
tt_did::log::v0_1 => Authentication, did::log;
tt_witness::publish::v0_1 => Authentication, did::witness_publish;
tt_agent_name::update::v0_1 => Authentication, agent_name::update;
tt_agent_name::remove::v0_1 => Authentication, agent_name::remove;
tt_agent_name::list::v0_1 => Authentication, agent_name::list;
tt_agent_name::check::v0_1 => Authentication, agent_name::check;
tt_agent_name::resolve::v0_1 => Authentication, agent_name::resolve;
tt_me::domains::v0_1 => Authentication, domain::me_domains;
tt_domain::list::v0_1 => Authentication, domain::list;
tt_domain::create::v0_1 => Authentication, domain::create;
tt_domain::update::v0_1 => Authentication, domain::update;
tt_domain::set_state::v0_1 => Authentication, domain::set_state;
tt_domain::set_default::v0_1 => Authentication, domain::set_default;
tt_domain::purge::v0_1 => Authentication, domain::purge;
tt_domain::assign::v0_1 => Authentication, domain::assign;
tt_domain::unassign::v0_1 => Authentication, domain::unassign;
tt_registry::list::v0_1 => Authentication, registry::list;
tt_registry::get::v0_1 => Authentication, registry::get;
tt_registry::check::v0_1 => Authentication, registry::check;
tt_registry::admin_register::v0_1 => Authentication, registry::admin_register;
tt_registry::deregister::v0_1 => Authentication, registry::deregister;
tt_registry::purge_domain::v0_1 => Authentication, registry::purge_domain;
tt_server::info::v0_1 => Optional, server::info;
tt_server::config::v0_1 => Authentication, server::config;
tt_server::metrics::v0_1 => Authentication, server::metrics;
tt_stats::get::v0_1 => Authentication, server::stats_get;
tt_stats::timeseries::v0_1 => Authentication, server::timeseries;
tt_identity::list::v0_1 => Authentication, server::identity_list;
tt_identity::retire::v0_1 => Authentication, server::identity_retire;
tt_step_up::start::v0_1 => Authentication, auth::step_up_start;
tt_step_up::approve_response::v0_5 => AssertionMethod, auth::approve_response;
tt_revoke_session::v0_2 => Authentication, auth::revoke_session;
tt_login::start::v0_2 => Optional, auth::login_start;
tt_login::finish::v0_2 => SessionKey, auth::login_finish;
tt_invite::v0_2 => Authentication, enrol::invite;
tt_enroll::redeem::start::v0_1 => Optional, enrol::redeem_start;
tt_enroll::redeem::finish::v0_1 => Optional, enrol::redeem_finish;
tt_enroll::start::v0_2 => Authentication, enrol::start;
tt_enroll::finish::v0_2 => Authentication, enrol::finish;
tt_invite::list::v0_1 => Authentication, enrol::invite_list;
tt_invite::update::v0_1 => Authentication, enrol::invite_update;
tt_invite::revoke::v0_1 => Authentication, enrol::invite_revoke;
tt_pk_list::v0_1 => Authentication, passkey::list;
tt_pk_admin_list::v0_1 => Authentication, passkey::admin_list;
tt_pk_revoke::start::v0_2 => Authentication, passkey::revoke_start;
tt_pk_revoke::finish::v0_2 => Authentication, passkey::revoke_finish;
@bound_key(crate::oob::http) {
tt_oob::request::v0_1 => crate::oob::service::KEY_PURPOSES, crate::oob::http::exchange;
tt_oob::claim::v0_1 => crate::oob::service::KEY_PURPOSES, crate::oob::http::exchange;
tt_oob::prove::v0_1 => crate::oob::service::KEY_PURPOSES, crate::oob::http::exchange;
tt_oob::prove::v0_2 => crate::oob::service::KEY_PURPOSES, crate::oob::http::exchange;
tt_oob::respond::v0_1 => crate::oob::service::KEY_PURPOSES, crate::oob::http::exchange;
tt_oob::respond::v0_2 => crate::oob::service::KEY_PURPOSES, crate::oob::http::exchange;
tt_oob::redeem::v0_1 => crate::oob::service::KEY_PURPOSES, crate::oob::http::exchange;
tt_oob::cancel::v0_1 => crate::oob::service::KEY_PURPOSES, crate::oob::http::exchange;
}
}
pub(crate) fn proof_rule(type_uri: &str) -> Option<ProofRule> {
TASKS
.iter()
.find(|(uri, _)| *uri == type_uri)
.map(|(_, rule)| *rule)
}
pub(crate) static SERVED_TRUST_TASK_URIS: std::sync::LazyLock<Vec<&'static str>> =
std::sync::LazyLock::new(|| {
let mut uris: Vec<&'static str> = TASKS.iter().map(|(uri, _)| *uri).collect();
uris.extend_from_slice(did_hosting_common::server::trust_tasks::ACL_TASK_URIS);
uris
});
async fn serve<P, V>(
state: &AppState,
bearer: Option<&AuthClaims>,
transport: &(impl TransportHandler + Sync),
policy: ProofPolicy<'_, V>,
doc: TrustTask<Value>,
codes: &'static [DeclaredErrorCode],
handler: impl AsyncFnOnce(&Cx<'_>, P) -> Result<P::Response, TaskError>,
) -> DispatchOutcome
where
P: RequestPayload + Clone + Send + Sync + 'static,
P::Response: serde::Serialize,
V: ProofVerifier + ?Sized,
{
let Some(my_vid) = state.config.server_did.as_deref() else {
return DispatchOutcome::Rejected(
doc.reject_with(
new_id(),
ErrorPayload::new(StandardCode::InternalError)
.with_message("this service has no DID configured"),
),
);
};
let typed: TrustTask<P> = match Dispatcher::new()
.on::<P, _>(|d| d)
.dispatch_or_reject(doc, new_id())
{
Ok(typed) => typed,
Err(err) => return DispatchOutcome::Rejected(err),
};
run_pipeline(
transport,
policy,
typed,
my_vid,
|doc: TrustTask<P>, parties: ResolvedParties| async move {
let cx = Cx {
state,
caller: parties.issuer.clone(),
bearer,
proof_vm: doc.proof.as_ref().map(|p| p.verification_method.clone()),
proof_created: doc.proof.as_ref().map(|p| p.created),
via: did_hosting_common::server::didcomm_profile::ObservedTransport::from_binding_uri(
transport.binding_uri(),
),
};
match handler(&cx, doc.payload.clone()).await {
Ok(resp) => Ok(doc.respond_with(new_id(), resp)),
Err(e) => Err(e.reject(&doc, codes)),
}
},
)
.await
}
pub(crate) struct Cx<'a> {
pub state: &'a AppState,
caller: Option<String>,
bearer: Option<&'a AuthClaims>,
pub proof_vm: Option<String>,
#[allow(dead_code)]
pub proof_created: Option<chrono::DateTime<chrono::Utc>>,
#[allow(dead_code)]
pub via: Option<did_hosting_common::server::didcomm_profile::ObservedTransport>,
}
impl Cx<'_> {
#[allow(dead_code)] pub fn caller(&self) -> Result<&str, TaskError> {
self.caller.as_deref().ok_or_else(|| {
TaskError::Standard(
StandardCode::PermissionDenied,
"the request carries no verified issuer".into(),
)
})
}
pub fn bearer_session(&self) -> Option<&AuthClaims> {
self.bearer
}
pub async fn admin(&self) -> Result<AuthClaims, TaskError> {
let auth = self.auth().await?;
if auth.role != crate::acl::Role::Admin {
return Err(TaskError::Standard(
StandardCode::PermissionDenied,
"this task requires administrator standing".into(),
));
}
Ok(auth)
}
pub async fn metrics_read(&self) -> Result<AuthClaims, TaskError> {
use crate::acl::Role;
let auth = self.authenticated_claims().await?;
if !matches!(auth.role, Role::Admin | Role::MetricsReader) {
return Err(TaskError::Standard(
StandardCode::PermissionDenied,
"this task requires administrator or metrics-reader standing".into(),
));
}
Ok(auth)
}
pub async fn auth(&self) -> Result<AuthClaims, TaskError> {
let auth = self.authenticated_claims().await?;
if auth.role == crate::acl::Role::MetricsReader {
return Err(TaskError::Standard(
StandardCode::PermissionDenied,
"a metrics-reader credential is not valid for this task".into(),
));
}
Ok(auth)
}
async fn authenticated_claims(&self) -> Result<AuthClaims, TaskError> {
let did = self.caller.as_deref().ok_or_else(|| {
TaskError::Standard(
StandardCode::PermissionDenied,
"the request carries no verified issuer".into(),
)
})?;
let role = check_acl(&self.state.acl_ks, did).await.map_err(|_| {
TaskError::Standard(
StandardCode::PermissionDenied,
"caller is not present in the maintainer's ACL".into(),
)
})?;
if let Some(bearer) = self.bearer.filter(|b| b.did == did) {
return Ok(AuthClaims {
role,
..bearer.clone()
});
}
Ok(AuthClaims {
did: did.to_string(),
role,
session_id: String::new(),
session_pubkey_b58btc: None,
amr: vec!["did".to_string()],
acr: "aal1".to_string(),
})
}
}
#[derive(Debug)]
pub(crate) enum TaskError {
App(AppError),
Declared(DeclaredErrorCode, String),
Standard(StandardCode, String),
}
impl From<AppError> for TaskError {
fn from(e: AppError) -> Self {
TaskError::App(e)
}
}
impl From<serde_json::Error> for TaskError {
fn from(e: serde_json::Error) -> Self {
TaskError::App(AppError::Serialization(e))
}
}
impl TaskError {
fn declared(codes: &'static [DeclaredErrorCode], locals: &[&str]) -> Option<DeclaredErrorCode> {
locals
.iter()
.find_map(|local| codes.iter().find(|c| c.local() == *local))
.copied()
}
pub(crate) fn payload(self, codes: &'static [DeclaredErrorCode]) -> ErrorPayload {
let (code, message) = match self {
TaskError::Declared(code, message) => {
return ErrorPayload::from(code).with_message(message);
}
TaskError::Standard(code, message) => {
return ErrorPayload::new(code).with_message(message);
}
TaskError::App(e) => (map_app_error(&e, codes), e),
};
match code {
Mapped::Declared(code) => ErrorPayload::from(code).with_message(message.user_message()),
Mapped::Standard(StandardCode::InternalError) => {
tracing::error!(error = %message, "trust task: internal failure");
ErrorPayload::new(StandardCode::InternalError)
.with_message("the maintainer encountered an internal failure")
}
Mapped::Standard(StandardCode::Unavailable) => {
let retry_after = match &message {
AppError::RateLimited {
retry_after_secs, ..
} => Some(
chrono::Utc::now() + chrono::Duration::seconds(*retry_after_secs as i64),
),
_ => None,
};
let p = ErrorPayload::new(StandardCode::Unavailable)
.with_message(message.user_message());
match retry_after {
Some(at) => p.with_retry_after(at),
None => p,
}
}
Mapped::Standard(code) => ErrorPayload::new(code).with_message(message.user_message()),
}
}
pub(crate) fn reject<P>(
self,
doc: &TrustTask<P>,
codes: &'static [DeclaredErrorCode],
) -> ErrorResponse {
doc.reject_with(new_id(), self.payload(codes))
}
}
enum Mapped {
Declared(DeclaredErrorCode),
Standard(StandardCode),
}
fn map_app_error(e: &AppError, codes: &'static [DeclaredErrorCode]) -> Mapped {
let pick = |locals: &[&str], fallback: StandardCode| match TaskError::declared(codes, locals) {
Some(code) => Mapped::Declared(code),
None => Mapped::Standard(fallback),
};
match e {
AppError::NotFound(_) => pick(&["notFound"], StandardCode::TaskFailed),
AppError::Forbidden(_) | AppError::Unauthorized(_) | AppError::Authentication(_) => pick(
&["notOwner", "forbidden", "notAuthorized"],
StandardCode::PermissionDenied,
),
AppError::StepUpRequired(_) => pick(&["stepUpRequired"], StandardCode::PermissionDenied),
AppError::Conflict(_) => pick(
&["pathTaken", "nameTaken", "alreadyExists", "conflict"],
StandardCode::TaskFailed,
),
AppError::Validation(message) => {
if message.contains("unknown_domain") {
return pick(&["unknownDomain"], StandardCode::MalformedRequest);
}
match e.validation_kind() {
ValidationKind::InvalidLog => pick(
&["invalidLog", "invalidDidData"],
StandardCode::MalformedRequest,
),
ValidationKind::InvalidPath => {
pick(&["invalidPath"], StandardCode::MalformedRequest)
}
ValidationKind::InvalidWitness => {
pick(&["invalidWitness"], StandardCode::MalformedRequest)
}
ValidationKind::Other => Mapped::Standard(StandardCode::MalformedRequest),
}
}
AppError::QuotaExceeded(_) => pick(&["quotaExceeded"], StandardCode::TaskFailed),
AppError::DomainDisabled { .. } => pick(&["domainDisabled"], StandardCode::TaskFailed),
AppError::RateLimited { .. } => Mapped::Standard(StandardCode::Unavailable),
AppError::AgentName(kind) => match kind {
AgentNameError::Reserved => pick(&["nameReserved"], StandardCode::TaskFailed),
AgentNameError::Taken => pick(&["nameTaken"], StandardCode::TaskFailed),
AgentNameError::NotFound => pick(&["notFound"], StandardCode::TaskFailed),
AgentNameError::AlsoKnownAsMismatch => {
pick(&["alsoKnownAsMismatch"], StandardCode::MalformedRequest)
}
AgentNameError::NotDisabled | AgentNameError::AlreadyDisabled => {
Mapped::Standard(StandardCode::TaskFailed)
}
},
_ => Mapped::Standard(StandardCode::InternalError),
}
}
pub(crate) fn new_id() -> String {
format!("urn:uuid:{}", uuid::Uuid::new_v4())
}
pub(crate) fn did_url(state: &AppState, mnemonic: &str) -> String {
let base_url = state
.config
.did_hosting_url
.as_deref()
.or(state.config.public_url.as_deref())
.unwrap_or("http://localhost");
format!("{}/{mnemonic}/did.jsonl", base_url.trim_end_matches('/'))
}
pub(crate) fn spec_record<T: DeserializeOwned>(
state: &AppState,
record: &DidRecord,
) -> Result<T, TaskError> {
let value = crate::messaging::spec_did_record_json(record, &did_url(state, &record.mnemonic));
serde_json::from_value(value).map_err(|e| {
warn!(error = %e, mnemonic = %record.mnemonic, "DID record does not fit its schema");
TaskError::App(AppError::Internal(format!(
"DID record projection does not fit its schema: {e}"
)))
})
}
pub(crate) fn typed<T: DeserializeOwned>(value: Value, what: &str) -> Result<T, TaskError> {
serde_json::from_value(value).map_err(|e| {
TaskError::App(AppError::Internal(format!(
"{what} does not fit its schema: {e}"
)))
})
}
pub(crate) fn at(secs: u64) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::<chrono::Utc>::from_timestamp(secs as i64, 0).unwrap_or_default()
}