#[cfg(any(feature = "didcomm", feature = "tsp"))]
use std::time::Duration;
use affinidi_data_integrity::{DataIntegrityProof, SignOptions, crypto_suites::CryptoSuite};
use serde_json::{Value, json};
use vti_common::error::AppError;
#[cfg(any(feature = "didcomm", feature = "tsp"))]
const CONSENT_PUSH_DELIVER_BY_SECS: u64 = 300;
use crate::policy::consent::PendingTaskConsent;
use crate::policy::effects::Effect;
use crate::policy::types::TaskClass;
use crate::server::AppState;
pub(super) const TASK_CONSENT_REQUEST_0_1: &str =
"https://trusttasks.org/spec/task-consent/request/0.1";
#[cfg(any(feature = "didcomm", feature = "tsp"))]
pub(super) const TASK_CONSENT_GRANTED_0_1: &str =
"https://trusttasks.org/spec/task-consent/granted/0.1";
pub(super) async fn mint_signed_requests(
state: &AppState,
pending: &PendingTaskConsent,
members: &[String],
class: TaskClass,
effects: &[Effect],
subject: Option<&str>,
origin: Option<&str>,
) -> Result<Vec<Value>, AppError> {
let vta_did =
state.config.read().await.vta_did.clone().ok_or_else(|| {
AppError::Internal("VTA DID not configured; cannot sign consent".into())
})?;
let secret = super::load_operational_secret(state, &vta_did, "task-consent").await?;
let class_value = serde_json::to_value(class)
.map_err(|e| AppError::Internal(format!("serialize task class: {e}")))?;
let expires_at = chrono::DateTime::from_timestamp(pending.expires_at as i64, 0)
.ok_or_else(|| AppError::Internal("consent expiry out of range".into()))?
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let mut signed = Vec::new();
for approver in members {
if pending.exclude_requester && approver == &pending.requester_did {
continue;
}
let mut payload = json!({
"challenge": pending.challenge,
"taskType": pending.type_uri,
"payloadDigest": pending.wire_digest,
"sideEffects": class_value.get("sideEffects"),
"exposure": class_value.get("exposure"),
"effects": effects,
"requester": pending.requester_did,
"approverSet": pending.approver_set,
"minApprovals": pending.min_approvals,
"excludeRequester": pending.exclude_requester,
"expiresAt": expires_at,
});
if let Some(s) = subject {
payload["subject"] = json!(s);
}
if let Some(o) = origin {
payload["origin"] = json!(o);
}
if let Some(pin) = &pending.state_pin {
payload["statePin"] = serde_json::to_value(pin)
.map_err(|e| AppError::Internal(format!("serialize state pin: {e}")))?;
}
let unsigned = json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": TASK_CONSENT_REQUEST_0_1,
"issuer": vta_did,
"recipient": approver,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": payload,
});
let proof = DataIntegrityProof::sign(
&unsigned,
&secret,
SignOptions::new()
.with_proof_purpose("authentication")
.with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
.map_err(|e| AppError::Internal(format!("sign task-consent request: {e}")))?;
let mut doc = unsigned;
doc["proof"] = serde_json::to_value(&proof)
.map_err(|e| AppError::Internal(format!("serialize proof: {e}")))?;
signed.push(doc);
}
Ok(signed)
}
pub(super) async fn push_signed_requests(state: &AppState, requests: &[Value]) {
for request in requests {
let Some(approver) = request.get("recipient").and_then(Value::as_str) else {
continue;
};
push_one(state, approver, request).await;
}
}
async fn push_one(
state: &AppState,
approver: &str,
#[cfg_attr(not(any(feature = "didcomm", feature = "tsp")), allow(unused))] request: &Value,
) {
#[cfg(any(feature = "didcomm", feature = "tsp"))]
match super::step_up::push_to_device(
state,
approver,
request,
Duration::from_secs(CONSENT_PUSH_DELIVER_BY_SECS),
)
.await
{
super::step_up::DevicePush::Queued { push, mediator } => tracing::info!(
approver = %approver, mediator = %mediator, push = %push,
"consent request queued for approver"
),
super::step_up::DevicePush::NoRoute {
configured_mediator,
} => tracing::warn!(
approver = %approver,
configured_mediator = ?configured_mediator,
"no mediator route for consent approver — NOT notifying; the approver \
learns of this request only if the requester relays it (a CLI cannot). \
A did:key approver routes via the VTA's own [messaging] mediator_did, \
so an unset config produces this; any other approver routes via the \
mediator its own DID document advertises, so a document carrying no \
DIDCommMessaging service — or one that would not resolve — produces \
it too. The preceding log line says which."
),
super::step_up::DevicePush::Refused => {}
}
}
pub(super) async fn push_granted(
state: &AppState,
#[cfg_attr(not(any(feature = "didcomm", feature = "tsp")), allow(unused))] requester: &str,
#[cfg_attr(not(any(feature = "didcomm", feature = "tsp")), allow(unused))] wire_digest: &str,
#[cfg_attr(not(any(feature = "didcomm", feature = "tsp")), allow(unused))] correlator: &str,
#[cfg_attr(not(any(feature = "didcomm", feature = "tsp")), allow(unused))] type_uri: &str,
) {
#[cfg(any(feature = "didcomm", feature = "tsp"))]
{
let mut body = serde_json::json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": TASK_CONSENT_GRANTED_0_1,
"threadId": correlator,
"recipient": requester,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": {
"status": "granted",
"payloadDigest": wire_digest,
"taskType": type_uri,
},
});
if let Some(vta_did) = state.config.read().await.vta_did.clone() {
body["issuer"] = serde_json::json!(vta_did);
}
match super::step_up::push_to_device(
state,
requester,
&body,
Duration::from_secs(CONSENT_PUSH_DELIVER_BY_SECS),
)
.await
{
super::step_up::DevicePush::Queued { push, .. } => tracing::debug!(
requester = %requester, push = %push,
"granted notice queued for the requester"
),
super::step_up::DevicePush::NoRoute { .. } => tracing::debug!(
requester = %requester,
"no mediator route for consent requester; skipping granted notice \
(it will re-submit on its own)"
),
super::step_up::DevicePush::Refused => {}
}
}
}
#[cfg(all(test, feature = "didcomm", feature = "webvh"))]
mod tests {
use crate::messaging::registry::MediatorBinding;
const MEDIATOR: &str = "did:example:mediator";
const REQUESTER: &str = "did:key:zRequester";
#[tokio::test]
async fn granted_notice_is_pushed_to_the_requester() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
state
.mediator_registry
.record_activate(MediatorBinding {
mediator_did: MEDIATOR.into(),
endpoint: "https://mediator.test".into(),
})
.await;
{
let mut cfg = state.config.write().await;
cfg.messaging = Some(vti_common::config::MessagingConfig {
mediator_url: String::new(),
mediator_did: MEDIATOR.into(),
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
}
super::push_granted(
&state,
REQUESTER,
"digest-abc",
"urn:uuid:correlator-abc",
"https://example.org/task/1.0",
)
.await;
let pushed = crate::messaging::push::take_pushes(&state);
assert_eq!(pushed.len(), 1, "the requester is notified exactly once");
assert_eq!(
pushed[0].body.get("type").and_then(|t| t.as_str()),
Some(super::TASK_CONSENT_GRANTED_0_1),
"the task type belongs in the enveloped document, not on the envelope"
);
assert_eq!(pushed[0].recipient_did, REQUESTER);
assert_eq!(
pushed[0].body["payload"]["payloadDigest"].as_str(),
Some("digest-abc")
);
}
#[tokio::test]
async fn the_notice_threads_on_the_correlator_not_the_digest() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
state
.mediator_registry
.record_activate(MediatorBinding {
mediator_did: MEDIATOR.into(),
endpoint: "https://mediator.test".into(),
})
.await;
{
let mut cfg = state.config.write().await;
cfg.messaging = Some(vti_common::config::MessagingConfig {
mediator_url: String::new(),
mediator_did: MEDIATOR.into(),
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
}
super::push_granted(
&state,
REQUESTER,
"digest-abc",
"urn:uuid:correlator-abc",
"https://example.org/task/1.0",
)
.await;
let pushed = crate::messaging::push::take_pushes(&state);
let one = pushed.first().expect("a notice was pushed");
assert_eq!(
one.body["threadId"], "urn:uuid:correlator-abc",
"the document must thread on the minted correlator: {}",
one.body
);
assert_eq!(
one.body["payload"]["payloadDigest"], "digest-abc",
"the body still carries the digest, so a requester matching on it \
is unaffected: {}",
one.body
);
}
}