use affinidi_data_integrity::{DataIntegrityProof, SignOptions, crypto_suites::CryptoSuite};
use affinidi_secrets_resolver::secrets::Secret;
use chrono::{DateTime, Utc};
use dtg_credentials::{DTGCredentialType, IssuerScope};
use serde::Deserialize;
use serde_json::{Value, json};
use super::card::CLOCK_SKEW;
use super::{VettingError, did_of, verify_attached_proof};
use crate::protocols::vetting::{role_matches, role_of_action};
use crate::trust_task_proof::TrustTaskVmResolver;
const VP_WHAT: &str = "eligibility presentation";
const ROLE_WHAT: &str = "vetter role credential";
pub const ELIGIBILITY_PROOF_PURPOSE: &str = "authentication";
const ROLE_CREDENTIAL_PROOF_PURPOSE: &str = "assertionMethod";
const VC_CONTEXT_V2: &str = "https://www.w3.org/ns/credentials/v2";
#[must_use]
pub fn community_roles(credential: &Value) -> Option<(String, Vec<String>)> {
let vac = super::dtg_shape(credential).ok()?;
if vac.type_() != DTGCredentialType::Authority {
return None;
}
let authority = vac.credential().authority()?;
if authority.scope != vac.issuer() || authority.parent.is_some() {
return None;
}
let roles: Vec<String> = authority
.actions
.iter()
.filter_map(|a| role_of_action(a))
.map(str::to_string)
.collect();
if roles.is_empty() {
return None;
}
Some((authority.scope.clone(), roles))
}
pub async fn build_eligibility_vp(
holder: &Secret,
credentials: Vec<Value>,
challenge: &str,
domain: &str,
) -> Result<Value, VettingError> {
let holder_did = did_of(&holder.id);
if holder_did.len() <= "did:".len() || !holder_did.starts_with("did:") {
return Err(VettingError::WrongSigner {
what: VP_WHAT,
role: "holder",
});
}
let mut vp = json!({
"@context": [VC_CONTEXT_V2],
"type": ["VerifiablePresentation"],
"holder": holder_did,
"verifiableCredential": credentials,
"nonce": challenge,
"domain": domain,
});
let proof = DataIntegrityProof::sign(
&vp,
holder,
SignOptions::new()
.with_proof_purpose(ELIGIBILITY_PROOF_PURPOSE)
.with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
.map_err(|e| VettingError::Sign(e.to_string()))?;
vp.as_object_mut()
.expect("presentation is an object")
.insert(
"proof".into(),
serde_json::to_value(proof).map_err(|e| VettingError::Sign(e.to_string()))?,
);
Ok(vp)
}
#[derive(Debug, Clone, Copy)]
pub struct EligibilityExpectations<'a> {
pub vetter: &'a str,
pub community: &'a str,
pub role: &'a str,
pub challenge: &'a str,
pub domain: &'a str,
pub now: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct VerifiedEligibility {
credential_id: Option<String>,
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
credential_status: Option<Value>,
}
impl VerifiedEligibility {
#[must_use]
pub fn credential_id(&self) -> Option<&str> {
self.credential_id.as_deref()
}
#[must_use]
pub fn valid_from(&self) -> DateTime<Utc> {
self.valid_from
}
#[must_use]
pub fn valid_until(&self) -> DateTime<Utc> {
self.valid_until
}
#[must_use]
pub fn credential_status(&self) -> Option<&Value> {
self.credential_status.as_ref()
}
}
#[derive(Deserialize)]
struct WirePresentation {
#[serde(rename = "type")]
types: OneOrMany,
holder: String,
#[serde(default)]
nonce: Option<String>,
#[serde(default)]
domain: Option<String>,
#[serde(rename = "verifiableCredential", default)]
credentials: Vec<Value>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
impl OneOrMany {
fn contains(&self, wanted: &str) -> bool {
match self {
Self::One(t) => t == wanted,
Self::Many(ts) => ts.iter().any(|t| t == wanted),
}
}
}
pub async fn verify_eligibility_vp(
vp: &Value,
expect: &EligibilityExpectations<'_>,
resolver: &TrustTaskVmResolver,
) -> Result<VerifiedEligibility, VettingError> {
let wire: WirePresentation =
serde_json::from_value(vp.clone()).map_err(|e| VettingError::Malformed {
what: VP_WHAT,
detail: e.to_string(),
})?;
if !wire.types.contains("VerifiablePresentation") {
return Err(VettingError::Malformed {
what: VP_WHAT,
detail: "type does not include VerifiablePresentation".into(),
});
}
if wire.holder != expect.vetter {
return Err(VettingError::WrongSigner {
what: VP_WHAT,
role: "vetter",
});
}
if wire.nonce.as_deref() != Some(expect.challenge) {
return Err(VettingError::Binding("nonce"));
}
if wire.domain.as_deref() != Some(expect.domain) {
return Err(VettingError::Binding("domain"));
}
let signer = verify_attached_proof(VP_WHAT, vp, ELIGIBILITY_PROOF_PURPOSE, resolver).await?;
if signer != wire.holder {
return Err(VettingError::WrongSigner {
what: VP_WHAT,
role: "holder",
});
}
let mut first_error = None;
for credential in &wire.credentials {
let Some((community, roles)) = community_roles(credential) else {
continue;
};
if community != expect.community || !roles.iter().any(|r| role_matches(r, expect.role)) {
continue;
}
match verify_role_credential(credential, expect, resolver).await {
Ok(verified) => return Ok(verified),
Err(e) => {
first_error.get_or_insert(e);
}
}
}
Err(first_error.unwrap_or(VettingError::NoRoleCredential))
}
async fn verify_role_credential(
credential: &Value,
expect: &EligibilityExpectations<'_>,
resolver: &TrustTaskVmResolver,
) -> Result<VerifiedEligibility, VettingError> {
let malformed = |detail: String| VettingError::Malformed {
what: ROLE_WHAT,
detail,
};
let vac = super::dtg_shape(credential).map_err(|e| malformed(e.to_string()))?;
if vac.type_() != DTGCredentialType::Authority {
return Err(malformed("not an AuthorityCredential".into()));
}
let issuer = vac.issuer().to_string();
if issuer != expect.community {
return Err(VettingError::WrongSigner {
what: ROLE_WHAT,
role: "issuer",
});
}
if vac.issuer_scope() != IssuerScope::Public {
return Err(malformed(
"a community role credential declares issuerScope public".into(),
));
}
let authority = vac
.credential()
.authority()
.ok_or_else(|| malformed("no credentialSubject.authority".into()))?;
if authority.parent.is_some() {
return Err(malformed(
"an attenuated VAC is not a community role grant".into(),
));
}
if authority.scope != expect.community {
return Err(VettingError::Binding("authority.scope"));
}
if vac.subject() != expect.vetter {
return Err(VettingError::Binding("credentialSubject"));
}
let valid_until = vac
.valid_until()
.ok_or_else(|| malformed("no validUntil — a role grant is bounded".into()))?;
if vac.valid_from() > expect.now + CLOCK_SKEW || expect.now > valid_until + CLOCK_SKEW {
return Err(VettingError::Expired(ROLE_WHAT));
}
let signer = verify_attached_proof(
ROLE_WHAT,
credential,
ROLE_CREDENTIAL_PROOF_PURPOSE,
resolver,
)
.await?;
if signer != issuer {
return Err(VettingError::WrongSigner {
what: ROLE_WHAT,
role: "issuer",
});
}
Ok(VerifiedEligibility {
credential_id: vac.id().map(str::to_string),
valid_from: vac.valid_from(),
valid_until,
credential_status: vac.credential().credential_status.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vetting::test_support::{did, secret};
use chrono::Duration;
use dtg_credentials::DTGCredential;
const CHALLENGE: &str = "urn:uuid:3f1c9a52-8c1e-4f2b-9d7a-0b6e5c4d3a21";
const DOMAIN: &str = "did:key:z6MkApplicantJoinDid";
async fn role_vac(
community: &Secret,
subject: &str,
community_did: &str,
role: &str,
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
) -> Value {
let mut credential = DTGCredential::new_vac(
did(community),
IssuerScope::Public,
subject.to_string(),
community_did.to_string(),
vec![crate::protocols::vetting::role_action(role)],
valid_from,
valid_until,
)
.unwrap()
.with_max_attenuation(0)
.unwrap()
.with_id("urn:uuid:vetter-grant".to_string());
credential.sign(community, None).await.unwrap();
serde_json::to_value(&credential).unwrap()
}
async fn live_vac(community: &Secret, vetter: &Secret, role: &str) -> Value {
let now = Utc::now();
let c = did(community);
role_vac(
community,
&did(vetter),
&c,
role,
now - Duration::days(1),
now + Duration::days(364),
)
.await
}
async fn verify(
vp: &Value,
vetter: &str,
community: &str,
role: &str,
) -> Result<VerifiedEligibility, VettingError> {
verify_eligibility_vp(
vp,
&EligibilityExpectations {
vetter,
community,
role,
challenge: CHALLENGE,
domain: DOMAIN,
now: Utc::now(),
},
&TrustTaskVmResolver::did_key_only(),
)
.await
}
async fn with_proof_set(community: &Secret, mut vac: Value) -> Value {
let first = vac["proof"].clone();
let mut proofless = vac.clone();
proofless.as_object_mut().unwrap().remove("proof");
let second = affinidi_data_integrity::DataIntegrityProof::sign(
&proofless,
community,
affinidi_data_integrity::SignOptions::new().with_proof_purpose("assertionMethod"),
)
.await
.unwrap();
vac["proof"] = serde_json::json!([first, serde_json::to_value(second).unwrap()]);
vac
}
#[tokio::test]
async fn vti_57_a_grant_carrying_a_proof_set_names_its_roles() {
let (community, vetter) = (secret(0xC7), secret(0x17));
let vac = with_proof_set(&community, live_vac(&community, &vetter, "vetter").await).await;
assert!(vac["proof"].is_array());
assert_eq!(
community_roles(&vac),
Some((did(&community), vec!["vetter".to_string()]))
);
}
#[tokio::test]
async fn vti_57_an_eligibility_vp_over_a_proof_set_grant_verifies() {
let (community, vetter) = (secret(0xC8), secret(0x18));
let vac = with_proof_set(&community, live_vac(&community, &vetter, "vetter").await).await;
let vp = build_eligibility_vp(&vetter, vec![vac], CHALLENGE, DOMAIN)
.await
.unwrap();
verify(&vp, &did(&vetter), &did(&community), "vetter")
.await
.expect("a proof-set grant verifies");
}
#[tokio::test]
async fn vti_57_a_tampered_proof_in_the_set_is_still_refused() {
let (community, vetter) = (secret(0xC9), secret(0x19));
let mut vac =
with_proof_set(&community, live_vac(&community, &vetter, "vetter").await).await;
vac["proof"][1]["proofValue"] = vac["proof"][0]["proofValue"].clone();
vac["proof"][1]["created"] = serde_json::json!("2020-01-01T00:00:00Z");
let vp = build_eligibility_vp(&vetter, vec![vac], CHALLENGE, DOMAIN)
.await
.unwrap();
assert!(
verify(&vp, &did(&vetter), &did(&community), "vetter")
.await
.is_err()
);
}
#[tokio::test]
async fn a_vetter_proves_the_community_named_them() {
let (community, vetter) = (secret(0xC0), secret(0x11));
let vac = live_vac(&community, &vetter, "vetter").await;
assert_eq!(
community_roles(&vac),
Some((did(&community), vec!["vetter".to_string()]))
);
assert_eq!(vac["credentialSubject"]["authority"]["maxAttenuation"], 0);
let vp = build_eligibility_vp(&vetter, vec![vac], CHALLENGE, DOMAIN)
.await
.unwrap();
assert_eq!(vp["holder"], did(&vetter));
assert_eq!(vp["proof"]["proofPurpose"], ELIGIBILITY_PROOF_PURPOSE);
let verified = verify(&vp, &did(&vetter), &did(&community), "vetter")
.await
.unwrap();
assert_eq!(verified.credential_id(), Some("urn:uuid:vetter-grant"));
assert!(verified.valid_until() > verified.valid_from());
assert!(verified.credential_status().is_none());
verify(&vp, &did(&vetter), &did(&community), "custom:vetter")
.await
.unwrap();
}
#[tokio::test]
async fn a_presentation_for_another_session_is_refused() {
let (community, vetter) = (secret(0xC0), secret(0x11));
let vac = live_vac(&community, &vetter, "vetter").await;
let other_challenge = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq";
let vp = build_eligibility_vp(&vetter, vec![vac.clone()], other_challenge, DOMAIN)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::Binding("nonce"))
));
let vp = build_eligibility_vp(&vetter, vec![vac], CHALLENGE, "did:web:elsewhere")
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::Binding("domain"))
));
}
#[tokio::test]
async fn a_role_credential_from_another_community_does_not_count() {
let (community, other, vetter) = (secret(0xC0), secret(0xC1), secret(0x11));
let vac = live_vac(&other, &vetter, "vetter").await;
let vp = build_eligibility_vp(&vetter, vec![vac], CHALLENGE, DOMAIN)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::NoRoleCredential)
));
let now = Utc::now();
let forged = role_vac(
&other,
&did(&vetter),
&did(&community),
"vetter",
now - Duration::days(1),
now + Duration::days(30),
)
.await;
assert_eq!(community_roles(&forged), None);
let vp = build_eligibility_vp(&vetter, vec![forged.clone()], CHALLENGE, DOMAIN)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::NoRoleCredential)
));
let mut relabelled = forged;
relabelled["issuer"] = json!(did(&community));
let vp = build_eligibility_vp(&vetter, vec![relabelled], CHALLENGE, DOMAIN)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::Proof { .. })
));
}
#[tokio::test]
async fn a_different_role_does_not_count() {
let (community, vetter) = (secret(0xC0), secret(0x11));
let vac = live_vac(&community, &vetter, "moderator").await;
let vp = build_eligibility_vp(&vetter, vec![vac], CHALLENGE, DOMAIN)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::NoRoleCredential)
));
}
#[tokio::test]
async fn a_tampered_role_credential_is_refused() {
let (community, vetter) = (secret(0xC0), secret(0x11));
let mut vac = live_vac(&community, &vetter, "vetter").await;
vac["validUntil"] = json!((Utc::now() + Duration::days(3650)).to_rfc3339());
let vp = build_eligibility_vp(&vetter, vec![vac], CHALLENGE, DOMAIN)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::Proof { .. })
));
}
#[tokio::test]
async fn an_expired_role_credential_is_refused() {
let (community, vetter) = (secret(0xC0), secret(0x11));
let now = Utc::now();
let vac = role_vac(
&community,
&did(&vetter),
&did(&community),
"vetter",
now - Duration::days(400),
now - Duration::days(1),
)
.await;
let vp = build_eligibility_vp(&vetter, vec![vac], CHALLENGE, DOMAIN)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::Expired(_))
));
}
#[tokio::test]
async fn someone_else_presenting_a_vetters_credential_is_refused() {
let (community, vetter, mallory) = (secret(0xC0), secret(0x11), secret(0x66));
let vac = live_vac(&community, &vetter, "vetter").await;
let vp = build_eligibility_vp(&mallory, vec![vac.clone()], CHALLENGE, DOMAIN)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::WrongSigner { role: "vetter", .. })
));
let mut relabelled = vp;
relabelled["holder"] = json!(did(&vetter));
assert!(
verify(&relabelled, &did(&vetter), &did(&community), "vetter")
.await
.is_err()
);
let vp = build_eligibility_vp(&mallory, vec![vac], CHALLENGE, DOMAIN)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&mallory), &did(&community), "vetter").await,
Err(VettingError::Binding("credentialSubject"))
));
}
#[tokio::test]
async fn a_holder_without_a_did_cannot_present() {
let mut holder = secret(0x11);
holder.id = "key-0".into();
assert!(matches!(
build_eligibility_vp(&holder, vec![], CHALLENGE, DOMAIN).await,
Err(VettingError::WrongSigner { role: "holder", .. })
));
}
#[tokio::test]
async fn a_role_credential_declaring_a_narrower_scope_is_refused() {
let (community, vetter) = (secret(0xC0), secret(0x11));
let now = Utc::now();
let mut vac = DTGCredential::new_vac(
did(&community),
IssuerScope::Directed,
did(&vetter),
did(&community),
vec!["role:vetter".into()],
now - Duration::days(1),
now + Duration::days(30),
)
.unwrap();
vac.sign(&community, None).await.unwrap();
let vp = build_eligibility_vp(
&vetter,
vec![serde_json::to_value(&vac).unwrap()],
CHALLENGE,
DOMAIN,
)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::Malformed { .. })
));
}
#[test]
fn only_community_issued_role_vacs_are_role_credentials() {
let now = Utc::now();
let vac = |issuer: &str, scope: &str, actions: &[&str]| {
let vac = DTGCredential::new_vac(
issuer.into(),
IssuerScope::Public,
"did:key:z".into(),
scope.into(),
actions.iter().map(|a| a.to_string()).collect(),
now,
now + Duration::days(1),
)
.unwrap();
crate::vetting::tests_support_json(&vac)
};
assert_eq!(
community_roles(&vac(
"did:web:c",
"did:web:c",
&["role:vetter", "read", "role:mod"]
)),
Some(("did:web:c".into(), vec!["vetter".into(), "mod".into()]))
);
assert!(community_roles(&vac("did:web:c", "did:web:c", &["vetter"])).is_none());
assert!(community_roles(&vac("did:web:c", "did:web:d", &["role:vetter"])).is_none());
let mut legacy = vac("did:web:c", "did:web:c", &["role:vetter"]);
legacy["@context"] = json!(["https://www.w3.org/ns/credentials/v2"]);
assert!(community_roles(&legacy).is_none());
assert!(
community_roles(&json!({
"type": ["VerifiableCredential", "EndorsementCredential"],
"issuer": "did:web:c",
"credentialSubject": { "id": "did:key:z", "endorsement": {
"type": "CommunityRole", "role": "vetter", "communityDid": "did:web:c"
} }
}))
.is_none()
);
}
}