use std::sync::Arc;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use serde_json::Value as JsonValue;
use tokio::sync::RwLock;
use tracing::{info, warn};
use vti_common::seed_store::SeedStore;
use crate::auth::AuthClaims;
use crate::config::AppConfig;
use crate::didcomm_bridge::DIDCommBridge;
use crate::error::AppError;
use crate::operations::did_webvh::{UpdateDidWebvhOptions, update_did_webvh};
use crate::store::KeyspaceHandle;
use crate::webvh_store;
#[derive(Debug, Clone)]
pub struct DidCleanupOutcome {
pub did: String,
pub removed_vm_count: usize,
pub error: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct CleanupSummary {
pub touched: Vec<DidCleanupOutcome>,
pub succeeded: usize,
pub failed: usize,
}
#[allow(clippy::too_many_arguments)]
pub async fn strip_all_passkey_vms(
config: &Arc<RwLock<AppConfig>>,
keys_ks: &KeyspaceHandle,
imported_ks: &KeyspaceHandle,
contexts_ks: &KeyspaceHandle,
webvh_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
seed_store: &dyn SeedStore,
did_resolver: &DIDCacheClient,
didcomm_bridge: &Arc<DIDCommBridge>,
auth: &AuthClaims,
webvh_auth_locks: &crate::operations::did_webvh::WebvhAuthLocks,
channel: &str,
) -> Result<CleanupSummary, AppError> {
let dids = webvh_store::list_dids(webvh_ks).await?;
let mut summary = CleanupSummary::default();
for record in dids {
let Some(did_log) = webvh_store::get_did_log(webvh_ks, &record.did).await? else {
continue;
};
let current_doc = match super::document::current_document_from_log(&did_log) {
Ok(doc) => doc,
Err(e) => {
warn!(
did = %record.did,
error = %e,
"passkey-vm cleanup: failed to parse current document; skipping",
);
summary.failed += 1;
summary.touched.push(DidCleanupOutcome {
did: record.did,
removed_vm_count: 0,
error: Some(format!("parse current doc: {e}")),
});
continue;
}
};
let (patched_doc, removed_count) = strip_passkey_vms_from_doc(current_doc);
if removed_count == 0 {
continue;
}
let scid = &record.scid;
let vta_did = {
let cfg = config.read().await;
cfg.vta_did.clone()
};
let deps = crate::operations::did_webvh::WebvhDeps {
keys_ks,
imported_ks,
contexts_ks,
webvh_ks,
audit_ks,
seed_store,
did_resolver,
didcomm_bridge,
auth_locks: webvh_auth_locks,
};
let result = update_did_webvh(
&deps,
auth,
scid,
UpdateDidWebvhOptions {
document: Some(patched_doc),
..Default::default()
},
vta_did.as_deref(),
channel,
)
.await;
match result {
Ok(_) => {
summary.succeeded += 1;
info!(
did = %record.did,
removed = removed_count,
"passkey-vm cleanup: removed VMs",
);
summary.touched.push(DidCleanupOutcome {
did: record.did,
removed_vm_count: removed_count,
error: None,
});
}
Err(e) => {
summary.failed += 1;
warn!(
did = %record.did,
removed = removed_count,
error = %e,
"passkey-vm cleanup: WebVH update failed; operator should retry",
);
summary.touched.push(DidCleanupOutcome {
did: record.did,
removed_vm_count: removed_count,
error: Some(format!("WebVH update: {e}")),
});
}
}
}
Ok(summary)
}
pub fn strip_passkey_vms_from_doc(mut doc: JsonValue) -> (JsonValue, usize) {
let Some(obj) = doc.as_object_mut() else {
return (doc, 0);
};
let mut removed_ids: Vec<String> = Vec::new();
if let Some(vms) = obj
.get_mut("verificationMethod")
.and_then(JsonValue::as_array_mut)
{
vms.retain(|vm| {
let is_passkey = vm.get("webauthnCredentialId").is_some();
if is_passkey {
if let Some(id) = vm.get("id").and_then(JsonValue::as_str) {
removed_ids.push(id.to_string());
}
false } else {
true }
});
}
if removed_ids.is_empty() {
return (doc, 0);
}
for relation in &[
"authentication",
"assertionMethod",
"keyAgreement",
"capabilityInvocation",
"capabilityDelegation",
] {
if let Some(arr) = obj.get_mut(*relation).and_then(JsonValue::as_array_mut) {
arr.retain(|entry| {
let entry_id = match entry {
JsonValue::String(s) => s.as_str(),
JsonValue::Object(inner) => {
inner.get("id").and_then(JsonValue::as_str).unwrap_or("")
}
_ => "",
};
!removed_ids.iter().any(|rid| rid == entry_id)
});
}
}
let count = removed_ids.len();
(doc, count)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn doc_with(passkey_count: usize, other_vm_count: usize) -> JsonValue {
let mut vms = Vec::new();
for i in 0..passkey_count {
vms.push(json!({
"id": format!("did:webvh:test#passkey-{i}"),
"type": "Multikey",
"controller": "did:webvh:test",
"publicKeyMultibase": format!("z{i}"),
"webauthnCredentialId": format!("cred-{i}"),
}));
}
for i in 0..other_vm_count {
vms.push(json!({
"id": format!("did:webvh:test#key-{i}"),
"type": "Multikey",
"controller": "did:webvh:test",
"publicKeyMultibase": format!("z-other-{i}"),
}));
}
json!({
"id": "did:webvh:test",
"verificationMethod": vms,
"authentication": [
"did:webvh:test#passkey-0",
"did:webvh:test#key-0",
],
"assertionMethod": [
"did:webvh:test#passkey-1",
],
})
}
#[test]
fn strip_removes_passkey_vms_and_scrubs_relations() {
let doc = doc_with(2, 2);
let (patched, count) = strip_passkey_vms_from_doc(doc);
assert_eq!(count, 2);
let vms = patched
.get("verificationMethod")
.unwrap()
.as_array()
.unwrap();
assert_eq!(vms.len(), 2);
for vm in vms {
assert!(vm.get("webauthnCredentialId").is_none());
let id = vm.get("id").unwrap().as_str().unwrap();
assert!(id.contains("#key-"));
}
let auth = patched.get("authentication").unwrap().as_array().unwrap();
assert_eq!(auth.len(), 1);
assert_eq!(auth[0].as_str().unwrap(), "did:webvh:test#key-0");
let am = patched.get("assertionMethod").unwrap().as_array().unwrap();
assert!(am.is_empty(), "passkey-1 was removed from assertionMethod");
}
#[test]
fn strip_is_no_op_when_no_passkey_vms() {
let doc = doc_with(0, 3);
let (patched, count) = strip_passkey_vms_from_doc(doc.clone());
assert_eq!(count, 0);
assert_eq!(patched, doc, "byte-equivalent when no passkey VMs present");
}
#[test]
fn strip_handles_doc_with_no_verification_method() {
let doc = json!({ "id": "did:webvh:test" });
let (patched, count) = strip_passkey_vms_from_doc(doc.clone());
assert_eq!(count, 0);
assert_eq!(patched, doc);
}
#[test]
fn strip_handles_object_form_in_relation_arrays() {
let doc = json!({
"id": "did:webvh:test",
"verificationMethod": [
{
"id": "did:webvh:test#passkey-0",
"type": "Multikey",
"controller": "did:webvh:test",
"publicKeyMultibase": "z0",
"webauthnCredentialId": "cred-0",
}
],
"authentication": [
{ "id": "did:webvh:test#passkey-0", "type": "Multikey" },
"did:webvh:test#some-other-key",
],
});
let (patched, count) = strip_passkey_vms_from_doc(doc);
assert_eq!(count, 1);
let auth = patched.get("authentication").unwrap().as_array().unwrap();
assert_eq!(auth.len(), 1);
assert_eq!(auth[0].as_str().unwrap(), "did:webvh:test#some-other-key");
}
}