use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracing::{info, warn};
use trust_tasks_rs::specs::auth::step_up::approve_request::v0_4 as approve_request;
use trust_tasks_rs::specs::auth::step_up::approve_response::v0_4 as approve_response;
use trust_tasks_rs::specs::auth::step_up::approver::attest::v0_1 as attest;
use vti_common::audit::{AuditEvent, OperationStepUpApprovedData, OperationStepUpData};
use vti_common::auth::passkey::store::{
get_passkey_user_by_cred, get_passkey_user_by_did, store_passkey_user,
};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use webauthn_rs::prelude::{Passkey, PasskeyAuthentication, PublicKeyCredential};
use crate::auth::session::now_epoch;
use crate::server::AppState;
pub const MARK_TTL_SECS: u64 = 300;
pub const EVIDENCE_WEBAUTHN: &str = "webauthn";
pub const EVIDENCE_APPROVER_SIGNED: &str = "approverSigned";
const DIGEST_DOMAIN: &[u8] = b"vtc/step-up/v1\0";
const PENDING_PREFIX: &str = "pending:";
const MARK_PREFIX: &str = "mark:";
pub fn operation_digest(type_uri: &str, payload: &Value) -> Result<String, AppError> {
digest_with(type_uri, payload, None)
}
pub fn wire_digest(type_uri: &str, payload: &Value, challenge: &str) -> Result<String, AppError> {
digest_with(type_uri, payload, Some(challenge))
}
fn digest_with(
type_uri: &str,
payload: &Value,
challenge: Option<&str>,
) -> Result<String, AppError> {
vti_common::task_consent::domain_digest(DIGEST_DOMAIN, type_uri, payload, challenge)
}
pub type ApproveRequest = approve_request::Payload;
#[derive(Serialize, Deserialize)]
struct PendingMark {
admin_did: String,
digest: String,
bound_to: String,
type_uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
auth_state: Option<PasskeyAuthentication>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
approvers: Vec<String>,
#[serde(default)]
created_at: u64,
expires_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
requester: Option<String>,
}
impl PendingMark {
fn created_at(&self) -> u64 {
if self.created_at == 0 {
self.expires_at.saturating_sub(MARK_TTL_SECS)
} else {
self.created_at
}
}
}
impl std::fmt::Debug for PendingMark {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingMark")
.field("admin_did", &self.admin_did)
.field("bound_to", &self.bound_to)
.field("type_uri", &self.type_uri)
.field("approvers", &self.approvers)
.field("expires_at", &self.expires_at)
.field("requester", &self.requester)
.finish_non_exhaustive()
}
}
#[derive(Debug, Serialize, Deserialize)]
struct RedeemableMark {
expires_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
credential_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bound_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
challenge: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
created_at: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepUpEvidence {
pub kind: String,
pub credential_id: String,
pub bound_to: String,
}
impl From<StepUpEvidence> for vti_common::audit::StepUpEvidence {
fn from(e: StepUpEvidence) -> Self {
Self {
kind: e.kind,
credential_id: e.credential_id,
bound_to: e.bound_to,
}
}
}
#[derive(Debug, Clone)]
pub struct MarkInfo {
pub challenge: String,
pub created_at: u64,
pub expires_at: u64,
pub evidence: StepUpEvidence,
}
#[derive(Debug)]
pub enum EvidencedGate {
Satisfied(StepUpEvidence),
Required(Box<approve_request::Payload>),
}
async fn take<T: serde::de::DeserializeOwned>(
ks: &KeyspaceHandle,
key: String,
) -> Result<Option<T>, AppError> {
ks.take_raw(key)
.await?
.map(|raw| serde_json::from_slice(&raw))
.transpose()
.map_err(|e| AppError::Internal(format!("step-up mark does not parse: {e}")))
}
fn pending_key(challenge: &str) -> String {
format!("{PENDING_PREFIX}{challenge}")
}
fn mark_key(admin_did: &str, digest: &str) -> String {
format!("{MARK_PREFIX}{admin_did}:{digest}")
}
#[derive(Debug)]
pub enum Gate {
Satisfied,
Required(Box<approve_request::Payload>),
}
pub async fn redeem_or_request(
state: &AppState,
admin_did: &str,
type_uri: &str,
payload: &Value,
reason: &str,
) -> Result<Gate, AppError> {
Ok(
match redeem_or_request_with_evidence(state, admin_did, type_uri, payload, reason).await? {
EvidencedGate::Satisfied(_) => Gate::Satisfied,
EvidencedGate::Required(r) => Gate::Required(r),
},
)
}
pub async fn redeem_or_request_with_evidence(
state: &AppState,
admin_did: &str,
type_uri: &str,
payload: &Value,
reason: &str,
) -> Result<EvidencedGate, AppError> {
let ks = &state.step_up_marks_ks;
let digest = operation_digest(type_uri, payload)?;
let key = mark_key(admin_did, &digest);
if let Some(mark) = take::<RedeemableMark>(ks, key).await?
&& now_epoch() < mark.expires_at
{
info!(admin = %admin_did, task = %type_uri, "operation-bound step-up spent");
return Ok(EvidencedGate::Satisfied(StepUpEvidence {
kind: mark.kind.unwrap_or_else(|| EVIDENCE_WEBAUTHN.into()),
credential_id: mark.credential_id.unwrap_or_default(),
bound_to: mark.bound_to.unwrap_or_default(),
}));
}
let request = request_step_up(state, admin_did, type_uri, payload, reason).await?;
Ok(EvidencedGate::Required(Box::new(request)))
}
pub(crate) struct Factors {
pub approvers: Vec<String>,
pub passkeys: Vec<Passkey>,
}
impl Factors {
pub fn is_empty(&self) -> bool {
self.approvers.is_empty() && self.passkeys.is_empty()
}
}
pub(crate) async fn factors_of(state: &AppState, admin_did: &str) -> Result<Factors, AppError> {
let approvers: Vec<String> = super::approver::live_approvers(state, admin_did)
.await?
.into_iter()
.map(|r| r.approver_did)
.collect();
let step_up_passkeys = crate::step_up_passkey::credentials_of(state, admin_did).await?;
let passkeys = if approvers.is_empty() && step_up_passkeys.is_empty() {
get_passkey_user_by_did(&state.passkey_ks, admin_did)
.await?
.map(|u| u.credentials)
.unwrap_or_default()
} else {
step_up_passkeys
};
Ok(Factors {
approvers,
passkeys,
})
}
async fn holds_dedicated_factor(state: &AppState, admin_did: &str) -> Result<bool, AppError> {
Ok(!super::approver::live_approvers(state, admin_did)
.await?
.is_empty()
|| !crate::step_up_passkey::credentials_of(state, admin_did)
.await?
.is_empty())
}
fn fresh_challenge() -> String {
use base64::Engine as _;
use rand::Rng as _;
let mut raw = [0u8; 32];
rand::rng().fill_bytes(&mut raw);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw)
}
pub(crate) async fn request_step_up(
state: &AppState,
admin_did: &str,
type_uri: &str,
payload: &Value,
reason: &str,
) -> Result<approve_request::Payload, AppError> {
let factors = factors_of(state, admin_did).await?;
let webauthn = state
.webauthn
.as_ref()
.filter(|_| !factors.passkeys.is_empty());
if factors.approvers.is_empty() && webauthn.is_none() {
return Err(AppError::StepUpRequired(if factors.passkeys.is_empty() {
format!(
"this operation needs a step-up from {admin_did}, who holds no step-up factor at \
this community (no step-up approver and no passkey). Another community \
administrator can invite them to enrol an approver (Members → the member → \
Invite to enrol an approver), or the operator can mint that invite on the host \
with `vtc admin enrol-approver --did {admin_did}` while the daemon is stopped; \
then retry"
)
} else {
"this operation needs a passkey gesture, and this community has no WebAuthn \
relying party configured"
.into()
}));
}
let (challenge, auth_state, options) = match webauthn {
Some(webauthn) => {
let (rcr, auth_state) = webauthn
.start_passkey_authentication(&factors.passkeys)
.map_err(|e| {
AppError::Internal(format!("webauthn authentication start failed: {e}"))
})?;
let options = serde_json::to_value(&rcr.public_key)
.map_err(|e| AppError::Internal(format!("webauthn options serialise: {e}")))?;
let challenge = options["challenge"]
.as_str()
.ok_or_else(|| AppError::Internal("webauthn options carry no challenge".into()))?
.to_string();
(challenge, Some(auth_state), Some(options))
}
None => (fresh_challenge(), None, None),
};
let bound_to = wire_digest(type_uri, payload, &challenge)?;
let now = now_epoch();
let pending = PendingMark {
admin_did: admin_did.to_string(),
digest: operation_digest(type_uri, payload)?,
bound_to: bound_to.clone(),
type_uri: type_uri.to_string(),
auth_state,
approvers: factors.approvers.clone(),
created_at: now,
expires_at: now.saturating_add(MARK_TTL_SECS),
requester: None,
};
state
.step_up_marks_ks
.insert(pending_key(&challenge), &pending)
.await?;
let request = approve_request_payload(
admin_did,
&challenge,
&bound_to,
reason,
options.as_ref(),
&factors.approvers,
)?;
info!(
admin = %admin_did,
task = %type_uri,
%bound_to,
approvers = factors.approvers.len(),
webauthn = options.is_some(),
"operation-bound step-up requested"
);
Ok(request)
}
pub async fn request_for_enrolment(
state: &AppState,
identity_did: &str,
requester: &str,
type_uri: &str,
payload: &Value,
reason: &str,
) -> Result<approve_request::Payload, AppError> {
let webauthn = state.webauthn.as_ref().ok_or_else(|| {
AppError::StepUpRequired(
"enrolling a signing key needs a passkey gesture, and this community has no \
WebAuthn relying party configured"
.into(),
)
})?;
let passkeys = vti_common::auth::passkey::store::get_all_passkeys(&state.passkey_ks).await?;
if passkeys.is_empty() {
return Err(AppError::StepUpRequired(
"enrolling a signing key needs a passkey gesture, and no passkey is registered \
with this community"
.into(),
));
}
let (rcr, auth_state) = webauthn
.start_passkey_authentication(&passkeys)
.map_err(|e| AppError::Internal(format!("webauthn authentication start failed: {e}")))?;
let options = serde_json::to_value(&rcr.public_key)
.map_err(|e| AppError::Internal(format!("webauthn options serialise: {e}")))?;
let challenge = options["challenge"]
.as_str()
.ok_or_else(|| AppError::Internal("webauthn options carry no challenge".into()))?
.to_string();
let bound_to = wire_digest(type_uri, payload, &challenge)?;
let now = now_epoch();
let pending = PendingMark {
admin_did: identity_did.to_string(),
digest: operation_digest(type_uri, payload)?,
bound_to: bound_to.clone(),
type_uri: type_uri.to_string(),
auth_state: Some(auth_state),
approvers: Vec::new(),
created_at: now,
expires_at: now.saturating_add(MARK_TTL_SECS),
requester: Some(requester.to_string()),
};
state
.step_up_marks_ks
.insert(pending_key(&challenge), &pending)
.await?;
info!(identity = %identity_did, %requester, %bound_to, "signing-key enrolment step-up requested");
approve_request_payload(
identity_did,
&challenge,
&bound_to,
reason,
Some(&options),
&[],
)
}
pub async fn spend_mark(
state: &AppState,
admin_did: &str,
type_uri: &str,
payload: &Value,
) -> Result<bool, AppError> {
let key = mark_key(admin_did, &operation_digest(type_uri, payload)?);
let ks = &state.step_up_marks_ks;
Ok(take::<RedeemableMark>(ks, key)
.await?
.is_some_and(|mark| now_epoch() < mark.expires_at))
}
pub async fn has_mark(
state: &AppState,
admin_did: &str,
type_uri: &str,
payload: &Value,
) -> Result<bool, AppError> {
Ok(peek_mark(state, admin_did, type_uri, payload)
.await?
.is_some())
}
pub async fn peek_mark(
state: &AppState,
admin_did: &str,
type_uri: &str,
payload: &Value,
) -> Result<Option<MarkInfo>, AppError> {
let digest = operation_digest(type_uri, payload)?;
let Some(mark) = state
.step_up_marks_ks
.get::<RedeemableMark>(mark_key(admin_did, &digest))
.await?
.filter(|mark| now_epoch() < mark.expires_at)
else {
return Ok(None);
};
let created_at = mark
.created_at
.unwrap_or_else(|| mark.expires_at.saturating_sub(2 * MARK_TTL_SECS));
Ok(Some(MarkInfo {
challenge: mark.challenge.unwrap_or_default(),
created_at,
expires_at: mark.expires_at,
evidence: StepUpEvidence {
kind: mark.kind.unwrap_or_else(|| EVIDENCE_WEBAUTHN.into()),
credential_id: mark.credential_id.unwrap_or_default(),
bound_to: mark.bound_to.unwrap_or_default(),
},
}))
}
fn approve_request_payload(
admin_did: &str,
challenge: &str,
bound_to: &str,
reason: &str,
options: Option<&Value>,
approvers: &[String],
) -> Result<approve_request::Payload, AppError> {
let mut accepts = Vec::new();
let mut value = json!({
"subject": admin_did,
"challenge": challenge,
"boundTo": bound_to,
"reason": reason,
"targetAcr": "aal2",
"ttl": MARK_TTL_SECS,
});
if !approvers.is_empty() {
accepts.push(EVIDENCE_APPROVER_SIGNED);
value["approvers"] = json!(approvers);
}
if let Some(options) = options {
let allow_credentials: Vec<Value> = options["allowCredentials"]
.as_array()
.map(|creds| {
creds
.iter()
.map(|c| json!({ "type": "public-key", "id": c["id"] }))
.collect()
})
.unwrap_or_default();
let mut webauthn = json!({
"challenge": challenge,
"allowCredentials": allow_credentials,
"userVerification": "required",
});
if let Some(rp_id) = options.get("rpId").filter(|v| v.is_string()) {
webauthn["rpId"] = rp_id.clone();
}
if let Some(timeout) = options.get("timeout").filter(|v| v.is_u64()) {
webauthn["timeout"] = timeout.clone();
}
accepts.push(EVIDENCE_WEBAUTHN);
value["webauthn"] = webauthn;
}
value["accepts"] = json!(accepts);
use trust_tasks_rs::validate::ValidatedPayload as _;
approve_request::Payload::validate_value(&value)
.map_err(|e| AppError::Internal(format!("approve-request does not conform: {e}")))?;
serde_json::from_value(value)
.map_err(|e| AppError::Internal(format!("approve-request does not parse: {e}")))
}
pub fn refusal_details(request: &approve_request::Payload) -> Value {
json!({ "stepUpRequest": request })
}
#[derive(Debug)]
pub enum ApproveError {
ChallengeUnknown,
ChallengeExpired,
SubjectMismatch,
NoGate,
AssertionInvalid(&'static str),
ProofRequired,
StatementInvalid,
ApproverNotBound,
Internal(AppError),
}
impl From<AppError> for ApproveError {
fn from(e: AppError) -> Self {
Self::Internal(e)
}
}
#[derive(Debug)]
pub enum Approved {
Recorded { bound_to: String },
Declined { reason: String },
}
pub async fn approve(
state: &AppState,
payload: &approve_response::Payload,
signed_by_subject: bool,
) -> Result<Approved, ApproveError> {
let ks = &state.step_up_marks_ks;
let challenge = payload.challenge.to_string();
let key = pending_key(&challenge);
let Some(pending) = take::<PendingMark>(ks, key).await? else {
return Err(ApproveError::ChallengeUnknown);
};
if now_epoch() >= pending.expires_at {
return Err(ApproveError::ChallengeExpired);
}
if payload.subject.as_str() != pending.admin_did || payload.session_id.is_some() {
return Err(ApproveError::SubjectMismatch);
}
if payload.decision == approve_response::PayloadDecision::Denied {
if !signed_by_subject {
return Err(ApproveError::ProofRequired);
}
info!(admin = %pending.admin_did, task = %pending.type_uri, "operation-bound step-up declined");
return Ok(Approved::Declined {
reason: payload
.denied_reason
.as_ref()
.map(|r| r.to_string())
.unwrap_or_else(|| "declined by the approver".to_string()),
});
}
let assertion = match payload.evidence.as_ref() {
Some(approve_response::Evidence::Webauthn(assertion)) => assertion,
_ => return Err(ApproveError::NoGate),
};
let Some(auth_state) = pending.auth_state.as_ref() else {
return Err(ApproveError::NoGate);
};
let credential = public_key_credential(assertion)?;
let webauthn = state.webauthn.as_ref().ok_or_else(|| {
ApproveError::Internal(AppError::Internal("WebAuthn not configured".into()))
})?;
let result = webauthn
.finish_passkey_authentication(&credential, auth_state)
.map_err(|e| {
warn!(admin = %pending.admin_did, error = %e, "operation-bound step-up assertion did not verify");
ApproveError::AssertionInvalid("verificationFailed")
})?;
if !result.user_verified() {
return Err(ApproveError::AssertionInvalid("userNotVerified"));
}
let cred_id_hex = hex::encode(<_ as AsRef<[u8]>>::as_ref(result.cred_id()));
let (mut user, step_up) =
match get_passkey_user_by_cred(&state.passkey_ks, &cred_id_hex).await? {
Some(u) => (u, false),
None => (
get_passkey_user_by_cred(&state.step_up_passkeys_ks, &cred_id_hex)
.await?
.ok_or(ApproveError::AssertionInvalid("credentialUnregistered"))?,
true,
),
};
if step_up && !signed_by_subject {
return Err(ApproveError::ProofRequired);
}
if user.did != pending.admin_did {
warn!(
admin = %pending.admin_did,
asserted = %user.did,
"operation-bound step-up refused: another subject's passkey answered"
);
return Err(ApproveError::AssertionInvalid("notSubjectPasskey"));
}
if !step_up && holds_dedicated_factor(state, &pending.admin_did).await? {
warn!(
admin = %pending.admin_did,
"operation-bound step-up refused: a session passkey answered for a subject who \
holds a dedicated step-up factor"
);
return Err(ApproveError::AssertionInvalid("notSubjectPasskey"));
}
if step_up {
crate::step_up_passkey::record_use(&state.step_up_passkeys_ks, user, &result).await?;
} else {
for cred in &mut user.credentials {
cred.update_credential(&result);
}
store_passkey_user(&state.passkey_ks, &user).await?;
}
let expires_at = now_epoch().saturating_add(MARK_TTL_SECS);
ks.insert(
mark_key(&pending.admin_did, &pending.digest),
&RedeemableMark {
expires_at,
credential_id: Some(cred_id_hex.clone()),
bound_to: Some(pending.bound_to.clone()),
kind: Some(EVIDENCE_WEBAUTHN.into()),
challenge: Some(challenge),
created_at: Some(pending.created_at()),
},
)
.await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&pending.admin_did,
None,
AuditEvent::OperationStepUpRecorded(OperationStepUpData {
task: pending.type_uri.clone(),
bound_to: pending.bound_to.clone(),
credential_id: cred_id_hex,
expires_at: DateTime::<Utc>::from_timestamp(expires_at as i64, 0)
.unwrap_or_default(),
}),
)
.await?;
}
info!(admin = %pending.admin_did, task = %pending.type_uri, bound_to = %pending.bound_to, "operation-bound step-up recorded");
Ok(Approved::Recorded {
bound_to: pending.bound_to,
})
}
pub async fn approve_with_statement(
state: &AppState,
subject: &str,
challenge: &str,
session_id_present: bool,
statement: &Value,
) -> Result<Approved, ApproveError> {
let ks = &state.step_up_marks_ks;
let Some(pending) = take::<PendingMark>(ks, pending_key(challenge)).await? else {
return Err(ApproveError::ChallengeUnknown);
};
if now_epoch() >= pending.expires_at {
return Err(ApproveError::ChallengeExpired);
}
if subject != pending.admin_did || session_id_present {
return Err(ApproveError::SubjectMismatch);
}
if pending.approvers.is_empty() || pending.requester.is_some() {
return Err(ApproveError::NoGate);
}
let ts = |t: u64| DateTime::<Utc>::from_timestamp(t as i64, 0).unwrap_or_default();
let expected = super::approver::ExpectedStatement {
purpose: attest::PayloadPurpose::StepUp,
subject: &pending.admin_did,
challenge,
bound_to: &pending.bound_to,
not_before: ts(pending.created_at()),
not_after: ts(pending.expires_at),
approver: super::approver::ExpectedApprover::BoundAmong(&pending.approvers),
};
let verified = super::approver::verify_statement(state, statement, &expected)
.await
.map_err(|e| match e {
super::approver::StatementError::Invalid(_) => ApproveError::StatementInvalid,
super::approver::StatementError::NotBound => ApproveError::ApproverNotBound,
super::approver::StatementError::Internal(e) => ApproveError::Internal(e),
})?;
let approver_did = verified.approver_did().to_string();
let expires_at = now_epoch().saturating_add(MARK_TTL_SECS);
ks.insert(
mark_key(&pending.admin_did, &pending.digest),
&RedeemableMark {
expires_at,
credential_id: Some(approver_did.clone()),
bound_to: Some(pending.bound_to.clone()),
kind: Some(EVIDENCE_APPROVER_SIGNED.into()),
challenge: Some(challenge.to_string()),
created_at: Some(pending.created_at()),
},
)
.await?;
super::approver::record_use(&state.step_up_approvers_ks, &approver_did).await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&pending.admin_did,
None,
AuditEvent::OperationStepUpApproved(OperationStepUpApprovedData {
task: pending.type_uri.clone(),
bound_to: pending.bound_to.clone(),
evidence_kind: EVIDENCE_APPROVER_SIGNED.into(),
approver_did: approver_did.clone(),
expires_at: ts(expires_at),
}),
)
.await?;
}
info!(
admin = %pending.admin_did,
task = %pending.type_uri,
bound_to = %pending.bound_to,
approver = %approver_did,
"operation-bound step-up recorded from a step-up approver's statement"
);
Ok(Approved::Recorded {
bound_to: pending.bound_to,
})
}
fn public_key_credential(
assertion: &approve_response::AssertionResponse,
) -> Result<PublicKeyCredential, ApproveError> {
let r = &assertion.response;
let mut response = json!({
"authenticatorData": r.authenticator_data,
"clientDataJSON": r.client_data_json,
"signature": r.signature,
});
if let Some(handle) = &r.user_handle {
response["userHandle"] = json!(handle);
}
let value = json!({
"id": assertion.id,
"rawId": assertion.raw_id,
"response": response,
"type": "public-key",
"extensions": {},
});
serde_json::from_value(value).map_err(|e| {
warn!(error = %e, "operation-bound step-up assertion does not parse");
ApproveError::AssertionInvalid("unparseable")
})
}
pub(crate) async fn verify_assertion_over_challenge(
state: &AppState,
did: &str,
challenge: &[u8],
assertion: &Value,
) -> Result<String, &'static str> {
use base64::Engine as _;
let webauthn = state.webauthn.as_ref().ok_or("noRelyingParty")?;
let mut passkeys = crate::step_up_passkey::credentials_of(state, did)
.await
.map_err(|_| "internal")?;
passkeys.extend(
get_passkey_user_by_did(&state.passkey_ks, did)
.await
.map_err(|_| "internal")?
.map(|u| u.credentials)
.unwrap_or_default(),
);
if passkeys.is_empty() {
return Err("noPasskey");
}
let (_, auth_state) = webauthn
.start_passkey_authentication(&passkeys)
.map_err(|_| "internal")?;
let mut state_json = serde_json::to_value(&auth_state).map_err(|_| "internal")?;
let slot = state_json.pointer_mut("/ast/challenge").ok_or("internal")?;
*slot = Value::String(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(challenge));
let auth_state: PasskeyAuthentication =
serde_json::from_value(state_json).map_err(|_| "internal")?;
let r = &assertion["response"];
let mut response = json!({
"authenticatorData": r["authenticatorData"],
"clientDataJSON": r["clientDataJSON"],
"signature": r["signature"],
});
if r["userHandle"].is_string() {
response["userHandle"] = r["userHandle"].clone();
}
let credential: PublicKeyCredential = serde_json::from_value(json!({
"id": assertion["id"],
"rawId": assertion["rawId"],
"response": response,
"type": "public-key",
"extensions": {},
}))
.map_err(|_| "unparseable")?;
let result = webauthn
.finish_passkey_authentication(&credential, &auth_state)
.map_err(|e| {
warn!(%did, error = %e, "decision evidence assertion did not verify");
"verificationFailed"
})?;
if !result.user_verified() {
return Err("userNotVerified");
}
let cred_id_hex = hex::encode(<_ as AsRef<[u8]>>::as_ref(result.cred_id()));
if let Ok(Some(mut user)) = get_passkey_user_by_cred(&state.passkey_ks, &cred_id_hex).await
&& user.did == did
{
for cred in &mut user.credentials {
cred.update_credential(&result);
}
store_passkey_user(&state.passkey_ks, &user)
.await
.map_err(|_| "internal")?;
} else if let Ok(Some(user)) =
get_passkey_user_by_cred(&state.step_up_passkeys_ks, &cred_id_hex).await
&& user.did == did
{
crate::step_up_passkey::record_use(&state.step_up_passkeys_ks, user, &result)
.await
.map_err(|_| "internal")?;
} else {
return Err("credentialUnregistered");
}
Ok(cred_id_hex)
}
pub async fn sweep_expired(ks: &KeyspaceHandle, now: DateTime<Utc>) -> Result<usize, AppError> {
let now = now.timestamp().max(0) as u64;
let mut removed = 0;
for (prefix, expiry) in [
(
PENDING_PREFIX,
expiry_of::<PendingMark> as fn(&[u8]) -> Option<u64>,
),
(MARK_PREFIX, expiry_of::<RedeemableMark>),
] {
for (key, value) in ks.prefix_iter_raw(prefix.as_bytes().to_vec()).await? {
if expiry(&value).is_none_or(|at| now >= at) {
ks.remove(key).await?;
removed += 1;
}
}
}
Ok(removed)
}
trait Expiring {
fn expires_at(&self) -> u64;
}
impl Expiring for PendingMark {
fn expires_at(&self) -> u64 {
self.expires_at
}
}
impl Expiring for RedeemableMark {
fn expires_at(&self) -> u64 {
self.expires_at
}
}
fn expiry_of<T: Expiring + serde::de::DeserializeOwned>(bytes: &[u8]) -> Option<u64> {
serde_json::from_slice::<T>(bytes)
.ok()
.map(|m| m.expires_at())
}
#[cfg(test)]
pub async fn record_mark_for_test(
state: &AppState,
admin_did: &str,
type_uri: &str,
payload: &Value,
) -> Result<(), AppError> {
let digest = operation_digest(type_uri, payload)?;
let now = now_epoch();
state
.step_up_marks_ks
.insert(
mark_key(admin_did, &digest),
&RedeemableMark {
expires_at: now.saturating_add(MARK_TTL_SECS),
credential_id: Some("c0ffee".into()),
bound_to: Some("zTestBound".into()),
kind: Some(EVIDENCE_WEBAUTHN.into()),
challenge: Some("dGVzdC1jaGFsbGVuZ2UtMDEyMzQ1Njc4OQ".into()),
created_at: Some(now),
},
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
const GRANT: &str = "https://trusttasks.org/spec/acl/grant/0.1";
const CHANGE_ROLE: &str = "https://trusttasks.org/spec/acl/change-role/0.1";
fn payload(subject: &str) -> Value {
json!({ "entry": { "subject": subject, "role": "admin", "scopes": [] } })
}
#[test]
fn the_digest_is_stable_across_member_order() {
let a = json!({ "entry": { "role": "admin", "subject": "did:key:z1", "scopes": [] } });
let b = payload("did:key:z1");
assert_eq!(
operation_digest(GRANT, &a).unwrap(),
operation_digest(GRANT, &b).unwrap()
);
}
#[test]
fn the_digest_binds_the_payload_and_the_task() {
let p = payload("did:key:z1");
let d = operation_digest(GRANT, &p).unwrap();
assert_ne!(d, operation_digest(GRANT, &payload("did:key:z2")).unwrap());
assert_ne!(
d,
operation_digest(CHANGE_ROLE, &p).unwrap(),
"a gesture for one task must not authorize another with the same payload"
);
}
#[test]
fn the_wire_digest_is_salted_and_never_the_internal_one() {
let p = payload("did:key:z1");
let internal = operation_digest(GRANT, &p).unwrap();
let a = wire_digest(GRANT, &p, "challenge-a").unwrap();
let b = wire_digest(GRANT, &p, "challenge-b").unwrap();
assert_ne!(a, internal);
assert_ne!(
a, b,
"the salt is the challenge, so two requests do not link"
);
}
#[test]
fn the_digest_is_a_base58btc_sha256_multihash() {
let d = operation_digest(GRANT, &payload("did:key:z1")).unwrap();
let (base, bytes) = multibase::decode(&d).unwrap();
assert_eq!(base, multibase::Base::Base58Btc);
assert_eq!(&bytes[..2], &[0x12, 0x20], "sha2-256 multihash prefix");
assert_eq!(bytes.len(), 34);
}
fn options() -> Value {
json!({
"challenge": "Y2hhbGxlbmdlLWNoYWxsZW5nZS1jaGFsbGVuZ2U",
"rpId": "vtc.example.com",
"timeout": 60000,
"allowCredentials": [{ "type": "public-key", "id": "Y3JlZA", "transports": ["usb"] }],
"userVerification": "preferred",
"hints": [],
})
}
#[test]
fn the_inline_request_conforms_and_carries_no_session() {
let req = approve_request_payload(
"did:key:zAdmin",
"Y2hhbGxlbmdlLWNoYWxsZW5nZS1jaGFsbGVuZ2U",
"zBound",
"Grant the administrator role to did:key:zOther",
Some(&options()),
&[],
)
.expect("a conforming approve-request");
let v = serde_json::to_value(&req).unwrap();
assert!(v.get("sessionId").is_none(), "{v}");
assert_eq!(v["boundTo"], "zBound");
assert_eq!(v["webauthn"]["challenge"], v["challenge"]);
assert_eq!(v["webauthn"]["userVerification"], "required");
assert_eq!(v["accepts"], json!(["webauthn"]));
assert!(v.get("approvers").is_none(), "{v}");
}
#[test]
fn vti_apv_015_the_inline_request_offers_exactly_the_factors_held() {
let approver = "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH".to_string();
let only = approve_request_payload(
"did:webvh:QmScid:wallet.example:alice",
"Y2hhbGxlbmdlLWNoYWxsZW5nZS1jaGFsbGVuZ2U",
"zBound",
"Grant the administrator role",
None,
std::slice::from_ref(&approver),
)
.expect("conforms");
let v = serde_json::to_value(&only).unwrap();
assert_eq!(v["accepts"], json!(["approverSigned"]));
assert_eq!(v["approvers"], json!([approver]));
assert!(v.get("webauthn").is_none(), "{v}");
let both = approve_request_payload(
"did:key:zAdmin",
"Y2hhbGxlbmdlLWNoYWxsZW5nZS1jaGFsbGVuZ2U",
"zBound",
"Grant the administrator role",
Some(&options()),
std::slice::from_ref(&approver),
)
.expect("conforms");
let v = serde_json::to_value(&both).unwrap();
assert_eq!(v["accepts"], json!(["approverSigned", "webauthn"]));
assert!(v.get("webauthn").is_some());
}
#[tokio::test]
async fn concurrent_spends_of_one_mark_succeed_once() {
let tv = crate::test_support::build_test_vtc().await;
let p = payload("did:key:zRaced");
record_mark_for_test(&tv.state, "did:key:zAdmin", GRANT, &p)
.await
.unwrap();
let spends = (0..16).map(|_| spend_mark(&tv.state, "did:key:zAdmin", GRANT, &p));
let won = futures_util::future::join_all(spends)
.await
.into_iter()
.filter(|r| matches!(r, Ok(true)))
.count();
assert_eq!(won, 1);
}
}