use std::sync::Arc;
use std::time::Duration;
use affinidi_messaging_delivery::Delivery;
use affinidi_messaging_didcomm::Message;
use async_trait::async_trait;
use tokio::sync::OnceCell;
use uuid::Uuid;
use vti_common::capability_client::{
self, TRUST_TASK_ENVELOPE_TYPE, WriteOutcome, build_git_trust_grant, build_git_trust_revoke,
};
use crate::credentials::LocalSigner;
use crate::messaging::VtcMessaging;
use super::reply::PendingReplies;
use super::{CapabilityWriter, HookJob, HookOp, HookWriteError};
pub const DEFAULT_REPLY_TIMEOUT_SECONDS: u64 = 60;
pub struct DidcommCapabilityWriter {
didcomm: Arc<OnceCell<Arc<VtcMessaging>>>,
signer: Arc<LocalSigner>,
registry_did: String,
replies: PendingReplies,
reply_timeout: Duration,
}
impl DidcommCapabilityWriter {
pub fn new(
didcomm: Arc<OnceCell<Arc<VtcMessaging>>>,
signer: Arc<LocalSigner>,
registry_did: String,
replies: PendingReplies,
) -> Self {
Self {
didcomm,
signer,
registry_did,
replies,
reply_timeout: Duration::from_secs(DEFAULT_REPLY_TIMEOUT_SECONDS),
}
}
#[cfg(test)]
pub fn with_reply_timeout(mut self, timeout: Duration) -> Self {
self.reply_timeout = timeout;
self
}
}
#[async_trait]
impl CapabilityWriter for DidcommCapabilityWriter {
async fn write(&self, job: &HookJob) -> Result<WriteOutcome, HookWriteError> {
let messaging = self.didcomm.get().ok_or_else(|| {
HookWriteError::Transient("VTC messaging not running yet".to_string())
})?;
let issuer = messaging.vtc_did.clone();
let doc = self.build_signed_document(&issuer, job).await?;
let receiver = self.replies.register(&doc.id);
if let Err(e) = self.send_envelope(messaging, &doc).await {
self.replies.abandon(&doc.id);
return Err(e);
}
match tokio::time::timeout(self.reply_timeout, receiver).await {
Ok(Ok(reply)) => capability_client::classify_git_trust_reply(&reply).ok_or_else(|| {
HookWriteError::Transient(format!(
"uninterpretable reply type `{}`",
reply.type_uri
))
}),
Ok(Err(_closed)) => {
self.replies.abandon(&doc.id);
Err(HookWriteError::Transient(
"reply channel closed".to_string(),
))
}
Err(_elapsed) => {
self.replies.abandon(&doc.id);
Err(HookWriteError::Transient(format!(
"no reply within {}s",
self.reply_timeout.as_secs()
)))
}
}
}
}
impl DidcommCapabilityWriter {
async fn build_signed_document(
&self,
issuer: &str,
job: &HookJob,
) -> Result<trust_tasks_rs::TrustTask<serde_json::Value>, HookWriteError> {
let doc = match job.op {
HookOp::Grant => {
build_git_trust_grant(issuer, &self.registry_did, &job.subject_did, &job.resource)
}
HookOp::Revoke => build_git_trust_revoke(
issuer,
&self.registry_did,
&job.subject_did,
&job.resource,
job.reason.as_deref(),
),
}
.map_err(|e| HookWriteError::Transient(format!("build capability document: {e}")))?;
let mut doc_value = serde_json::to_value(&doc)
.map_err(|e| HookWriteError::Transient(format!("serialise document: {e}")))?;
self.signer
.sign_doc(&mut doc_value)
.await
.map_err(|e| HookWriteError::Transient(format!("sign document: {e}")))?;
serde_json::from_value(doc_value)
.map_err(|e| HookWriteError::Transient(format!("reparse signed document: {e}")))
}
async fn send_envelope(
&self,
messaging: &VtcMessaging,
doc: &trust_tasks_rs::TrustTask<serde_json::Value>,
) -> Result<(), HookWriteError> {
let body = serde_json::to_value(doc)
.map_err(|e| HookWriteError::Transient(format!("serialise envelope body: {e}")))?;
let envelope = Message::build(
format!("urn:uuid:{}", Uuid::new_v4()),
TRUST_TASK_ENVELOPE_TYPE.to_string(),
body,
)
.from(messaging.vtc_did.clone())
.to(self.registry_did.clone())
.thid(doc.id.clone())
.finalize();
let (packed, _) = messaging
.atm
.pack_encrypted(
&envelope,
&self.registry_did,
Some(&messaging.vtc_did),
Some(&messaging.vtc_did),
)
.await
.map_err(|e| HookWriteError::Unreachable(format!("pack failed: {e}")))?;
messaging
.service
.send(
&self.registry_did,
packed.into_bytes(),
Delivery::BestEffort,
)
.await
.map_err(|e| HookWriteError::Unreachable(format!("send failed: {e}")))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::hooks::reply::PendingReplies;
use std::time::Duration;
use trust_tasks_rs::TrustTask;
const VTC: &str = "did:webvh:vtc.example";
const REGISTRY: &str = "did:webvh:registry.example";
fn writer() -> DidcommCapabilityWriter {
DidcommCapabilityWriter::new(
Arc::new(OnceCell::new()),
Arc::new(LocalSigner::from_ed25519_seed(VTC.into(), &[0x11; 32])),
REGISTRY.into(),
PendingReplies::new(),
)
.with_reply_timeout(Duration::from_millis(50))
}
fn grant_job() -> HookJob {
HookJob::new(
"seq".into(),
HookOp::Grant,
"did:example:signer".into(),
"openvtc".into(),
None,
chrono::Utc::now(),
)
}
#[tokio::test]
async fn signed_document_verifies_against_the_vtc_key() {
let w = writer();
let doc = w.build_signed_document(VTC, &grant_job()).await.unwrap();
assert_eq!(doc.type_uri.slug(), "git-trust/grant");
assert_eq!(doc.issuer.as_deref(), Some(VTC));
assert_eq!(doc.recipient.as_deref(), Some(REGISTRY));
let proof = doc.proof.clone().expect("document is signed");
let mut value = serde_json::to_value(&doc).unwrap();
value.as_object_mut().unwrap().remove("proof");
let di_proof: affinidi_data_integrity::DataIntegrityProof =
serde_json::from_value(serde_json::to_value(&proof).unwrap()).unwrap();
let signer = LocalSigner::from_ed25519_seed(VTC.into(), &[0x11; 32]);
di_proof
.verify_with_public_key(
&value,
signer.public_bytes(),
affinidi_data_integrity::VerifyOptions::new(),
)
.expect("proof verifies with the VTC key");
}
#[tokio::test]
async fn write_is_transient_when_messaging_is_not_up() {
let w = writer();
let err = w.write(&grant_job()).await.unwrap_err();
assert!(matches!(err, HookWriteError::Transient(_)), "got {err}");
}
#[tokio::test]
async fn pending_replies_correlate_by_thread_id() {
let replies = PendingReplies::new();
let rx = replies.register("urn:uuid:req");
let mut wrong: TrustTask<serde_json::Value> = TrustTask::new(
"urn:uuid:x".to_string(),
"https://trusttasks.org/spec/git-trust/grant/0.1#response"
.parse()
.unwrap(),
serde_json::json!({}),
);
wrong.thread_id = Some("urn:uuid:other".into());
assert!(!replies.complete(wrong));
let mut right: TrustTask<serde_json::Value> = TrustTask::new(
"urn:uuid:y".to_string(),
"https://trusttasks.org/spec/git-trust/grant/0.1#response"
.parse()
.unwrap(),
serde_json::json!({}),
);
right.thread_id = Some("urn:uuid:req".into());
assert!(replies.complete(right));
assert!(rx.await.is_ok());
}
}