use affinidi_data_integrity::{DataIntegrityProof, SignOptions, crypto_suites::CryptoSuite};
use affinidi_secrets_resolver::secrets::Secret;
use chrono::{DateTime, Utc};
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::{COMMUNITY_ROLE_ENDORSEMENT_TYPE, role_matches};
use crate::trust_task_proof::TrustTaskVmResolver;
const VP_WHAT: &str = "eligibility presentation";
const VEC_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_role(credential: &Value) -> Option<(String, String)> {
let is_endorsement = match credential.get("type")? {
Value::String(t) => t == "EndorsementCredential",
Value::Array(types) => types
.iter()
.any(|t| t.as_str() == Some("EndorsementCredential")),
_ => false,
};
if !is_endorsement {
return None;
}
let endorsement = credential.pointer("/credentialSubject/endorsement")?;
if endorsement.get("type")?.as_str()? != COMMUNITY_ROLE_ENDORSEMENT_TYPE {
return None;
}
Some((
endorsement.get("communityDid")?.as_str()?.to_string(),
endorsement.get("role")?.as_str()?.to_string(),
))
}
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),
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireRoleCredential {
id: Option<String>,
issuer: WireIssuer,
valid_from: DateTime<Utc>,
valid_until: Option<DateTime<Utc>>,
credential_subject: WireSubject,
credential_status: Option<Value>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum WireIssuer {
Id(String),
Object { id: String },
}
#[derive(Deserialize)]
struct WireSubject {
id: String,
}
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, role)) = community_role(credential) else {
continue;
};
if community != expect.community || !role_matches(&role, 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: VEC_WHAT,
detail,
};
let wire: WireRoleCredential =
serde_json::from_value(credential.clone()).map_err(|e| malformed(e.to_string()))?;
let issuer = match wire.issuer {
WireIssuer::Id(id) | WireIssuer::Object { id } => id,
};
if issuer != expect.community {
return Err(VettingError::WrongSigner {
what: VEC_WHAT,
role: "issuer",
});
}
if wire.credential_subject.id != expect.vetter {
return Err(VettingError::Binding("credentialSubject"));
}
let valid_until = wire
.valid_until
.ok_or_else(|| malformed("no validUntil — a role grant is bounded".into()))?;
if wire.valid_from > expect.now + CLOCK_SKEW || expect.now > valid_until + CLOCK_SKEW {
return Err(VettingError::Expired(VEC_WHAT));
}
let signer = verify_attached_proof(
VEC_WHAT,
credential,
ROLE_CREDENTIAL_PROOF_PURPOSE,
resolver,
)
.await?;
if signer != issuer {
return Err(VettingError::WrongSigner {
what: VEC_WHAT,
role: "issuer",
});
}
Ok(VerifiedEligibility {
credential_id: wire.id,
valid_from: wire.valid_from,
valid_until,
credential_status: wire.credential_status,
})
}
#[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_vec(
community: &Secret,
subject: &str,
community_did: &str,
role: &str,
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
) -> Value {
let mut credential = DTGCredential::new_vec(
did(community),
subject.to_string(),
valid_from,
Some(valid_until),
json!({
"type": COMMUNITY_ROLE_ENDORSEMENT_TYPE,
"role": role,
"communityDid": community_did,
}),
)
.with_id("urn:uuid:vetter-grant".to_string());
credential.sign(community, None).await.unwrap();
serde_json::to_value(&credential).unwrap()
}
async fn live_vec(community: &Secret, vetter: &Secret, role: &str) -> Value {
let now = Utc::now();
let c = did(community);
role_vec(
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
}
#[tokio::test]
async fn a_vetter_proves_the_community_named_them() {
let (community, vetter) = (secret(0xC0), secret(0x11));
let vec = live_vec(&community, &vetter, "vetter").await;
assert_eq!(
community_role(&vec),
Some((did(&community), "vetter".to_string()))
);
let vp = build_eligibility_vp(&vetter, vec![vec], 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 vec = live_vec(&community, &vetter, "vetter").await;
let other_challenge = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq";
let vp = build_eligibility_vp(&vetter, vec![vec.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![vec], 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 vec = live_vec(&other, &vetter, "vetter").await;
let vp = build_eligibility_vp(&vetter, vec![vec], CHALLENGE, DOMAIN)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::NoRoleCredential)
));
let now = Utc::now();
let forged = role_vec(
&other,
&did(&vetter),
&did(&community),
"vetter",
now - Duration::days(1),
now + Duration::days(30),
)
.await;
let vp = build_eligibility_vp(&vetter, vec![forged], CHALLENGE, DOMAIN)
.await
.unwrap();
assert!(matches!(
verify(&vp, &did(&vetter), &did(&community), "vetter").await,
Err(VettingError::WrongSigner { role: "issuer", .. })
));
}
#[tokio::test]
async fn a_different_role_does_not_count() {
let (community, vetter) = (secret(0xC0), secret(0x11));
let vec = live_vec(&community, &vetter, "moderator").await;
let vp = build_eligibility_vp(&vetter, vec![vec], 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 vec = live_vec(&community, &vetter, "vetter").await;
vec["validUntil"] = json!((Utc::now() + Duration::days(3650)).to_rfc3339());
let vp = build_eligibility_vp(&vetter, vec![vec], 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 vec = role_vec(
&community,
&did(&vetter),
&did(&community),
"vetter",
now - Duration::days(400),
now - Duration::days(1),
)
.await;
let vp = build_eligibility_vp(&vetter, vec![vec], 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 vec = live_vec(&community, &vetter, "vetter").await;
let vp = build_eligibility_vp(&mallory, vec![vec.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![vec], 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", .. })
));
}
#[test]
fn only_community_role_endorsements_are_role_credentials() {
let vc = |endorsement: Value| {
json!({
"type": ["VerifiableCredential", "EndorsementCredential"],
"credentialSubject": { "id": "did:key:z", "endorsement": endorsement }
})
};
assert!(
community_role(&vc(json!({
"type": "CommunityRole", "role": "vetter", "communityDid": "did:web:c"
})))
.is_some()
);
assert!(community_role(&vc(json!({ "type": "IdentityVetting" }))).is_none());
assert!(
community_role(&vc(json!({ "type": "CommunityRole", "role": "vetter" }))).is_none()
);
assert!(community_role(&json!({ "type": ["VerifiableCredential"] })).is_none());
}
}