use chrono::{DateTime, Duration, Utc};
use serde::Serialize;
use serde_json::Value;
use trust_tasks_rs::{RejectReason, TrustTask, VerificationError};
use super::verifier::{TransportBoundVerifier, controller_did};
use crate::server::didcomm_unpack::{FRESHNESS_WINDOW_SECS, FUTURE_SKEW_SECS};
#[derive(Debug, thiserror::Error)]
pub enum BoundError {
#[error("document carries no proof; a signed document is required")]
MissingProof,
#[error("document carries no in-band issuer")]
MissingIssuer,
#[error("document issuer {issuer} is not the expected peer {expected}")]
UnexpectedIssuer { issuer: String, expected: String },
#[error("document issuer {issuer} does not match the transport sender {sender}")]
SenderMismatch { issuer: String, sender: String },
#[error("document carries no in-band recipient")]
MissingRecipient,
#[error("document is addressed to {recipient}, not {expected}")]
WrongRecipient { recipient: String, expected: String },
#[error("document carries no issuedAt")]
MissingIssuedAt,
#[error("document issuedAt is outside the freshness window")]
Stale,
#[error("document expired at {0}")]
Expired(DateTime<Utc>),
#[error("proof verification failed: {0}")]
Proof(#[from] VerificationError),
}
impl BoundError {
pub fn is_transient(&self) -> bool {
matches!(self, BoundError::Proof(e) if super::verifier::is_unreachable(e))
}
pub fn reject_reason(&self) -> RejectReason {
if self.is_transient() {
return RejectReason::Unavailable { retry_after: None };
}
match self {
BoundError::MissingProof => RejectReason::ProofRequired,
BoundError::MissingIssuer
| BoundError::MissingRecipient
| BoundError::MissingIssuedAt => RejectReason::MalformedRequest {
reason: self.to_string(),
},
BoundError::WrongRecipient {
recipient,
expected,
} => RejectReason::WrongRecipient {
in_band: recipient.clone(),
expected: expected.clone(),
},
BoundError::Expired(at) => RejectReason::Expired { expires_at: *at },
BoundError::Stale => RejectReason::MalformedRequest {
reason: self.to_string(),
},
BoundError::UnexpectedIssuer { .. } | BoundError::SenderMismatch { .. } => {
RejectReason::PermissionDenied {
reason: self.to_string(),
}
}
BoundError::Proof(e) => RejectReason::ProofInvalid {
reason: e.to_string(),
},
}
}
}
pub async fn verify_sender_bound<P>(
doc: &TrustTask<P>,
expected_issuer: Option<&str>,
transport_sender: Option<&str>,
my_vid: &str,
verifier: &TransportBoundVerifier,
) -> Result<String, BoundError>
where
P: Serialize + Send + Sync,
{
verify_sender_bound_at(
doc,
expected_issuer,
transport_sender,
my_vid,
verifier,
Utc::now(),
)
.await
}
pub async fn verify_sender_bound_approval<P>(
doc: &TrustTask<P>,
transport_sender: Option<&str>,
my_vid: &str,
verifier: &TransportBoundVerifier,
) -> Result<String, BoundError>
where
P: Serialize + Send + Sync,
{
verify_bound(
doc,
None,
transport_sender,
my_vid,
verifier,
Utc::now(),
Relationship::AssertionMethod,
)
.await
}
#[derive(Clone, Copy)]
enum Relationship {
Authentication,
AssertionMethod,
}
pub async fn verify_sender_bound_at<P>(
doc: &TrustTask<P>,
expected_issuer: Option<&str>,
transport_sender: Option<&str>,
my_vid: &str,
verifier: &TransportBoundVerifier,
now: DateTime<Utc>,
) -> Result<String, BoundError>
where
P: Serialize + Send + Sync,
{
verify_bound(
doc,
expected_issuer,
transport_sender,
my_vid,
verifier,
now,
Relationship::Authentication,
)
.await
}
async fn verify_bound<P>(
doc: &TrustTask<P>,
expected_issuer: Option<&str>,
transport_sender: Option<&str>,
my_vid: &str,
verifier: &TransportBoundVerifier,
now: DateTime<Utc>,
relationship: Relationship,
) -> Result<String, BoundError>
where
P: Serialize + Send + Sync,
{
if doc.proof.is_none() {
return Err(BoundError::MissingProof);
}
let issuer = doc.issuer.as_deref().ok_or(BoundError::MissingIssuer)?;
if let Some(expected) = expected_issuer
&& issuer != expected
{
return Err(BoundError::UnexpectedIssuer {
issuer: issuer.to_string(),
expected: expected.to_string(),
});
}
if let Some(sender) = transport_sender
&& controller_did(sender) != issuer
{
return Err(BoundError::SenderMismatch {
issuer: issuer.to_string(),
sender: sender.to_string(),
});
}
let recipient = doc
.recipient
.as_deref()
.ok_or(BoundError::MissingRecipient)?;
if recipient != my_vid {
return Err(BoundError::WrongRecipient {
recipient: recipient.to_string(),
expected: my_vid.to_string(),
});
}
let issued_at = doc.issued_at.ok_or(BoundError::MissingIssuedAt)?;
if issued_at < now - Duration::seconds(FRESHNESS_WINDOW_SECS as i64)
|| issued_at > now + Duration::seconds(FUTURE_SKEW_SECS as i64)
{
return Err(BoundError::Stale);
}
if let Some(expires_at) = doc.expires_at
&& expires_at <= now
{
return Err(BoundError::Expired(expires_at));
}
match relationship {
Relationship::Authentication => verifier.verify_operational(doc).await?,
Relationship::AssertionMethod => verifier.verify_approval(doc).await?,
}
Ok(issuer.to_string())
}
#[derive(Debug, thiserror::Error)]
pub enum BoundKeyError {
#[error("the issuer must be an Ed25519 did:key")]
KeyUnsupported,
#[error("document carries no proof; a signed document is required")]
MissingProof,
#[error("the proof's verificationMethod is not the issuer's own key")]
NotIssuerKey,
#[error("proofPurpose {0} is not one this task accepts")]
Purpose(String),
#[error("proof verification failed: {0}")]
Proof(#[from] VerificationError),
}
impl BoundKeyError {
pub fn reject_reason(&self) -> RejectReason {
match self {
BoundKeyError::MissingProof => RejectReason::ProofRequired,
BoundKeyError::KeyUnsupported => RejectReason::PermissionDenied {
reason: self.to_string(),
},
_ => RejectReason::ProofInvalid {
reason: self.to_string(),
},
}
}
}
fn did_key_verifier() -> &'static TransportBoundVerifier {
static VERIFIER: std::sync::OnceLock<TransportBoundVerifier> = std::sync::OnceLock::new();
VERIFIER.get_or_init(|| {
TransportBoundVerifier::with_resolver(std::sync::Arc::new(
affinidi_data_integrity::DidKeyResolver,
))
})
}
pub async fn verify_bound_key<P>(
doc: &TrustTask<P>,
purposes: &[&str],
) -> Result<String, BoundKeyError>
where
P: Serialize + Send + Sync,
{
let issuer = doc.issuer.as_deref().unwrap_or_default();
let multikey = issuer
.strip_prefix("did:key:")
.filter(|mk| crate::server::auth::session::is_ed25519_multikey(mk))
.ok_or(BoundKeyError::KeyUnsupported)?;
let proof = doc.proof.as_ref().ok_or(BoundKeyError::MissingProof)?;
if proof.verification_method != format!("{issuer}#{multikey}") {
return Err(BoundKeyError::NotIssuerKey);
}
if !purposes.contains(&proof.proof_purpose.as_str()) {
return Err(BoundKeyError::Purpose(proof.proof_purpose.clone()));
}
trust_tasks_rs::ProofVerifier::verify(did_key_verifier(), doc).await?;
Ok(issuer.to_string())
}
pub async fn sign_document(
doc: &TrustTask<Value>,
signer: &affinidi_tdk::secrets_resolver::secrets::Secret,
) -> Result<TrustTask<Value>, SignError> {
use trust_tasks_proof::affinidi::{CryptoSuite, SignOptions, sign_trust_task};
let unsigned = serde_json::to_value(doc).map_err(|e| SignError(e.to_string()))?;
let signed = sign_trust_task(
&unsigned,
signer,
SignOptions::new()
.with_proof_purpose(super::verifier::OPERATIONAL_PROOF_PURPOSE)
.with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
.map_err(|e| SignError(e.to_string()))?;
serde_json::from_value(signed).map_err(|e| SignError(e.to_string()))
}
#[derive(Debug, thiserror::Error)]
#[error("sign trust task document: {0}")]
pub struct SignError(pub String);
pub fn identity_signing_secret(
identity: &crate::server::identity::ServiceIdentity,
expected_did: &str,
) -> Result<affinidi_tdk::secrets_resolver::secrets::Secret, SignError> {
let generation = identity.current();
if generation.did != expected_did {
return Err(SignError(format!(
"current identity generation DID {} does not match the configured service DID \
{expected_did}",
generation.did
)));
}
identity
.secrets()
.into_iter()
.find(|s| s.id == generation.signing_kid)
.ok_or_else(|| {
SignError(format!(
"no signing secret loaded for {}",
generation.signing_kid
))
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use affinidi_data_integrity::DidKeyResolver;
use affinidi_tdk::secrets_resolver::secrets::Secret;
use serde_json::json;
use super::*;
use crate::server::trust_tasks::send::build_request;
const TYPE: &str = "https://trusttasks.org/spec/webvh/sync/delete/0.1";
const ME: &str = "did:example:edge";
fn signer(seed: u8) -> (String, Secret) {
let secret = Secret::generate_ed25519(None, Some(&[seed; 32]));
let pk = secret.get_public_keymultibase().unwrap();
let did = format!("did:key:{pk}");
let mut s = secret;
s.id = format!("{did}#{pk}");
(did, s)
}
fn verifier() -> TransportBoundVerifier {
TransportBoundVerifier::with_resolver(Arc::new(DidKeyResolver))
}
async fn signed_by(seed: u8, to: &str) -> (String, TrustTask<Value>) {
let (did, secret) = signer(seed);
let doc = build_request(TYPE, &did, to, json!({ "mnemonic": "alice" })).unwrap();
(did.clone(), sign_document(&doc, &secret).await.unwrap())
}
const PURPOSES: &[&str] = &["authentication", "assertionMethod"];
#[tokio::test]
async fn a_bound_key_document_verifies_locally_to_its_issuer() {
let (did, doc) = signed_by(3, ME).await;
assert_eq!(verify_bound_key(&doc, PURPOSES).await.unwrap(), did);
}
#[tokio::test]
async fn a_bound_key_document_is_refused_for_a_purpose_its_task_does_not_list() {
let (_, doc) = signed_by(3, ME).await;
assert!(matches!(
verify_bound_key(&doc, &["assertionMethod"]).await,
Err(BoundKeyError::Purpose(p)) if p == "authentication"
));
}
#[tokio::test]
async fn a_bound_key_document_must_be_signed_by_the_key_its_issuer_names() {
let (_, mut doc) = signed_by(3, ME).await;
doc.issuer = Some("did:web:example.com".into());
assert!(matches!(
verify_bound_key(&doc, PURPOSES).await,
Err(BoundKeyError::KeyUnsupported)
));
let (other, _) = signed_by(4, ME).await;
let (_, mut doc) = signed_by(3, ME).await;
doc.issuer = Some(other);
assert!(matches!(
verify_bound_key(&doc, PURPOSES).await,
Err(BoundKeyError::NotIssuerKey)
));
let (_, mut doc) = signed_by(3, ME).await;
doc.id = "urn:uuid:tampered".into();
assert!(matches!(
verify_bound_key(&doc, PURPOSES).await,
Err(BoundKeyError::Proof(_))
));
let (_, mut doc) = signed_by(3, ME).await;
doc.proof = None;
assert!(matches!(
verify_bound_key(&doc, PURPOSES).await,
Err(BoundKeyError::MissingProof)
));
}
#[tokio::test]
async fn a_signed_fresh_addressed_document_verifies_to_its_issuer() {
let (did, doc) = signed_by(1, ME).await;
let proven = verify_sender_bound(&doc, Some(&did), Some(&did), ME, &verifier())
.await
.expect("verifies");
assert_eq!(proven, did);
}
#[tokio::test]
async fn an_unsigned_document_is_refused() {
let (did, _) = signer(1);
let doc = build_request(TYPE, &did, ME, json!({})).unwrap();
let err = verify_sender_bound(&doc, None, Some(&did), ME, &verifier())
.await
.unwrap_err();
assert!(matches!(err, BoundError::MissingProof));
}
#[tokio::test]
async fn a_valid_proof_from_another_signer_is_refused_whatever_the_transport_says() {
let (control, _) = signer(9);
let (_attacker, doc) = signed_by(2, ME).await;
let err = verify_sender_bound(&doc, Some(&control), Some(&control), ME, &verifier())
.await
.unwrap_err();
assert!(
matches!(err, BoundError::UnexpectedIssuer { .. }),
"{err:?}"
);
}
#[tokio::test]
async fn an_issuer_that_disagrees_with_the_transport_sender_is_refused() {
let (admin, _) = signer(9);
let (_attacker, doc) = signed_by(2, ME).await;
let err = verify_sender_bound(&doc, None, Some(&admin), ME, &verifier())
.await
.unwrap_err();
assert!(matches!(err, BoundError::SenderMismatch { .. }), "{err:?}");
}
#[tokio::test]
async fn an_issuer_the_proof_does_not_bind_to_is_refused() {
let (victim, _) = signer(9);
let (_attacker, secret) = signer(2);
let mut doc = build_request(TYPE, &victim, ME, json!({})).unwrap();
doc.issuer = Some(secret.id.split('#').next().unwrap().to_string());
let mut signed = sign_document(&doc, &secret).await.unwrap();
signed.issuer = Some(victim.clone());
let err = verify_sender_bound(&signed, Some(&victim), Some(&victim), ME, &verifier())
.await
.unwrap_err();
assert!(
matches!(err, BoundError::Proof(VerificationError::IssuerMismatch(_))),
"{err:?}"
);
}
#[tokio::test]
async fn a_document_for_another_recipient_is_refused() {
let (did, doc) = signed_by(1, "did:example:other-edge").await;
let err = verify_sender_bound(&doc, Some(&did), None, ME, &verifier())
.await
.unwrap_err();
assert!(matches!(err, BoundError::WrongRecipient { .. }), "{err:?}");
}
#[tokio::test]
async fn a_stale_or_future_document_is_refused() {
let (did, doc) = signed_by(1, ME).await;
let issued = doc.issued_at.unwrap();
for now in [
issued + Duration::seconds(FRESHNESS_WINDOW_SECS as i64 + 1),
issued - Duration::seconds(FUTURE_SKEW_SECS as i64 + 1),
] {
let err = verify_sender_bound_at(&doc, Some(&did), None, ME, &verifier(), now)
.await
.unwrap_err();
assert!(matches!(err, BoundError::Stale), "{err:?}");
}
}
#[tokio::test]
async fn a_tampered_payload_is_refused() {
let (did, mut doc) = signed_by(1, ME).await;
doc.payload = json!({ "mnemonic": "bob" });
let err = verify_sender_bound(&doc, Some(&did), None, ME, &verifier())
.await
.unwrap_err();
assert!(matches!(err, BoundError::Proof(_)), "{err:?}");
}
#[tokio::test]
async fn an_assertion_method_proof_is_refused() {
use trust_tasks_proof::affinidi::{CryptoSuite, SignOptions, sign_trust_task};
let (did, secret) = signer(1);
let doc = build_request(TYPE, &did, ME, json!({})).unwrap();
let signed = sign_trust_task(
&serde_json::to_value(&doc).unwrap(),
&secret,
SignOptions::new()
.with_proof_purpose("assertionMethod")
.with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
.unwrap();
let doc: TrustTask<Value> = serde_json::from_value(signed).unwrap();
let err = verify_sender_bound(&doc, Some(&did), Some(&did), ME, &verifier())
.await
.unwrap_err();
assert!(
matches!(err, BoundError::Proof(VerificationError::MalformedProof(ref m)) if m.contains("authentication")),
"{err:?}"
);
}
#[tokio::test]
async fn documents_are_signed_for_authentication() {
let (_, doc) = signed_by(1, ME).await;
assert_eq!(doc.proof.unwrap().proof_purpose, "authentication");
}
#[tokio::test]
async fn an_unresolvable_signer_is_reported_as_retryable() {
use affinidi_did_resolver_cache_sdk::{DIDCacheClient, config::DIDCacheConfigBuilder};
let did = "did:web:control.invalid";
let (_, secret) = signer(3);
let mut doc = build_request(TYPE, did, ME, json!({})).unwrap();
doc.issuer = Some(secret.id.split('#').next().unwrap().to_string());
let mut signed = sign_document(&doc, &secret).await.unwrap();
signed.issuer = Some(did.to_string());
let mut proof = signed.proof.take().unwrap();
proof.verification_method = format!("{did}#key-1");
signed.proof = Some(proof);
let client = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.unwrap();
let err = verify_sender_bound(
&signed,
Some(did),
None,
ME,
&TransportBoundVerifier::with_did_cache(client),
)
.await
.unwrap_err();
assert!(err.is_transient(), "{err:?}");
assert!(err.to_string().contains("control.invalid"), "{err}");
assert!(matches!(
err.reject_reason(),
RejectReason::Unavailable { .. }
));
}
#[test]
fn missing_proof_is_reported_as_proof_required() {
assert_eq!(
BoundError::MissingProof.reject_reason(),
RejectReason::ProofRequired
);
}
}