use std::time::Duration;
use affinidi_messaging_didcomm::Message;
use axum::Json;
use axum::extract::State;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use tracing::{info, warn};
use crate::auth::AdminAuth;
use crate::auth::session::now_epoch;
use crate::error::AppError;
use crate::server::{AppState, PendingConfirm};
const CONTROL_LISTENER_ID: &str = "control";
const CONSENT_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_ACTION_CHARS: usize = 500;
const DIGEST_DOMAIN: &[u8] = b"did-hosting/task-consent/v1\0";
#[derive(Debug, Deserialize)]
pub struct ConsentRequest {
pub holder_did: String,
pub action: String,
}
#[derive(Debug, Serialize)]
pub struct ConsentResult {
pub approved: bool,
}
pub(crate) fn wire_digest(
task_type: &str,
payload: &Value,
challenge: &str,
) -> Result<String, AppError> {
let canonical = serde_json_canonicalizer::to_string(payload)
.map_err(|e| AppError::Internal(format!("payload JCS canonicalization failed: {e}")))?;
let mut h = Sha256::new();
h.update(DIGEST_DOMAIN);
h.update((task_type.len() as u64).to_be_bytes());
h.update(task_type.as_bytes());
h.update((canonical.len() as u64).to_be_bytes());
h.update(canonical.as_bytes());
h.update(challenge.as_bytes());
Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn build_signed_request_document(
control_did: &str,
signing_secret: &affinidi_tdk::secrets_resolver::secrets::Secret,
holder_did: &str,
requester_did: &str,
action: &str,
task_type: &str,
challenge: &str,
digest: &str,
issued_at: &str,
expires_at: &str,
) -> Result<Value, AppError> {
let request_type = did_hosting_common::did_hosting_tasks::TASK_CONSENT_REQUEST_0_1.as_str();
let unsigned = json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": request_type,
"issuer": control_did,
"recipient": holder_did,
"issuedAt": issued_at,
"payload": {
"challenge": challenge,
"taskType": task_type,
"payloadDigest": digest,
"sideEffects": "mutating",
"exposure": { "discloses": "none", "actsAsSubject": false },
"effects": [],
"consequences": [
"Approving authorizes the control-plane administrator to proceed \
with the one admin action described in the requester's note. The \
service cannot compute the action's concrete effects.",
],
"requester": requester_did,
"note": action,
"approverSet": "holder",
"minApprovals": 1,
"excludeRequester": false,
"expiresAt": expires_at,
},
});
crate::signing::sign_trust_task_document(unsigned, signing_secret).await
}
pub async fn request(
auth: AdminAuth,
State(state): State<AppState>,
Json(req): Json<ConsentRequest>,
) -> Result<Json<ConsentResult>, AppError> {
if req.holder_did.is_empty() {
return Err(AppError::Validation("holder_did must not be empty".into()));
}
if req.holder_did.len() > 512 {
return Err(AppError::Validation(
"holder_did exceeds maximum length".into(),
));
}
if req.action.is_empty() {
return Err(AppError::Validation("action must not be empty".into()));
}
if req.action.chars().count() > MAX_ACTION_CHARS {
return Err(AppError::Validation(format!(
"action exceeds the {MAX_ACTION_CHARS}-character note limit"
)));
}
let control_did = state
.config
.server_did
.as_deref()
.ok_or_else(|| {
AppError::Config("server_did not configured; cannot send consent request".into())
})?
.to_string();
let svc = state
.didcomm_service
.get()
.ok_or_else(|| AppError::Internal("DIDComm service not started".into()))?;
let signing_secret = crate::signing::control_assertion_secret(&state, &control_did)?;
let challenge = rand::random::<[u8; 16]>()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
let task_type = did_hosting_common::did_hosting_tasks::TASK_ADMIN_ACTION_1_0.as_str();
let pending_payload = json!({ "action": req.action });
let digest = wire_digest(task_type, &pending_payload, &challenge)?;
let now = chrono::Utc::now();
let expires_at = (now + chrono::Duration::from_std(CONSENT_TIMEOUT).expect("static"))
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let request_type = did_hosting_common::did_hosting_tasks::TASK_CONSENT_REQUEST_0_1.as_str();
let document = build_signed_request_document(
&control_did,
&signing_secret,
&req.holder_did,
&auth.0.did,
&req.action,
task_type,
&challenge,
&digest,
&now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
&expires_at,
)
.await?;
let (tx, rx) = tokio::sync::oneshot::channel::<bool>();
{
let mut pending = state.pending_confirms.lock().await;
pending.insert(
challenge.clone(),
PendingConfirm {
holder_did: req.holder_did.clone(),
expected_digest: digest.clone(),
tx,
},
);
}
let message = Message::build(
uuid::Uuid::new_v4().to_string(),
request_type.to_string(),
document,
)
.from(control_did)
.to(req.holder_did.clone())
.created_time(now_epoch())
.finalize();
info!(
holder_did = %req.holder_did,
challenge = %challenge,
"sending task-consent request"
);
if let Err(e) = svc
.send_message(CONTROL_LISTENER_ID, message, &req.holder_did)
.await
{
state.pending_confirms.lock().await.remove(&challenge);
return Err(AppError::Internal(format!(
"failed to send task-consent request: {e}"
)));
}
match tokio::time::timeout(CONSENT_TIMEOUT, rx).await {
Ok(Ok(approved)) => {
info!(holder_did = %req.holder_did, approved, "task-consent decision resolved");
Ok(Json(ConsentResult { approved }))
}
Ok(Err(_recv_err)) => {
state.pending_confirms.lock().await.remove(&challenge);
Err(AppError::Internal(
"consent channel closed before a decision arrived".into(),
))
}
Err(_timed_out) => {
state.pending_confirms.lock().await.remove(&challenge);
warn!(holder_did = %req.holder_did, "task-consent request timed out");
Err(AppError::Internal(
"wallet did not respond within the consent window".into(),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn digest_binds_type_payload_and_challenge() {
let payload = json!({ "action": "Rotate the registry signing key" });
let base = wire_digest("https://a.example/t/1.0", &payload, "c1").unwrap();
assert_ne!(
base,
wire_digest("https://a.example/other/1.0", &payload, "c1").unwrap()
);
assert_ne!(
base,
wire_digest(
"https://a.example/t/1.0",
&json!({ "action": "something else" }),
"c1"
)
.unwrap()
);
assert_ne!(
base,
wire_digest("https://a.example/t/1.0", &payload, "c2").unwrap()
);
assert_eq!(
base,
wire_digest("https://a.example/t/1.0", &payload, "c1").unwrap()
);
}
#[tokio::test]
async fn signed_request_document_verifies_and_binds_issuer() {
use std::sync::Arc;
use affinidi_data_integrity::DidKeyResolver;
use did_hosting_common::server::trust_tasks::TransportBoundVerifier;
use trust_tasks_rs::{ProofVerifier, TrustTask};
let (control_did, signer) = crate::signing::test_util::did_key_signer(&[11u8; 32]);
let task_type = did_hosting_common::did_hosting_tasks::TASK_ADMIN_ACTION_1_0.as_str();
let action = "Rotate the registry signing key";
let challenge = "00112233445566778899aabbccddeeff";
let digest = wire_digest(task_type, &json!({ "action": action }), challenge).unwrap();
let document = build_signed_request_document(
&control_did,
&signer,
"did:web:holder.example",
"did:web:admin.example",
action,
task_type,
challenge,
&digest,
"2026-07-29T00:00:00Z",
"2026-07-29T00:01:00Z",
)
.await
.expect("build + sign");
assert_eq!(document["issuer"], control_did);
let vm = document["proof"]["verificationMethod"]
.as_str()
.expect("proof carries a verificationMethod");
assert_eq!(vm.split('#').next().unwrap(), control_did);
assert_eq!(document["proof"]["cryptosuite"], "eddsa-jcs-2022");
assert_eq!(document["proof"]["proofPurpose"], "assertionMethod");
let doc: TrustTask<Value> =
serde_json::from_value(document.clone()).expect("signed doc parses as a TrustTask");
TransportBoundVerifier::with_resolver(Arc::new(DidKeyResolver))
.verify(&doc)
.await
.expect("request proof verifies");
let mut tampered = doc;
tampered.payload["note"] = json!("Approve something else entirely");
TransportBoundVerifier::with_resolver(Arc::new(DidKeyResolver))
.verify(&tampered)
.await
.expect_err("tampered note must fail verification");
}
#[test]
fn digest_is_canonical_over_key_order() {
let a = json!({ "action": "x", "extra": 1 });
let b = serde_json::from_str::<Value>(r#"{"extra":1,"action":"x"}"#).unwrap();
assert_eq!(
wire_digest("https://a.example/t/1.0", &a, "c").unwrap(),
wire_digest("https://a.example/t/1.0", &b, "c").unwrap()
);
}
}