use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
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 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 crate::operations::step_up::{StepUpDecision, resolve_step_up};
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::di_proof::DiProofError;
let signer_did = crate::auth::di_proof::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:no_gate"),
GateError::SubjectMismatch => {
step_up_failure("auth/step-up/approve-response:subject_mismatch")
}
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:assertion_invalid");
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:subject_mismatch"),
);
};
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:challenge_unknown"),
);
}
Ok(ConsumeOutcome::Expired) => {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:challenge_expired"),
);
}
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:subject_mismatch"),
);
}
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:approver_unauthorized"),
);
}
};
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:approver_unauthorized"),
);
}
};
if !delegated_any_approver_covers(&issuer_entry, &subject_entry) {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:approver_unauthorized"),
);
}
} 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:approver_unauthorized"),
);
}
}
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.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),
}
}
};
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:acr_unsatisfied"),
);
}
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:challenge_unknown"),
);
}
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(); 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;
async fn mint_pending_step_up(
sessions_ks: &KeyspaceHandle,
vta_did: &str,
subject: &str,
recipient: &str,
approver_any: bool,
session_id: &str,
reason: &str,
) -> Result<Value, ()> {
let acceptable = vec!["did-signed".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 pending = new_pending_step_up(
challenge.clone(),
session_id,
subject,
recipient,
approver_any,
STEP_UP_TARGET_ACR,
acceptable.clone(),
STEP_UP_TTL_SECS,
);
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": "https://trusttasks.org/spec/auth/step-up/approve-request/0.1",
"issuer": vta_did,
"payload": {
"subject": subject,
"sessionId": session_id,
"challenge": challenge,
"reason": reason,
"targetAcr": STEP_UP_TARGET_ACR,
"acceptableEvidence": acceptable,
"ttl": STEP_UP_TTL_SECS,
},
});
if !approver_any && !recipient.is_empty() {
doc["recipient"] = json!(recipient);
}
Ok(doc)
}
pub(crate) async fn issue_step_up_challenge(
sessions_ks: &KeyspaceHandle,
vta_did: &str,
subject: &str,
recipient: &str,
approver_any: bool,
session_id: &str,
reason: &str,
) -> Response {
let approve_request = match mint_pending_step_up(
sessions_ks,
vta_did,
subject,
recipient,
approver_any,
session_id,
reason,
)
.await
{
Ok(ar) => ar,
Err(()) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[(axum::http::header::CONTENT_TYPE, "application/json")],
br#"{"error":"internal_error"}"#.to_vec(),
)
.into_response();
}
};
let body = json!({
"error": "step_up_required",
"requiredAcr": STEP_UP_TARGET_ACR,
"approveRequest": approve_request,
});
(
StatusCode::FORBIDDEN,
[(axum::http::header::CONTENT_TYPE, "application/json")],
serde_json::to_vec(&body).unwrap_or_default(),
)
.into_response()
}
fn step_up_denied_response() -> Response {
let body = json!({
"error": "step_up_required",
"requiredAcr": STEP_UP_TARGET_ACR,
"reason": "no step-up approver is configured for this subject; an operator must register one",
});
(
StatusCode::FORBIDDEN,
[(axum::http::header::CONTENT_TYPE, "application/json")],
serde_json::to_vec(&body).unwrap_or_default(),
)
.into_response()
}
#[cfg(feature = "didcomm")]
const STEP_UP_APPROVE_REQUEST_TYPE: &str =
"https://trusttasks.org/spec/auth/step-up/approve-request/0.1";
pub(super) fn approver_mediator(approver_did: &str, configured: Option<&str>) -> Option<String> {
if !approver_did.starts_with("did:key:") {
return None;
}
configured.filter(|m| !m.is_empty()).map(str::to_string)
}
async fn maybe_push_step_up(
state: &AppState,
recipient: &str,
caller_did: &str,
#[cfg_attr(not(feature = "didcomm"), allow(unused))] approve_request: &Value,
) {
if recipient == caller_did {
return; }
let mediator_did = {
let cfg = state.config.read().await;
approver_mediator(
recipient,
cfg.messaging.as_ref().map(|m| m.mediator_did.as_str()),
)
};
#[cfg_attr(not(feature = "didcomm"), 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 = "didcomm")]
{
let pending = crate::messaging::registry::PendingResponse {
recipient_did: recipient.to_string(),
message_type: STEP_UP_APPROVE_REQUEST_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"
);
}
}
#[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,
) {
const TRUST_TASK_ENVELOPE_TYPE: &str = "https://trusttasks.org/binding/didcomm/0.1/envelope";
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.1",
"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(_) => {
tracing::info!(gateway = %gateway, approver = %approver, "push/wake sent to gateway")
}
Err(e) => tracing::warn!(
error = %e, gateway = %gateway, approver = %approver,
"push/wake to gateway failed (best-effort)"
),
}
});
}
pub(super) async fn require_step_up(
state: &AppState,
auth: &AuthClaims,
op_class: &str,
doc: &TrustTask<Value>,
) -> Option<TrustTaskOutcome> {
if auth.acr == STEP_UP_TARGET_ACR {
return None;
}
let (recipient, approver_any) =
match resolve_step_up(&state.config, &state.acl_ks, op_class, &auth.did, false).await {
StepUpDecision::Allow => return None,
StepUpDecision::Require { recipient } => (recipient, false),
StepUpDecision::RequireAny => (String::new(), true),
StepUpDecision::Deny => {
return Some(reject_with(
doc,
RejectReason::TaskFailed {
reason: "auth:step_up_required".to_string(),
details: Some(json!({
"requiredAcr": STEP_UP_TARGET_ACR,
"reason": "no step-up approver is configured for this subject",
})),
},
));
}
};
let vta_did = state
.config
.read()
.await
.vta_did
.clone()
.unwrap_or_default();
let reject = match mint_pending_step_up(
&state.sessions_ks,
&vta_did,
&auth.did,
&recipient,
approver_any,
&auth.session_id,
"this operation requires a stepped-up (AAL2) session",
)
.await
{
Ok(approve_request) => {
if !approver_any {
maybe_push_step_up(state, &recipient, &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(),
},
};
Some(reject_with(doc, reject))
}
pub use crate::operations::step_up::op;
pub trait StepUpOp {
const OP_CLASS: &'static str;
const IS_NON_ESCALATING: bool = false;
}
macro_rules! step_up_op {
($name:ident, $class:expr) => {
pub struct $name;
impl StepUpOp for $name {
const OP_CLASS: &'static str = $class;
}
};
($name:ident, $class:expr, non_escalating) => {
pub struct $name;
impl StepUpOp for $name {
const OP_CLASS: &'static str = $class;
const IS_NON_ESCALATING: bool = true;
}
};
}
step_up_op!(AclGrantOp, op::ACL_GRANT);
step_up_op!(AclChangeRoleOp, op::ACL_CHANGE_ROLE);
step_up_op!(AclRevokeOp, op::ACL_REVOKE);
step_up_op!(AclSwapKeyOp, op::ACL_SWAP_KEY, non_escalating);
step_up_op!(ContextDeleteOp, op::CONTEXT_DELETE);
pub struct RequireStepUp<O: StepUpOp>(std::marker::PhantomData<O>);
impl<O: StepUpOp> FromRequestParts<AppState> for RequireStepUp<O> {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Response> {
let claims = AuthClaims::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
if claims.acr == "aal2" {
return Ok(RequireStepUp(std::marker::PhantomData));
}
let (recipient, approver_any) = match resolve_step_up(
&state.config,
&state.acl_ks,
O::OP_CLASS,
&claims.did,
O::IS_NON_ESCALATING,
)
.await
{
StepUpDecision::Allow => return Ok(RequireStepUp(std::marker::PhantomData)),
StepUpDecision::Require { recipient } => (recipient, false),
StepUpDecision::RequireAny => (String::new(), true),
StepUpDecision::Deny => return Err(step_up_denied_response()),
};
let vta_did = state
.config
.read()
.await
.vta_did
.clone()
.unwrap_or_default();
Err(issue_step_up_challenge(
&state.sessions_ks,
&vta_did,
&claims.did,
&recipient,
approver_any,
&claims.session_id,
"this operation requires a stepped-up (AAL2) session",
)
.await)
}
}
#[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 http_body_util::BodyExt;
use multibase::Base;
use serde_json::json;
#[test]
fn approver_mediator_routes_did_key_to_configured_mediator() {
assert_eq!(
approver_mediator("did:key:z6MkApprover", Some("did:web:mediator")),
Some("did:web:mediator".to_string())
);
assert_eq!(approver_mediator("did:key:z6MkApprover", None), None);
assert_eq!(approver_mediator("did:key:z6MkApprover", Some("")), None);
assert_eq!(
approver_mediator("did:webvh:scid:host:approver", Some("did:web:mediator")),
None
);
}
#[tokio::test]
async fn issue_step_up_challenge_mints_pending_and_403s() {
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 resp = issue_step_up_challenge(
&ks,
"did:web:vta.example",
"did:key:zHolder",
"did:key:zHolder",
false,
"sess-9",
"rotate keys",
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["error"], "step_up_required");
assert_eq!(v["requiredAcr"], "aal2");
assert_eq!(
v["approveRequest"]["type"],
"https://trusttasks.org/spec/auth/step-up/approve-request/0.1"
);
assert_eq!(v["approveRequest"]["issuer"], "did:web:vta.example");
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");
let challenge = v["approveRequest"]["payload"]["challenge"]
.as_str()
.expect("challenge string");
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()]
);
}
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 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",
"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(_))
));
}
}