use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tracing::info;
use vta_sdk::protocols::vetting::CheckShape;
use vta_sdk::protocols::vetting::revoke_statement::v0_1::{
Payload as RevokeStatement, PayloadReason as RevocationReason,
};
use vti_common::audit::{AuditEvent, VettingStatementRevokedData};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use crate::error::TaskError;
use crate::members::storage::get_member;
pub const REVOKE_STATEMENT_ERR_NOT_MEMBER: &str =
trust_tasks_rs::specs::vtc::vetting::revoke_statement::v0_1::error_codes::NOT_MEMBER.code;
use crate::server::AppState;
const KEY_DOMAIN: &[u8] = b"vtc-vetting-revocation/v1\0";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevocationNotice {
pub issuer: String,
pub statement_id: String,
pub statement_digest_multibase: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<RevocationReason>,
pub recorded_at: DateTime<Utc>,
}
fn notice_key(
issuer: &str,
statement_id: &str,
digest_multibase: &str,
) -> Result<String, AppError> {
let (algorithm, digest) = dtg_credentials::decode_digest_multibase(digest_multibase)
.map_err(|e| AppError::Validation(format!("statementDigestMultibase: {e}")))?;
let mut hasher = Sha256::new();
hasher.update(KEY_DOMAIN);
hasher.update(issuer.as_bytes());
hasher.update([0u8]);
hasher.update(statement_id.as_bytes());
hasher.update([0u8]);
hasher.update(algorithm.to_be_bytes());
hasher.update(&digest);
Ok(hex::encode(hasher.finalize()))
}
pub async fn is_revoked(
ks: &KeyspaceHandle,
issuer: &str,
statement_id: &str,
digest_multibase: &str,
) -> Result<bool, AppError> {
let Ok(key) = notice_key(issuer, statement_id, digest_multibase) else {
return Ok(false);
};
Ok(ks.get_raw(key.as_bytes()).await?.is_some())
}
pub async fn record(
ks: &KeyspaceHandle,
issuer: &str,
body: &RevokeStatement,
now: DateTime<Utc>,
) -> Result<(RevocationNotice, bool), AppError> {
body.check_shape()
.map_err(|e| AppError::Validation(e.to_string()))?;
let key = notice_key(issuer, &body.statement_id, &body.statement_digest_multibase)?;
if let Some(bytes) = ks.get_raw(key.as_bytes()).await? {
let existing = serde_json::from_slice(&bytes)
.map_err(|e| AppError::Internal(format!("revocation notice decode: {e}")))?;
return Ok((existing, false));
}
let notice = RevocationNotice {
issuer: issuer.to_string(),
statement_id: body.statement_id.as_str().to_owned(),
statement_digest_multibase: body.statement_digest_multibase.as_str().to_owned(),
reason: body.reason,
recorded_at: now,
};
ks.insert(key, ¬ice).await?;
Ok((notice, true))
}
pub async fn list_notices(ks: &KeyspaceHandle) -> Result<Vec<RevocationNotice>, AppError> {
let rows = ks.prefix_iter_raw(Vec::new()).await?;
let mut notices: Vec<RevocationNotice> = rows
.into_iter()
.filter_map(|(_k, v)| match serde_json::from_slice(&v) {
Ok(n) => Some(n),
Err(e) => {
tracing::warn!(error = %e, "skipping an undecodable withdrawal notice");
None
}
})
.collect();
notices.sort_by_key(|n| std::cmp::Reverse(n.recorded_at));
Ok(notices)
}
pub async fn withdraw(
state: &AppState,
vetter_did: &str,
body: &RevokeStatement,
) -> Result<RevocationNotice, TaskError> {
if get_member(&state.members_ks, vetter_did).await?.is_none() {
return Err(TaskError::declared(
REVOKE_STATEMENT_ERR_NOT_MEMBER,
AppError::Forbidden(
"only a member of this community can withdraw a vetting statement".into(),
),
));
}
let (notice, created) =
record(&state.vetting_revocations_ks, vetter_did, body, Utc::now()).await?;
if created {
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
vetter_did,
None,
AuditEvent::VettingStatementRevoked(VettingStatementRevokedData {
statement_id: notice.statement_id.clone(),
statement_digest_multibase: notice.statement_digest_multibase.clone(),
reason: notice.reason.map(|r| r.to_string()),
}),
)
.await?;
}
info!(statement = %notice.statement_id, "vetting statement withdrawn");
crate::admin_actions::queues::raise_vetting_reviews(state).await;
}
Ok(notice)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
async fn ks() -> (tempfile::TempDir, Store, KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace("vetting_revocations").unwrap();
(dir, store, ks)
}
fn body(id: &str, digest: &str) -> RevokeStatement {
serde_json::from_value(json!({
"statementId": id,
"statementDigestMultibase": digest,
"reason": "mistake",
}))
.unwrap()
}
fn digest_of(v: serde_json::Value) -> String {
dtg_credentials::digest_multibase_json(&v).unwrap()
}
#[tokio::test]
async fn a_notice_withdraws_only_its_own_issuers_statement() {
let (_d, _s, ks) = ks().await;
let digest = digest_of(json!({ "statement": 1 }));
record(
&ks,
"did:key:zCarol",
&body("urn:uuid:s1", &digest),
Utc::now(),
)
.await
.unwrap();
assert!(
is_revoked(&ks, "did:key:zCarol", "urn:uuid:s1", &digest)
.await
.unwrap()
);
assert!(
!is_revoked(&ks, "did:key:zMallory", "urn:uuid:s1", &digest)
.await
.unwrap(),
"a notice is keyed by its sender — it cannot reach another issuer's statement"
);
let other = digest_of(json!({ "statement": 2 }));
assert!(
!is_revoked(&ks, "did:key:zCarol", "urn:uuid:s1", &other)
.await
.unwrap(),
"a different credential reusing the id is not withdrawn"
);
}
#[tokio::test]
async fn repeating_a_notice_converges_on_the_first() {
let (_d, _s, ks) = ks().await;
let digest = digest_of(json!({ "statement": 1 }));
let first_time = Utc::now() - chrono::Duration::hours(1);
let (first, created) = record(
&ks,
"did:key:zCarol",
&body("urn:uuid:s1", &digest),
first_time,
)
.await
.unwrap();
assert!(created);
let (again, created) = record(
&ks,
"did:key:zCarol",
&body("urn:uuid:s1", &digest),
Utc::now(),
)
.await
.unwrap();
assert!(!created);
assert_eq!(again.recorded_at, first.recorded_at);
assert_eq!(again.reason, Some(RevocationReason::Mistake));
}
#[tokio::test]
async fn a_digest_that_does_not_decode_is_refused_and_names_nothing() {
let (_d, _s, ks) = ks().await;
let undecodable = "z1111111111111111111111";
let err = record(
&ks,
"did:key:zCarol",
&body("urn:uuid:s1", undecodable),
Utc::now(),
)
.await
.unwrap_err();
assert!(matches!(err, AppError::Validation(_)), "{err:?}");
assert!(
!is_revoked(&ks, "did:key:zCarol", "urn:uuid:s1", undecodable)
.await
.unwrap()
);
}
#[test]
fn a_notice_that_breaks_its_schema_never_reaches_the_store() {
let digest = digest_of(json!({ "statement": 1 }));
for (id, digest) in [(" ", digest.as_str()), ("urn:uuid:s1", "not-multibase")] {
assert!(
serde_json::from_value::<RevokeStatement>(json!({
"statementId": id,
"statementDigestMultibase": digest,
}))
.is_err(),
"{id} / {digest}"
);
}
}
}