use affinidi_crypto::did_key as did_key_helpers;
use affinidi_data_integrity::{
DataIntegrityProof, SignOptions, VerifyOptions, crypto_suites::CryptoSuite,
};
use affinidi_secrets_resolver::secrets::Secret;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
const DPV_CONTEXT: &str = "https://w3id.org/dpv";
const DCT_CONTEXT: &str = "http://purl.org/dc/terms/";
const CONFORMS_TO: &str = "https://w3c.github.io/dpv/guides/consent-27560";
const RECORD_PREFIX: &str = "consent:";
fn record_key(id: &str) -> Vec<u8> {
format!("{RECORD_PREFIX}{id}").into_bytes()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConsentStatusEvent {
#[serde(rename = "@type")]
pub event_type: ConsentStatusType,
#[serde(rename = "dct:date")]
pub date: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConsentStatusType {
#[serde(rename = "dpv:ConsentGiven")]
ConsentGiven,
#[serde(rename = "dpv:ConsentWithdrawn")]
ConsentWithdrawn,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageCondition {
#[serde(rename = "dct:valid")]
pub valid: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConsentProcess {
#[serde(rename = "@type")]
pub type_: ProcessType,
#[serde(rename = "dpv:hasPurpose")]
pub purpose: String,
#[serde(rename = "dct:source")]
pub credential: String,
#[serde(rename = "dpv:hasPersonalData")]
pub personal_data: Vec<String>,
#[serde(rename = "dpv:hasRecipient")]
pub recipient: String,
#[serde(rename = "dpv:hasProcessing")]
pub processing: ProcessingType,
#[serde(rename = "dpv:hasStorageCondition")]
pub storage_condition: StorageCondition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProcessType {
#[serde(rename = "dpv:Process")]
Process,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProcessingType {
#[serde(rename = "dpv:Disclose")]
Disclose,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RecordType {
#[serde(rename = "dpv:ConsentRecord")]
ConsentRecord,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConsentRecord {
#[serde(rename = "@context")]
pub context: Vec<String>,
#[serde(rename = "@type")]
pub type_: RecordType,
#[serde(rename = "dct:identifier")]
pub identifier: String,
#[serde(rename = "dct:conformsTo")]
pub conforms_to: String,
#[serde(rename = "dpv:hasDataSubject")]
pub data_subject: String,
#[serde(rename = "dpv:hasProcess")]
pub process: ConsentProcess,
#[serde(rename = "dpv:hasStatus")]
pub status: Vec<ConsentStatusEvent>,
pub proof: Value,
}
impl ConsentRecord {
fn latest_status(&self) -> Option<&ConsentStatusEvent> {
self.status.last()
}
pub fn is_given(&self) -> bool {
matches!(
self.latest_status().map(|s| s.event_type),
Some(ConsentStatusType::ConsentGiven)
)
}
fn signing_doc(&self) -> Result<Value, AppError> {
let mut doc = serde_json::to_value(self)
.map_err(|e| AppError::Internal(format!("serialize consent record: {e}")))?;
if let Some(obj) = doc.as_object_mut() {
obj.remove("proof");
}
Ok(doc)
}
async fn sign_with(&mut self, holder_key: &Secret) -> Result<(), AppError> {
self.proof = Value::Null;
let signing_doc = self.signing_doc()?;
let proof = DataIntegrityProof::sign(
&signing_doc,
holder_key,
SignOptions::new()
.with_proof_purpose("assertionMethod")
.with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
.map_err(|e| AppError::Internal(format!("sign consent record: {e}")))?;
self.proof = serde_json::to_value(&proof)
.map_err(|e| AppError::Internal(format!("serialize consent proof: {e}")))?;
Ok(())
}
pub fn verify_proof(&self) -> Result<(), AppError> {
let proof: DataIntegrityProof = serde_json::from_value(self.proof.clone())
.map_err(|e| AppError::Validation(format!("parse consent proof: {e}")))?;
if !matches!(proof.cryptosuite, CryptoSuite::EddsaJcs2022) {
return Err(AppError::Validation(format!(
"unsupported consent cryptosuite {:?} (expected eddsa-jcs-2022)",
proof.cryptosuite
)));
}
let vm_did = proof
.verification_method
.split_once('#')
.map(|(d, _)| d)
.ok_or_else(|| {
AppError::Validation("consent proof verificationMethod missing '#'".into())
})?;
if vm_did != self.data_subject {
return Err(AppError::Validation(format!(
"consent proof verificationMethod DID '{vm_did}' does not match dataSubject '{}'",
self.data_subject
)));
}
let holder_pub = did_key_helpers::did_key_to_ed25519_pub(&self.data_subject)
.map_err(|e| AppError::Validation(format!("consent dataSubject not a did:key: {e}")))?;
let signing_doc = self.signing_doc()?;
proof
.verify_with_public_key(&signing_doc, &holder_pub, VerifyOptions::new())
.map_err(|e| AppError::Validation(format!("consent proof verification failed: {e}")))?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ConsentGrant<'a> {
pub holder_did: &'a str,
pub credential_id: &'a str,
pub verifier_did: &'a str,
pub purpose: &'a str,
pub claims: Vec<String>,
pub valid_until: DateTime<Utc>,
}
pub async fn create(
vault: &KeyspaceHandle,
grant: &ConsentGrant<'_>,
holder_key: &Secret,
) -> Result<ConsentRecord, AppError> {
if grant.holder_did.trim().is_empty() {
return Err(AppError::Validation(
"consent holder_did must be non-empty".into(),
));
}
if grant.verifier_did.trim().is_empty() {
return Err(AppError::Validation(
"consent verifier_did must be non-empty".into(),
));
}
if grant.credential_id.trim().is_empty() {
return Err(AppError::Validation(
"consent credential_id must be non-empty (consent is per-credential, §13)".into(),
));
}
if grant.claims.is_empty() {
return Err(AppError::Validation(
"consent claims must be non-empty (default-deny: an empty reveal set authorizes nothing)"
.into(),
));
}
let now = Utc::now();
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
let mut record = ConsentRecord {
context: vec![DPV_CONTEXT.to_string(), DCT_CONTEXT.to_string()],
type_: RecordType::ConsentRecord,
identifier: id.clone(),
conforms_to: CONFORMS_TO.to_string(),
data_subject: grant.holder_did.to_string(),
process: ConsentProcess {
type_: ProcessType::Process,
purpose: grant.purpose.to_string(),
credential: grant.credential_id.to_string(),
personal_data: grant.claims.clone(),
recipient: grant.verifier_did.to_string(),
processing: ProcessingType::Disclose,
storage_condition: StorageCondition {
valid: rfc3339(grant.valid_until),
},
},
status: vec![ConsentStatusEvent {
event_type: ConsentStatusType::ConsentGiven,
date: rfc3339(now),
}],
proof: Value::Null,
};
record.sign_with(holder_key).await?;
record.verify_proof()?;
vault.insert(record_key(&id), &record).await?;
Ok(record)
}
pub async fn get(vault: &KeyspaceHandle, id: &str) -> Result<Option<ConsentRecord>, AppError> {
let Some(record): Option<ConsentRecord> = vault.get(record_key(id)).await? else {
return Ok(None);
};
record.verify_proof()?;
Ok(Some(record))
}
pub async fn withdraw(
vault: &KeyspaceHandle,
id: &str,
holder_key: &Secret,
) -> Result<Option<ConsentRecord>, AppError> {
let Some(mut record) = get(vault, id).await? else {
return Ok(None);
};
record.status.push(ConsentStatusEvent {
event_type: ConsentStatusType::ConsentWithdrawn,
date: rfc3339(Utc::now()),
});
record.sign_with(holder_key).await?;
record.verify_proof()?;
vault.insert(record_key(id), &record).await?;
Ok(Some(record))
}
pub async fn list(vault: &KeyspaceHandle) -> Result<Vec<ConsentRecord>, AppError> {
let rows = vault
.prefix_iter_raw(RECORD_PREFIX.as_bytes().to_vec())
.await?;
let mut out = Vec::with_capacity(rows.len());
for (_key, value) in rows {
let record: ConsentRecord = match serde_json::from_slice(&value) {
Ok(r) => r,
Err(_) => continue,
};
if record.verify_proof().is_ok() {
out.push(record);
}
}
Ok(out)
}
pub fn authorizes(
record: &ConsentRecord,
credential_id: &str,
verifier_did: &str,
requested_claims: &[String],
now: DateTime<Utc>,
) -> bool {
if record.process.credential != credential_id {
return false;
}
if !record.is_given() {
return false;
}
let valid_until = match record
.process
.storage_condition
.valid
.parse::<DateTime<Utc>>()
{
Ok(t) => t,
Err(_) => return false,
};
if now >= valid_until {
return false;
}
if record.process.recipient != verifier_did {
return false;
}
requested_claims
.iter()
.all(|c| record.process.personal_data.contains(c))
}
fn rfc3339(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn fresh_vault() -> (tempfile::TempDir, Store, KeyspaceHandle) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
let ks = store
.keyspace(crate::keyspaces::VAULT)
.expect("vault keyspace");
(dir, store, ks)
}
fn holder_identity(seed: [u8; 32]) -> (String, Secret) {
use ed25519_dalek::SigningKey;
let sk = SigningKey::from_bytes(&seed);
let pub_bytes = sk.verifying_key().to_bytes();
let did = did_key_helpers::ed25519_pub_to_did_key(&pub_bytes);
let vm = format!(
"{did}#{}",
did.strip_prefix("did:key:").expect("did:key prefix")
);
let mut secret = Secret::generate_ed25519(Some(&vm), Some(&seed));
secret.id = vm;
(did, secret)
}
fn grant<'a>(
holder: &'a str,
verifier: &'a str,
claims: Vec<String>,
valid_until: DateTime<Utc>,
) -> ConsentGrant<'a> {
ConsentGrant {
holder_did: holder,
credential_id: "cred-under-test",
verifier_did: verifier,
purpose: "join the Acme community",
claims,
valid_until,
}
}
#[tokio::test]
async fn create_signs_a_verifiable_record_with_all_fields() {
let (_dir, _store, vault) = fresh_vault();
let (holder, key) = holder_identity([1u8; 32]);
let verifier = "did:web:acme-verifier.example";
let valid_until = Utc::now() + Duration::hours(1);
let g = grant(
&holder,
verifier,
vec!["givenName".into(), "memberSince".into()],
valid_until,
);
let rec = create(&vault, &g, &key).await.unwrap();
rec.verify_proof().expect("proof must verify");
assert_eq!(rec.data_subject, holder);
assert_eq!(rec.process.recipient, verifier);
assert_eq!(rec.process.purpose, "join the Acme community");
assert_eq!(
rec.process.personal_data,
vec!["givenName".to_string(), "memberSince".to_string()]
);
assert_eq!(rec.process.storage_condition.valid, rfc3339(valid_until));
assert!(rec.is_given());
assert_eq!(rec.status.len(), 1);
assert_eq!(rec.status[0].event_type, ConsentStatusType::ConsentGiven);
let json = serde_json::to_value(&rec).unwrap();
assert_eq!(json["@type"], "dpv:ConsentRecord");
assert_eq!(json["dpv:hasDataSubject"], holder);
assert_eq!(json["dpv:hasProcess"]["@type"], "dpv:Process");
assert_eq!(json["dpv:hasProcess"]["dpv:hasProcessing"], "dpv:Disclose");
assert_eq!(json["dpv:hasProcess"]["dpv:hasRecipient"], verifier);
assert_eq!(
json["dpv:hasStatus"][0]["@type"], "dpv:ConsentGiven",
"status log opens with ConsentGiven"
);
assert!(json["proof"].is_object(), "carries a DI proof");
assert_eq!(json["dct:conformsTo"], CONFORMS_TO);
let got = get(&vault, &rec.identifier)
.await
.unwrap()
.expect("present");
assert_eq!(got, rec);
assert!(get(&vault, "urn:uuid:nope").await.unwrap().is_none());
}
#[tokio::test]
async fn authorizes_only_when_given_unexpired_recipient_and_claims_subset() {
let (_dir, _store, vault) = fresh_vault();
let (holder, key) = holder_identity([2u8; 32]);
let verifier = "did:web:acme-verifier.example";
let now = Utc::now();
let valid_until = now + Duration::hours(1);
let g = grant(
&holder,
verifier,
vec!["givenName".into(), "memberSince".into()],
valid_until,
);
let rec = create(&vault, &g, &key).await.unwrap();
assert!(authorizes(
&rec,
"cred-under-test",
verifier,
&["givenName".into()],
now
));
assert!(authorizes(
&rec,
"cred-under-test",
verifier,
&["givenName".into(), "memberSince".into()],
now
));
assert!(
!authorizes(
&rec,
"cred-under-test",
"did:web:evil.example",
&["givenName".into()],
now
),
"a different verifier must not be authorized"
);
assert!(
!authorizes(
&rec,
"cred-under-test",
verifier,
&["dateOfBirth".into()],
now
),
"a claim outside hasPersonalData must not be authorized"
);
assert!(
!authorizes(
&rec,
"cred-under-test",
verifier,
&["givenName".into(), "dateOfBirth".into()],
now
),
"a request that mixes in an out-of-scope claim must be refused as a whole"
);
}
#[tokio::test]
async fn consent_is_bound_to_one_credential() {
let (_dir, _store, vault) = fresh_vault();
let (holder, key) = holder_identity([9u8; 32]);
let verifier = "did:web:acme-verifier.example";
let now = Utc::now();
let valid_until = now + Duration::hours(1);
let g = ConsentGrant {
holder_did: &holder,
credential_id: "cred-A",
verifier_did: verifier,
purpose: "join the Acme community",
claims: vec!["givenName".into()],
valid_until,
};
let rec = create(&vault, &g, &key).await.unwrap();
assert_eq!(rec.process.credential, "cred-A");
let json = serde_json::to_value(&rec).unwrap();
assert_eq!(json["dpv:hasProcess"]["dct:source"], "cred-A");
assert!(authorizes(
&rec,
"cred-A",
verifier,
&["givenName".into()],
now
));
assert!(
!authorizes(&rec, "cred-B", verifier, &["givenName".into()], now),
"a consent record for cred-A must not authorize disclosing cred-B"
);
}
#[tokio::test]
async fn create_rejects_empty_credential_id() {
let (_dir, _store, vault) = fresh_vault();
let (holder, key) = holder_identity([10u8; 32]);
let g = ConsentGrant {
holder_did: &holder,
credential_id: "",
verifier_did: "did:web:acme-verifier.example",
purpose: "join",
claims: vec!["givenName".into()],
valid_until: Utc::now() + Duration::hours(1),
};
let err = create(&vault, &g, &key).await.unwrap_err();
assert!(
matches!(err, AppError::Validation(_)),
"an empty credential_id must be refused (consent is per-credential), got {err:?}"
);
}
#[tokio::test]
async fn expired_record_authorizes_nothing() {
let (_dir, _store, vault) = fresh_vault();
let (holder, key) = holder_identity([3u8; 32]);
let verifier = "did:web:acme-verifier.example";
let valid_until = Utc::now() - Duration::minutes(1);
let g = grant(&holder, verifier, vec!["givenName".into()], valid_until);
let rec = create(&vault, &g, &key).await.unwrap();
assert!(
!authorizes(
&rec,
"cred-under-test",
verifier,
&["givenName".into()],
Utc::now()
),
"an expired record must authorize nothing"
);
}
#[tokio::test]
async fn withdraw_flips_latest_status_and_revokes_authority() {
let (_dir, _store, vault) = fresh_vault();
let (holder, key) = holder_identity([4u8; 32]);
let verifier = "did:web:acme-verifier.example";
let now = Utc::now();
let valid_until = now + Duration::hours(1);
let g = grant(&holder, verifier, vec!["givenName".into()], valid_until);
let rec = create(&vault, &g, &key).await.unwrap();
assert!(authorizes(
&rec,
"cred-under-test",
verifier,
&["givenName".into()],
now
));
let withdrawn = withdraw(&vault, &rec.identifier, &key)
.await
.unwrap()
.expect("record exists");
assert!(!withdrawn.is_given());
assert_eq!(
withdrawn.status.last().unwrap().event_type,
ConsentStatusType::ConsentWithdrawn
);
assert_eq!(
withdrawn.status.first().unwrap().event_type,
ConsentStatusType::ConsentGiven,
"the original ConsentGiven event is retained for audit"
);
withdrawn
.verify_proof()
.expect("withdrawn record must be re-signed and verify");
assert!(
!authorizes(
&withdrawn,
"cred-under-test",
verifier,
&["givenName".into()],
now
),
"a withdrawn record must authorize nothing"
);
let reloaded = get(&vault, &rec.identifier)
.await
.unwrap()
.expect("present");
assert!(!reloaded.is_given());
assert!(
withdraw(&vault, "urn:uuid:nope", &key)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn tampered_record_fails_proof_on_get() {
let (_dir, _store, vault) = fresh_vault();
let (holder, key) = holder_identity([5u8; 32]);
let verifier = "did:web:acme-verifier.example";
let valid_until = Utc::now() + Duration::hours(1);
let g = grant(&holder, verifier, vec!["givenName".into()], valid_until);
let mut rec = create(&vault, &g, &key).await.unwrap();
rec.process.personal_data.push("dateOfBirth".into());
vault
.insert(record_key(&rec.identifier), &rec)
.await
.unwrap();
let err = get(&vault, &rec.identifier).await.unwrap_err();
assert!(
matches!(err, AppError::Validation(_)),
"a tampered record must fail proof verification on get"
);
}
#[tokio::test]
async fn proof_by_unrelated_key_is_rejected() {
let (_dir, _store, _vault) = fresh_vault();
let (holder, _holder_key) = holder_identity([6u8; 32]);
let (_attacker_did, attacker_key) = holder_identity([7u8; 32]);
let verifier = "did:web:acme-verifier.example";
let valid_until = Utc::now() + Duration::hours(1);
let mut rec = ConsentRecord {
context: vec![DPV_CONTEXT.to_string(), DCT_CONTEXT.to_string()],
type_: RecordType::ConsentRecord,
identifier: "urn:uuid:forged".into(),
conforms_to: CONFORMS_TO.to_string(),
data_subject: holder.clone(),
process: ConsentProcess {
type_: ProcessType::Process,
purpose: "steal".into(),
credential: "cred-under-test".into(),
personal_data: vec!["givenName".into()],
recipient: verifier.to_string(),
processing: ProcessingType::Disclose,
storage_condition: StorageCondition {
valid: rfc3339(valid_until),
},
},
status: vec![ConsentStatusEvent {
event_type: ConsentStatusType::ConsentGiven,
date: rfc3339(Utc::now()),
}],
proof: Value::Null,
};
rec.sign_with(&attacker_key).await.unwrap();
let err = rec.verify_proof().unwrap_err();
assert!(
matches!(err, AppError::Validation(_)),
"a proof whose verificationMethod is not the dataSubject must be rejected"
);
}
#[tokio::test]
async fn list_is_local_audit_surface_over_consent_namespace() {
let (_dir, _store, vault) = fresh_vault();
let (holder, key) = holder_identity([8u8; 32]);
let verifier = "did:web:acme-verifier.example";
let valid_until = Utc::now() + Duration::hours(1);
let a = create(
&vault,
&grant(&holder, verifier, vec!["givenName".into()], valid_until),
&key,
)
.await
.unwrap();
let b = create(
&vault,
&grant(&holder, verifier, vec!["memberSince".into()], valid_until),
&key,
)
.await
.unwrap();
let mut ids = list(&vault)
.await
.unwrap()
.into_iter()
.map(|r| r.identifier)
.collect::<Vec<_>>();
ids.sort();
let mut want = vec![a.identifier, b.identifier];
want.sort();
assert_eq!(ids, want);
}
}