use std::future::Future;
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::document::{ErrorResponse, TrustTask};
use crate::error::RejectReason;
use crate::payload::Payload;
use crate::proof::{ProofVerifier, VerificationError};
use crate::transport::{ResolvedParties, TransportHandler};
#[non_exhaustive]
pub enum ProofPolicy<'a, V: ProofVerifier + ?Sized> {
Verify(&'a V),
RejectIfPresent,
AcceptUnverified,
}
#[derive(Debug)]
pub enum ConsumeOutcome<R> {
Handled(TrustTask<R>),
Rejected(ErrorResponse),
Suppressed,
}
#[allow(clippy::too_many_arguments)]
pub async fn consume_inbound<P, R, T, V, F, Fut>(
transport: &T,
policy: ProofPolicy<'_, V>,
doc: TrustTask<P>,
my_vid: &str,
now: DateTime<Utc>,
error_id_factory: impl FnOnce() -> String,
handler: F,
) -> ConsumeOutcome<R>
where
P: Payload + Serialize + Send + Sync,
T: TransportHandler + Sync + ?Sized,
V: ProofVerifier + ?Sized,
F: FnOnce(TrustTask<P>, ResolvedParties) -> Fut,
Fut: Future<Output = Result<TrustTask<R>, ErrorResponse>>,
{
if let Err(reason) = doc.validate_basic(now, my_vid) {
return route_rejection(transport, &doc, reason, error_id_factory);
}
let parties = match transport.resolve_parties(&doc) {
Ok(p) => p,
Err(mismatch) => {
return route_rejection(
transport,
&doc,
RejectReason::IdentityMismatch(mismatch),
error_id_factory,
);
}
};
if doc.proof.is_none() && P::IS_PROOF_REQUIRED {
return route_rejection(
transport,
&doc,
RejectReason::ProofRequired,
error_id_factory,
);
}
match (&policy, doc.proof.as_ref()) {
(ProofPolicy::Verify(v), Some(_)) => {
if let Err(err) = v.verify(&doc).await {
return route_rejection(
transport,
&doc,
proof_error_to_reject(err),
error_id_factory,
);
}
}
(ProofPolicy::RejectIfPresent, Some(_)) => {
return route_rejection(
transport,
&doc,
RejectReason::MalformedRequest {
reason: PROOF_NOT_ACCEPTED_BY_POLICY.to_string(),
},
error_id_factory,
);
}
(ProofPolicy::Verify(_), None)
| (ProofPolicy::RejectIfPresent, None)
| (ProofPolicy::AcceptUnverified, _) => {}
}
if let Err(reason) = doc.enforce_audience_binding() {
return route_rejection(transport, &doc, reason, error_id_factory);
}
match handler(doc, parties).await {
Ok(response) => ConsumeOutcome::Handled(response),
Err(error_response) => ConsumeOutcome::Rejected(error_response),
}
}
pub const PROOF_NOT_ACCEPTED_BY_POLICY: &str =
"in-band proof not accepted by consumer policy (SPEC §7.2 item 7)";
fn route_rejection<P, R, T>(
transport: &T,
doc: &TrustTask<P>,
reason: RejectReason,
error_id_factory: impl FnOnce() -> String,
) -> ConsumeOutcome<R>
where
P: Serialize,
T: TransportHandler + Sync + ?Sized,
{
match transport.reject(doc, error_id_factory(), reason) {
Some(error_response) => ConsumeOutcome::Rejected(error_response),
None => ConsumeOutcome::Suppressed,
}
}
fn proof_error_to_reject(err: VerificationError) -> RejectReason {
RejectReason::ProofInvalid {
reason: err.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handlers::NoopHandler;
use crate::proof::Proof;
use crate::specs::acl::grant::v0_1 as grant;
use crate::StandardCode;
fn entry() -> grant::AclEntry {
grant::AclEntry {
subject: "did:web:alice.example".into(),
role: "admin".into(),
scopes: vec![],
label: None,
created_at: None,
created_by: None,
updated_at: None,
updated_by: None,
expires_at: None,
ext: None,
}
}
fn grant_payload() -> grant::Payload {
grant::Payload {
entry: entry(),
reason: None,
ext: None,
}
}
fn dummy_proof() -> Proof {
Proof {
proof_type: "DataIntegrityProof".into(),
cryptosuite: "eddsa-rdfc-2022".into(),
verification_method: "did:web:org.example#key-1".into(),
created: Utc::now(),
proof_purpose: "assertionMethod".into(),
proof_value: "z3kg".into(),
extra: Default::default(),
}
}
struct StubVerifier {
outcome: Result<(), VerificationError>,
}
#[async_trait::async_trait]
impl ProofVerifier for StubVerifier {
async fn verify<P>(&self, _doc: &TrustTask<P>) -> Result<(), VerificationError>
where
P: Serialize + Send + Sync,
{
match &self.outcome {
Ok(()) => Ok(()),
Err(e) => Err(match e {
VerificationError::SignatureInvalid => VerificationError::SignatureInvalid,
other => VerificationError::Other(other.to_string()),
}),
}
}
}
#[tokio::test]
async fn handler_runs_when_all_checks_pass() {
let transport = NoopHandler::new();
let verifier = StubVerifier { outcome: Ok(()) };
let mut doc = TrustTask::for_payload("req-1", grant_payload());
doc.issuer = Some("did:web:org.example".into());
doc.recipient = Some("did:web:maintainer.example".into());
doc.proof = Some(dummy_proof());
let outcome: ConsumeOutcome<grant::Response> = consume_inbound(
&transport,
ProofPolicy::Verify(&verifier),
doc,
"did:web:maintainer.example",
Utc::now(),
|| "err-1".to_string(),
|req, parties| async move {
assert_eq!(parties.issuer.as_deref(), Some("did:web:org.example"));
assert_eq!(
parties.recipient.as_deref(),
Some("did:web:maintainer.example")
);
let resp_payload = grant::Response {
entry: req.payload.entry.clone(),
ext: None,
};
Ok(req.respond_with("resp-1", resp_payload))
},
)
.await;
match outcome {
ConsumeOutcome::Handled(resp) => {
assert_eq!(resp.id, "resp-1");
assert_eq!(resp.payload.entry.subject, "did:web:alice.example");
}
other => panic!("expected Handled, got {other:?}"),
}
}
#[tokio::test]
async fn wrong_recipient_routes_error_to_original_issuer() {
let transport = NoopHandler::new();
let mut doc = TrustTask::for_payload("req-2", grant_payload());
doc.issuer = Some("did:web:org.example".into());
doc.recipient = Some("did:web:someone-else.example".into());
let outcome: ConsumeOutcome<grant::Response> =
consume_inbound::<_, _, _, StubVerifier, _, _>(
&transport,
ProofPolicy::RejectIfPresent,
doc,
"did:web:maintainer.example",
Utc::now(),
|| "err-2".to_string(),
|_req, _parties| async move {
panic!("handler must not run when validate_basic rejects");
#[allow(unreachable_code)]
Ok::<TrustTask<grant::Response>, ErrorResponse>(unreachable!())
},
)
.await;
match outcome {
ConsumeOutcome::Rejected(err) => {
assert_eq!(err.recipient.as_deref(), Some("did:web:org.example"));
assert_eq!(err.payload.code, StandardCode::WrongRecipient.into());
}
other => panic!("expected Rejected, got {other:?}"),
}
}
#[tokio::test]
async fn proof_required_fires_for_spec_with_required_proof() {
let transport = NoopHandler::new();
let verifier = StubVerifier { outcome: Ok(()) };
let mut doc = TrustTask::for_payload("req-3", grant_payload());
doc.issuer = Some("did:web:org.example".into());
doc.recipient = Some("did:web:maintainer.example".into());
let outcome: ConsumeOutcome<grant::Response> = consume_inbound(
&transport,
ProofPolicy::Verify(&verifier),
doc,
"did:web:maintainer.example",
Utc::now(),
|| "err-3".to_string(),
|_req, _parties| async move {
panic!("handler must not run when proof_required fires");
#[allow(unreachable_code)]
Ok::<TrustTask<grant::Response>, ErrorResponse>(unreachable!())
},
)
.await;
match outcome {
ConsumeOutcome::Rejected(err) => {
assert_eq!(err.payload.code, StandardCode::ProofRequired.into());
}
other => panic!("expected Rejected, got {other:?}"),
}
}
#[tokio::test]
async fn proof_present_under_reject_if_present_rejected_as_malformed_request() {
let transport = NoopHandler::new();
let mut doc = TrustTask::for_payload("req-4", grant_payload());
doc.issuer = Some("did:web:org.example".into());
doc.recipient = Some("did:web:maintainer.example".into());
doc.proof = Some(dummy_proof());
let outcome: ConsumeOutcome<grant::Response> =
consume_inbound::<_, _, _, StubVerifier, _, _>(
&transport,
ProofPolicy::RejectIfPresent,
doc,
"did:web:maintainer.example",
Utc::now(),
|| "err-4".to_string(),
|_req, _parties| async move {
panic!("handler must not run under RejectIfPresent + proof");
#[allow(unreachable_code)]
Ok::<TrustTask<grant::Response>, ErrorResponse>(unreachable!())
},
)
.await;
match outcome {
ConsumeOutcome::Rejected(err) => {
assert_eq!(err.payload.code, StandardCode::MalformedRequest.into());
let msg = err.payload.message.as_deref().unwrap_or("");
assert!(
msg.contains("policy") && msg.contains("§7.2"),
"wire message should cite policy + spec, not name internals: {msg}"
);
assert!(
!msg.contains("verifier"),
"wire leak (configuration): {msg}"
);
assert!(
!msg.contains("configured"),
"wire leak (configuration): {msg}"
);
}
other => panic!("expected Rejected, got {other:?}"),
}
}
#[tokio::test]
async fn handler_error_response_is_passed_through() {
let transport = NoopHandler::new();
let verifier = StubVerifier { outcome: Ok(()) };
let mut doc = TrustTask::for_payload("req-5", grant_payload());
doc.issuer = Some("did:web:org.example".into());
doc.recipient = Some("did:web:maintainer.example".into());
doc.proof = Some(dummy_proof());
let outcome: ConsumeOutcome<grant::Response> = consume_inbound(
&transport,
ProofPolicy::Verify(&verifier),
doc,
"did:web:maintainer.example",
Utc::now(),
|| "err-5".to_string(),
|req, _parties| async move {
Err(req.reject_with(
"err-handler",
crate::ErrorPayload::new(grant::Payload::extended_code("role_not_recognized"))
.with_message("role string not in maintainer vocabulary"),
))
},
)
.await;
match outcome {
ConsumeOutcome::Rejected(err) => {
assert_eq!(err.id, "err-handler");
assert!(matches!(
err.payload.code,
crate::TrustTaskCode::Extended { ref slug, ref local }
if slug == "acl/grant" && local == "role_not_recognized"
));
assert_eq!(err.recipient.as_deref(), Some("did:web:org.example"));
assert_eq!(err.issuer.as_deref(), Some("did:web:maintainer.example"));
}
other => panic!("expected Rejected, got {other:?}"),
}
}
#[tokio::test]
async fn recommended_spec_accepts_proofless_under_verify_policy() {
use crate::specs::acl::list::v0_1 as list;
let transport = NoopHandler::new();
let verifier = StubVerifier { outcome: Ok(()) };
let mut doc = TrustTask::for_payload(
"req-rec-1",
list::Payload {
role: None,
scope: None,
subject_prefix: None,
page_size: None,
cursor: None,
ext: None,
},
);
doc.issuer = Some("did:web:org.example".into());
doc.recipient = Some("did:web:maintainer.example".into());
let outcome: ConsumeOutcome<list::Response> = consume_inbound(
&transport,
ProofPolicy::Verify(&verifier),
doc,
"did:web:maintainer.example",
Utc::now(),
|| "err-rec-1".to_string(),
|req, _parties| async move {
Ok(req.respond_with(
"resp-rec-1",
list::Response {
entries: vec![],
cursor: None,
redacted_fields: vec![],
truncated: false,
ext: None,
},
))
},
)
.await;
assert!(matches!(outcome, ConsumeOutcome::Handled(_)));
}
#[tokio::test]
async fn accept_unverified_passes_proof_bearing_doc_through() {
use crate::specs::acl::list::v0_1 as list;
let transport = NoopHandler::new();
let mut doc = TrustTask::for_payload(
"req-au-1",
list::Payload {
role: None,
scope: None,
subject_prefix: None,
page_size: None,
cursor: None,
ext: None,
},
);
doc.issuer = Some("did:web:org.example".into());
doc.recipient = Some("did:web:maintainer.example".into());
doc.proof = Some(dummy_proof());
let outcome: ConsumeOutcome<list::Response> =
consume_inbound::<_, _, _, StubVerifier, _, _>(
&transport,
ProofPolicy::AcceptUnverified,
doc,
"did:web:maintainer.example",
Utc::now(),
|| "err-au-1".to_string(),
|req, _parties| async move {
Ok(req.respond_with(
"resp-au-1",
list::Response {
entries: vec![],
cursor: None,
redacted_fields: vec![],
truncated: false,
ext: None,
},
))
},
)
.await;
assert!(matches!(outcome, ConsumeOutcome::Handled(_)));
}
#[tokio::test]
async fn proof_invalid_rejected_with_verifier_error_message() {
let transport = NoopHandler::new();
let verifier = StubVerifier {
outcome: Err(VerificationError::SignatureInvalid),
};
let mut doc = TrustTask::for_payload("req-pi-1", grant_payload());
doc.issuer = Some("did:web:org.example".into());
doc.recipient = Some("did:web:maintainer.example".into());
doc.proof = Some(dummy_proof());
let outcome: ConsumeOutcome<grant::Response> = consume_inbound(
&transport,
ProofPolicy::Verify(&verifier),
doc,
"did:web:maintainer.example",
Utc::now(),
|| "err-pi-1".to_string(),
|_req, _parties| async move {
panic!("handler must not run on proof_invalid");
#[allow(unreachable_code)]
Ok::<TrustTask<grant::Response>, ErrorResponse>(unreachable!())
},
)
.await;
match outcome {
ConsumeOutcome::Rejected(err) => {
assert_eq!(err.payload.code, StandardCode::ProofInvalid.into());
let msg = err.payload.message.as_deref().unwrap_or("");
assert!(msg.contains("signature"), "expected signature error: {msg}");
}
other => panic!("expected Rejected, got {other:?}"),
}
}
#[tokio::test]
async fn resolved_parties_filled_in_from_transport_when_in_band_absent() {
use crate::handlers::InMemoryHandler;
use crate::specs::acl::list::v0_1 as list;
let transport = InMemoryHandler::new()
.with_local("did:web:maintainer.example")
.with_peer("did:web:org.example");
let doc = TrustTask::for_payload(
"req-rp-1",
list::Payload {
role: None,
scope: None,
subject_prefix: None,
page_size: None,
cursor: None,
ext: None,
},
);
let outcome: ConsumeOutcome<list::Response> =
consume_inbound::<_, _, _, StubVerifier, _, _>(
&transport,
ProofPolicy::RejectIfPresent,
doc,
"did:web:maintainer.example",
Utc::now(),
|| "err-rp-1".to_string(),
|req, parties| async move {
assert_eq!(parties.issuer.as_deref(), Some("did:web:org.example"));
assert_eq!(
parties.recipient.as_deref(),
Some("did:web:maintainer.example")
);
Ok(req.respond_with(
"resp-rp-1",
list::Response {
entries: vec![],
cursor: None,
redacted_fields: vec![],
truncated: false,
ext: None,
},
))
},
)
.await;
assert!(matches!(outcome, ConsumeOutcome::Handled(_)));
}
#[tokio::test]
async fn identity_mismatch_with_no_transport_sender_is_suppressed() {
use crate::handlers::InMemoryHandler;
let transport = InMemoryHandler::new()
.with_local("did:web:maintainer.example")
.with_peer("did:web:org.example");
let mut doc = TrustTask::for_payload(
"req-im-1",
crate::specs::acl::list::v0_1::Payload {
role: None,
scope: None,
subject_prefix: None,
page_size: None,
cursor: None,
ext: None,
},
);
doc.issuer = Some("did:web:attacker.example".into());
doc.recipient = Some("did:web:maintainer.example".into());
struct MismatchingNoSenderTransport;
impl crate::TransportHandler for MismatchingNoSenderTransport {
fn binding_uri(&self) -> &str {
"urn:test:mismatching-no-sender"
}
fn derive_parties(&self) -> crate::TransportContext {
crate::TransportContext {
issuer: None,
recipient: None,
}
}
fn resolve_parties<P>(
&self,
doc: &TrustTask<P>,
) -> Result<crate::ResolvedParties, crate::ConsistencyError> {
Err(crate::ConsistencyError::IssuerMismatch {
in_band: doc
.issuer
.clone()
.unwrap_or_else(|| "did:web:in-band.example".into()),
transport: "did:web:transport.example".into(),
})
}
}
let outcome: ConsumeOutcome<crate::specs::acl::list::v0_1::Response> =
consume_inbound::<_, _, _, StubVerifier, _, _>(
&MismatchingNoSenderTransport,
ProofPolicy::RejectIfPresent,
doc,
"did:web:maintainer.example",
Utc::now(),
|| "err-im-1".to_string(),
|_req, _parties| async move {
panic!("handler must not run on identity_mismatch");
#[allow(unreachable_code)]
Ok::<TrustTask<_>, ErrorResponse>(unreachable!())
},
)
.await;
let _ = transport;
assert!(
matches!(outcome, ConsumeOutcome::Suppressed),
"expected Suppressed under identity_mismatch with no transport-authenticated sender"
);
}
}