#![allow(clippy::result_large_err)]
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use serde_json::Value;
use trust_tasks_https::status_for_code;
use trust_tasks_rs::{
ErrorPayload, ErrorResponse, RejectReason, TrustTask, TrustTaskCode, TypeUri,
};
use uuid::Uuid;
use vta_sdk::protocols::trust_task_reject_reasons as reasons;
use vti_common::error::AppError;
use crate::server::AppState;
use vta_sdk::protocols::join_requests::VerdictResponse;
#[derive(Debug)]
pub(crate) struct TrustTaskOutcome {
pub(crate) status: StatusCode,
pub(crate) body: Vec<u8>,
}
impl IntoResponse for TrustTaskOutcome {
fn into_response(self) -> Response {
(
self.status,
[(axum::http::header::CONTENT_TYPE, "application/json")],
self.body,
)
.into_response()
}
}
pub(crate) fn parse_payload<T: serde::de::DeserializeOwned>(
doc: &TrustTask<Value>,
) -> Result<T, TrustTaskOutcome> {
serde_json::from_value::<T>(doc.payload.clone()).map_err(|e| {
reject_with(
doc,
RejectReason::MalformedRequest {
reason: format!("payload parse: {e}"),
},
)
})
}
pub(crate) fn app_error_to_reject<P>(doc: &TrustTask<P>, err: &AppError) -> TrustTaskOutcome {
if let AppError::ApprovalRequired { code, details } = err
&& *code == crate::admin_actions::ACTION_PARKED
{
return next_step_response(doc, details);
}
let message = err.to_string();
let reason = match err {
AppError::Authentication(_)
| AppError::Unauthorized(_)
| AppError::Forbidden(_)
| AppError::StepUpRequired(_) => RejectReason::PermissionDenied { reason: message },
AppError::Validation(_)
| AppError::TrustTaskMalformed(_)
| AppError::TrustTaskMissing
| AppError::InvalidCursor => RejectReason::MalformedRequest { reason: message },
AppError::NotFound(_) => task_failed_because(message, reasons::NOT_FOUND),
AppError::Conflict(_) | AppError::IdempotencyKeyConflict => {
task_failed_because(message, reasons::CONFLICT)
}
AppError::Gone(_) => task_failed_because(message, reasons::GONE),
AppError::ApprovalRequired { code, details } => {
let mut details = details.clone();
if let Some(map) = details.as_object_mut() {
map.insert("reason".into(), Value::String((*code).to_string()));
}
RejectReason::TaskFailed {
reason: (*code).to_string(),
details: Some(details),
}
}
other => {
tracing::error!(cause = %other, "trust task failed with an internal error");
RejectReason::InternalError {
reason: OPAQUE_INTERNAL_ERROR.to_string(),
}
}
};
reject_with(doc, reason)
}
pub(crate) fn task_error_to_reject<P>(
doc: &TrustTask<P>,
err: &crate::error::TaskError,
) -> TrustTaskOutcome {
use crate::error::TaskError;
match err {
TaskError::App(e) => app_error_to_reject(doc, e),
TaskError::StepUp { request, .. } => reject_with_code(
doc,
trust_tasks_rs::TrustTaskCode::Standard(trust_tasks_rs::StandardCode::PermissionDenied),
"a passkey gesture bound to this operation is required",
Some(crate::acl::bound_step_up::refusal_details(request)),
),
TaskError::Declared { code, error } => {
let message = error.to_string();
let marker = match error {
AppError::NotFound(_) => Some(reasons::NOT_FOUND),
AppError::Conflict(_) | AppError::IdempotencyKeyConflict => Some(reasons::CONFLICT),
AppError::Gone(_) => Some(reasons::GONE),
_ => None,
};
let code = declared_code(code);
match marker {
Some(reason) => reject_with_code_because(doc, code, message, None, reason),
None => reject_with_code(doc, code, message, None),
}
}
}
}
pub(crate) fn declared_code(code: &str) -> TrustTaskCode {
code.parse()
.unwrap_or_else(|e| panic!("declared code {code:?} is not a Trust Task code: {e}"))
}
pub(crate) fn extended_code(code: &str) -> TrustTaskCode {
let (slug, local) = code
.rsplit_once(':')
.expect("an extended code is <slug>:<local>");
TrustTaskCode::Extended {
slug: slug.to_string(),
local: local.to_string(),
}
}
fn task_failed_because(message: String, reason: &str) -> RejectReason {
RejectReason::TaskFailed {
reason: message,
details: Some(serde_json::json!({ "reason": reason })),
}
}
pub(crate) fn reject_with_code_because<P>(
doc: &TrustTask<P>,
code: TrustTaskCode,
message: impl Into<String>,
details: Option<Value>,
reason: &str,
) -> TrustTaskOutcome {
let details = match details {
Some(Value::Object(mut map)) => {
map.insert("reason".into(), Value::String(reason.to_string()));
Value::Object(map)
}
Some(other) => {
tracing::warn!(
"non-object `details` on a coded reject; the reason marker was added beside it"
);
serde_json::json!({ "reason": reason, "details": other })
}
None => serde_json::json!({ "reason": reason }),
};
reject_with_code(doc, code, message, Some(details))
}
pub(crate) const OPAQUE_INTERNAL_ERROR: &str =
"the consumer could not complete this task; the request itself was accepted";
pub(crate) const DETAILS_MAX_JCS_BYTES: usize = 4096;
const DETAILS_MAX_MEMBERS: usize = 16;
fn bound_details(details: Option<Value>) -> Option<Value> {
let details = details?;
let too_many_members = details
.as_object()
.is_some_and(|o| o.len() > DETAILS_MAX_MEMBERS);
let too_large = serde_json_canonicalizer::to_string(&details)
.map(|jcs| jcs.len() > DETAILS_MAX_JCS_BYTES)
.unwrap_or(true);
if too_many_members || too_large {
tracing::warn!(
members = details.as_object().map(serde_json::Map::len),
"error `details` exceeds the framework bound and was dropped; the code still went out"
);
return None;
}
Some(details)
}
pub(crate) fn reject_with<P>(doc: &TrustTask<P>, reason: RejectReason) -> TrustTaskOutcome {
let reason = match reason {
RejectReason::TaskFailed { reason, details } => RejectReason::TaskFailed {
reason,
details: bound_details(details),
},
other => other,
};
let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), reason);
error_response(routed)
}
pub(crate) fn reject_with_code<P>(
doc: &TrustTask<P>,
code: TrustTaskCode,
message: impl Into<String>,
details: Option<Value>,
) -> TrustTaskOutcome {
let mut payload = ErrorPayload::new(code).with_message(message);
if let Some(d) = bound_details(details) {
payload = payload.with_details(d);
}
let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), payload);
error_response(routed)
}
pub(crate) fn success_response<P, R: Serialize>(
doc: &TrustTask<P>,
payload: R,
) -> TrustTaskOutcome {
let response_doc = doc.respond_with(format!("urn:uuid:{}", Uuid::new_v4()), payload);
let body = match serde_json::to_vec(&response_doc) {
Ok(b) => b,
Err(e) => {
tracing::error!(error = %e, "failed to serialise Trust Task success document");
return reject_with(
doc,
RejectReason::InternalError {
reason: format!("response serialisation: {e}"),
},
);
}
};
TrustTaskOutcome {
status: StatusCode::OK,
body,
}
}
pub(crate) fn next_step_response<P>(doc: &TrustTask<P>, details: &Value) -> TrustTaskOutcome {
let payload =
crate::admin_actions::next_step_payload(&doc.id, &doc.type_uri.to_string(), details);
let mut next = doc.respond_with(format!("urn:uuid:{}", Uuid::new_v4()), payload);
next.type_uri = super::action_tasks::NEXT_STEP_TYPE
.parse()
.expect("the next-step Type URI parses");
match serde_json::to_vec(&next) {
Ok(body) => TrustTaskOutcome {
status: StatusCode::ACCEPTED,
body,
},
Err(e) => reject_with(
doc,
RejectReason::InternalError {
reason: format!("next-step serialisation: {e}"),
},
),
}
}
pub(crate) fn acknowledge<P>(doc: &TrustTask<P>) -> TrustTaskOutcome {
success_response(doc, serde_json::Map::new())
}
pub(crate) fn verdict_response(
doc: &TrustTask<Value>,
verdict: VerdictResponse,
) -> TrustTaskOutcome {
success_response(doc, verdict)
}
pub(crate) fn error_response(err_doc: ErrorResponse) -> TrustTaskOutcome {
let status = StatusCode::from_u16(status_for_code(&err_doc.payload.code))
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body = serde_json::to_vec(&err_doc).unwrap_or_default();
TrustTaskOutcome { status, body }
}
pub(crate) fn framework_error_type_uri() -> TypeUri {
"https://trusttasks.org/spec/trust-task-error/0.5"
.parse()
.expect("framework error Type URI parses")
}
pub(crate) fn body_parse_error_response(reason: &str) -> TrustTaskOutcome {
let reject = RejectReason::MalformedRequest {
reason: format!("body did not parse as a Trust Task document: {reason}"),
};
unrouted_error_response(reject.into())
}
pub(crate) fn unrouted_error_response(payload: ErrorPayload) -> TrustTaskOutcome {
let type_uri: TypeUri = framework_error_type_uri();
let err = ErrorResponse {
id: format!("urn:uuid:{}", Uuid::new_v4()),
thread_id: None,
parent_thread_id: None,
type_uri,
issuer: None,
recipient: None,
issued_at: Some(chrono::Utc::now()),
expires_at: None,
payload,
context: None,
ceremony: None,
proof: None,
extra: Default::default(),
};
error_response(err)
}
pub(crate) async fn verify_trust_task_proof(
state: &AppState,
doc: &TrustTask<Value>,
) -> Result<String, AppError> {
vti_common::auth::verify_trust_task_proof_with(doc, &state.trust_task_vm_resolver())
.await
.map_err(|e| AppError::Unauthorized(format!("Trust Task {e}")))
}
pub(crate) async fn verify_received_trust_task_proof(
state: &AppState,
received: &Value,
) -> Result<String, AppError> {
vti_common::auth::verify_trust_task_proof_value(received, &state.trust_task_vm_resolver())
.await
.map_err(|e| AppError::Unauthorized(format!("Trust Task {e}")))
}
pub(crate) async fn verify_received_approval_proof(
state: &AppState,
received: &Value,
type_uri: &str,
) -> Result<String, AppError> {
vti_common::auth::verify_approval_proof_value(received, &state.trust_task_vm_resolver())
.await
.map_err(|e| {
tracing::warn!(
type_uri,
error = %e,
cause = e.cause().unwrap_or_default(),
"approval refused at its proof"
);
AppError::Unauthorized(format!("Trust Task {e}"))
})
}
pub(crate) fn is_approval_type(type_uri: &str) -> bool {
type_uri == super::STEP_UP_APPROVE_RESPONSE_TYPE
|| type_uri == super::STEP_UP_APPROVE_RESPONSE_V0_5_TYPE
|| type_uri == super::STEP_UP_APPROVE_RESPONSE_V0_6_TYPE
|| type_uri == crate::acl::admin_consent::DECISION_TYPE
|| type_uri == crate::acl::admin_consent::DECISION_V0_2_TYPE
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn doc() -> TrustTask<Value> {
let uri: TypeUri = "https://trusttasks.org/spec/acl/list/0.1"
.parse()
.expect("acl/list Type URI parses");
TrustTask::new("urn:uuid:test", uri, json!({}))
}
#[test]
fn an_internal_error_does_not_send_its_cause() {
let secret = "vtc_did not configured";
let out = app_error_to_reject(&doc(), &AppError::Internal(secret.into()));
let body = String::from_utf8(out.body).expect("the reject body is UTF-8");
assert!(
!body.contains(secret),
"the cause must not reach the wire: {body}"
);
assert!(
body.contains(OPAQUE_INTERNAL_ERROR),
"and the opaque sentence must: {body}"
);
let out = app_error_to_reject(
&doc(),
&AppError::SecretStore("keyring backend unavailable at /run/user/1000".into()),
);
let body = String::from_utf8(out.body).expect("the reject body is UTF-8");
assert!(!body.contains("keyring backend"), "{body}");
assert!(body.contains(OPAQUE_INTERNAL_ERROR), "{body}");
}
#[test]
fn caller_visible_failures_carry_their_reason_marker() {
for (err, want) in [
(AppError::NotFound("no such row".into()), reasons::NOT_FOUND),
(AppError::Conflict("already open".into()), reasons::CONFLICT),
(AppError::Gone("withdrawn".into()), reasons::GONE),
(AppError::IdempotencyKeyConflict, reasons::CONFLICT),
] {
let out = app_error_to_reject(&doc(), &err);
let body: Value = serde_json::from_slice(&out.body).expect("reject body is JSON");
assert_eq!(
body.pointer("/payload/details/reason")
.and_then(Value::as_str),
Some(want),
"{err:?} must carry {want}: {body}"
);
}
}
#[test]
fn unrouted_and_routed_errors_agree_on_the_type_uri() {
let routed = doc().reject_with(
"urn:uuid:routed",
RejectReason::InternalError {
reason: "probe".into(),
},
);
assert_eq!(
framework_error_type_uri(),
routed.type_uri,
"the unrouted body-parse error names a different document type than \
the framework stamps on a routed rejection"
);
}
#[test]
fn the_body_parse_error_goes_out_as_a_framework_error_document() {
let outcome = body_parse_error_response("not json");
let doc: Value = serde_json::from_slice(&outcome.body).expect("error doc parses");
assert_eq!(
doc["type"].as_str().expect("type present"),
framework_error_type_uri().to_string()
);
}
fn did_key_from_seed(seed_byte: u8) -> (String, String) {
use ed25519_dalek::SigningKey;
let seed = [seed_byte; 32];
let sk = SigningKey::from_bytes(&seed);
let did = format!(
"did:key:{}",
vta_sdk::did_key::ed25519_multibase_pubkey(&sk.verifying_key().to_bytes())
);
let mut buf = vec![0x80, 0x26];
buf.extend_from_slice(&seed);
(did, multibase::encode(multibase::Base::Base58Btc, &buf))
}
#[tokio::test]
async fn a_resolver_failure_and_a_bad_signature_render_identically() {
let tv = crate::test_support::build_test_vtc().await;
let resolver_fail_doc: TrustTask<Value> = serde_json::from_value(json!({
"id": "urn:uuid:00000000-0000-4000-8000-000000000020",
"type": "https://trusttasks.org/spec/acl/list/0.1",
"issuer": "did:webvh:QmScid:example.com:glenn",
"recipient": "did:webvh:vtc.example.com:abc",
"payload": {},
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"proofPurpose": "assertionMethod",
"verificationMethod": "did:webvh:QmScid:example.com:glenn#key-0",
"created": "2026-01-01T00:00:00Z",
"proofValue": "z2aBcD"
}
}))
.expect("well-formed document");
let (signer_did, secret_mb) = did_key_from_seed(31);
let signed = vta_sdk::trust_task_sign::build_signed(
"https://trusttasks.org/spec/acl/list/0.1",
json!({}),
&signer_did,
&secret_mb,
"did:webvh:vtc.example.com:abc",
)
.await
.expect("build a validly-signed document");
let mut bad_sig_doc: TrustTask<Value> =
serde_json::from_str(&signed).expect("signed doc parses");
let proof = bad_sig_doc.proof.as_mut().expect("document is signed");
let last = proof.proof_value.pop().expect("non-empty proofValue");
proof.proof_value.push(if last == '1' { '2' } else { '1' });
let resolver_failure = verify_trust_task_proof(&tv.state, &resolver_fail_doc).await;
let bad_signature = verify_trust_task_proof(&tv.state, &bad_sig_doc).await;
match (resolver_failure, bad_signature) {
(Err(AppError::Unauthorized(a)), Err(AppError::Unauthorized(b))) => {
assert_eq!(
a, b,
"a resolver failure must render exactly as a bad signature does"
);
}
other => panic!("expected both to be Unauthorized errors, got {other:?}"),
}
}
}