use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use vti_common::error::AppError;
use vti_common::store::{Batch, KeyspaceHandle};
pub const PSEUDONYM_PREFIX: &str = "personhood_pseudonym:";
const DOMAIN_TAG: &[u8] = b"vtc-personhood-pseudonym/v1\0";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PseudonymClaim {
pub member_did: String,
pub claimed_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endorsement_id: Option<Uuid>,
}
fn key(community_did: &str, pseudonym: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(DOMAIN_TAG);
hasher.update(community_did.as_bytes());
hasher.update([0u8]);
hasher.update(pseudonym.as_bytes());
format!("{PSEUDONYM_PREFIX}{}", hex::encode(hasher.finalize()))
}
pub async fn holder(
members_ks: &KeyspaceHandle,
community_did: &str,
pseudonym: &str,
) -> Result<Option<PseudonymClaim>, AppError> {
let raw = members_ks.get_raw(key(community_did, pseudonym)).await?;
let Some(bytes) = raw else { return Ok(None) };
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| AppError::Internal(format!("PseudonymClaim decode: {e}")))
}
const CLAIM_ATTEMPTS: usize = 64;
fn already_held() -> AppError {
AppError::Conflict("this person already holds a membership in this community".into())
}
async fn read_claim(
members_ks: &KeyspaceHandle,
key: &str,
) -> Result<Option<(Vec<u8>, PseudonymClaim)>, AppError> {
let Some(bytes) = members_ks.get_raw(key).await? else {
return Ok(None);
};
let claim = serde_json::from_slice(&bytes)
.map_err(|e| AppError::Internal(format!("PseudonymClaim decode: {e}")))?;
Ok(Some((bytes, claim)))
}
pub async fn claim(
members_ks: &KeyspaceHandle,
community_did: &str,
pseudonym: &str,
member_did: &str,
) -> Result<(), AppError> {
claim_all(members_ks, community_did, &[pseudonym], member_did).await
}
pub async fn claim_all(
members_ks: &KeyspaceHandle,
community_did: &str,
pseudonyms: &[&str],
member_did: &str,
) -> Result<(), AppError> {
let mut keys: Vec<String> = pseudonyms.iter().map(|p| key(community_did, p)).collect();
keys.sort();
keys.dedup();
if keys.is_empty() {
return Ok(());
}
for _ in 0..CLAIM_ATTEMPTS {
let mut batch = Batch::new();
for k in &keys {
match read_claim(members_ks, k).await? {
Some((_, existing)) if existing.member_did != member_did => {
return Err(already_held());
}
Some((raw, _)) => batch.require_unchanged(k.clone(), raw),
None => batch.require_absent(k.clone()),
};
batch.insert(
k.clone(),
&PseudonymClaim {
member_did: member_did.to_string(),
claimed_at: Utc::now(),
endorsement_id: None,
},
)?;
}
if members_ks.apply_batch(batch).await?.is_applied() {
return Ok(());
}
}
Err(AppError::Conflict(
"a personhood pseudonym kept changing while it was being claimed".into(),
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatementClaim {
Bound,
AlreadyHeld,
}
pub async fn claim_for_statement(
members_ks: &KeyspaceHandle,
community_did: &str,
pseudonym: &str,
member_did: &str,
endorsement_id: Uuid,
) -> Result<StatementClaim, AppError> {
let k = key(community_did, pseudonym);
for _ in 0..CLAIM_ATTEMPTS {
let mut batch = Batch::new();
let outcome = match read_claim(members_ks, &k).await? {
Some((_, c)) if c.member_did != member_did => return Err(already_held()),
Some((_, c)) if c.endorsement_id.is_none() => return Ok(StatementClaim::AlreadyHeld),
Some((raw, _)) => {
batch.require_unchanged(k.clone(), raw);
StatementClaim::AlreadyHeld
}
None => {
batch.require_absent(k.clone());
StatementClaim::Bound
}
};
batch.insert(
k.clone(),
&PseudonymClaim {
member_did: member_did.to_string(),
claimed_at: Utc::now(),
endorsement_id: Some(endorsement_id),
},
)?;
if members_ks.apply_batch(batch).await?.is_applied() {
return Ok(outcome);
}
}
Err(AppError::Conflict(
"a personhood pseudonym kept changing while it was being claimed".into(),
))
}
async fn all_claims(
members_ks: &KeyspaceHandle,
) -> Result<Vec<(Vec<u8>, Vec<u8>, PseudonymClaim)>, AppError> {
let rows = members_ks
.prefix_iter_raw(PSEUDONYM_PREFIX.as_bytes())
.await?;
Ok(rows
.into_iter()
.filter_map(|(key, bytes)| {
serde_json::from_slice::<PseudonymClaim>(&bytes)
.ok()
.map(|c| (key, bytes, c))
})
.collect())
}
async fn remove_if_unchanged(
members_ks: &KeyspaceHandle,
key: Vec<u8>,
bytes: Vec<u8>,
) -> Result<bool, AppError> {
let mut batch = Batch::new();
batch.require_unchanged(key.clone(), bytes).remove(key);
Ok(members_ks.apply_batch(batch).await?.is_applied())
}
pub async fn is_bound(members_ks: &KeyspaceHandle, member_did: &str) -> Result<bool, AppError> {
Ok(all_claims(members_ks)
.await?
.iter()
.any(|(_, _, c)| c.member_did == member_did))
}
pub async fn release_for_statement(
members_ks: &KeyspaceHandle,
endorsement_id: Uuid,
) -> Result<usize, AppError> {
let mut freed = 0usize;
for (key, bytes, claim) in all_claims(members_ks).await? {
if claim.endorsement_id == Some(endorsement_id)
&& remove_if_unchanged(members_ks, key, bytes).await?
{
freed += 1;
}
}
Ok(freed)
}
pub async fn release(
members_ks: &KeyspaceHandle,
community_did: &str,
pseudonym: &str,
) -> Result<(), AppError> {
let mut batch = Batch::new();
batch.remove(key(community_did, pseudonym));
members_ks.apply_batch(batch).await?;
Ok(())
}
pub async fn release_for_member(
members_ks: &KeyspaceHandle,
member_did: &str,
) -> Result<usize, AppError> {
let rows = members_ks
.prefix_iter_raw(PSEUDONYM_PREFIX.as_bytes())
.await?;
let mut freed = 0usize;
for (key, bytes) in rows {
let Ok(claim) = serde_json::from_slice::<PseudonymClaim>(&bytes) else {
continue;
};
if claim.member_did == member_did && remove_if_unchanged(members_ks, key, bytes).await? {
freed += 1;
}
}
Ok(freed)
}
pub fn extract(vp_claims: &JsonValue, accepted_idvps: &[String]) -> Vec<String> {
let Some(credentials) = vp_claims.get("credentials").and_then(|c| c.as_array()) else {
return Vec::new();
};
credentials
.iter()
.filter(|cred| {
let issuer = cred
.get("issuer")
.and_then(|i| i.as_str().or_else(|| i.get("id").and_then(|v| v.as_str())));
issuer.is_some_and(|did| accepted_idvps.iter().any(|a| a == did))
})
.filter_map(|cred| {
let subject = cred.get("credentialSubject")?;
subject
.get("pseudonym")
.and_then(|p| p.as_str())
.filter(|p| !p.is_empty())
.map(str::to_owned)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const COMMUNITY: &str = "did:webvh:acme.example";
const IDVP: &str = "did:webvh:idvp.example";
const ALICE: &str = "did:key:zAlice";
const BOB: &str = "did:key:zBob";
fn temp_ks() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = vti_common::config::StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = vti_common::store::Store::open(&cfg).expect("store");
(store.keyspace("members-test").expect("ks"), dir)
}
#[tokio::test]
async fn a_first_claim_binds_the_pseudonym() {
let (ks, _dir) = temp_ks();
claim(&ks, COMMUNITY, "p-1", ALICE).await.expect("claim");
let held = holder(&ks, COMMUNITY, "p-1")
.await
.expect("read")
.expect("claimed");
assert_eq!(held.member_did, ALICE);
}
#[tokio::test]
async fn a_second_person_cannot_claim_the_same_pseudonym() {
let (ks, _dir) = temp_ks();
claim(&ks, COMMUNITY, "p-1", ALICE).await.expect("claim");
let err = claim(&ks, COMMUNITY, "p-1", BOB)
.await
.expect_err("the same person may not join twice");
assert!(matches!(err, AppError::Conflict(_)), "{err:?}");
}
#[tokio::test]
async fn the_refusal_does_not_name_the_holder() {
let (ks, _dir) = temp_ks();
claim(&ks, COMMUNITY, "p-1", ALICE).await.expect("claim");
let err = claim(&ks, COMMUNITY, "p-1", BOB)
.await
.expect_err("refused");
assert!(
!err.to_string().contains(ALICE),
"the holder must not be named: {err}"
);
}
#[tokio::test]
async fn the_same_member_may_reclaim() {
let (ks, _dir) = temp_ks();
claim(&ks, COMMUNITY, "p-1", ALICE).await.expect("first");
claim(&ks, COMMUNITY, "p-1", ALICE)
.await
.expect("re-asserting is not a second person");
}
#[tokio::test]
async fn release_frees_the_pseudonym() {
let (ks, _dir) = temp_ks();
claim(&ks, COMMUNITY, "p-1", ALICE).await.expect("claim");
release(&ks, COMMUNITY, "p-1").await.expect("release");
assert!(holder(&ks, COMMUNITY, "p-1").await.expect("read").is_none());
claim(&ks, COMMUNITY, "p-1", BOB)
.await
.expect("released pseudonyms are reclaimable");
}
#[tokio::test]
async fn releasing_an_unclaimed_pseudonym_is_a_noop() {
let (ks, _dir) = temp_ks();
release(&ks, COMMUNITY, "never-claimed")
.await
.expect("idempotent");
}
#[tokio::test]
async fn releasing_for_a_member_frees_only_their_claims() {
let (ks, _dir) = temp_ks();
claim(&ks, COMMUNITY, "p-alice-1", ALICE).await.expect("a1");
claim(&ks, COMMUNITY, "p-alice-2", ALICE).await.expect("a2");
claim(&ks, COMMUNITY, "p-bob", BOB).await.expect("b");
let freed = release_for_member(&ks, ALICE).await.expect("release");
assert_eq!(freed, 2, "both of Alice's claims");
assert!(holder(&ks, COMMUNITY, "p-alice-1").await.unwrap().is_none());
assert!(holder(&ks, COMMUNITY, "p-alice-2").await.unwrap().is_none());
assert!(
holder(&ks, COMMUNITY, "p-bob").await.unwrap().is_some(),
"purging one member must not free another's claim"
);
}
#[tokio::test]
async fn releasing_for_a_member_with_no_claims_is_a_noop() {
let (ks, _dir) = temp_ks();
claim(&ks, COMMUNITY, "p-bob", BOB).await.expect("b");
assert_eq!(release_for_member(&ks, ALICE).await.expect("release"), 0);
assert!(holder(&ks, COMMUNITY, "p-bob").await.unwrap().is_some());
}
#[tokio::test]
async fn the_scan_ignores_rows_outside_the_prefix() {
let (ks, _dir) = temp_ks();
ks.insert(
"member:did:key:zAlice".to_string(),
&json!({ "did": ALICE }),
)
.await
.expect("unrelated row");
claim(&ks, COMMUNITY, "p-alice", ALICE)
.await
.expect("claim");
assert_eq!(release_for_member(&ks, ALICE).await.expect("release"), 1);
assert!(
ks.get_raw("member:did:key:zAlice".to_string())
.await
.expect("read")
.is_some(),
"a member row must survive a pseudonym release"
);
}
#[tokio::test]
async fn a_statement_binding_is_found_by_member_and_released_by_its_statement() {
let (ks, _dir) = temp_ks();
let first = Uuid::new_v4();
assert_eq!(
claim_for_statement(&ks, COMMUNITY, "p-1", ALICE, first)
.await
.unwrap(),
StatementClaim::Bound
);
assert!(is_bound(&ks, ALICE).await.unwrap());
assert!(!is_bound(&ks, BOB).await.unwrap());
assert!(matches!(
claim_for_statement(&ks, COMMUNITY, "p-1", BOB, Uuid::new_v4()).await,
Err(AppError::Conflict(_))
));
let second = Uuid::new_v4();
assert_eq!(
claim_for_statement(&ks, COMMUNITY, "p-1", ALICE, second)
.await
.unwrap(),
StatementClaim::AlreadyHeld
);
assert_eq!(release_for_statement(&ks, first).await.unwrap(), 0);
assert_eq!(release_for_statement(&ks, second).await.unwrap(), 1);
assert!(!is_bound(&ks, ALICE).await.unwrap());
claim(&ks, COMMUNITY, "p-2", ALICE).await.unwrap();
let third = Uuid::new_v4();
assert_eq!(
claim_for_statement(&ks, COMMUNITY, "p-2", ALICE, third)
.await
.unwrap(),
StatementClaim::AlreadyHeld
);
assert_eq!(release_for_statement(&ks, third).await.unwrap(), 0);
assert!(is_bound(&ks, ALICE).await.unwrap());
}
#[test]
fn the_stored_key_does_not_contain_the_pseudonym() {
let k = key(COMMUNITY, "national-id-hash-12345");
assert!(!k.contains("national-id-hash-12345"), "{k}");
assert!(k.starts_with(PSEUDONYM_PREFIX));
}
#[test]
fn the_same_pseudonym_keys_differently_per_community() {
assert_ne!(key(COMMUNITY, "p-1"), key("did:webvh:other.example", "p-1"));
}
fn vp_with(issuer: &str, subject: JsonValue) -> JsonValue {
json!({
"holder": ALICE,
"credentials": [{
"type": ["VerifiableCredential"],
"issuer": issuer,
"credentialSubject": subject,
}]
})
}
#[test]
fn a_plain_idvc_pseudonym_is_read() {
let vp = vp_with(IDVP, json!({ "id": ALICE, "pseudonym": "p-1" }));
assert_eq!(extract(&vp, &[IDVP.into()]), vec!["p-1".to_string()]);
}
#[test]
fn a_community_vetted_statement_carries_no_pseudonym() {
let vp = json!({
"holder": ALICE,
"credentials": [{
"type": ["VerifiableCredential", "DTGCredential", "StatementCredential"],
"issuer": COMMUNITY,
"credentialSubject": {
"id": ALICE,
"predicate": dtg_credentials::VETTED_V1,
"object": { "value": { "community": COMMUNITY, "method": "inPerson" } }
}
}]
});
assert!(extract(&vp, &[COMMUNITY.into()]).is_empty());
}
#[test]
fn a_pseudonym_from_an_unaccepted_issuer_is_ignored() {
let vp = vp_with(
"did:key:zRandomStranger",
json!({ "id": ALICE, "pseudonym": "p-1" }),
);
assert!(
extract(&vp, &[IDVP.into()]).is_empty(),
"only accepted providers may establish uniqueness"
);
}
#[test]
fn an_object_issuer_is_matched_on_its_id() {
let vp = json!({
"holder": ALICE,
"credentials": [{
"issuer": { "id": IDVP, "name": "Example IDVP" },
"credentialSubject": { "id": ALICE, "pseudonym": "p-1" },
}]
});
assert_eq!(extract(&vp, &[IDVP.into()]), vec!["p-1".to_string()]);
}
#[test]
fn an_empty_accepted_list_accepts_nothing() {
let vp = vp_with(IDVP, json!({ "id": ALICE, "pseudonym": "p-1" }));
assert!(extract(&vp, &[]).is_empty());
}
#[test]
fn a_credential_without_a_pseudonym_contributes_nothing() {
let vp = vp_with(IDVP, json!({ "id": ALICE }));
assert!(extract(&vp, &[IDVP.into()]).is_empty());
let blank = vp_with(IDVP, json!({ "id": ALICE, "pseudonym": "" }));
assert!(
extract(&blank, &[IDVP.into()]).is_empty(),
"an empty pseudonym is absent, not a value everyone shares"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn racing_claims_bind_a_pseudonym_to_exactly_one_member() {
let (ks, _dir) = temp_ks();
for round in 0..40 {
let pseudonym = format!("race-{round}");
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(16));
let mut tasks = Vec::new();
for i in 0..16 {
let (ks, barrier, pseudonym) = (ks.clone(), barrier.clone(), pseudonym.clone());
tasks.push(tokio::spawn(async move {
let member = format!("did:key:zMember{i}");
barrier.wait().await;
if i % 2 == 0 {
claim(&ks, COMMUNITY, &pseudonym, &member).await.is_ok()
} else {
claim_for_statement(&ks, COMMUNITY, &pseudonym, &member, Uuid::new_v4())
.await
.is_ok()
}
}));
}
let mut bound = 0;
for t in tasks {
bound += t.await.unwrap() as usize;
}
assert_eq!(bound, 1, "one pseudonym, {bound} members bound");
}
}
#[tokio::test]
async fn a_refused_multi_claim_binds_nothing() {
let (ks, _dir) = temp_ks();
claim(&ks, COMMUNITY, "p-2", BOB).await.unwrap();
let err = claim_all(&ks, COMMUNITY, &["p-1", "p-2", "p-3"], ALICE)
.await
.unwrap_err();
assert!(matches!(err, AppError::Conflict(_)), "{err}");
assert!(holder(&ks, COMMUNITY, "p-1").await.unwrap().is_none());
assert!(holder(&ks, COMMUNITY, "p-3").await.unwrap().is_none());
assert_eq!(
holder(&ks, COMMUNITY, "p-2")
.await
.unwrap()
.unwrap()
.member_did,
BOB
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn racing_multi_claims_are_all_or_nothing() {
let (ks, _dir) = temp_ks();
for round in 0..40 {
let (a, b, c) = (
format!("a{round}"),
format!("b{round}"),
format!("c{round}"),
);
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
let t1 = {
let (ks, barrier, a, b) = (ks.clone(), barrier.clone(), a.clone(), b.clone());
tokio::spawn(async move {
barrier.wait().await;
claim_all(&ks, COMMUNITY, &[&a, &b], ALICE).await.is_ok()
})
};
let t2 = {
let (ks, barrier, b, c) = (ks.clone(), barrier.clone(), b.clone(), c.clone());
tokio::spawn(async move {
barrier.wait().await;
claim_all(&ks, COMMUNITY, &[&b, &c], BOB).await.is_ok()
})
};
let (alice, bob) = (t1.await.unwrap(), t2.await.unwrap());
assert!(alice ^ bob, "exactly one of the overlapping claims wins");
let held = |p: String| {
let ks = ks.clone();
async move {
holder(&ks, COMMUNITY, &p)
.await
.unwrap()
.map(|c| c.member_did)
}
};
if alice {
assert_eq!(
held(c.clone()).await,
None,
"Bob kept part of a refused claim"
);
} else {
assert_eq!(
held(a.clone()).await,
None,
"Alice kept part of a refused claim"
);
}
}
}
}