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);
const AUTHORITY_TYPE: &str = "AuthorityCredential";
const MEMBERSHIP_TYPE: &str = "MembershipCredential";
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MintedPresentation {
pub presentation: Value,
pub expires_at: String,
}
pub async fn present(
state: &AppState,
auth: &AuthClaims,
agent_did: &str,
room_id: &str,
action: &str,
) -> Result<MintedPresentation, AppError> {
let scope = auth.act_scope();
let vac = find_room_credential(&state.vault_ks, room_id, AUTHORITY_TYPE, &scope).await?;
let vmc = find_room_credential(&state.vault_ks, room_id, MEMBERSHIP_TYPE, &scope).await?;
let root: DTGCredential = serde_json::from_value(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(
agent_did.to_string(),
vec![action.to_string()],
now,
expires,
)
.map_err(|e| {
AppError::Validation(format!(
"cannot attenuate the principal's authority for `{room_id}` to `{action}`: {e}"
))
})?;
let keys = crate::operations::holder_keys::resolve_holder_keys(
&state.keys_ks,
&state.seed_store,
auth,
root.subject(),
)
.await?;
leaf.sign(&keys.consent_secret, None)
.await
.map_err(|e| AppError::Internal(format!("sign the attenuated credential: {e}")))?;
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(&vmc, "membership credential")?,
"authority": [
as_text(&leaf_json, "attenuated credential")?,
as_text(&vac, "room-issued credential")?,
],
});
Ok(MintedPresentation {
presentation,
expires_at: expires.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
})
}
async fn find_room_credential(
vault: &KeyspaceHandle,
room_id: &str,
type_tag: &str,
scope: &ActScope,
) -> Result<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 found = crate::vault::query::search(vault, &query, scope).await?;
match found.len() {
0 => Err(AppError::NotFound(format!(
"this VTA holds no {type_tag} issued by room `{room_id}`"
))),
1 => {
let stored = crate::vault::storage::get(vault, &found[0].id)
.await?
.ok_or_else(|| {
AppError::Internal(format!("credential `{}` vanished mid-read", found[0].id))
})?;
serde_json::from_slice(&stored.body)
.map_err(|e| AppError::Internal(format!("stored {type_tag} for `{room_id}`: {e}")))
}
n => Err(AppError::Conflict(format!(
"this VTA holds {n} {type_tag}s issued by room `{room_id}`; which one to \
attenuate from is not a question this can answer safely"
))),
}
}
#[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(),
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(),
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}"
);
}
}