#[cfg(feature = "didcomm")]
use std::time::Duration;
use affinidi_data_integrity::{DataIntegrityProof, SignOptions, crypto_suites::CryptoSuite};
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use affinidi_secrets_resolver::secrets::Secret;
use base64::Engine as _;
use base64::engine::general_purpose;
use serde_json::{Value, json};
use trust_tasks_rs::specs::auth::step_up::approve_response::v0_1 as approve_response;
use vta_sdk::trust_tasks as uris;
#[cfg(feature = "didcomm")]
use trust_tasks_didcomm::ENVELOPE_TYPE as TRUST_TASK_ENVELOPE_TYPE;
#[cfg(feature = "didcomm")]
use trust_tasks_rs::specs::push::wake::v0_2 as push_wake;
use trust_tasks_rs::{RejectReason, TrustTask};
use uuid::Uuid;
use crate::audit::audit;
use crate::auth::AuthClaims;
use crate::auth::session::{get_session, now_epoch, update_session};
use crate::operations::passkey_login::{
VtaVmResolver, enumerate_passkey_vms, verify_passkey_login,
};
use crate::server::AppState;
use vti_common::acl::{delegated_any_approver_covers, get_acl_entry};
use vti_common::auth::step_up::{
ConsumeOutcome, consume_pending_step_up, new_pending_step_up, store_pending_step_up,
};
use vti_common::store::KeyspaceHandle;
use super::helpers::{TrustTaskOutcome, reject_with, success_response};
#[derive(Debug, PartialEq)]
pub(super) enum GateError {
NoGate,
SubjectMismatch,
ProofInvalid(String),
}
pub(super) async fn verify_did_signed_gate(
doc: &TrustTask<Value>,
expected_signer: &str,
) -> Result<(), GateError> {
use crate::auth::DiProofError;
let signer_did = crate::auth::verify_trust_task_proof(doc)
.await
.map_err(|e| match e {
DiProofError::NoProof => GateError::NoGate,
DiProofError::NotDataIntegrity => {
GateError::ProofInvalid("not a Data Integrity proof".to_string())
}
DiProofError::NoDid | DiProofError::VerifyFailed(_) => {
GateError::ProofInvalid(e.to_string())
}
})?;
if signer_did != expected_signer {
return Err(GateError::SubjectMismatch);
}
Ok(())
}
fn step_up_failure(code: &str) -> RejectReason {
RejectReason::TaskFailed {
reason: code.to_string(),
details: None,
}
}
fn acr_rank(acr: &str) -> u8 {
match acr {
"aal3" => 3,
"aal2" => 2,
"aal1" => 1,
_ => 0,
}
}
fn gate_err_to_reject(e: GateError) -> RejectReason {
match e {
GateError::NoGate => step_up_failure("auth/step-up/approve-response:noGate"),
GateError::SubjectMismatch => {
step_up_failure("auth/step-up/approve-response:subjectMismatch")
}
GateError::ProofInvalid(_) => {
step_up_failure("auth/step-up/approve-response:proof_invalid")
}
}
}
async fn verify_webauthn_gate(
state: &AppState,
approver: &str,
challenge: &str,
assertion: &approve_response::AssertionResponse,
) -> Result<(), RejectReason> {
let did_resolver = state
.did_resolver
.clone()
.ok_or_else(|| RejectReason::InternalError {
reason: "DID resolver not configured".to_string(),
})?;
let public_url = state
.config
.read()
.await
.public_url
.clone()
.ok_or_else(|| RejectReason::InternalError {
reason: "public_url not configured".to_string(),
})?;
let config = vti_webauthn::VerifierConfig::from_public_url(&public_url, true).map_err(|e| {
RejectReason::InternalError {
reason: format!("verifier config: {e}"),
}
})?;
let resolver = VtaVmResolver::new(did_resolver);
let invalid = || step_up_failure("auth/step-up/approve-response:assertionInvalid");
let dec = |s: &str| {
general_purpose::URL_SAFE_NO_PAD
.decode(s.as_bytes())
.or_else(|_| general_purpose::URL_SAFE.decode(s.as_bytes()))
};
let credential_id = dec(&assertion.id).map_err(|_| invalid())?;
let vms = enumerate_passkey_vms(&resolver, approver)
.await
.map_err(|e| RejectReason::InternalError {
reason: format!("passkey VM enumeration: {e}"),
})?;
let vm = vms
.into_iter()
.find(|v| v.credential_id == credential_id)
.ok_or_else(invalid)?;
let payload = vti_webauthn::AssertionPayload {
credential_id,
authenticator_data: dec(&assertion.response.authenticator_data).map_err(|_| invalid())?,
client_data_json: dec(&assertion.response.client_data_json).map_err(|_| invalid())?,
signature: dec(&assertion.response.signature).map_err(|_| invalid())?,
verification_method: vm.vm_url,
};
verify_passkey_login(&payload, challenge.as_bytes(), &resolver, &config)
.await
.map(|_| ())
.map_err(|_| invalid())
}
pub(super) async fn handle_approve_response(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let payload: approve_response::Payload = {
let mut payload_value = doc.payload.clone();
super::wire_v0_2::kebabize_paths(&mut payload_value, &["evidence.kind"]);
match serde_json::from_value(payload_value) {
Ok(p) => p,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("payload parse: {e}"),
},
);
}
}
};
let subject = payload.subject.to_string();
let session_id = payload.session_id.to_string();
let challenge = payload.challenge.to_string();
let Some(issuer) = doc.issuer.as_deref().map(str::to_string) else {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:subjectMismatch"),
);
};
if auth.did != issuer {
return reject_with(
&doc,
RejectReason::PermissionDenied {
reason: "the approve-response issuer must be the authenticated caller".to_string(),
},
);
}
let pending = match consume_pending_step_up(&state.sessions_ks, &challenge, now_epoch()).await {
Ok(ConsumeOutcome::Found(p)) => *p,
Ok(ConsumeOutcome::NotFound) => {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:challengeUnknown"),
);
}
Ok(ConsumeOutcome::Expired) => {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:challengeExpired"),
);
}
Err(e) => {
tracing::error!(error = %e, "step-up consume failed");
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("step-up lookup: {e}"),
},
);
}
};
if pending.subject != subject || pending.session_id != session_id {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:subjectMismatch"),
);
}
if pending.approver_any {
let now = now_epoch();
let issuer_entry = match get_acl_entry(&state.acl_ks, &issuer).await {
Ok(Some(e)) if !e.is_expired(now) => e,
_ => {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:approverUnauthorized"),
);
}
};
let subject_entry = match get_acl_entry(&state.acl_ks, &subject).await {
Ok(Some(e)) => e,
_ => {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:approverUnauthorized"),
);
}
};
if !delegated_any_approver_covers(&issuer_entry, &subject_entry) {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:approverUnauthorized"),
);
}
} else {
let authorized_signer = if pending.approver.is_empty() {
subject.as_str()
} else {
pending.approver.as_str()
};
if issuer != authorized_signer {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:approverUnauthorized"),
);
}
}
if payload.decision == approve_response::PayloadDecision::Denied {
if let Err(e) = verify_did_signed_gate(&doc, &issuer).await {
return reject_with(&doc, gate_err_to_reject(e));
}
audit!(
"auth.step_up_denied",
actor = &subject,
resource = &session_id,
outcome = "declined"
);
return success_response(
&doc,
json!({
"status": "rejected",
"reason": payload
.denied_reason
.map(|r| r.to_string())
.unwrap_or_else(|| "user declined".to_string()),
}),
);
}
let factor: &str = match payload.evidence.as_ref() {
None | Some(approve_response::Evidence::DidSigned) => {
if let Err(e) = verify_did_signed_gate(&doc, &issuer).await {
return reject_with(&doc, gate_err_to_reject(e));
}
"did"
}
Some(approve_response::Evidence::Webauthn(assertion)) => {
match verify_webauthn_gate(state, &issuer, &challenge, assertion).await {
Ok(()) => "passkey",
Err(reason) => return reject_with(&doc, reason),
}
}
Some(_) => {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:evidenceUnsupported"),
);
}
};
let granted = payload.granted_acr.as_deref().unwrap_or("aal2");
let target = pending.target_acr.as_str();
if acr_rank(target) > acr_rank(granted) {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:acrUnsatisfied"),
);
}
if let Some(preview_id) = pending.bound_to.as_deref() {
let store = super::persona::store(state);
match store.approve_preview(preview_id).await {
Ok(marked) => {
audit!(
"persona.disclosure.step_up_approved",
actor = &pending.subject,
resource = preview_id,
outcome = if marked { "success" } else { "expired" }
);
}
Err(e) => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("record disclosure approval: {e}"),
},
);
}
}
if doc.type_uri.to_string() == uris::TASK_AUTH_STEP_UP_APPROVE_RESPONSE_0_3 {
audit!(
"auth.step_up.recorded",
actor = &pending.subject,
resource = preview_id,
outcome = "success"
);
return success_response(&doc, json!({ "status": "recorded", "boundTo": preview_id }));
}
}
let mut session = match get_session(&state.sessions_ks, &session_id).await {
Ok(Some(s)) => s,
Ok(None) => {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:challengeUnknown"),
);
}
Err(e) => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("session lookup: {e}"),
},
);
}
};
if !session.amr.iter().any(|m| m == factor) {
session.amr.push(factor.to_string());
}
session.acr = target.to_string(); session.acr_expires_at = Some(now_epoch().saturating_add(STEP_UP_ELEVATION_TTL_SECS));
if let Err(e) = update_session(&state.sessions_ks, &session).await {
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("session update: {e}"),
},
);
}
audit!(
"auth.step_up",
actor = &subject,
resource = &session_id,
outcome = "success"
);
let issued_at = chrono::DateTime::from_timestamp(session.created_at as i64, 0)
.map(|d| d.to_rfc3339())
.unwrap_or_default();
let expires_at = session
.refresh_expires_at
.and_then(|e| chrono::DateTime::from_timestamp(e as i64, 0))
.map(|d| d.to_rfc3339())
.unwrap_or_default();
success_response(
&doc,
json!({
"status": "elevated",
"session": {
"id": session.session_id,
"subject": session.did,
"issuedAt": issued_at,
"expiresAt": expires_at,
"amr": session.amr,
"acr": session.acr,
},
}),
)
}
const STEP_UP_TARGET_ACR: &str = "aal2";
const STEP_UP_TTL_SECS: u64 = 300;
const STEP_UP_ELEVATION_TTL_SECS: u64 = 900;
const DEFAULT_STEP_UP_REASON: &str = "this operation requires a stepped-up (AAL2) session";
const EXT_KEY_AUTHZ_CONTEXT: &str = "org.openvtc.authorization-context";
const PERSONA_AUTHZ_CONTEXT_TYPE: &str = "https://openvtc.org/persona/authorization-context/0.1";
fn reason_and_context(payload: &Value) -> (&str, Option<&Value>) {
let ctx = payload.get("authorizationContext");
let reason = ctx
.and_then(|c| c.get("summary"))
.and_then(|s| s.as_str())
.unwrap_or(DEFAULT_STEP_UP_REASON);
(reason, ctx)
}
async fn load_step_up_signing_secret(state: &AppState, vta_did: &str) -> Result<Secret, ()> {
crate::operations::credentials::load_vta_issuer_secret(state, vta_did, "step-up")
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to load VTA issuer key for step-up approve-request");
})
}
async fn mint_pending_step_up(
sessions_ks: &KeyspaceHandle,
vta_did: &str,
secret: &Secret,
subject: &str,
recipient: &str,
approver_any: bool,
session_id: &str,
reason: &str,
authorization_context: Option<&Value>,
bound_to: Option<&str>,
) -> Result<Value, ()> {
let acceptable = vec!["did-signed".to_string(), "webauthn".to_string()];
let acceptable_wire = vec!["didSigned".to_string(), "webauthn".to_string()];
let mut raw = Vec::with_capacity(32);
raw.extend_from_slice(Uuid::new_v4().as_bytes());
raw.extend_from_slice(Uuid::new_v4().as_bytes());
let challenge = general_purpose::URL_SAFE_NO_PAD.encode(&raw);
let mut pending = new_pending_step_up(
challenge.clone(),
session_id,
subject,
recipient,
approver_any,
STEP_UP_TARGET_ACR,
acceptable.clone(),
STEP_UP_TTL_SECS,
);
pending.bound_to = bound_to.map(str::to_string);
if let Err(e) = store_pending_step_up(sessions_ks, &pending).await {
tracing::error!(error = %e, "failed to persist pending step-up");
return Err(());
}
let mut doc = json!({
"id": format!("urn:uuid:{}", Uuid::new_v4()),
"type": STEP_UP_APPROVE_REQUEST_TYPE,
"issuer": vta_did,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": {
"subject": subject,
"sessionId": session_id,
"challenge": challenge,
"reason": reason,
"targetAcr": STEP_UP_TARGET_ACR,
"acceptableEvidence": acceptable_wire,
"ttl": STEP_UP_TTL_SECS,
},
});
if !approver_any && !recipient.is_empty() {
doc["recipient"] = json!(recipient);
}
if let Some(ctx) = authorization_context {
doc["payload"]["ext"] = json!({ EXT_KEY_AUTHZ_CONTEXT: ctx });
}
let proof = match DataIntegrityProof::sign(
&doc,
secret,
SignOptions::new()
.with_proof_purpose("assertionMethod")
.with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
{
Ok(p) => p,
Err(e) => {
tracing::error!(error = %e, "failed to sign step-up approve-request");
return Err(());
}
};
match serde_json::to_value(&proof) {
Ok(p) => doc["proof"] = p,
Err(e) => {
tracing::error!(error = %e, "failed to serialize step-up approve-request proof");
return Err(());
}
}
Ok(doc)
}
const STEP_UP_APPROVE_REQUEST_TYPE: &str =
"https://trusttasks.org/spec/auth/step-up/approve-request/0.2";
const ROUTE_RESOLVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
fn route_for(
approver_did: &str,
advertised: Option<&str>,
configured: Option<&str>,
) -> Option<String> {
let chosen = if approver_did.starts_with("did:key:") {
configured
} else {
advertised
};
chosen.filter(|m| !m.is_empty()).map(str::to_string)
}
pub(super) async fn approver_mediator(
approver_did: &str,
configured: Option<&str>,
resolver: Option<&DIDCacheClient>,
) -> Option<String> {
if approver_did.starts_with("did:key:") {
return route_for(approver_did, None, configured);
}
let Some(resolver) = resolver else {
tracing::debug!(
approver = %approver_did,
"no DID resolver on this node; cannot read the approver's advertised mediator"
);
return None;
};
let resolve = vta_sdk::session::resolve_mediator_did_with_resolver(approver_did, resolver);
let advertised = match tokio::time::timeout(ROUTE_RESOLVE_TIMEOUT, resolve).await {
Ok(Ok(Some(m))) => Some(m),
Ok(Ok(None)) => {
tracing::debug!(
approver = %approver_did,
"approver advertises no DIDCommMessaging service; relay fallback applies"
);
None
}
Ok(Err(e)) => {
tracing::warn!(
error = %e, approver = %approver_did,
"could not resolve the approver's DID document to find its mediator; \
relay fallback applies"
);
None
}
Err(_) => {
tracing::warn!(
approver = %approver_did,
timeout_secs = ROUTE_RESOLVE_TIMEOUT.as_secs(),
"timed out resolving the approver's DID document to find its mediator; \
relay fallback applies"
);
None
}
};
route_for(approver_did, advertised.as_deref(), configured)
}
#[cfg(feature = "tsp")]
pub(super) async fn try_push_over_tsp(state: &AppState, recipient: &str, doc: &Value) -> bool {
if !state.tsp_reach.fresh(recipient) {
return false;
}
let Some(transport) = state.tsp_transport() else {
return false; };
let body = match serde_json::to_vec(doc) {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, recipient = %recipient, "serialising TSP push failed; DIDComm fallback");
return false;
}
};
let body = vta_sdk::tsp_binding::wrap_envelope(&body);
match transport.send_to(recipient, &body).await {
Ok(_) => {
tracing::debug!(recipient = %recipient, "delivered Trust-Task over TSP (learn-from-inbound)");
true
}
Err(e) => {
tracing::warn!(error = %e, recipient = %recipient, "TSP push failed; falling back to DIDComm");
false
}
}
}
async fn maybe_push_step_up(
state: &AppState,
recipient: &str,
caller_did: &str,
#[cfg_attr(not(any(feature = "didcomm", feature = "tsp")), allow(unused))]
approve_request: &Value,
) {
if recipient == caller_did {
return; }
let configured_mediator = {
let cfg = state.config.read().await;
cfg.messaging.as_ref().map(|m| m.mediator_did.clone())
};
let mediator_did = approver_mediator(
recipient,
configured_mediator.as_deref(),
state.did_resolver.as_ref(),
)
.await;
#[cfg_attr(not(any(feature = "didcomm", feature = "tsp")), allow(unused))]
let Some(mediator_did) = mediator_did else {
tracing::debug!(
approver = %recipient,
"no mediator route for delegated approver; relying on the relay fallback"
);
return;
};
#[cfg(feature = "tsp")]
if try_push_over_tsp(state, recipient, approve_request).await {
#[cfg(feature = "didcomm")]
trigger_gateway_wake(state, recipient, &mediator_did).await;
return;
}
#[cfg(feature = "didcomm")]
{
#[cfg(feature = "webvh")]
{
let pending = crate::messaging::registry::PendingResponse {
recipient_did: recipient.to_string(),
message_type: TRUST_TASK_ENVELOPE_TYPE.to_string(),
body: approve_request.clone(),
thread_id: approve_request
.get("id")
.and_then(|v| v.as_str())
.map(str::to_string),
};
if let Err(e) = state
.mediator_registry
.buffer_outbound(&mediator_did, pending)
.await
{
tracing::warn!(
error = %e, approver = %recipient, mediator = %mediator_did,
"failed to buffer delegated step-up push; relay fallback applies"
);
}
}
if let Err(e) = state
.didcomm_bridge
.send_guaranteed(
"vta-main",
recipient,
TRUST_TASK_ENVELOPE_TYPE,
approve_request.clone(),
approve_request
.get("id")
.and_then(|v| v.as_str())
.map(str::to_string),
Duration::from_secs(STEP_UP_TTL_SECS),
)
.await
{
tracing::warn!(
error = %e, approver = %recipient,
"delegated step-up push enqueue failed; relay fallback applies"
);
}
}
#[cfg(feature = "didcomm")]
trigger_gateway_wake(state, recipient, &mediator_did).await;
}
#[cfg(feature = "didcomm")]
pub(super) async fn trigger_gateway_wake(
state: &AppState,
recipient: &str,
approver_mediator: &str,
) {
let wake = match get_acl_entry(&state.acl_ks, recipient).await {
Ok(Some(entry)) => entry.device.and_then(|d| d.wake),
_ => None,
};
let Some(wake) = wake else {
return; };
if !wake.gateway.starts_with("did:") {
return; }
let vta_did = state.config.read().await.vta_did.clone();
let wake_doc = json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": "https://trusttasks.org/spec/push/wake/0.2",
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"issuer": vta_did,
"recipient": wake.gateway,
"payload": {
"handle": wake.handle,
"v": 1,
"mediator": approver_mediator,
"urgency": "interactive",
},
});
let bridge = state.didcomm_bridge.clone();
let gateway = wake.gateway.clone();
let approver = recipient.to_string();
tokio::spawn(async move {
match bridge
.send_and_wait(
&gateway,
TRUST_TASK_ENVELOPE_TYPE,
wake_doc,
TRUST_TASK_ENVELOPE_TYPE,
vta_sdk::protocols::PROBLEM_REPORT_TYPE,
15,
)
.await
{
Ok(reply) => match wake_reply_status(&reply.body) {
Some(push_wake::ResponseStatus::TokenUnregistered) => tracing::warn!(
gateway = %gateway, approver = %approver,
"push/wake: gateway reports tokenUnregistered — dead platform token, \
handle dropped; mediator queue + pickup fallback applies"
),
Some(push_wake::ResponseStatus::Delivered) => {
tracing::info!(gateway = %gateway, approver = %approver, "push/wake delivered by gateway")
}
Some(other) => tracing::warn!(
gateway = %gateway, approver = %approver, status = ?other,
"push/wake: gateway returned a status this build does not know; \
treating as sent — mediator queue + pickup fallback applies"
),
None => tracing::info!(
gateway = %gateway, approver = %approver,
"push/wake sent to gateway (no recognizable 0.2 response status)"
),
},
Err(e) => tracing::warn!(
error = %e, gateway = %gateway, approver = %approver,
"push/wake to gateway failed (best-effort)"
),
}
});
}
#[cfg(feature = "didcomm")]
fn wake_reply_status(envelope_body: &Value) -> Option<push_wake::ResponseStatus> {
envelope_body
.pointer("/payload/status")
.and_then(|s| serde_json::from_value(s.clone()).ok())
}
pub(super) async fn initiate_disclosure_step_up(
state: &AppState,
auth: &AuthClaims,
preview_id: &str,
verifier_did: &str,
purpose: Option<&str>,
claim_types: &[String],
) -> Result<Value, RejectReason> {
let vta_did = state
.config
.read()
.await
.vta_did
.clone()
.unwrap_or_default();
let secret = match load_step_up_signing_secret(state, &vta_did).await {
Ok(s) => s,
Err(()) => {
return Err(RejectReason::InternalError {
reason: "failed to initiate step-up".to_string(),
});
}
};
let reason = format!(
"Approve disclosing {} to {verifier_did}",
match claim_types.len() {
1 => "1 attribute".to_string(),
n => format!("{n} attributes"),
}
);
let mut action = json!({
"kind": "disclose",
"previewId": preview_id,
"verifierDid": verifier_did,
"claimTypes": claim_types,
});
if let Some(p) = purpose {
action["purpose"] = json!(p);
}
let context = json!({
"type": PERSONA_AUTHZ_CONTEXT_TYPE,
"summary": reason,
"risk": "high",
"action": action,
});
match mint_pending_step_up(
&state.sessions_ks,
&vta_did,
&secret,
&auth.did,
&auth.did, false,
&auth.session_id,
&reason,
Some(&context),
Some(preview_id),
)
.await
{
Ok(approve_request) => {
maybe_push_step_up(state, &auth.did, &auth.did, &approve_request).await;
Ok(json!({
"previewId": preview_id,
"previewRetained": true,
"approveRequest": approve_request,
}))
}
Err(()) => Err(RejectReason::InternalError {
reason: "failed to initiate step-up for disclosure".to_string(),
}),
}
}
pub(super) async fn initiate_self_step_up(
state: &AppState,
auth: &AuthClaims,
payload: &Value,
) -> RejectReason {
let vta_did = state
.config
.read()
.await
.vta_did
.clone()
.unwrap_or_default();
let (reason, authorization_context) = reason_and_context(payload);
let secret = match load_step_up_signing_secret(state, &vta_did).await {
Ok(s) => s,
Err(()) => {
return RejectReason::InternalError {
reason: "failed to initiate step-up".to_string(),
};
}
};
match mint_pending_step_up(
&state.sessions_ks,
&vta_did,
&secret,
&auth.did,
&auth.did, false,
&auth.session_id,
reason,
authorization_context,
None,
)
.await
{
Ok(approve_request) => {
maybe_push_step_up(state, &auth.did, &auth.did, &approve_request).await;
RejectReason::TaskFailed {
reason: "auth:step_up_required".to_string(),
details: Some(json!({
"requiredAcr": STEP_UP_TARGET_ACR,
"approveRequest": approve_request,
})),
}
}
Err(()) => RejectReason::InternalError {
reason: "failed to initiate step-up".to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_data_integrity::DataIntegrityProof;
use affinidi_data_integrity::crypto_suites::CryptoSuite;
use affinidi_data_integrity::prepare_sign_input;
use ed25519_dalek::{Signer, SigningKey};
use multibase::Base;
use serde_json::json;
#[cfg(feature = "didcomm")]
#[test]
fn wake_reply_status_parses_camel_case_token_unregistered() {
let reply = json!({
"type": "https://trusttasks.org/spec/push/wake/0.2#response",
"payload": { "status": "tokenUnregistered" },
});
assert_eq!(
wake_reply_status(&reply),
Some(push_wake::ResponseStatus::TokenUnregistered)
);
}
#[cfg(feature = "didcomm")]
#[test]
fn wake_reply_status_parses_delivered() {
let reply = json!({
"type": "https://trusttasks.org/spec/push/wake/0.2#response",
"payload": { "status": "delivered" },
});
assert_eq!(
wake_reply_status(&reply),
Some(push_wake::ResponseStatus::Delivered)
);
}
#[cfg(feature = "didcomm")]
#[test]
fn wake_reply_status_rejects_retired_kebab_case_and_junk() {
let legacy = json!({ "payload": { "status": "token-unregistered" } });
assert_eq!(wake_reply_status(&legacy), None);
assert_eq!(wake_reply_status(&json!({})), None);
assert_eq!(wake_reply_status(&json!({ "payload": {} })), None);
}
#[test]
fn approver_mediator_routes_did_key_to_configured_mediator() {
assert_eq!(
route_for("did:key:z6MkApprover", None, Some("did:web:mediator")),
Some("did:web:mediator".to_string())
);
assert_eq!(route_for("did:key:z6MkApprover", None, None), None);
assert_eq!(route_for("did:key:z6MkApprover", None, Some("")), None);
assert_eq!(
route_for(
"did:key:z6MkApprover",
Some("did:web:other"),
Some("did:web:mediator")
),
Some("did:web:mediator".to_string())
);
}
#[test]
fn approver_mediator_routes_a_routable_did_to_its_own_mediator() {
assert_eq!(
route_for(
"did:webvh:scid:host:approver",
Some("did:web:their-mediator"),
Some("did:web:ours"),
),
Some("did:web:their-mediator".to_string())
);
assert_eq!(
route_for("did:peer:2.Ez6Mk", Some("did:web:their-mediator"), None),
Some("did:web:their-mediator".to_string())
);
}
#[test]
fn a_routable_approver_never_falls_back_to_our_mediator() {
assert_eq!(
route_for("did:webvh:scid:host:approver", None, Some("did:web:ours")),
None
);
assert_eq!(
route_for("did:peer:2.Ez6Mk", Some(""), Some("did:web:ours")),
None
);
}
#[tokio::test]
async fn minting_a_step_up_binds_a_pending_and_signs_the_request() {
use vti_common::auth::step_up::get_pending_step_up;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace(crate::keyspaces::SESSIONS).unwrap();
let sk = SigningKey::from_bytes(&[42u8; 32]);
let (vta_did, mb) = did_key(&sk);
let secret = issuer_secret(&sk, &format!("{vta_did}#{mb}"));
let v = json!({
"approveRequest": mint_pending_step_up(
&ks,
&vta_did,
&secret,
"did:key:zHolder",
"did:key:zHolder",
false,
"sess-9",
"rotate keys",
None,
None,
)
.await
.expect("mint succeeds"),
});
assert_eq!(
v["approveRequest"]["type"],
"https://trusttasks.org/spec/auth/step-up/approve-request/0.2"
);
assert_eq!(v["approveRequest"]["issuer"], vta_did);
assert_eq!(v["approveRequest"]["recipient"], "did:key:zHolder");
assert_eq!(v["approveRequest"]["payload"]["sessionId"], "sess-9");
assert_eq!(v["approveRequest"]["payload"]["targetAcr"], "aal2");
assert_eq!(v["approveRequest"]["payload"]["reason"], "rotate keys");
assert_eq!(
v["approveRequest"]["payload"]["acceptableEvidence"],
json!(["didSigned", "webauthn"])
);
let challenge = v["approveRequest"]["payload"]["challenge"]
.as_str()
.expect("challenge string");
assert!(
challenge.len() >= 16,
"challenge must carry ≥128 bits: {challenge}"
);
let proof = &v["approveRequest"]["proof"];
assert_eq!(proof["type"], "DataIntegrityProof", "{v}");
assert_eq!(proof["cryptosuite"], "eddsa-jcs-2022", "{v}");
assert_eq!(proof["proofPurpose"], "assertionMethod", "{v}");
let task: TrustTask<Value> = serde_json::from_value(v["approveRequest"].clone()).unwrap();
let signer = crate::auth::verify_trust_task_proof(&task)
.await
.expect("approve-request proof verifies");
assert_eq!(
signer, vta_did,
"issuer DID == proof verificationMethod DID"
);
let pending = get_pending_step_up(&ks, challenge).await.unwrap().unwrap();
assert_eq!(pending.session_id, "sess-9");
assert_eq!(pending.subject, "did:key:zHolder");
assert_eq!(pending.approver, "did:key:zHolder");
assert_eq!(pending.target_acr, "aal2");
assert_eq!(
pending.acceptable_evidence,
vec!["did-signed".to_string(), "webauthn".to_string()]
);
}
#[test]
fn reason_and_context_prefers_summary_and_passes_context_through() {
let no_ctx = json!({ "holder": "did:key:z" });
let (r, c) = reason_and_context(&no_ctx);
assert_eq!(r, DEFAULT_STEP_UP_REASON);
assert!(c.is_none());
let payload = json!({
"authorizationContext": {
"summary": "finance wants to share salaryBand with travel",
"action": { "kind": "share", "from": "finance", "to": "travel" }
}
});
let (r, c) = reason_and_context(&payload);
assert_eq!(r, "finance wants to share salaryBand with travel");
assert_eq!(c.unwrap()["action"]["kind"], "share");
let summariless = json!({ "authorizationContext": { "action": {} } });
let (r, c) = reason_and_context(&summariless);
assert_eq!(r, DEFAULT_STEP_UP_REASON);
assert!(c.is_some());
}
#[tokio::test]
async fn step_up_challenge_embeds_authorization_context() {
use vti_common::config::StoreConfig;
use vti_common::store::Store;
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace(crate::keyspaces::SESSIONS).unwrap();
let ctx = json!({
"type": "https://openvtc.org/cierge/authorization-context/0.1",
"summary": "finance wants to share salaryBand with travel",
"risk": "high",
"action": { "kind": "share", "from": "finance", "to": "travel", "ttlSeconds": 3600 },
});
let sk = SigningKey::from_bytes(&[43u8; 32]);
let (vta_did, mb) = did_key(&sk);
let secret = issuer_secret(&sk, &format!("{vta_did}#{mb}"));
let v = json!({
"approveRequest": mint_pending_step_up(
&ks,
&vta_did,
&secret,
"did:key:zHolder",
"did:key:zHolder",
false,
"sess-ctx",
"finance wants to share salaryBand with travel",
Some(&ctx),
None,
)
.await
.expect("mint succeeds"),
});
let task: TrustTask<Value> = serde_json::from_value(v["approveRequest"].clone()).unwrap();
let signer = crate::auth::verify_trust_task_proof(&task)
.await
.expect("approve-request proof verifies over ext");
assert_eq!(signer, vta_did);
let payload = &v["approveRequest"]["payload"];
let ctx = &payload["ext"]["org.openvtc.authorization-context"];
assert_eq!(ctx["action"]["kind"], "share");
assert_eq!(ctx["risk"], "high");
assert_eq!(ctx["action"]["ttlSeconds"], 3600);
assert_eq!(
payload["reason"],
"finance wants to share salaryBand with travel"
);
}
use trust_tasks_rs::Proof;
fn did_key(sk: &SigningKey) -> (String, String) {
let pk = sk.verifying_key();
let mut mc = vec![0xed, 0x01];
mc.extend_from_slice(pk.as_bytes());
let mb = multibase::encode(Base::Base58Btc, mc);
(format!("did:key:{mb}"), mb)
}
fn issuer_secret(sk: &SigningKey, id: &str) -> Secret {
Secret::generate_ed25519(Some(id), Some(sk.as_bytes()))
}
fn signed_doc(sk: &SigningKey, subject: &str, vm: &str) -> TrustTask<Value> {
let doc_json = json!({
"id": "approve-resp-1",
"type": "https://trusttasks.org/spec/auth/step-up/approve-response/0.1",
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"issuer": subject,
"recipient": "did:web:vta.example",
"payload": {
"subject": subject,
"sessionId": "sess-1",
"challenge": "VHJhbnNmZXJDb25maXJtTm9uY2VYWQ",
"decision": "approved",
"grantedAcr": "aal2",
},
});
let mut doc: TrustTask<Value> = serde_json::from_value(doc_json).unwrap();
let mut di = DataIntegrityProof::new(
CryptoSuite::EddsaJcs2022,
vm.to_string(),
"assertionMethod".to_string(),
None,
Some("2026-05-31T00:00:00Z".to_string()),
None,
);
let input = prepare_sign_input(&doc, &di, CryptoSuite::EddsaJcs2022).unwrap();
let sig = sk.sign(&input);
di.proof_value = Some(multibase::encode(Base::Base58Btc, sig.to_bytes()));
let proof_json = serde_json::to_value(&di).unwrap();
doc.proof = Some(serde_json::from_value::<Proof>(proof_json).unwrap());
doc
}
#[tokio::test]
async fn verifies_a_did_signed_approve_response() {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let (did, mb) = did_key(&sk);
let vm = format!("{did}#{mb}");
let doc = signed_doc(&sk, &did, &vm);
assert_eq!(verify_did_signed_gate(&doc, &did).await, Ok(()));
}
#[tokio::test]
async fn rejects_when_proof_absent() {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let (did, mb) = did_key(&sk);
let vm = format!("{did}#{mb}");
let mut doc = signed_doc(&sk, &did, &vm);
doc.proof = None;
assert_eq!(
verify_did_signed_gate(&doc, &did).await,
Err(GateError::NoGate)
);
}
#[tokio::test]
async fn rejects_when_vm_did_is_not_the_subject() {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let (did, mb) = did_key(&sk);
let vm = format!("{did}#{mb}");
let doc = signed_doc(&sk, &did, &vm);
assert_eq!(
verify_did_signed_gate(&doc, "did:key:zSomeoneElse").await,
Err(GateError::SubjectMismatch)
);
}
#[tokio::test]
async fn rejects_a_tampered_document() {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let (did, mb) = did_key(&sk);
let vm = format!("{did}#{mb}");
let mut doc = signed_doc(&sk, &did, &vm);
doc.payload = json!({ "subject": did, "decision": "approved", "tampered": true });
assert!(matches!(
verify_did_signed_gate(&doc, &did).await,
Err(GateError::ProofInvalid(_))
));
}
}
#[cfg(all(test, feature = "didcomm", feature = "webvh"))]
mod envelope_push_tests {
use crate::messaging::registry::MediatorBinding;
use serde_json::json;
const MEDIATOR: &str = "did:example:mediator";
const APPROVER: &str = "did:key:zStepUpApprover";
const CALLER: &str = "did:key:zCaller";
#[tokio::test]
async fn delegated_step_up_push_is_an_envelope() {
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,
});
}
let approve_request = json!({
"id": "urn:uuid:11111111-1111-1111-1111-111111111111",
"type": super::STEP_UP_APPROVE_REQUEST_TYPE,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"issuer": "did:key:zVta",
"payload": { "subject": CALLER, "challenge": "c" },
});
super::maybe_push_step_up(&state, APPROVER, CALLER, &approve_request).await;
let pushed = state.mediator_registry.take_outbound(MEDIATOR).await;
assert_eq!(pushed.len(), 1, "the approver is pushed exactly once");
assert_eq!(
pushed[0].message_type,
trust_tasks_didcomm::ENVELOPE_TYPE,
"the DIDComm message must carry the binding's envelope type"
);
assert_eq!(
pushed[0].body.get("type").and_then(|t| t.as_str()),
Some(super::STEP_UP_APPROVE_REQUEST_TYPE),
"the task type belongs in the enveloped document, not on the envelope"
);
assert_eq!(pushed[0].recipient_did, APPROVER);
}
#[tokio::test]
async fn self_approval_pushes_nothing() {
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;
super::maybe_push_step_up(&state, CALLER, CALLER, &json!({})).await;
assert!(
state
.mediator_registry
.take_outbound(MEDIATOR)
.await
.is_empty(),
"a caller satisfying its own step-up must not ring its own phone"
);
}
}