use affinidi_tdk::didcomm::Message;
use crate::acl::check_acl_full;
use crate::auth::AuthClaims;
use crate::auth::session::{now_epoch, resolve_did_session};
use crate::error::AppError;
use crate::store::KeyspaceHandle;
pub async fn auth_from_message(
msg: &Message,
acl_ks: &KeyspaceHandle,
sessions_ks: &KeyspaceHandle,
) -> Result<AuthClaims, AppError> {
let did = msg
.from
.as_deref()
.ok_or_else(|| AppError::Authentication("message has no sender (from)".into()))?;
auth_from_did(did, acl_ks, sessions_ks).await
}
pub async fn auth_for_trust_task_envelope(
state: &crate::server::AppState,
sender_did: &str,
body: &[u8],
) -> Result<AuthClaims, AppError> {
use crate::trust_tasks::ceremony;
let denial = match auth_from_did(sender_did, &state.acl_ks, &state.sessions_ks).await {
Ok(auth) => return Ok(auth),
Err(AppError::Forbidden(why)) => why,
Err(e) => return Err(e),
};
let type_uri = ceremony::peek_type_uri(body);
match type_uri.as_deref() {
Some(uri)
if ceremony::is_ceremony_task(uri)
&& ceremony::may_attempt_ceremony(state, uri, sender_did).await =>
{
tracing::info!(
sender = %sender_did,
type_uri = %uri,
acl = %denial,
"ceremony task from a sender with no ACL standing — dispatching on a \
zero-authority claim; the document's own proof is the authority"
);
Ok(ceremony::ceremony_claims(sender_did))
}
other => {
let uri = other.unwrap_or("<unparseable>");
if ceremony::is_ceremony_task(uri) {
tracing::warn!(
sender = %sender_did,
type_uri = %uri,
"refusing a ceremony task: this sender is in no configured approver set \
and has no ACL entry ({denial}). If this is an approver device, add it to \
the approver set the policy names"
);
} else {
tracing::warn!(
sender = %sender_did,
type_uri = %uri,
"refusing trust task: {denial}"
);
}
Err(AppError::Forbidden(denial))
}
}
}
pub async fn auth_from_did(
did: &str,
acl_ks: &KeyspaceHandle,
sessions_ks: &KeyspaceHandle,
) -> Result<AuthClaims, AppError> {
let base_did = did.split('#').next().unwrap_or(did);
let (role, allowed_contexts) = check_acl_full(acl_ks, base_did).await?;
let session = resolve_did_session(sessions_ks, base_did, now_epoch()).await?;
Ok(AuthClaims {
did: base_did.to_string(),
role,
allowed_contexts,
session_id: session.session_id,
access_expires_at: 0,
issued_at: session.created_at,
amr: session.amr,
acr: session.acr,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::{AclEntry, Role, store_acl_entry};
use crate::auth::session::now_epoch;
use crate::store::Store;
use vti_common::config::StoreConfig;
fn message_from(did: &str) -> Message {
Message::build(
"test-id".to_string(),
"https://example.com/test/1.0/ping".to_string(),
serde_json::json!({}),
)
.from(did.to_string())
.finalize()
}
async fn fresh_acl_ks() -> (Store, KeyspaceHandle, KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let acl_ks = store.keyspace(crate::keyspaces::ACL).unwrap();
let sessions_ks = store.keyspace(crate::keyspaces::SESSIONS).unwrap();
(store, acl_ks, sessions_ks, dir)
}
#[tokio::test]
async fn rejects_expired_entry() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let did = "did:key:zExpired";
store_acl_entry(
&acl_ks,
&AclEntry::new(did, Role::Admin, "test")
.with_contexts(vec!["ctx-a".into()])
.with_created_at(now_epoch().saturating_sub(7200))
.with_expires_at(Some(now_epoch().saturating_sub(60))), )
.await
.unwrap();
let msg = message_from(did);
let err = auth_from_message(&msg, &acl_ks, &sessions_ks)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Forbidden(ref m) if m.contains("expired")),
"expected Forbidden(expired), got {err:?}"
);
}
#[tokio::test]
async fn accepts_unexpired_entry_with_role_and_contexts() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let did = "did:key:zLive";
store_acl_entry(
&acl_ks,
&AclEntry::new(did, Role::Admin, "test")
.with_contexts(vec!["ctx-a".into(), "ctx-b".into()])
.with_expires_at(Some(now_epoch() + 3600)),
)
.await
.unwrap();
let msg = message_from(did);
let claims = auth_from_message(&msg, &acl_ks, &sessions_ks)
.await
.unwrap();
assert_eq!(claims.did, did);
assert_eq!(claims.role, Role::Admin);
assert_eq!(claims.allowed_contexts, vec!["ctx-a", "ctx-b"]);
}
#[tokio::test]
async fn fragment_in_sender_collapses_to_base_did() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let base = "did:key:zBase";
store_acl_entry(&acl_ks, &AclEntry::new(base, Role::Reader, "test"))
.await
.unwrap();
let msg = message_from(&format!("{base}#zBase"));
let claims = auth_from_message(&msg, &acl_ks, &sessions_ks)
.await
.unwrap();
assert_eq!(claims.did, base);
}
#[tokio::test]
async fn auth_from_did_resolves_role_and_contexts() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let did = "did:key:zDidCore";
store_acl_entry(
&acl_ks,
&AclEntry::new(did, Role::Admin, "test")
.with_contexts(vec!["ctx-a".into(), "ctx-b".into()])
.with_expires_at(Some(now_epoch() + 3600)),
)
.await
.unwrap();
let claims = auth_from_did(did, &acl_ks, &sessions_ks).await.unwrap();
assert_eq!(claims.did, did);
assert_eq!(claims.role, Role::Admin);
assert_eq!(claims.allowed_contexts, vec!["ctx-a", "ctx-b"]);
}
#[tokio::test]
async fn auth_from_did_unknown_did_errors() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let err = auth_from_did("did:key:zUnknownPeer", &acl_ks, &sessions_ks)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Forbidden(_) | AppError::NotFound(_)),
"expected unauthorized-class error, got {err:?}"
);
}
#[tokio::test]
async fn auth_from_did_fragment_collapses() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let base = "did:key:zCoreBase";
store_acl_entry(&acl_ks, &AclEntry::new(base, Role::Reader, "test"))
.await
.unwrap();
let claims = auth_from_did(&format!("{base}#zCoreBase"), &acl_ks, &sessions_ks)
.await
.unwrap();
assert_eq!(claims.did, base);
}
#[tokio::test]
async fn missing_sender_is_authentication_error() {
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let mut msg = message_from("did:key:zAnything");
msg.from = None;
let err = auth_from_message(&msg, &acl_ks, &sessions_ks)
.await
.unwrap_err();
assert!(matches!(err, AppError::Authentication(_)), "got {err:?}");
}
const DECISION: &str = vta_sdk::trust_tasks::TASK_TASK_CONSENT_DECISION_0_1;
const STEP_UP: &str = vta_sdk::trust_tasks::TASK_AUTH_STEP_UP_APPROVE_RESPONSE_0_2;
const ORDINARY: &str = "https://trusttasks.org/spec/vta/webvh/dids/update/1.0";
const APPROVER: &str = "did:key:zApproverNotInAcl";
fn envelope(type_uri: &str) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"id": "urn:uuid:00000000-0000-0000-0000-000000000001",
"type": type_uri,
"issuer": APPROVER,
"recipient": "did:example:vta",
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": { "challenge": "n", "payloadDigest": "d", "decision": "approve" },
}))
.unwrap()
}
async fn state_with_named_approver() -> (crate::server::AppState, tempfile::TempDir) {
let (state, dir) = crate::test_support::build_signing_test_app_state().await;
state
.config
.write()
.await
.policy
.approver_sets
.insert("webvh-approvers".into(), vec![APPROVER.into()]);
(state, dir)
}
#[tokio::test]
async fn a_ceremony_task_from_an_unenrolled_approver_is_dispatched() {
let (state, _dir) = state_with_named_approver().await;
let claims = auth_for_trust_task_envelope(&state, APPROVER, &envelope(DECISION))
.await
.expect("an unenrolled approver must be able to deliver its decision");
assert_eq!(claims.did, APPROVER);
assert_eq!(claims.role, Role::Monitor);
assert!(claims.allowed_contexts.is_empty());
assert!(!claims.is_super_admin());
}
#[tokio::test]
async fn a_stranger_cannot_use_a_consent_decision_to_reach_the_handler() {
let (state, _dir) = state_with_named_approver().await;
let err = auth_for_trust_task_envelope(&state, "did:key:zPasserby", &envelope(DECISION))
.await
.expect_err("a DID in no approver set must not reach the consent handler");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[tokio::test]
async fn a_step_up_approve_response_needs_no_approver_set_membership() {
let (state, _dir) = state_with_named_approver().await;
let claims =
auth_for_trust_task_envelope(&state, "did:key:zHoldersPhone", &envelope(STEP_UP))
.await
.expect("a delegated step-up approver holds neither ACL entry nor set membership");
assert_eq!(claims.role, Role::Monitor);
assert!(claims.allowed_contexts.is_empty());
}
#[tokio::test]
async fn a_named_approver_still_cannot_submit_an_ordinary_task() {
let (state, _dir) = state_with_named_approver().await;
let err = auth_for_trust_task_envelope(&state, APPROVER, &envelope(ORDINARY))
.await
.expect_err("a webvh update from an unenrolled DID must still be refused");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[tokio::test]
async fn an_unreadable_envelope_from_an_unenrolled_sender_is_refused() {
let (state, _dir) = state_with_named_approver().await;
for body in [b"not json".as_slice(), b"{}".as_slice(), b"".as_slice()] {
let err = auth_for_trust_task_envelope(&state, APPROVER, body)
.await
.expect_err("an unparseable body must not reach the carve-out");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
}
#[tokio::test]
async fn an_enrolled_sender_keeps_its_real_claims_on_a_ceremony_task() {
let (state, _dir) = state_with_named_approver().await;
let did = "did:key:zEnrolledApprover";
store_acl_entry(
&state.acl_ks,
&AclEntry::new(did, Role::Admin, "test").with_contexts(vec!["ctx-a".into()]),
)
.await
.unwrap();
let claims = auth_for_trust_task_envelope(&state, did, &envelope(DECISION))
.await
.unwrap();
assert_eq!(claims.role, Role::Admin);
assert_eq!(claims.allowed_contexts, vec!["ctx-a"]);
}
#[tokio::test]
async fn an_expired_grant_still_lets_a_ceremony_task_through_with_nothing() {
let (state, _dir) = state_with_named_approver().await;
store_acl_entry(
&state.acl_ks,
&AclEntry::new(APPROVER, Role::Admin, "test")
.with_contexts(vec!["ctx-a".into()])
.with_created_at(now_epoch().saturating_sub(7200))
.with_expires_at(Some(now_epoch().saturating_sub(60))),
)
.await
.unwrap();
let claims = auth_for_trust_task_envelope(&state, APPROVER, &envelope(DECISION))
.await
.expect("a lapsed grant must not strand an approval");
assert_eq!(
claims.role,
Role::Monitor,
"the lapsed entry's admin role must NOT be resurrected by the carve-out"
);
assert!(claims.allowed_contexts.is_empty());
}
#[tokio::test]
async fn the_carve_out_does_not_mint_a_session_for_an_unenrolled_did() {
use crate::auth::session::get_session;
let (state, _dir) = state_with_named_approver().await;
auth_for_trust_task_envelope(&state, APPROVER, &envelope(DECISION))
.await
.unwrap();
assert!(
get_session(&state.sessions_ks, APPROVER)
.await
.unwrap()
.is_none(),
"a ceremony dispatch must not create session state for an unenrolled DID"
);
}
#[tokio::test]
async fn auth_from_did_reports_persisted_elevated_acr() {
use crate::auth::session::{get_session, update_session};
let (_store, acl_ks, sessions_ks, _dir) = fresh_acl_ks().await;
let did = "did:key:zElevatedCaller";
store_acl_entry(
&acl_ks,
&AclEntry::new(did, Role::Admin, "test").with_expires_at(Some(now_epoch() + 3600)),
)
.await
.unwrap();
let first = auth_from_did(did, &acl_ks, &sessions_ks).await.unwrap();
assert_eq!(first.acr, "aal1");
assert_eq!(first.session_id, did);
let mut s = get_session(&sessions_ks, did).await.unwrap().unwrap();
s.acr = "aal2".into();
s.acr_expires_at = Some(now_epoch() + 900);
update_session(&sessions_ks, &s).await.unwrap();
let next = auth_from_did(did, &acl_ks, &sessions_ks).await.unwrap();
assert_eq!(next.acr, "aal2");
}
}