pub mod bound;
pub mod entry;
pub mod ext;
#[allow(clippy::result_large_err)]
pub mod size;
#[allow(clippy::result_large_err)]
pub mod handlers;
pub mod send;
pub mod transport;
pub mod verifier;
pub use bound::{
BoundError, BoundKeyError, identity_signing_secret, sign_document, verify_bound_key,
verify_sender_bound, verify_sender_bound_approval,
};
pub use didwebvh_rs::host_policy::HostPolicy;
pub use transport::{TSP_BINDING_URI, TspTransportHandler};
pub use verifier::{TransportBoundVerifier, build_verifier, is_unreachable};
use chrono::Utc;
use serde::Serialize;
use trust_tasks_rs::{
ConsumeChecks, ConsumeOutcome, Dispatcher, ErrorResponse, NoValidator, Payload, PayloadPolicy,
ProofPolicy, ProofVerifier, ResolvedParties, TransportHandler, TrustTask, consume_inbound,
specs::{
acl::{change_role, grant, list, revoke, show},
trust_task_discovery as discovery,
},
};
use uuid::Uuid;
use crate::server::path_locks::PathLocks;
use crate::server::store::KeyspaceHandle;
#[derive(Debug)]
pub enum TypedInbound {
Grant(Box<TrustTask<grant::v0_1::Payload>>),
Revoke(TrustTask<revoke::v0_1::Payload>),
ChangeRole(TrustTask<change_role::v0_1::Payload>),
Show(TrustTask<show::v0_1::Payload>),
List(TrustTask<list::v0_1::Payload>),
Discovery(TrustTask<discovery::v0_1::Payload>),
}
pub const ACL_TASK_URIS: &[&str] = &[
<grant::v0_1::Payload as Payload>::TYPE_URI,
<revoke::v0_1::Payload as Payload>::TYPE_URI,
<change_role::v0_1::Payload as Payload>::TYPE_URI,
<show::v0_1::Payload as Payload>::TYPE_URI,
<list::v0_1::Payload as Payload>::TYPE_URI,
<discovery::v0_1::Payload as Payload>::TYPE_URI,
];
pub fn build_dispatcher() -> Dispatcher<TypedInbound> {
Dispatcher::new()
.on::<grant::v0_1::Payload, _>(|d| TypedInbound::Grant(Box::new(d)))
.on::<revoke::v0_1::Payload, _>(TypedInbound::Revoke)
.on::<change_role::v0_1::Payload, _>(TypedInbound::ChangeRole)
.on::<show::v0_1::Payload, _>(TypedInbound::Show)
.on::<list::v0_1::Payload, _>(TypedInbound::List)
.on::<discovery::v0_1::Payload, _>(TypedInbound::Discovery)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProofRule {
Optional,
Authentication,
AssertionMethod,
SessionKey,
BoundKey(&'static [&'static str]),
}
impl ProofRule {
pub fn admits_bearer(self) -> bool {
!matches!(self, ProofRule::BoundKey(_))
}
pub fn uses_shared_replay_cache(self) -> bool {
!matches!(self, ProofRule::BoundKey(_))
}
}
#[derive(Debug)]
pub enum DispatchOutcome {
Handled(TrustTask<serde_json::Value>),
Rejected(ErrorResponse),
Suppressed,
}
#[derive(Clone)]
pub struct TrustTaskContext<'a> {
pub acl_ks: &'a KeyspaceHandle,
pub acl_locks: &'a PathLocks,
pub my_vid: &'a str,
}
pub const ACL_WRITE_LOCK_KEY: &str = "::trust-tasks::acl-write";
pub async fn run_pipeline<P, R, V, F, Fut>(
transport: &(impl TransportHandler + Sync),
policy: ProofPolicy<'_, V>,
doc: TrustTask<P>,
my_vid: &str,
handler: F,
) -> DispatchOutcome
where
P: Payload + Serialize + Send + Sync,
R: Serialize,
V: ProofVerifier + ?Sized,
F: FnOnce(TrustTask<P>, ResolvedParties) -> Fut,
Fut: std::future::Future<Output = Result<TrustTask<R>, ErrorResponse>>,
{
let now = Utc::now();
let new_id = || format!("urn:uuid:{}", Uuid::new_v4());
let outcome: ConsumeOutcome<R> = consume_inbound(
transport,
policy,
PayloadPolicy::<NoValidator>::AcceptUnvalidated,
ConsumeChecks::not_consequential(),
doc,
my_vid,
now,
new_id,
handler,
)
.await;
match outcome {
ConsumeOutcome::Handled(typed_resp) => {
let value = serde_json::to_value(&typed_resp)
.expect("typed response document serialises (codegened structs)");
let value_doc: TrustTask<serde_json::Value> = serde_json::from_value(value)
.expect("TrustTask<Value> from any TrustTask<Serialize> round-trips");
DispatchOutcome::Handled(value_doc)
}
ConsumeOutcome::Rejected(err) => DispatchOutcome::Rejected(err),
ConsumeOutcome::Suppressed => DispatchOutcome::Suppressed,
ConsumeOutcome::Duplicate {
prior_response,
in_flight,
} => match prior_response {
Some(value) => match serde_json::from_value::<TrustTask<serde_json::Value>>(value) {
Ok(prior) => DispatchOutcome::Handled(prior),
Err(e) => {
tracing::warn!(
error = %e,
"replay guard held a prior response that is not a Trust Task document; \
emitting nothing"
);
DispatchOutcome::Suppressed
}
},
None => {
tracing::info!(
in_flight,
"duplicate document absorbed with no prior response to return"
);
DispatchOutcome::Suppressed
}
},
}
}
pub fn framework_error_type_uri() -> trust_tasks_rs::TypeUri {
"https://trusttasks.org/spec/trust-task-error/0.5"
.parse()
.expect("framework error Type URI parses")
}
pub fn metrics_request_type_uri() -> &'static str {
<trust_tasks_rs::specs::did_management::server::metrics::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI
}
pub(crate) fn reject_with<P>(
request: &TrustTask<P>,
payload: trust_tasks_rs::ErrorPayload,
) -> ErrorResponse {
let id = format!("urn:uuid:{}", Uuid::new_v4());
request.reject_with(id, payload)
}
pub async fn dispatch_inbound<V>(
ctx: &TrustTaskContext<'_>,
transport: &(impl TransportHandler + Sync),
policy: ProofPolicy<'_, V>,
doc: TrustTask<serde_json::Value>,
) -> DispatchOutcome
where
V: ProofVerifier + ?Sized,
{
if matches!(policy, ProofPolicy::RejectIfPresent) && doc.proof.is_some() {
tracing::warn!(
type_uri = %doc.type_uri,
"inbound trust-task carries a `proof` member but this maintainer has not opted \
into proof verification (`trust_tasks.enforce_proofs = false`). Rejecting with \
malformed_request. Flip `enforce_proofs = true` to enable strict-mode verification."
);
}
let error_id = format!("urn:uuid:{}", Uuid::new_v4());
let typed = match build_dispatcher().dispatch_or_reject(doc, error_id) {
Ok(t) => t,
Err(err) => return DispatchOutcome::Rejected(err),
};
match typed {
TypedInbound::Grant(d) => handlers::grant::handle(ctx, transport, policy, *d).await,
TypedInbound::Revoke(d) => handlers::revoke::handle(ctx, transport, policy, d).await,
TypedInbound::ChangeRole(d) => {
handlers::change_role::handle(ctx, transport, policy, d).await
}
TypedInbound::Show(d) => handlers::show::handle(ctx, transport, policy, d).await,
TypedInbound::List(d) => handlers::list::handle(ctx, transport, policy, d).await,
TypedInbound::Discovery(d) => handlers::discovery::handle(ctx, transport, policy, d).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
use trust_tasks_rs::{Payload, handlers::InMemoryHandler};
use crate::server::acl::{self, AclEntry, Role};
use crate::server::config::StoreConfig;
use crate::server::domain::DomainScope;
use crate::server::store::{KS_ACL, Store};
const SERVICE_DID: &str = "did:web:maintainer.example";
const ADMIN_DID: &str = "did:web:admin.example";
#[test]
fn unrouted_and_routed_errors_agree_on_the_type_uri() {
let request: TrustTask<serde_json::Value> = TrustTask::new(
"urn:uuid:probe",
list::v0_1::Payload::TYPE_URI
.parse()
.expect("acl/list Type URI parses"),
serde_json::json!({}),
);
let routed = request.reject_with(
"urn:uuid:routed",
trust_tasks_rs::RejectReason::InternalError {
reason: "probe".into(),
},
);
assert_eq!(
framework_error_type_uri(),
routed.type_uri,
"the unrouted error paths name a different document type than the \
framework stamps on a routed rejection"
);
}
#[test]
fn dispatcher_routes_every_registered_type() {
let d = build_dispatcher();
let registered: std::collections::HashSet<&str> = d.registered_uris().into_iter().collect();
for uri in [
grant::v0_1::Payload::TYPE_URI,
revoke::v0_1::Payload::TYPE_URI,
change_role::v0_1::Payload::TYPE_URI,
show::v0_1::Payload::TYPE_URI,
list::v0_1::Payload::TYPE_URI,
discovery::v0_1::Payload::TYPE_URI,
] {
assert!(
registered.contains(uri),
"dispatcher missing route for {uri}"
);
}
}
#[tokio::test]
async fn dispatch_inbound_runs_full_pipeline_end_to_end() {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
};
std::mem::forget(dir);
let store = Store::open(&cfg).await.expect("open store");
let acl_ks = store.keyspace(KS_ACL).expect("acl keyspace");
acl::store_acl_entry(
&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: DomainScope::All,
},
)
.await
.unwrap();
let acl_locks = crate::server::path_locks::PathLocks::new();
let ctx = TrustTaskContext {
acl_ks: &acl_ks,
acl_locks: &acl_locks,
my_vid: SERVICE_DID,
};
let transport = InMemoryHandler::new()
.with_local(SERVICE_DID.to_string())
.with_peer(ADMIN_DID.to_string());
let body = serde_json::json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": grant::v0_1::Payload::TYPE_URI,
"issuer": ADMIN_DID,
"recipient": SERVICE_DID,
"issuedAt": chrono::Utc::now().to_rfc3339(),
"payload": {
"entry": {
"subject": "did:web:carol.example",
"role": "owner",
"ext": {
"vnd.affinidi.webvh": {
"domains": { "kind": "all" }
}
}
}
},
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": format!("{ADMIN_DID}#key-1"),
"created": chrono::Utc::now().to_rfc3339(),
"proofPurpose": "assertionMethod",
"proofValue": "z-stub"
}
});
let doc: TrustTask<serde_json::Value> = serde_json::from_value(body).expect("parse");
let outcome = dispatch_inbound::<trust_tasks_proof::affinidi::Verifier>(
&ctx,
&transport,
ProofPolicy::AcceptUnverified,
doc,
)
.await;
match outcome {
DispatchOutcome::Handled(resp) => {
assert_eq!(
resp.type_uri.to_string(),
format!("{}#response", grant::v0_1::Payload::TYPE_URI)
);
assert_eq!(resp.payload["entry"]["subject"], "did:web:carol.example");
}
other => panic!("expected Handled, got {other:?}"),
}
assert!(
acl::get_acl_entry(&acl_ks, "did:web:carol.example")
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn dispatch_inbound_session_key_proof_needs_in_band_issuer_and_delegate() {
use affinidi_data_integrity::{DataIntegrityProof, DidKeyResolver, SignOptions};
use affinidi_secrets_resolver::secrets::Secret;
use std::sync::Arc;
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
};
std::mem::forget(dir);
let store = Store::open(&cfg).await.expect("open store");
let acl_ks = store.keyspace(KS_ACL).expect("acl keyspace");
acl::store_acl_entry(
&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: DomainScope::All,
},
)
.await
.unwrap();
let acl_locks = crate::server::path_locks::PathLocks::new();
let ctx = TrustTaskContext {
acl_ks: &acl_ks,
acl_locks: &acl_locks,
my_vid: SERVICE_DID,
};
let secret = Secret::generate_ed25519(None, Some(&[9u8; 32]));
let pk_mb = secret.get_public_keymultibase().unwrap();
let mut signer = secret.clone();
signer.id = format!("did:key:{pk_mb}#{pk_mb}");
let signed_grant = |subject: &'static str, issuer: Option<&'static str>| {
let signer = signer.clone();
async move {
let mut body = serde_json::json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": grant::v0_1::Payload::TYPE_URI,
"recipient": SERVICE_DID,
"issuedAt": chrono::Utc::now().to_rfc3339(),
"payload": {
"entry": {
"subject": subject,
"role": "owner",
"ext": { "vnd.affinidi.webvh": { "domains": { "kind": "all" } } }
}
}
});
if let Some(issuer) = issuer {
body["issuer"] = serde_json::json!(issuer);
}
let doc_noproof: TrustTask<serde_json::Value> =
serde_json::from_value(body).expect("parse");
let signing_value = serde_json::to_value(&doc_noproof).unwrap();
let di_proof = DataIntegrityProof::sign(
&signing_value,
&signer,
SignOptions::new().with_proof_purpose("authentication"),
)
.await
.expect("sign");
let mut full = signing_value;
full.as_object_mut().unwrap().insert(
"proof".to_string(),
serde_json::to_value(&di_proof).unwrap(),
);
serde_json::from_value::<TrustTask<serde_json::Value>>(full).expect("proofed")
}
};
let transport = InMemoryHandler::new()
.with_local(SERVICE_DID.to_string())
.with_peer(ADMIN_DID.to_string());
let verifier = super::TransportBoundVerifier::with_resolver(Arc::new(DidKeyResolver))
.with_session_delegate(ADMIN_DID, format!("did:key:{pk_mb}#{pk_mb}"));
let doc = signed_grant("did:web:mallory.example", None).await;
let outcome = dispatch_inbound(&ctx, &transport, ProofPolicy::Verify(&verifier), doc).await;
assert!(
!matches!(outcome, DispatchOutcome::Handled(_)),
"an issuer-less proof must not authorise: {outcome:?}"
);
assert!(
acl::get_acl_entry(&acl_ks, "did:web:mallory.example")
.await
.unwrap()
.is_none()
);
let doc = signed_grant("did:web:dave.example", Some(ADMIN_DID)).await;
let outcome = dispatch_inbound(&ctx, &transport, ProofPolicy::Verify(&verifier), doc).await;
match outcome {
DispatchOutcome::Handled(resp) => {
assert_eq!(resp.payload["entry"]["subject"], "did:web:dave.example");
}
other => panic!("expected Handled (grant applied), got {other:?}"),
}
assert!(
acl::get_acl_entry(&acl_ks, "did:web:dave.example")
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn dispatch_inbound_rejects_unknown_type_uri() {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
};
std::mem::forget(dir);
let store = Store::open(&cfg).await.expect("open store");
let acl_ks = store.keyspace(KS_ACL).expect("acl keyspace");
let acl_locks = crate::server::path_locks::PathLocks::new();
let ctx = TrustTaskContext {
acl_ks: &acl_ks,
acl_locks: &acl_locks,
my_vid: SERVICE_DID,
};
let transport = InMemoryHandler::new()
.with_local(SERVICE_DID.to_string())
.with_peer(ADMIN_DID.to_string());
let body = serde_json::json!({
"id": "urn:uuid:test",
"type": "https://trusttasks.org/spec/kyc-handoff/1.0",
"issuer": ADMIN_DID,
"recipient": SERVICE_DID,
"issuedAt": "2026-05-18T10:00:00Z",
"payload": {}
});
let doc: TrustTask<serde_json::Value> = serde_json::from_value(body).expect("parse");
let outcome = dispatch_inbound::<trust_tasks_proof::affinidi::Verifier>(
&ctx,
&transport,
ProofPolicy::RejectIfPresent,
doc,
)
.await;
match outcome {
DispatchOutcome::Rejected(err) => assert_eq!(
err.payload.code,
trust_tasks_rs::TrustTaskCode::Standard(
trust_tasks_rs::StandardCode::UnsupportedType
)
),
other => panic!("expected Rejected, got {other:?}"),
}
}
}