use chrono::{Duration, Utc};
use dtg_credentials::DTGCredential;
use serde_json::Value;
use vti_common::acl::ActScope;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use crate::auth::AuthClaims;
use crate::server::AppState;
pub const PRESENTATION_LIFETIME: Duration = Duration::hours(4);
pub(crate) const AUTHORITY_TYPE: &str = "AuthorityCredential";
pub(crate) const MEMBERSHIP_TYPE: &str = "MembershipCredential";
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MintedPresentation {
pub presentation: Value,
pub expires_at: String,
}
#[derive(Debug, Clone)]
pub struct Principal {
pub did: String,
pub key: crate::operations::persona::PersonaKey,
vac: Value,
vmc: Value,
}
impl Principal {
pub fn confers(&self, action: &str) -> Result<bool, AppError> {
let root: DTGCredential = serde_json::from_value(self.vac.clone())
.map_err(|e| AppError::Internal(format!("stored authority credential: {e}")))?;
Ok(root
.credential()
.authority()
.is_some_and(|grant| grant.actions.iter().any(|a| a == action)))
}
}
pub async fn principals(
state: &AppState,
auth: &AuthClaims,
room_id: &str,
) -> Result<Vec<Principal>, AppError> {
let scope = auth.act_scope();
let stores = crate::operations::persona::PersonaStores::from_state(state);
let mut held: Vec<(DTGCredential, Value, crate::operations::persona::PersonaKey)> = Vec::new();
for vac in room_credentials(&state.vault_ks, room_id, AUTHORITY_TYPE, &scope).await? {
let parsed: DTGCredential = serde_json::from_value(vac.clone()).map_err(|e| {
AppError::Internal(format!("stored authority credential for `{room_id}`: {e}"))
})?;
if let Some(key) =
crate::operations::persona::persona_signing_key(stores, auth, parsed.subject()).await?
{
held.push((parsed, vac, key));
}
}
let mut subjects: Vec<String> = held
.iter()
.map(|(c, _, _)| c.subject().to_string())
.collect();
subjects.sort_unstable();
let total = subjects.len();
subjects.dedup();
if subjects.len() != total {
return Err(AppError::Conflict(format!(
"this VTA holds more than one {AUTHORITY_TYPE} issued by room `{room_id}` to one \
persona; which one to attenuate from is not a question this can answer safely"
)));
}
let vmcs = room_credentials(&state.vault_ks, room_id, MEMBERSHIP_TYPE, &scope).await?;
let mut out = Vec::with_capacity(held.len());
for (root, vac, key) in held {
let did = root.subject().to_string();
let mut mine = Vec::new();
for vmc in &vmcs {
let parsed: DTGCredential = serde_json::from_value(vmc.clone()).map_err(|e| {
AppError::Internal(format!("stored membership credential for `{room_id}`: {e}"))
})?;
if parsed.subject() == did {
mine.push(vmc.clone());
}
}
let vmc = match mine.len() {
0 => {
return Err(AppError::NotFound(format!(
"this VTA holds no {MEMBERSHIP_TYPE} issued by room `{room_id}` to the \
persona its {AUTHORITY_TYPE} names"
)));
}
1 => mine.remove(0),
n => {
return Err(AppError::Conflict(format!(
"this VTA holds {n} {MEMBERSHIP_TYPE}s issued by room `{room_id}` to one \
persona; which one to present is not a question this can answer safely"
)));
}
};
out.push(Principal { did, key, vac, vmc });
}
Ok(out)
}
pub async fn principal(
state: &AppState,
auth: &AuthClaims,
room_id: &str,
) -> Result<Principal, AppError> {
let mut held = principals(state, auth, room_id).await?;
match held.len() {
0 => Err(AppError::NotFound(format!(
"this VTA holds no {AUTHORITY_TYPE} issued by room `{room_id}` to a persona \
you may act as"
))),
1 => Ok(held.remove(0)),
n => Err(AppError::Conflict(format!(
"you may act as {n} personas holding a {AUTHORITY_TYPE} issued by room \
`{room_id}`; which one is acting is not a question this can answer \
safely — act from the context of the one you mean"
))),
}
}
pub async fn present(
state: &AppState,
auth: &AuthClaims,
agent_did: &str,
room_id: &str,
action: &str,
) -> Result<MintedPresentation, AppError> {
let principal = principal(state, auth, room_id).await?;
present_as(state, auth, &principal, agent_did, room_id, action).await
}
pub async fn present_as(
state: &AppState,
auth: &AuthClaims,
principal: &Principal,
agent_did: &str,
room_id: &str,
action: &str,
) -> Result<MintedPresentation, AppError> {
let root: DTGCredential = serde_json::from_value(principal.vac.clone()).map_err(|e| {
AppError::Internal(format!("stored authority credential for `{room_id}`: {e}"))
})?;
let now = Utc::now();
let expires = now + PRESENTATION_LIFETIME;
let mut leaf = root
.attenuate(
dtg_credentials::IssuerScope::Directed,
agent_did.to_string(),
vec![action.to_string()],
now,
expires,
None,
)
.map_err(|e| {
AppError::Validation(format!(
"cannot attenuate the principal's authority for `{room_id}` to `{action}`: {e}"
))
})?;
leaf.validate()
.map_err(|e| AppError::Internal(format!("the attenuated credential: {e}")))?;
let signer = crate::operations::room_issuance::VtaKeySigner::new(
crate::operations::room_issuance::SigningContext {
keys_ks: &state.keys_ks,
imported_ks: &state.imported_ks,
internal_ks: &state.internal_ks,
contexts_ks: &state.contexts_ks,
acl_ks: &state.acl_ks,
seed_store: &state.seed_store,
audit: &state.audit_sink,
auth,
},
principal.key.key_id.clone(),
principal.key.verification_method.clone(),
);
let proof = affinidi_data_integrity::DataIntegrityProof::sign(
&leaf,
&signer,
affinidi_data_integrity::SignOptions::new(),
)
.await
.map_err(|e| {
let mut cause = String::new();
let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
while let Some(inner) = src {
cause = format!("{cause}: {inner}");
src = inner.source();
}
AppError::Internal(format!("sign the attenuated credential: {e}{cause}"))
})?;
leaf.credential_mut().proof = Some(proof);
let leaf_json = serde_json::to_value(leaf.credential())
.map_err(|e| AppError::Internal(format!("serialise the attenuated credential: {e}")))?;
let as_text = |v: &Value, what: &str| -> Result<Value, AppError> {
serde_json::to_string(v)
.map(Value::String)
.map_err(|e| AppError::Internal(format!("serialise the {what}: {e}")))
};
let presentation = serde_json::json!({
"membership": as_text(&principal.vmc, "membership credential")?,
"authority": [
as_text(&leaf_json, "attenuated credential")?,
as_text(&principal.vac, "room-issued credential")?,
],
});
Ok(MintedPresentation {
presentation,
expires_at: expires.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
})
}
async fn room_credentials(
vault: &KeyspaceHandle,
room_id: &str,
type_tag: &str,
scope: &ActScope,
) -> Result<Vec<Value>, AppError> {
let query = crate::vault::query::CredentialQuery {
r#type: Some(type_tag.to_string()),
issuer_did: Some(room_id.to_string()),
..Default::default()
};
let mut out = Vec::new();
for found in crate::vault::query::search(vault, &query, scope).await? {
let stored = crate::vault::storage::get(vault, &found.id)
.await?
.ok_or_else(|| {
AppError::Internal(format!("credential `{}` vanished mid-read", found.id))
})?;
out.push(
serde_json::from_slice(&stored.body).map_err(|e| {
AppError::Internal(format!("stored {type_tag} for `{room_id}`: {e}"))
})?,
);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration as Span;
use dtg_credentials::DTGCredential;
use vti_rooms::authz::{Action, ChainVerifier};
use vti_rooms::wire::AuthorityPresentation;
use vti_rooms::{RetentionPolicy, Room, Visibility};
use vti_rooms_dtg::test_support::Party;
use vti_rooms_dtg::{DataIntegrityKeys, DtgChainVerifier};
async fn vault(state: &AppState, type_tag: &str, issuer: &str, cred: &DTGCredential) {
use crate::vault::model::{CredentialFormat, CredentialStatus, StoredCredential};
use vti_common::vault::VaultStatus;
let stored = StoredCredential {
id: format!("{type_tag}-{issuer}"),
format: CredentialFormat::EddsaJcs2022,
types: vec![type_tag.into()],
schema_id: None,
community_did: None,
context_id: None,
subject_did: None,
issuer_did: Some(issuer.to_string()),
purpose: None,
status: CredentialStatus::Unknown,
valid_from: None,
valid_until: None,
received_at: "2026-01-01T00:00:00Z".into(),
source: None,
tags: Default::default(),
body: serde_json::to_vec(cred).expect("serialise the credential"),
lifecycle: VaultStatus::Active,
archived_at: None,
deleted_at: None,
grace_until: None,
};
crate::vault::storage::put(&state.vault_ks, &stored)
.await
.expect("store the credential");
}
#[tokio::test]
async fn a_minted_presentation_verifies_at_a_host() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let member_did =
crate::test_support::seed_holder_key(&state, "m/44'/0'/7'/0'/1'", None).await;
let room = Party::new();
let agent = Party::new();
let now = Utc::now();
let mut vac = DTGCredential::new_vac(
room.did.clone(),
dtg_credentials::IssuerScope::Public,
member_did.clone(),
room.did.clone(),
vec!["read".into(), "write".into()],
now - Span::minutes(1),
now + Span::days(30),
)
.expect("the room's grant to the member")
.with_id("urn:uuid:vac-member");
vac.sign(&room.secret, None).await.expect("sign the VAC");
let mut vmc = DTGCredential::new_vmc(
room.did.clone(),
member_did.clone(),
now - Span::minutes(1),
Some(now + Span::days(30)),
false,
);
vmc.sign(&room.secret, None).await.expect("sign the VMC");
vault(&state, AUTHORITY_TYPE, &room.did, &vac).await;
vault(&state, MEMBERSHIP_TYPE, &room.did, &vmc).await;
let minted = present(
&state,
&crate::test_support::super_admin_claims(),
&agent.did,
&room.did,
"read",
)
.await
.expect("the oracle mints a presentation");
let presentation: AuthorityPresentation =
serde_json::from_value(minted.presentation.clone()).unwrap_or_else(|e| {
panic!(
"a host cannot read the minted presentation: {e}\n{:#}",
minted.presentation
)
});
let verifier = DtgChainVerifier::without_zk(Box::new(DataIntegrityKeys(
state.trust_task_vm_resolver(),
)));
let room_row = Room {
room_id: room.did.clone(),
owner_did: member_did.clone(),
visibility: Visibility::Open,
retention_policy: RetentionPolicy::Chained,
anchor_cadence: Default::default(),
epoch: 1,
next_version: 1,
retention_days: 90,
epoch_expires_at: None,
created_at: 0,
updated_at: 0,
mirror_of: None,
};
let verified = verifier
.verify(&room_row, &presentation, Action::Read, &agent.did)
.await
.expect("the host verifies the chain the oracle minted");
assert_eq!(verified.subject, agent.did);
assert!(verified.actions.iter().any(|a| a == "read"));
}
#[tokio::test]
async fn a_minted_presentation_is_useless_to_anyone_else() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let member_did =
crate::test_support::seed_holder_key(&state, "m/44'/0'/7'/0'/2'", None).await;
let room = Party::new();
let agent = Party::new();
let now = Utc::now();
let mut vac = DTGCredential::new_vac(
room.did.clone(),
dtg_credentials::IssuerScope::Public,
member_did.clone(),
room.did.clone(),
vec!["read".into()],
now - Span::minutes(1),
now + Span::days(30),
)
.expect("the room's grant")
.with_id("urn:uuid:vac-member-2");
vac.sign(&room.secret, None).await.expect("sign the VAC");
let mut vmc = DTGCredential::new_vmc(
room.did.clone(),
member_did.clone(),
now - Span::minutes(1),
Some(now + Span::days(30)),
false,
);
vmc.sign(&room.secret, None).await.expect("sign the VMC");
vault(&state, AUTHORITY_TYPE, &room.did, &vac).await;
vault(&state, MEMBERSHIP_TYPE, &room.did, &vmc).await;
let minted = present(
&state,
&crate::test_support::super_admin_claims(),
&agent.did,
&room.did,
"read",
)
.await
.expect("mint");
let presentation: AuthorityPresentation =
serde_json::from_value(minted.presentation).expect("readable presentation");
let verifier = DtgChainVerifier::without_zk(Box::new(DataIntegrityKeys(
state.trust_task_vm_resolver(),
)));
let room_row = Room {
room_id: room.did.clone(),
owner_did: member_did.clone(),
visibility: Visibility::Open,
retention_policy: RetentionPolicy::Chained,
anchor_cadence: Default::default(),
epoch: 1,
next_version: 1,
retention_days: 90,
epoch_expires_at: None,
created_at: 0,
updated_at: 0,
mirror_of: None,
};
let err = verifier
.verify(&room_row, &presentation, Action::Read, &member_did)
.await
.expect_err("the principal must not be able to present their agent's chain");
assert!(
format!("{err}").contains(&agent.did),
"the refusal should name who the leaf grants to: {err}"
);
}
use crate::operations::persona::test_fixture::{self as personas, Persona};
async fn vault_as(
state: &AppState,
id: &str,
type_tag: &str,
issuer: &str,
context_id: Option<&str>,
cred: &DTGCredential,
) {
use crate::vault::model::{CredentialFormat, CredentialStatus, StoredCredential};
use vti_common::vault::VaultStatus;
crate::vault::storage::put(
&state.vault_ks,
&StoredCredential {
id: id.into(),
format: CredentialFormat::EddsaJcs2022,
types: vec![type_tag.into()],
schema_id: None,
community_did: None,
context_id: context_id.map(str::to_string),
subject_did: None,
issuer_did: Some(issuer.to_string()),
purpose: None,
status: CredentialStatus::Unknown,
valid_from: None,
valid_until: None,
received_at: "2026-01-01T00:00:00Z".into(),
source: None,
tags: Default::default(),
body: serde_json::to_vec(cred).expect("serialise the credential"),
lifecycle: VaultStatus::Active,
archived_at: None,
deleted_at: None,
grace_until: None,
},
)
.await
.expect("store the credential");
}
async fn two_contexts(state: &AppState) {
for (id, name) in [("alice", "Alice"), ("bob", "Bob")] {
crate::contexts::create_context(&state.contexts_ks, id, name)
.await
.expect("create context");
}
}
async fn did_key_persona(state: &AppState, context: &str, key_id: &str) -> Persona {
personas::did_key_persona(
&state.keys_ks,
&state.internal_ks,
&state.contexts_ks,
&state.acl_ks,
&state.seed_store,
&state.audit_sink,
context,
key_id,
)
.await
}
#[cfg(feature = "webvh")]
async fn webvh_persona(state: &AppState, context: &str, key_id: &str) -> Persona {
personas::webvh_persona(
&state.keys_ks,
&state.internal_ks,
&state.contexts_ks,
&state.acl_ks,
&state.webvh_ks,
&state.seed_store,
&state.audit_sink,
context,
key_id,
)
.await
}
async fn grant(state: &AppState, room: &Party, subject: &str, tag: &str, actions: &[&str]) {
let now = Utc::now();
let mut vac = DTGCredential::new_vac(
room.did.clone(),
dtg_credentials::IssuerScope::Public,
subject.to_string(),
room.did.clone(),
actions.iter().map(|a| a.to_string()).collect(),
now - Span::minutes(1),
now + Span::days(30),
)
.expect("the room's grant")
.with_id(format!("urn:uuid:vac-{tag}"));
vac.sign(&room.secret, None).await.expect("sign the VAC");
let mut vmc = DTGCredential::new_vmc(
room.did.clone(),
subject.to_string(),
now - Span::minutes(1),
Some(now + Span::days(30)),
false,
);
vmc.sign(&room.secret, None).await.expect("sign the VMC");
vault_as(
state,
&format!("vac-{tag}"),
AUTHORITY_TYPE,
&room.did,
None,
&vac,
)
.await;
vault_as(
state,
&format!("vmc-{tag}"),
MEMBERSHIP_TYPE,
&room.did,
None,
&vmc,
)
.await;
}
fn room_row(room: &str, owner: &str) -> Room {
Room {
room_id: room.to_string(),
owner_did: owner.to_string(),
visibility: Visibility::Open,
retention_policy: RetentionPolicy::Chained,
anchor_cadence: Default::default(),
epoch: 1,
next_version: 1,
retention_days: 90,
epoch_expires_at: None,
created_at: 0,
updated_at: 0,
mirror_of: None,
}
}
async fn resolver_knowing(
state: &mut AppState,
docs: &[&Persona],
) -> affinidi_did_resolver_cache_sdk::DIDCacheClient {
let mut resolver = affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.unwrap();
for p in docs {
resolver
.add_did_document(&p.did, serde_json::from_value(p.document.clone()).unwrap())
.await;
}
state.did_resolver = Some(resolver.clone());
resolver
}
#[cfg(feature = "webvh")]
#[tokio::test]
async fn a_webvh_persona_presents_and_a_host_verifies_it() {
let (mut state, _dir) = crate::test_support::build_signing_test_app_state().await;
two_contexts(&state).await;
let alice = webvh_persona(&state, "alice", "alice-persona").await;
resolver_knowing(&mut state, &[&alice]).await;
let room = Party::new();
let agent = Party::new();
grant(&state, &room, &alice.did, "alice", &["read", "write"]).await;
let caller = crate::test_support::admin_claims_for_context("alice");
let p = principal(&state, &caller, &room.did).await.expect("alice");
assert_eq!(
p.did, alice.did,
"the member is the persona, not the caller"
);
assert_eq!(p.key.verification_method, format!("{}#key-1", alice.did));
let minted = present(&state, &caller, &agent.did, &room.did, "read")
.await
.expect("the oracle mints as the persona");
let presentation: AuthorityPresentation =
serde_json::from_value(minted.presentation).expect("a readable presentation");
let verifier = DtgChainVerifier::without_zk(Box::new(DataIntegrityKeys(
state.trust_task_vm_resolver(),
)));
let verified = verifier
.verify(
&room_row(&room.did, &alice.did),
&presentation,
Action::Read,
&agent.did,
)
.await
.expect("the host verifies a chain whose leaf the persona signed");
assert_eq!(verified.subject, agent.did);
}
#[cfg(feature = "webvh")]
#[tokio::test]
async fn a_persona_presenting_its_own_admin_authority_verifies() {
let (mut state, _dir) = crate::test_support::build_signing_test_app_state().await;
two_contexts(&state).await;
let owner = webvh_persona(&state, "alice", "owner-persona").await;
resolver_knowing(&mut state, &[&owner]).await;
let room = Party::new();
grant(
&state,
&room,
&owner.did,
"owner",
&["read", "write", "admin"],
)
.await;
let caller = crate::test_support::admin_claims_for_context("alice");
let p = principal(&state, &caller, &room.did)
.await
.expect("the owner");
let minted = present_as(&state, &caller, &p, &p.did, &room.did, "admin")
.await
.expect("mint");
let presentation: AuthorityPresentation =
serde_json::from_value(minted.presentation).expect("readable");
let verifier = DtgChainVerifier::without_zk(Box::new(DataIntegrityKeys(
state.trust_task_vm_resolver(),
)));
let verified = verifier
.verify(
&room_row(&room.did, &owner.did),
&presentation,
Action::Admin,
&owner.did,
)
.await
.expect("the owner persona presents its own admin authority");
assert_eq!(verified.subject, owner.did);
}
#[tokio::test]
async fn another_contexts_persona_is_not_presented() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
two_contexts(&state).await;
let bob = did_key_persona(&state, "bob", "bob-persona").await;
let room = Party::new();
grant(&state, &room, &bob.did, "bob", &["read"]).await;
let elsewhere = Party::new();
let alice_caller = crate::test_support::admin_claims_for_context("alice");
let err = present(&state, &alice_caller, &alice_caller.did, &room.did, "read")
.await
.expect_err("bob's credentials are not alice's caller's");
assert!(matches!(err, AppError::NotFound(_)), "{err}");
let unknown = present(
&state,
&alice_caller,
&alice_caller.did,
&elsewhere.did,
"read",
)
.await
.expect_err("a room nobody holds anything from");
assert_eq!(
err.to_string(),
unknown.to_string().replace(&elsewhere.did, &room.did),
"held-for-someone-else and held-for-nobody read the same"
);
present(
&state,
&crate::test_support::admin_claims_for_context("bob"),
&Party::new().did,
&room.did,
"read",
)
.await
.expect("bob presents bob's");
}
#[tokio::test]
async fn a_credential_naming_a_client_key_is_not_presented() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
let room = Party::new();
let client = Party::new();
grant(&state, &room, &client.did, "client", &["read"]).await;
let err = present(
&state,
&crate::test_support::super_admin_claims(),
&client.did,
&room.did,
"read",
)
.await
.expect_err("no persona");
assert!(matches!(err, AppError::NotFound(_)), "{err}");
}
#[tokio::test]
async fn two_personas_in_one_room_are_not_guessed_between() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
two_contexts(&state).await;
let alice = did_key_persona(&state, "alice", "alice-persona").await;
let bob = did_key_persona(&state, "bob", "bob-persona").await;
let room = Party::new();
grant(&state, &room, &alice.did, "alice", &["read"]).await;
grant(&state, &room, &bob.did, "bob", &["read"]).await;
let both = crate::auth::AuthClaims {
allowed_contexts: vec!["alice".into(), "bob".into()],
..crate::test_support::super_admin_claims()
};
let err = principal(&state, &both, &room.did)
.await
.expect_err("ambiguous");
assert!(matches!(err, AppError::Conflict(_)), "{err}");
assert!(err.to_string().contains("2 personas"), "{err}");
let alice_only = principal(
&state,
&crate::test_support::admin_claims_for_context("alice"),
&room.did,
)
.await
.expect("one persona");
assert_eq!(alice_only.did, alice.did);
}
#[tokio::test]
async fn the_membership_presented_is_the_same_personas() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
two_contexts(&state).await;
let alice = did_key_persona(&state, "alice", "alice-persona").await;
let room = Party::new();
grant(&state, &room, &alice.did, "alice", &["read"]).await;
let now = Utc::now();
let mut other = DTGCredential::new_vmc(
room.did.clone(),
Party::new().did,
now - Span::minutes(1),
Some(now + Span::days(30)),
false,
);
other.sign(&room.secret, None).await.unwrap();
vault_as(
&state,
"vmc-other",
MEMBERSHIP_TYPE,
&room.did,
None,
&other,
)
.await;
let p = principal(
&state,
&crate::test_support::admin_claims_for_context("alice"),
&room.did,
)
.await
.expect("alice's membership, unambiguously");
let vmc: DTGCredential = serde_json::from_value(p.vmc).unwrap();
assert_eq!(vmc.subject(), alice.did);
}
}