pub(crate) mod auth_cache;
mod concurrency;
mod document;
mod lifecycle;
mod realign;
mod register_server;
mod servers;
mod transport;
mod update;
pub(crate) mod webvh_keys;
pub use auth_cache::WebvhAuthLocks;
pub(crate) use auth_cache::{
agent_name_op_on_server, check_agent_name_on_server, delete_log_on_server,
list_agent_names_on_server, publish_log_to_server, register_did_atomic_on_server,
};
pub(crate) use concurrency::{RaceDetected, RecordSnapshot};
pub use document::{build_did_document, build_vta_did_document_with_sealed_transfer};
pub(crate) use document::{build_did_document_with_options, with_tsp_service};
pub use lifecycle::{get_did_webvh, list_dids_webvh};
pub use realign::{RealignDidKeysResultBody, RealignedKey, realign_did_key_records};
pub use register_server::{
RegisterDidWithServerError, RegisterDidWithServerParams, RegisterDidWithServerResult,
register_did_with_server,
};
pub use servers::{
list_webvh_server_domains, list_webvh_servers, reconcile_webvh_server_dids,
register_webvh_server, remove_webvh_server, retire_orphan_slot,
};
pub use update::{
AgentNameVerb, RotateDidWebvhKeysOptions, UpdateDidWebvhError, UpdateDidWebvhOptions,
UpdateDidWebvhResult, UpdatePlan, agent_name_op, check_agent_name, list_agent_names,
plan_did_webvh_update, resolve_webvh_did, rotate_did_webvh_keys, state_from_jsonl_pub,
update_did_webvh,
};
use std::sync::Arc;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use chrono::Utc;
use didwebvh_rs::create::{CreateDIDConfig, create_did};
use didwebvh_rs::log_entry::{LogEntry, LogEntryMethods};
use didwebvh_rs::parameters::Parameters as WebVHParameters;
use didwebvh_rs::url::WebVHURL;
use tracing::{info, warn};
use url::Url;
use affinidi_tdk::secrets_resolver::secrets::Secret;
use crate::didcomm_bridge::DIDCommBridge;
use vta_sdk::protocols::did_management::{
create::{CreateDidWebvhBody, CreateDidWebvhResultBody, WebvhPathMode},
delete::DeleteDidWebvhResultBody,
};
use vta_sdk::webvh::{WebvhDidRecord, WebvhServerRecord};
use crate::auth::AuthClaims;
use crate::config::AppConfig;
use crate::error::AppError;
use crate::keys::imported;
use crate::keys::seed_store::SeedStore;
use crate::keys::seeds::{get_active_seed_id, load_seed_bytes};
use crate::keys::{self, KeyType as SdkKeyType, encode_private_multibase};
use crate::store::KeyspaceHandle;
use crate::webvh_client::{RequestUriResponse, WebvhClient};
use crate::webvh_store;
use vta_sdk::keys::{KeyOrigin, KeyRecord, KeyStatus, KeyType};
use vta_support::version_time::next_version_time;
use zeroize::Zeroize;
use vti_common::slip10::{DerivationPath, ExtendedSigningKey};
pub struct DeleteCascadeDeps<'a> {
pub acl_ks: &'a KeyspaceHandle,
pub sessions_ks: &'a KeyspaceHandle,
pub issued_credentials_ks: &'a KeyspaceHandle,
}
pub struct WebvhDeps<'a> {
pub keys_ks: &'a KeyspaceHandle,
pub imported_ks: &'a KeyspaceHandle,
pub contexts_ks: &'a KeyspaceHandle,
pub webvh_ks: &'a KeyspaceHandle,
pub delete_cascade: Option<DeleteCascadeDeps<'a>>,
pub audit: &'a vta_audit::SharedAuditSink,
pub seed_store: &'a dyn SeedStore,
pub did_resolver: &'a DIDCacheClient,
pub didcomm_bridge: &'a Arc<DIDCommBridge>,
pub auth_locks: &'a WebvhAuthLocks,
#[cfg(feature = "tsp")]
pub tsp: Option<crate::operations::outbound::TspSender>,
}
impl<'a> WebvhDeps<'a> {
#[cfg(feature = "webvh")]
pub fn from_app_state(
s: &'a crate::server::AppState,
did_resolver: &'a DIDCacheClient,
) -> Self {
Self {
keys_ks: &s.keys_ks,
imported_ks: &s.imported_ks,
contexts_ks: &s.contexts_ks,
webvh_ks: &s.webvh_ks,
delete_cascade: Some(DeleteCascadeDeps {
acl_ks: &s.acl_ks,
sessions_ks: &s.sessions_ks,
issued_credentials_ks: &s.issued_credentials_ks,
}),
audit: &s.audit_sink,
seed_store: &*s.seed_store,
did_resolver,
#[cfg(any(feature = "didcomm", feature = "tsp"))]
didcomm_bridge: &s.didcomm_bridge,
#[cfg(not(any(feature = "didcomm", feature = "tsp")))]
didcomm_bridge: crate::didcomm_bridge::DIDCommBridge::placeholder_ref(),
auth_locks: &s.webvh_auth_locks,
#[cfg(feature = "tsp")]
tsp: crate::operations::outbound::TspSender::from_app_state(s),
}
}
#[cfg(all(feature = "webvh", feature = "didcomm"))]
pub fn from_vta_state(
s: &'a crate::messaging::router::VtaState,
did_resolver: &'a DIDCacheClient,
) -> Self {
Self {
keys_ks: &s.keys_ks,
imported_ks: &s.imported_ks,
contexts_ks: &s.contexts_ks,
webvh_ks: &s.webvh_ks,
delete_cascade: Some(DeleteCascadeDeps {
acl_ks: &s.acl_ks,
sessions_ks: &s.sessions_ks,
issued_credentials_ks: &s.issued_credentials_ks,
}),
audit: &s.audit_sink,
seed_store: &*s.seed_store,
did_resolver,
#[cfg(any(feature = "didcomm", feature = "tsp"))]
didcomm_bridge: &s.didcomm_bridge,
#[cfg(not(any(feature = "didcomm", feature = "tsp")))]
didcomm_bridge: crate::didcomm_bridge::DIDCommBridge::placeholder_ref(),
auth_locks: &s.webvh_auth_locks,
#[cfg(feature = "tsp")]
tsp: None,
}
}
}
pub struct CreateDidWebvhDeps<'a> {
pub keys_ks: &'a KeyspaceHandle,
pub imported_ks: &'a KeyspaceHandle,
pub contexts_ks: &'a KeyspaceHandle,
pub webvh_ks: &'a KeyspaceHandle,
pub did_templates_ks: &'a KeyspaceHandle,
pub audit: &'a vta_audit::SharedAuditSink,
pub seed_store: &'a dyn SeedStore,
pub config: &'a AppConfig,
pub did_resolver: &'a DIDCacheClient,
pub didcomm_bridge: &'a Arc<DIDCommBridge>,
pub auth_locks: &'a WebvhAuthLocks,
#[cfg(feature = "tsp")]
pub tsp: Option<crate::operations::outbound::TspSender>,
}
impl<'a> CreateDidWebvhDeps<'a> {
#[cfg(feature = "webvh")]
pub fn from_app_state(
s: &'a crate::server::AppState,
config: &'a AppConfig,
did_resolver: &'a DIDCacheClient,
) -> Self {
Self {
keys_ks: &s.keys_ks,
imported_ks: &s.imported_ks,
contexts_ks: &s.contexts_ks,
webvh_ks: &s.webvh_ks,
did_templates_ks: &s.did_templates_ks,
audit: &s.audit_sink,
seed_store: &*s.seed_store,
config,
did_resolver,
#[cfg(any(feature = "didcomm", feature = "tsp"))]
didcomm_bridge: &s.didcomm_bridge,
#[cfg(not(any(feature = "didcomm", feature = "tsp")))]
didcomm_bridge: crate::didcomm_bridge::DIDCommBridge::placeholder_ref(),
auth_locks: &s.webvh_auth_locks,
#[cfg(feature = "tsp")]
tsp: crate::operations::outbound::TspSender::from_app_state(s),
}
}
#[cfg(all(feature = "webvh", feature = "didcomm"))]
pub fn from_vta_state(
s: &'a crate::messaging::router::VtaState,
config: &'a AppConfig,
did_resolver: &'a DIDCacheClient,
) -> Self {
Self {
keys_ks: &s.keys_ks,
imported_ks: &s.imported_ks,
contexts_ks: &s.contexts_ks,
webvh_ks: &s.webvh_ks,
did_templates_ks: &s.did_templates_ks,
audit: &s.audit_sink,
seed_store: &*s.seed_store,
config,
did_resolver,
#[cfg(any(feature = "didcomm", feature = "tsp"))]
didcomm_bridge: &s.didcomm_bridge,
#[cfg(not(any(feature = "didcomm", feature = "tsp")))]
didcomm_bridge: crate::didcomm_bridge::DIDCommBridge::placeholder_ref(),
auth_locks: &s.webvh_auth_locks,
#[cfg(feature = "tsp")]
tsp: None,
}
}
}
pub(crate) async fn refresh_resolver_doc_from_log(
did_resolver: &DIDCacheClient,
did: &str,
did_log: &str,
channel: &str,
) {
let doc_value = match crate::operations::protocol::document::current_document_from_log(did_log)
{
Ok(doc) => doc,
Err(e) => {
warn!(
channel,
did = %did,
error = %e,
"resolver refresh skipped: parse current DID document from did.jsonl failed; keeping last-known-good cache entry"
);
return;
}
};
let doc = match serde_json::from_value(doc_value) {
Ok(doc) => doc,
Err(e) => {
warn!(
channel,
did = %did,
error = %e,
"resolver refresh skipped: deserialize DID document failed; keeping last-known-good cache entry"
);
return;
}
};
let mut cache = did_resolver.clone();
cache.add_did_document(did, doc).await;
}
#[derive(Debug)]
struct KeySlotPlan {
signing: Vec<KeyType>,
additional_signing: Vec<(String, Vec<KeyType>)>,
}
impl Default for KeySlotPlan {
fn default() -> Self {
Self {
signing: vec![KeyType::Ed25519],
additional_signing: Vec::new(),
}
}
}
fn log_entry_capable_signing(preference: &[KeyType]) -> Result<Vec<KeyType>, AppError> {
let usable: Vec<KeyType> = preference
.iter()
.filter(|k| matches!(k, KeyType::Ed25519))
.cloned()
.collect();
if usable.is_empty() {
return Err(AppError::Validation(format!(
"the template's `signing` slot names {preference:?}, and a did:webvh log entry can \
only be signed with ed25519 (didwebvh 1.0 mandates eddsa-jcs-2022). Add `ed25519` \
to that slot's algorithms and declare the post-quantum key as an additional \
signing slot — a DID publishing both is what lets its holder issue a credential \
carrying one proof each verifier can check."
)));
}
Ok(usable)
}
fn plan_key_slots(
slots: &std::collections::BTreeMap<String, vta_sdk::did_templates::KeySlot>,
) -> Result<KeySlotPlan, AppError> {
use vta_sdk::did_templates::{KeyPurpose, SLOT_KA, SLOT_SIGNING};
if slots.is_empty() {
return Ok(KeySlotPlan::default());
}
let parse =
|slot: &str, spec: &vta_sdk::did_templates::KeySlot| -> Result<Vec<KeyType>, AppError> {
spec.algorithms
.iter()
.map(|a| {
serde_json::from_value::<KeyType>(serde_json::Value::String(a.clone())).map_err(
|_| {
AppError::Validation(format!(
"key slot '{slot}' names algorithm '{a}', which this build does not \
know"
))
},
)
})
.collect()
};
let mut plan = KeySlotPlan {
signing: Vec::new(),
additional_signing: Vec::new(),
};
for (slot, spec) in slots {
match (slot.as_str(), spec.purpose) {
(SLOT_SIGNING, _) => plan.signing = parse(slot, spec)?,
(SLOT_KA, _) => {}
(_, KeyPurpose::Signing) => {
plan.additional_signing
.push((slot.clone(), parse(slot, spec)?));
}
(_, KeyPurpose::KeyAgreement) => {
return Err(AppError::Validation(format!(
"key slot '{slot}' declares a second key-agreement key, which this VTA \
cannot mint. One key-agreement key per DID: a second `keyAgreement` entry \
changes which secret an inbound encrypted message resolves to, and nothing \
here decides that. Declare it as `{SLOT_KA}` if it is the DID's only one."
)));
}
}
}
if plan.signing.is_empty() {
return Err(AppError::Validation(format!(
"the template declares key slots but none named '{SLOT_SIGNING}'; every DID this \
VTA mints needs a primary signing key"
)));
}
Ok(plan)
}
async fn resolve_template_for_render(
did_templates_ks: &KeyspaceHandle,
name: &str,
template_context: Option<&str>,
) -> Result<vta_sdk::did_templates::DidTemplateRecord, AppError> {
use vta_sdk::did_templates::{DidTemplateRecord, Scope, load_embedded};
if let Some(ctx) = template_context
&& let Some(record) =
crate::did_templates::get_context_template(did_templates_ks, ctx, name).await?
{
return Ok(record);
}
if let Some(record) = crate::did_templates::get_global_template(did_templates_ks, name).await? {
return Ok(record);
}
if let Ok(tpl) = load_embedded(name) {
return Ok(DidTemplateRecord {
template: tpl,
scope: Scope::Builtin,
created_at: 0,
updated_at: 0,
created_by: "builtin".into(),
});
}
Err(AppError::NotFound(format!(
"DID template '{name}' not found (searched{} global, builtin)",
template_context
.map(|c| format!(" context '{c}',"))
.unwrap_or_default()
)))
}
pub struct CreateDidWebvhParams {
pub context_id: String,
pub server_id: Option<String>,
pub url: Option<String>,
pub path_mode: WebvhPathMode,
pub domain: Option<String>,
pub label: Option<String>,
pub portable: bool,
pub add_mediator_service: bool,
pub add_tsp_service: bool,
pub additional_services: Option<Vec<serde_json::Value>>,
pub pre_rotation_count: u32,
pub did_document: Option<serde_json::Value>,
pub did_log: Option<String>,
pub set_primary: bool,
pub pre_derived: Option<keys::DerivedEntityKeys>,
pub signing_key_id: Option<String>,
pub ka_key_id: Option<String>,
pub template: Option<String>,
pub template_context: Option<String>,
pub template_vars: std::collections::HashMap<String, serde_json::Value>,
pub is_vta_identity: bool,
}
impl From<CreateDidWebvhBody> for CreateDidWebvhParams {
fn from(body: CreateDidWebvhBody) -> Self {
Self {
context_id: body.context_id,
server_id: body.server_id,
url: body.url,
path_mode: WebvhPathMode::resolve(body.path_mode, body.path),
domain: body.domain,
label: body.label,
portable: body.portable.unwrap_or(true),
add_mediator_service: body.add_mediator_service.unwrap_or(false),
add_tsp_service: body.add_tsp_service.unwrap_or(false),
additional_services: body.additional_services,
pre_rotation_count: body.pre_rotation_count.unwrap_or(0),
did_document: body.did_document,
did_log: body.did_log,
pre_derived: None,
set_primary: body.set_primary.unwrap_or(true),
signing_key_id: body.signing_key_id,
ka_key_id: body.ka_key_id,
template: body.template,
template_context: body.template_context,
template_vars: body.template_vars.unwrap_or_default(),
is_vta_identity: false,
}
}
}
async fn load_key_as_secret(
keys_ks: &KeyspaceHandle,
imported_ks: &KeyspaceHandle,
seed_store: &dyn SeedStore,
key_id: &str,
expected_type: KeyType,
auth: &AuthClaims,
) -> Result<(Secret, String, KeyRecord), AppError> {
let record: KeyRecord = keys_ks
.get(keys::store_key(key_id))
.await?
.ok_or_else(|| AppError::NotFound(format!("key {key_id} not found")))?;
if record.key_type != expected_type {
return Err(AppError::Validation(format!(
"key {key_id} is {} but expected {}",
record.key_type, expected_type
)));
}
if record.status != KeyStatus::Active {
return Err(AppError::Validation(format!(
"key {key_id} is not active (status: {:?})",
record.status
)));
}
if let Some(ref ctx) = record.context_id {
auth.require_context(ctx)?;
} else if !auth.is_super_admin() {
return Err(AppError::Forbidden(
"only super admin can use keys without a context".into(),
));
}
let private_key_multibase = match record.origin {
KeyOrigin::Internal => {
return Err(AppError::Validation(format!(
"key `{key_id}` is an internal key and cannot sign did:webvh log \
entries: internal keys are unrecoverable, and losing the update \
key would freeze this DID permanently. Use a derived key for the \
update authority; an internal key may still be a signing \
verificationMethod in the document"
)));
}
KeyOrigin::Imported => {
let seed = load_seed_bytes(keys_ks, seed_store, None)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
let mut secret_bytes = imported::load_secret(
imported_ks,
keys_ks,
&seed,
key_id,
&record.key_type.to_string(),
)
.await?;
let priv_mb = encode_private_multibase(&record.key_type, &secret_bytes);
secret_bytes.zeroize();
priv_mb
}
KeyOrigin::Derived => {
let seed = load_seed_bytes(keys_ks, seed_store, record.seed_id)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
let bip32 = ExtendedSigningKey::from_seed(&seed).map_err(|e| {
AppError::Internal(format!("failed to create BIP-32 root key: {e}"))
})?;
let derivation_path: DerivationPath = record
.derivation_path
.parse()
.map_err(|e| AppError::Internal(format!("invalid derivation path: {e}")))?;
let derived_key = bip32
.derive(&derivation_path)
.map_err(|e| AppError::Internal(format!("key derivation failed: {e}")))?;
encode_private_multibase(&KeyType::Ed25519, derived_key.signing_key.as_bytes())
}
};
let secret = Secret::from_multibase(&private_key_multibase, None).map_err(|e| {
AppError::Internal(format!("failed to construct Secret from key {key_id}: {e}"))
})?;
Ok((secret, record.public_key.clone(), record))
}
fn document_has_didcomm_service(doc: &serde_json::Value) -> bool {
doc.get("service")
.and_then(|s| s.as_array())
.is_some_and(|services| {
services.iter().any(|svc| {
svc.get("type")
.and_then(|t| t.as_str())
.is_some_and(|t| t == "DIDCommMessaging")
|| svc
.get("type")
.and_then(|t| t.as_array())
.is_some_and(|types| {
types
.iter()
.any(|t| t.as_str().is_some_and(|s| s == "DIDCommMessaging"))
})
})
})
}
#[allow(clippy::too_many_arguments)]
async fn authenticated_server_transport<'a>(
keys_ks: &KeyspaceHandle,
imported_ks: &KeyspaceHandle,
seed_store: &dyn SeedStore,
audit: &vta_audit::SharedAuditSink,
webvh_ks: &KeyspaceHandle,
did_resolver: &'a DIDCacheClient,
didcomm_bridge: &'a Arc<DIDCommBridge>,
auth_locks: &WebvhAuthLocks,
vta_did: Option<&str>,
server: &WebvhServerRecord,
#[cfg(feature = "tsp")] tsp: Option<crate::operations::outbound::TspSender>,
) -> Result<WebvhTransport<'a>, AppError> {
let vta_did = vta_did.ok_or_else(|| {
AppError::Validation(
"vta_did is not configured; the VTA needs its own DID to authenticate to a webvh \
hosting server (set `vta_did` in config / VTA_DID)"
.into(),
)
})?;
let identity = auth_cache::load_vta_webvh_signing_identity(
keys_ks,
imported_ks,
seed_store,
audit,
vta_did,
)
.await?;
let auth_ctx = auth_cache::AuthContext {
webvh_ks,
identity: &identity,
locks: auth_locks,
};
WebvhTransport::from_server_authenticated(
server,
did_resolver,
didcomm_bridge,
&auth_ctx,
#[cfg(feature = "tsp")]
tsp,
)
.await
}
pub async fn create_did_webvh(
deps: &CreateDidWebvhDeps<'_>,
auth: &AuthClaims,
mut params: CreateDidWebvhParams,
channel: &str,
) -> Result<CreateDidWebvhResultBody, AppError> {
let CreateDidWebvhDeps {
keys_ks,
imported_ks,
contexts_ks,
webvh_ks,
did_templates_ks,
audit,
seed_store,
config,
did_resolver,
didcomm_bridge,
auth_locks,
..
} = *deps;
auth.require_admin()?;
auth.require_context(¶ms.context_id)?;
if params.template.is_some() && (params.did_document.is_some() || params.did_log.is_some()) {
return Err(AppError::Validation(
"template is mutually exclusive with did_document and did_log".into(),
));
}
if params.did_document.is_some() && params.did_log.is_some() {
return Err(AppError::Validation(
"did_document and did_log are mutually exclusive".into(),
));
}
if params.ka_key_id.is_some() && params.signing_key_id.is_none() {
return Err(AppError::Validation(
"ka_key_id requires signing_key_id".into(),
));
}
let serverless = match (¶ms.server_id, ¶ms.url) {
(Some(_), Some(_)) => {
return Err(AppError::Validation(
"server_id and url are mutually exclusive".into(),
));
}
(None, None) => {
return Err(AppError::Validation(
"either server_id or url is required".into(),
));
}
(None, Some(_)) => true,
(Some(_), None) => false,
};
let mut ctx = crate::contexts::get_context(contexts_ks, ¶ms.context_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("context not found: {}", params.context_id)))?;
let now = Utc::now();
if let Some(ref did_log) = params.did_log {
if !serverless {
return Err(AppError::Validation(
"final-mode create (a pre-signed `did_log`) is only supported \
serverless — `server_id` must be unset. To create a server-hosted \
DID, use template or did_document mode instead."
.into(),
));
}
let log_entry = LogEntry::deserialize_string(did_log, None)
.map_err(|e| AppError::Validation(format!("invalid did_log: {e}")))?;
let final_did_document = log_entry.get_did_document().map_err(|e| {
AppError::Validation(format!("failed to extract DID document from log: {e}"))
})?;
let final_did = final_did_document["id"]
.as_str()
.ok_or_else(|| AppError::Validation("DID document missing 'id' field".into()))?
.to_string();
let scid = log_entry.get_scid().unwrap_or_default().to_string();
if params.set_primary {
ctx.did = Some(final_did.clone());
ctx.updated_at = now;
crate::contexts::store_context(contexts_ks, &ctx)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
}
let server_id_str = params
.server_id
.as_deref()
.unwrap_or("serverless")
.to_string();
let did_record = WebvhDidRecord {
did: final_did.clone(),
server_id: server_id_str.clone(),
mnemonic: String::new(),
scid: scid.clone(),
context_id: params.context_id.clone(),
portable: params.portable,
log_entry_count: 1,
pre_rotation_count: 0,
next_fragment_id: 1,
created_at: now,
updated_at: now,
};
webvh_store::store_did(webvh_ks, &did_record).await?;
webvh_store::store_did_log(webvh_ks, &final_did, did_log).await?;
refresh_resolver_doc_from_log(did_resolver, &final_did, did_log, channel).await;
info!(
channel,
did = %final_did,
context = %params.context_id,
"did:webvh created (final mode)"
);
return Ok(CreateDidWebvhResultBody {
did: final_did.clone(),
context_id: params.context_id,
server_id: if serverless { None } else { params.server_id },
mnemonic: None,
scid,
portable: params.portable,
signing_key_id: String::new(),
ka_key_id: String::new(),
additional_key_ids: std::collections::BTreeMap::new(),
pre_rotation_key_count: 0,
created_at: now,
did_document: Some(final_did_document),
log_entry: Some(did_log.clone()),
});
}
let label = params.label.as_deref().unwrap_or(¶ms.context_id);
let user_specified_keys = params.signing_key_id.is_some();
let template_record = match params.template {
Some(ref template_name) => Some(
resolve_template_for_render(
did_templates_ks,
template_name,
params.template_context.as_deref(),
)
.await?,
),
None => None,
};
let key_slots = template_record
.as_ref()
.map(|r| r.template.key_slots())
.unwrap_or_default();
let slot_plan = plan_key_slots(&key_slots)?;
if !slot_plan.additional_signing.is_empty()
&& (user_specified_keys || params.pre_derived.is_some())
{
let slots: Vec<&str> = slot_plan
.additional_signing
.iter()
.map(|(s, _)| s.as_str())
.collect();
return Err(AppError::Validation(format!(
"template declares key slot(s) {slots:?} beyond the signing/key-agreement pair, but \
this request supplies its own keys. The VTA mints an extra slot from its own seed; \
it cannot do that for a DID whose keys you brought. Either drop the caller-supplied \
key ids and let the VTA derive, or use a template without the extra slot."
)));
}
let (derived, active_seed_id) = if let Some(mut pre) = params.pre_derived.take() {
let active_seed_id = get_active_seed_id(keys_ks)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
let pub_mb = pre
.signing_secret
.get_public_keymultibase()
.map_err(|e| AppError::Internal(format!("{e}")))?;
pre.signing_secret.id = format!("did:key:{pub_mb}#{pub_mb}");
(pre, Some(active_seed_id))
} else if let Some(ref signing_key_id) = params.signing_key_id {
let (mut signing_secret, signing_pub, signing_record) = load_key_as_secret(
keys_ks,
imported_ks,
seed_store,
signing_key_id,
KeyType::Ed25519,
auth,
)
.await?;
let pub_mb = signing_secret
.get_public_keymultibase()
.map_err(|e| AppError::Internal(format!("{e}")))?;
signing_secret.id = format!("did:key:{pub_mb}#{pub_mb}");
let (ka_secret, ka_pub, ka_path, ka_label, ka_key_type) =
if let Some(ref ka_key_id) = params.ka_key_id {
let (ka_secret, ka_pub, ka_record) = load_key_as_secret(
keys_ks,
imported_ks,
seed_store,
ka_key_id,
KeyType::X25519,
auth,
)
.await?;
(
ka_secret,
ka_pub,
ka_record.derivation_path,
ka_record
.label
.unwrap_or_else(|| format!("{label} key-agreement key")),
ka_record.key_type,
)
} else {
(
Secret::generate_ed25519(None, None),
String::new(),
String::new(),
String::new(),
KeyType::X25519,
)
};
let derived = keys::DerivedEntityKeys {
signing_secret,
signing_path: signing_record.derivation_path.clone(),
signing_pub,
signing_priv: String::new(), signing_label: signing_record
.label
.unwrap_or_else(|| format!("{label} signing key")),
signing_key_type: signing_record.key_type.clone(),
ka_secret,
ka_path,
ka_pub,
ka_priv: String::new(),
ka_label,
ka_key_type,
additional_signing: Vec::new(),
};
(derived, signing_record.seed_id)
} else {
let active_seed_id = get_active_seed_id(keys_ks)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
let seed = load_seed_bytes(keys_ks, seed_store, Some(active_seed_id))
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
let mut derived = keys::derive_entity_keys_with_preference(
&seed,
&ctx.base_path,
&format!("{label} signing key"),
&format!("{label} key-agreement key"),
keys_ks,
&log_entry_capable_signing(&slot_plan.signing)?,
)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
for (slot, preference) in &slot_plan.additional_signing {
let key = keys::derive_additional_signing_key(
&seed,
&ctx.base_path,
slot,
&format!("{label} {slot} key"),
keys_ks,
preference,
)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
derived.additional_signing.push(key);
}
let pub_mb = derived
.signing_secret
.get_public_keymultibase()
.map_err(|e| AppError::Internal(format!("{e}")))?;
derived.signing_secret.id = format!("did:key:{pub_mb}#{pub_mb}");
(derived, Some(active_seed_id))
};
let sealed_transfer = if params.is_vta_identity && !user_specified_keys {
let seed_for_st = if let Some(sid) = active_seed_id {
load_seed_bytes(keys_ks, seed_store, Some(sid))
.await
.map_err(|e| AppError::Internal(format!("{e}")))?
} else {
return Err(AppError::Internal(
"is_vta_identity set but no active seed — VTA identity requires seed-derived keys"
.into(),
));
};
Some(
keys::derive_sealed_transfer_key(
&seed_for_st,
&ctx.base_path,
&format!("{label} sealed-transfer producer-assertion key"),
keys_ks,
)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?,
)
} else {
None
};
let (url_str, mnemonic) = if serverless {
let url_str = params
.url
.as_ref()
.ok_or_else(|| AppError::Validation("url is required when serverless=true".into()))?
.clone();
let parsed_url =
Url::parse(&url_str).map_err(|e| AppError::Validation(format!("invalid url: {e}")))?;
WebVHURL::parse_url(&parsed_url)
.map_err(|e| AppError::Validation(format!("failed to parse WebVH URL: {e}")))?;
(url_str, None)
} else {
let server_id = params.server_id.as_ref().ok_or_else(|| {
AppError::Validation("server_id is required when serverless=false".into())
})?;
let server = webvh_store::get_server(webvh_ks, server_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("webvh server not found: {server_id}")))?;
let transport = authenticated_server_transport(
keys_ks,
imported_ks,
seed_store,
audit,
webvh_ks,
did_resolver,
didcomm_bridge,
auth_locks,
config.vta_did.as_deref(),
&server,
#[cfg(feature = "tsp")]
deps.tsp.clone(),
)
.await?;
let uri_response = transport
.request_uri(params.path_mode.to_request_path(), params.domain.as_deref())
.await?;
let parsed_url = Url::parse(&uri_response.did_url)
.map_err(|e| AppError::Internal(format!("invalid did_url from server: {e}")))?;
WebVHURL::parse_url(&parsed_url)
.map_err(|e| AppError::Internal(format!("failed to parse WebVH URL: {e}")))?;
(uri_response.did_url, Some(uri_response.mnemonic))
};
let has_ka = params.ka_key_id.is_some() || !user_specified_keys;
if let (Some(template_name), Some(record)) = (params.template.as_ref(), template_record) {
let mut vars = vta_sdk::did_templates::TemplateVars::new();
vars.insert_string("DID", "{DID}");
vars.insert_string("SIGNING_KEY_MB", derived.signing_pub.clone());
if has_ka {
vars.insert_string("KA_KEY_MB", derived.ka_pub.clone());
}
for key in &derived.additional_signing {
vars.insert_string(
vta_sdk::did_templates::DidTemplate::slot_var(&key.slot),
key.public_multibase.clone(),
);
}
if let Some(ref vta_did) = config.vta_did {
vars.insert_string("VTA_DID", vta_did.clone());
}
if let Some(ref vta_url) = config.public_url {
vars.insert_string("VTA_URL", vta_url.clone());
}
vars.insert_string("CONTEXT_ID", params.context_id.clone());
if let Some(ref did) = ctx.did {
vars.insert_string("CONTEXT_DID", did.clone());
}
vars.insert_string("NOW", Utc::now().to_rfc3339());
for (k, v) in ¶ms.template_vars {
vars.insert(k.clone(), v.clone());
}
let mut rendered = record.template.render(&vars).map_err(|e| {
AppError::Validation(format!("template '{template_name}' render failed: {e}"))
})?;
document::with_tsp_in_rendered_document(params.add_tsp_service, &mut rendered)?;
params.did_document = Some(rendered);
}
let additional_services = with_tsp_service(
params.add_tsp_service,
config,
params.additional_services.take(),
);
let did_document = match params.did_document {
Some(doc) => doc,
None if user_specified_keys => {
build_did_document_with_options(
&derived,
config,
has_ka,
params.add_mediator_service,
&additional_services,
)
}
None if sealed_transfer.is_some() => build_vta_did_document_with_sealed_transfer(
&derived,
sealed_transfer.as_ref().unwrap(),
config,
params.add_mediator_service,
&additional_services,
),
None => build_did_document(
&derived,
config,
params.add_mediator_service,
&additional_services,
),
};
if !has_ka && (params.add_mediator_service || document_has_didcomm_service(&did_document)) {
return Err(AppError::Validation(
"DIDCommMessaging services require a key-agreement key (ka_key_id)".into(),
));
}
let seed_for_pre_rotation = if params.pre_rotation_count > 0 {
let sid = match active_seed_id {
Some(id) => id,
None => get_active_seed_id(keys_ks)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?,
};
Some(
load_seed_bytes(keys_ks, seed_store, Some(sid))
.await
.map_err(|e| AppError::Internal(format!("{e}")))?,
)
} else {
None
};
let (next_key_hashes, pre_rotation_keys) = if let Some(ref seed) = seed_for_pre_rotation {
derive_pre_rotation_keys(
seed,
&ctx.base_path,
label,
keys_ks,
params.pre_rotation_count,
)
.await?
} else {
(vec![], vec![])
};
let parameters = WebVHParameters {
update_keys: Some(Arc::new(vec![derived.signing_pub.clone().into()])),
portable: Some(params.portable),
next_key_hashes: if next_key_hashes.is_empty() {
None
} else {
Some(Arc::new(
next_key_hashes.iter().cloned().map(Into::into).collect(),
))
},
..Default::default()
};
let create_config = CreateDIDConfig::builder()
.address(&url_str)
.authorization_key(derived.signing_secret.clone())
.did_document(did_document.clone())
.parameters(parameters)
.version_time(next_version_time(0, None))
.build()
.map_err(|e| AppError::Internal(format!("failed to build DID config: {e}")))?;
let result = create_did(create_config)
.await
.map_err(|e| AppError::Internal(format!("failed to create DID: {e}")))?;
let final_did = result.did().to_string();
let scid = result
.log_entry()
.get_scid()
.unwrap_or_default()
.to_string();
let log_content = serde_json::to_string(result.log_entry())
.map_err(|e| AppError::Internal(format!("failed to serialize DID log: {e}")))?;
let additional_pubs: Vec<(String, String)> = derived
.additional_signing
.iter()
.map(|k| (k.slot.clone(), k.public_multibase.clone()))
.collect();
let vm_ids = document::minted_vm_ids(
&did_document,
&final_did,
&derived.signing_pub,
if has_ka {
Some(derived.ka_pub.as_str())
} else {
None
},
&additional_pubs,
);
let ka_vm_id = vm_ids
.key_agreement
.clone()
.unwrap_or_else(|| format!("{final_did}#key-1"));
if !user_specified_keys {
keys::save_entity_key_records_with_ids(
&vm_ids.signing,
&ka_vm_id,
&derived,
keys_ks,
Some(¶ms.context_id),
active_seed_id,
)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
for key in &derived.additional_signing {
let vm_id = vm_ids.additional_signing.get(&key.slot).ok_or_else(|| {
AppError::Internal(format!(
"key slot '{}' was minted but the published document for '{final_did}' has \
no verificationMethod carrying its public key — the template and the \
rendered document disagree about where that key goes",
key.slot
))
})?;
keys::save_additional_signing_key_record(
vm_id,
key,
keys_ks,
Some(¶ms.context_id),
active_seed_id,
)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
}
if let Some(ref st) = sealed_transfer {
keys::save_sealed_transfer_key_record(
&final_did,
st,
keys_ks,
Some(¶ms.context_id),
active_seed_id,
)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
}
} else {
keys::save_key_record(
keys_ks,
&vm_ids.signing,
&derived.signing_path,
derived.signing_key_type.clone(),
&derived.signing_pub,
&derived.signing_label,
Some(¶ms.context_id),
active_seed_id,
)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
if has_ka {
keys::save_key_record(
keys_ks,
&ka_vm_id,
&derived.ka_path,
derived.ka_key_type.clone(),
&derived.ka_pub,
&derived.ka_label,
Some(¶ms.context_id),
active_seed_id,
)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
}
}
let pre_rotation_seed_id = active_seed_id.unwrap_or(0);
for (i, pk) in pre_rotation_keys.iter().enumerate() {
keys::save_key_record(
keys_ks,
&format!("{final_did}#pre-rotation-{i}"),
&pk.path,
SdkKeyType::Ed25519,
&pk.public_key,
&pk.label,
Some(¶ms.context_id),
Some(pre_rotation_seed_id),
)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
}
let genesis_version_id = result
.log_entry()
.get_version_id_fields()
.map(|(n, h)| format!("{n}-{h}"))
.map_err(|e| AppError::Internal(format!("read genesis version id: {e}")))?;
let signing_hash = Secret::base58_hash_string(&derived.signing_pub)
.map_err(|e| AppError::Internal(format!("hash genesis signing pubkey: {e}")))?;
let now_ts = Utc::now();
let signing_handle = webvh_keys::WebvhKeyHandle {
scid: scid.clone(),
version_id: genesis_version_id.clone(),
hash: signing_hash,
public_key: derived.signing_pub.clone(),
derivation_path: derived.signing_path.clone(),
seed_id: active_seed_id,
role: webvh_keys::WebvhKeyRole::UpdateKey,
label: derived.signing_label.clone(),
created_at: now_ts,
};
webvh_keys::install(keys_ks, &signing_handle)
.await
.map_err(|e| AppError::Internal(format!("install genesis update-key handle: {e}")))?;
for (i, (hash, pk)) in next_key_hashes
.iter()
.zip(pre_rotation_keys.iter())
.enumerate()
{
let handle = webvh_keys::WebvhKeyHandle {
scid: scid.clone(),
version_id: genesis_version_id.clone(),
hash: hash.clone(),
public_key: pk.public_key.clone(),
derivation_path: pk.path.clone(),
seed_id: Some(pre_rotation_seed_id),
role: webvh_keys::WebvhKeyRole::PreRotation,
label: format!("genesis pre-rotation #{i}"),
created_at: now_ts,
};
webvh_keys::install(keys_ks, &handle).await.map_err(|e| {
AppError::Internal(format!("install genesis pre-rotation handle #{i}: {e}"))
})?;
}
if params.set_primary {
ctx.did = Some(final_did.clone());
ctx.updated_at = now;
crate::contexts::store_context(contexts_ks, &ctx)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
}
let final_did_document = result
.log_entry()
.get_did_document()
.ok()
.unwrap_or(did_document);
if serverless {
let did_record = WebvhDidRecord {
did: final_did.clone(),
server_id: "serverless".to_string(),
mnemonic: String::new(),
scid: scid.clone(),
context_id: params.context_id.clone(),
portable: params.portable,
log_entry_count: 1,
pre_rotation_count: pre_rotation_keys.len() as u32,
next_fragment_id: vm_ids.next_fragment_id,
created_at: now,
updated_at: now,
};
webvh_store::store_did(webvh_ks, &did_record).await?;
webvh_store::store_did_log(webvh_ks, &final_did, &log_content).await?;
refresh_resolver_doc_from_log(did_resolver, &final_did, &log_content, channel).await;
info!(
channel,
did = %final_did,
context = %params.context_id,
"did:webvh created (serverless)"
);
Ok(CreateDidWebvhResultBody {
did: final_did.clone(),
context_id: params.context_id,
server_id: None,
mnemonic: None,
scid,
portable: params.portable,
signing_key_id: vm_ids.signing.clone(),
ka_key_id: ka_vm_id.clone(),
additional_key_ids: vm_ids.additional_signing.clone(),
pre_rotation_key_count: pre_rotation_keys.len() as u32,
created_at: now,
did_document: Some(final_did_document),
log_entry: Some(log_content),
})
} else {
let server_id = params.server_id.as_ref().unwrap();
let mnemonic = mnemonic.as_ref().unwrap();
let server = webvh_store::get_server(webvh_ks, server_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("webvh server not found: {server_id}")))?;
let transport = authenticated_server_transport(
keys_ks,
imported_ks,
seed_store,
audit,
webvh_ks,
did_resolver,
didcomm_bridge,
auth_locks,
config.vta_did.as_deref(),
&server,
#[cfg(feature = "tsp")]
deps.tsp.clone(),
)
.await?;
transport
.publish_did(mnemonic, &log_content, params.domain.as_deref())
.await?;
let did_record = WebvhDidRecord {
did: final_did.clone(),
server_id: server_id.clone(),
mnemonic: mnemonic.clone(),
scid: scid.clone(),
context_id: params.context_id.clone(),
portable: params.portable,
log_entry_count: 1,
pre_rotation_count: pre_rotation_keys.len() as u32,
next_fragment_id: vm_ids.next_fragment_id,
created_at: now,
updated_at: now,
};
webvh_store::store_did(webvh_ks, &did_record).await?;
webvh_store::store_did_log(webvh_ks, &final_did, &log_content).await?;
webvh_store::set_published_version(webvh_ks, &final_did, &genesis_version_id).await?;
refresh_resolver_doc_from_log(did_resolver, &final_did, &log_content, channel).await;
info!(
channel,
did = %final_did,
context = %params.context_id,
server = %server_id,
"did:webvh created and published"
);
Ok(CreateDidWebvhResultBody {
did: final_did.clone(),
context_id: params.context_id,
server_id: Some(server_id.clone()),
mnemonic: Some(mnemonic.clone()),
scid,
portable: params.portable,
signing_key_id: vm_ids.signing.clone(),
ka_key_id: ka_vm_id.clone(),
additional_key_ids: vm_ids.additional_signing.clone(),
pre_rotation_key_count: pre_rotation_keys.len() as u32,
created_at: now,
did_document: Some(final_did_document),
log_entry: Some(log_content),
})
}
}
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct DeleteDidOptions<'a> {
pub local_only: bool,
pub contexts_being_deleted: &'a [String],
}
impl<'a> DeleteDidOptions<'a> {
#[must_use]
pub fn local_only() -> Self {
Self {
local_only: true,
..Self::default()
}
}
#[must_use]
pub fn within_context_deletion(contexts: &'a [String]) -> Self {
Self {
contexts_being_deleted: contexts,
..Self::default()
}
}
}
pub async fn delete_did_webvh(
deps: &WebvhDeps<'_>,
auth: &AuthClaims,
did: &str,
vta_did: Option<&str>,
channel: &str,
) -> Result<DeleteDidWebvhResultBody, AppError> {
delete_did_webvh_with(
deps,
auth,
did,
vta_did,
channel,
DeleteDidOptions::default(),
)
.await
}
pub async fn delete_did_webvh_with(
deps: &WebvhDeps<'_>,
auth: &AuthClaims,
did: &str,
vta_did: Option<&str>,
channel: &str,
options: DeleteDidOptions<'_>,
) -> Result<DeleteDidWebvhResultBody, AppError> {
auth.require_admin()?;
let record = webvh_store::get_did(deps.webvh_ks, did)
.await?
.ok_or_else(|| AppError::NotFound(format!("webvh DID not found: {did}")))?;
auth.require_context(&record.context_id)?;
let blockers = plan_did_deletion_with(deps, auth, did, vta_did, options)
.await?
.blockers;
if !blockers.is_empty() {
return Err(AppError::Conflict(format!(
"{did} cannot be deleted — {} blocker{} to resolve first:\n{}",
blockers.len(),
if blockers.len() == 1 { "" } else { "s" },
blockers
.iter()
.map(|b| format!(" - {b}"))
.collect::<Vec<_>>()
.join("\n")
)));
}
let cascade = deps.delete_cascade.as_ref().ok_or_else(|| {
AppError::Internal(
"this code path cannot delete a DID: it has no access to the ACL, session and \
issued-credential keyspaces that must be cleaned up with it"
.into(),
)
})?;
let revoked = revoke_credentials_for_did(cascade.issued_credentials_ks, did).await?;
if revoked > 0 {
info!(channel, did = %did, revoked, "revoked credentials issued to a DID being deleted");
}
let mut daemon_cleanup_error: Option<String> = None;
let server = if record.server_id == SERVERLESS_SERVER_ID {
None
} else {
match webvh_store::get_server(deps.webvh_ks, &record.server_id).await? {
Some(server) => Some(server),
None if options.local_only => {
let msg = format!(
"deleted locally only: hosting server `{}` is not registered, so the \
published log for {did} was not deleted there and may still resolve",
record.server_id
);
tracing::warn!(did = %did, server_id = %record.server_id, "{msg}");
daemon_cleanup_error = Some(msg);
None
}
None => {
return Err(AppError::Conflict(format!(
"{did} cannot be deleted — {}",
unregistered_server_refusal(did, &record.server_id)
)));
}
}
};
if let Some(server) = server {
match vta_did {
Some(vta_did_value) => {
if let Err(e) =
delete_log_on_server(deps, vta_did_value, &server, &record.mnemonic, None).await
{
tracing::warn!(
did = %did,
server_id = %server.id,
error = %e,
"did-hosting-daemon delete_did failed; continuing local cleanup but DID is now orphaned on the daemon"
);
daemon_cleanup_error = Some(format!(
"daemon `{}` rejected delete: {e} — DID is orphaned on the daemon and \
must be cleaned up out-of-band",
server.id
));
}
}
None => {
let msg = format!(
"VTA DID is not configured — skipping daemon-side delete on server `{}`. \
Local record removed, but the daemon entry is now orphaned.",
server.id
);
tracing::warn!(did = %did, "{msg}");
daemon_cleanup_error = Some(msg);
}
}
}
webvh_store::delete_did(deps.webvh_ks, did).await?;
let key_bound = record.next_fragment_id.clamp(2, 1024);
for i in 0..key_bound {
let _ = deps
.keys_ks
.remove(keys::store_key(&format!("{did}#key-{i}")))
.await;
}
let pre_rotation_bound = std::cmp::max(record.pre_rotation_count, 32);
for i in 0..pre_rotation_bound {
let key_id = format!("{did}#pre-rotation-{i}");
let store_key = keys::store_key(&key_id);
if deps.keys_ks.get_raw(store_key.clone()).await?.is_none() {
break;
}
let _ = deps.keys_ks.remove(store_key).await;
}
vti_common::acl::delete_acl_entry(cascade.acl_ks, did).await?;
let sessions_revoked = revoke_sessions_for_did(cascade.sessions_ks, did).await?;
info!(
channel,
did = %did,
credentials_revoked = revoked,
sessions_revoked,
"webvh DID deleted"
);
Ok(DeleteDidWebvhResultBody {
did: did.to_string(),
deleted: true,
daemon_cleanup_error,
})
}
#[derive(Debug, Default)]
pub struct DidDeletionPlan {
pub blockers: Vec<String>,
pub credentials_to_revoke: Vec<String>,
pub sessions_to_revoke: usize,
pub has_acl_entry: bool,
}
impl DidDeletionPlan {
#[must_use]
pub fn touches_anything_else(&self) -> bool {
!self.credentials_to_revoke.is_empty() || self.sessions_to_revoke > 0 || self.has_acl_entry
}
}
pub async fn plan_did_deletion(
deps: &WebvhDeps<'_>,
auth: &AuthClaims,
did: &str,
vta_did: Option<&str>,
) -> Result<DidDeletionPlan, AppError> {
plan_did_deletion_with(deps, auth, did, vta_did, DeleteDidOptions::default()).await
}
pub async fn plan_did_deletion_with(
deps: &WebvhDeps<'_>,
auth: &AuthClaims,
did: &str,
vta_did: Option<&str>,
options: DeleteDidOptions<'_>,
) -> Result<DidDeletionPlan, AppError> {
let blockers = delete_blockers(deps, auth, did, vta_did, options).await?;
let Some(cascade) = deps.delete_cascade.as_ref() else {
return Ok(DidDeletionPlan {
blockers,
..Default::default()
});
};
Ok(DidDeletionPlan {
blockers,
credentials_to_revoke: credentials_issued_to(cascade.issued_credentials_ks, did).await?,
sessions_to_revoke: sessions_for_did(cascade.sessions_ks, did).await?.len(),
has_acl_entry: vti_common::acl::get_acl_entry(cascade.acl_ks, did)
.await?
.is_some(),
})
}
async fn delete_blockers(
deps: &WebvhDeps<'_>,
auth: &AuthClaims,
did: &str,
vta_did_value: Option<&str>,
options: DeleteDidOptions<'_>,
) -> Result<Vec<String>, AppError> {
let mut blockers = Vec::new();
if let Some(record) = webvh_store::get_did(deps.webvh_ks, did).await? {
let serverless = record.server_id == SERVERLESS_SERVER_ID;
let registered = serverless
|| webvh_store::get_server(deps.webvh_ks, &record.server_id)
.await?
.is_some();
match (registered, options.local_only) {
(false, false) => blockers.push(unregistered_server_refusal(did, &record.server_id)),
(false, true) | (true, false) => {}
(true, true) if serverless => blockers.push(format!(
"--local-only was given, but {did} is serverless: there is no host copy to \
leave behind. Delete it without --local-only"
)),
(true, true) => blockers.push(format!(
"--local-only was given, but hosting server `{}` is still registered, so the \
published log can be deleted there. Delete {did} without --local-only",
record.server_id
)),
}
}
if vta_did_value == Some(did) {
blockers.push(format!(
"this VTA's own DID ({did}) — deleting it would strand the VTA rather than decommission it; change `vta_did` first if that is really the intent"
));
}
for ctx in crate::contexts::list_contexts(deps.contexts_ks).await? {
if ctx.did.as_deref() == Some(did) && !options.contexts_being_deleted.contains(&ctx.id) {
let id = &ctx.id;
blockers.push(format!(
"context `{id}` acts as this DID — reassign it first: `pnm contexts update {id} --did <new-did>`"
));
}
}
let _ = auth;
Ok(blockers)
}
const SERVERLESS_SERVER_ID: &str = "serverless";
fn unregistered_server_refusal(did: &str, server_id: &str) -> String {
format!(
"its published log is on hosting server `{server_id}`, which is no longer registered \
here. The VTA cannot delete the log on that host, and deleting only the local record \
would discard the keys needed to remove it later while the DID keeps resolving. \
Register the server again, then retry the delete: \
`pnm did-mgmt servers add --id {server_id} --did <server-did>` \
(offline: `vta did-mgmt servers add --id {server_id} --did <server-did>`). \
If that host is gone for good, delete only the local record with \
`vta did-mgmt dids delete {did} --local-only` (offline, VTA stopped)"
)
}
async fn credentials_issued_to(
issued_credentials_ks: &KeyspaceHandle,
did: &str,
) -> Result<Vec<String>, AppError> {
use crate::operations::credentials::IssuedCredentialRecord;
let mut ids = Vec::new();
for (_key, raw) in issued_credentials_ks
.prefix_iter_raw(b"cred:".to_vec())
.await?
{
let Ok(record) = serde_json::from_slice::<IssuedCredentialRecord>(&raw) else {
continue;
};
if record.holder == did && record.revoked_at.is_none() {
ids.push(record.id);
}
}
Ok(ids)
}
async fn sessions_for_did(
sessions_ks: &KeyspaceHandle,
did: &str,
) -> Result<Vec<String>, AppError> {
use vti_common::auth::session::list_sessions;
Ok(list_sessions(sessions_ks)
.await?
.into_iter()
.filter(|s| s.did == did)
.map(|s| s.session_id)
.collect())
}
async fn revoke_credentials_for_did(
issued_credentials_ks: &KeyspaceHandle,
did: &str,
) -> Result<usize, AppError> {
use crate::operations::credentials::IssuedCredentialRecord;
let mut revoked = 0usize;
for (key, raw) in issued_credentials_ks
.prefix_iter_raw(b"cred:".to_vec())
.await?
{
let Ok(mut record) = serde_json::from_slice::<IssuedCredentialRecord>(&raw) else {
tracing::warn!(
"unparseable issued-credential record skipped during DID deletion; \
it may name the deleted DID and will not have been revoked"
);
continue;
};
if record.holder != did || record.revoked_at.is_some() {
continue;
}
record.revoked_at = Some(chrono::Utc::now().to_rfc3339());
record.revocation_reason = Some("holder DID deleted".to_string());
let bytes = serde_json::to_vec(&record)
.map_err(|e| AppError::Internal(format!("re-encode credential record: {e}")))?;
issued_credentials_ks.insert_raw(key, bytes).await?;
revoked += 1;
}
Ok(revoked)
}
async fn revoke_sessions_for_did(
sessions_ks: &KeyspaceHandle,
did: &str,
) -> Result<usize, AppError> {
use vti_common::auth::session::delete_session;
let ids = sessions_for_did(sessions_ks, did).await?;
for session_id in &ids {
delete_session(sessions_ks, session_id).await?;
}
Ok(ids.len())
}
pub(super) enum WebvhTransport<'a> {
Rest(WebvhClient),
TrustTask(crate::webvh_didcomm::WebvhDIDCommClient<'a>),
}
impl<'a> WebvhTransport<'a> {
pub(super) async fn from_server(
server: &WebvhServerRecord,
did_resolver: &'a DIDCacheClient,
didcomm_bridge: &'a Arc<DIDCommBridge>,
#[cfg(feature = "tsp")] tsp: Option<crate::operations::outbound::TspSender>,
) -> Result<Self, AppError> {
let resolved = did_resolver.resolve(&server.did).await.map_err(|e| {
AppError::Internal(format!("failed to resolve server DID {}: {e}", server.did))
})?;
match transport::resolve_server_transport(&resolved.doc.service) {
Some(transport::ResolvedTransport::TrustTask) => {
info!(server_did = %server.did, transport = "trust-task", "resolved webvh server endpoint");
Ok(Self::TrustTask(
crate::webvh_didcomm::WebvhDIDCommClient::new(
didcomm_bridge,
did_resolver,
server.did.clone(),
#[cfg(feature = "tsp")]
tsp,
),
))
}
Some(transport::ResolvedTransport::Rest { url }) => {
info!(server_did = %server.did, transport = "rest", %url, "resolved webvh server endpoint");
let client = WebvhClient::new(&url, &server.did)?;
Ok(Self::Rest(client))
}
None => Err(AppError::Validation(format!(
"server DID {} has no supported webvh endpoint (expected: {})",
server.did,
transport::SUPPORTED_TYPES_HUMAN,
))),
}
}
async fn request_uri(
&self,
path: Option<&str>,
domain: Option<&str>,
) -> Result<RequestUriResponse, AppError> {
match self {
Self::Rest(c) => c.request_uri(path, domain).await,
Self::TrustTask(c) => c.request_uri(path, domain).await,
}
}
pub(super) async fn publish_did(
&self,
mnemonic: &str,
log_content: &str,
domain: Option<&str>,
) -> Result<(), AppError> {
match self {
Self::Rest(c) => c.publish_did(mnemonic, log_content, domain).await,
Self::TrustTask(c) => c.publish_did(mnemonic, log_content, domain).await,
}
}
pub(super) async fn from_server_authenticated(
server: &WebvhServerRecord,
did_resolver: &'a DIDCacheClient,
didcomm_bridge: &'a Arc<DIDCommBridge>,
auth_ctx: &auth_cache::AuthContext<'_>,
#[cfg(feature = "tsp")] tsp: Option<crate::operations::outbound::TspSender>,
) -> Result<Self, AppError> {
let mut transport = Self::from_server(
server,
did_resolver,
didcomm_bridge,
#[cfg(feature = "tsp")]
tsp,
)
.await?;
if let Self::Rest(ref mut client) = transport {
auth_cache::ensure_fresh_access_token(auth_ctx, server, client).await?;
}
Ok(transport)
}
pub(super) async fn publish_did_authenticated(
&mut self,
mnemonic: &str,
log_content: &str,
domain: Option<&str>,
auth_ctx: &auth_cache::AuthContext<'_>,
server: &WebvhServerRecord,
) -> Result<(), AppError> {
match self {
Self::Rest(c) => match c.publish_did(mnemonic, log_content, domain).await {
Ok(()) => Ok(()),
Err(AppError::Unauthorized(_)) => {
info!(
server_id = %server.id,
"webvh publish_did got 401; invalidating cache and retrying"
);
auth_cache::invalidate_cached_token(auth_ctx.webvh_ks, &server.id).await?;
auth_cache::ensure_fresh_access_token(auth_ctx, server, c).await?;
c.publish_did(mnemonic, log_content, domain).await
}
Err(e) => Err(e),
},
Self::TrustTask(c) => c.publish_did(mnemonic, log_content, domain).await,
}
}
pub(super) async fn delete_did_authenticated(
&mut self,
mnemonic: &str,
domain: Option<&str>,
auth_ctx: &auth_cache::AuthContext<'_>,
server: &WebvhServerRecord,
) -> Result<(), AppError> {
match self {
Self::Rest(c) => match c.delete_did(mnemonic, domain).await {
Ok(()) => Ok(()),
Err(AppError::Unauthorized(_)) => {
info!(
server_id = %server.id,
"webvh delete_did got 401; invalidating cache and retrying"
);
auth_cache::invalidate_cached_token(auth_ctx.webvh_ks, &server.id).await?;
auth_cache::ensure_fresh_access_token(auth_ctx, server, c).await?;
c.delete_did(mnemonic, domain).await
}
Err(e) => Err(e),
},
Self::TrustTask(c) => c.delete_did(mnemonic, domain).await,
}
}
pub(super) async fn register_did_atomic_authenticated(
&mut self,
path: &str,
did_log: &str,
force: bool,
domain: Option<&str>,
auth_ctx: &auth_cache::AuthContext<'_>,
server: &WebvhServerRecord,
) -> Result<RequestUriResponse, AppError> {
match self {
Self::Rest(c) => match c.register_did_atomic(path, did_log, force, domain).await {
Ok(r) => Ok(r),
Err(AppError::Unauthorized(_)) => {
info!(
server_id = %server.id,
"webvh register_did_atomic got 401; invalidating cache and retrying"
);
auth_cache::invalidate_cached_token(auth_ctx.webvh_ks, &server.id).await?;
auth_cache::ensure_fresh_access_token(auth_ctx, server, c).await?;
c.register_did_atomic(path, did_log, force, domain).await
}
Err(e) => Err(e),
},
Self::TrustTask(c) => c.register_did_atomic(path, did_log, force, domain).await,
}
}
pub(super) async fn list_agent_names_authenticated(
&mut self,
mnemonic: &str,
domain: Option<&str>,
auth_ctx: &auth_cache::AuthContext<'_>,
server: &WebvhServerRecord,
) -> Result<Vec<crate::webvh_client::AgentNameEntryWire>, AppError> {
let c = match self {
Self::TrustTask(c) => {
return c.list_agent_names(mnemonic, domain).await;
}
Self::Rest(c) => c,
};
match c.list_agent_names(mnemonic, domain).await {
Ok(v) => Ok(v),
Err(AppError::Unauthorized(_)) => {
info!(
server_id = %server.id,
"webvh list_agent_names got 401; invalidating cache and retrying"
);
auth_cache::invalidate_cached_token(auth_ctx.webvh_ks, &server.id).await?;
auth_cache::ensure_fresh_access_token(auth_ctx, server, c).await?;
c.list_agent_names(mnemonic, domain).await
}
Err(e) => Err(e),
}
}
pub(super) async fn check_agent_name_authenticated(
&mut self,
name: &str,
domain: Option<&str>,
auth_ctx: &auth_cache::AuthContext<'_>,
server: &WebvhServerRecord,
) -> Result<crate::webvh_client::AgentNameAvailabilityWire, AppError> {
let c = match self {
Self::TrustTask(c) => {
return c.check_agent_name(name, domain).await;
}
Self::Rest(c) => c,
};
match c.check_agent_name(name, domain).await {
Ok(v) => Ok(v),
Err(AppError::Unauthorized(_)) => {
info!(
server_id = %server.id,
"webvh check_agent_name got 401; invalidating cache and retrying"
);
auth_cache::invalidate_cached_token(auth_ctx.webvh_ks, &server.id).await?;
auth_cache::ensure_fresh_access_token(auth_ctx, server, c).await?;
c.check_agent_name(name, domain).await
}
Err(e) => Err(e),
}
}
pub(super) async fn agent_name_authenticated(
&mut self,
verb: update::AgentNameVerb,
mnemonic: &str,
name: &str,
did_log: &str,
domain: Option<&str>,
auth_ctx: &auth_cache::AuthContext<'_>,
server: &WebvhServerRecord,
) -> Result<(), AppError> {
let c = match self {
Self::TrustTask(client) => {
return match verb.host_state() {
Some(state) => {
client
.update_agent_name(mnemonic, name, state, did_log, domain)
.await
}
None => {
client
.remove_agent_name(mnemonic, name, did_log, domain)
.await
}
};
}
Self::Rest(c) => c,
};
let op = verb.host_endpoint();
let state = verb.host_state();
match c
.agent_name_op(op, mnemonic, name, state, did_log, domain)
.await
{
Ok(()) => Ok(()),
Err(AppError::Unauthorized(_)) => {
info!(
server_id = %server.id,
%op,
"webvh agent_name got 401; invalidating cache and retrying"
);
auth_cache::invalidate_cached_token(auth_ctx.webvh_ks, &server.id).await?;
auth_cache::ensure_fresh_access_token(auth_ctx, server, c).await?;
c.agent_name_op(op, mnemonic, name, state, did_log, domain)
.await
}
Err(e) => Err(e),
}
}
}
pub(crate) use crate::keys::derivation::derive_pre_rotation_keys;
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::store::Store;
use crate::webvh_store;
use affinidi_did_resolver_cache_sdk::{DIDCacheClient, config::DIDCacheConfigBuilder};
use didwebvh_rs::create::{CreateDIDConfig, create_did};
use serde_json::json;
use tempfile::TempDir;
use vti_common::acl::Role;
use vti_common::config::StoreConfig as VtiStoreConfig;
#[tokio::test]
async fn delete_did_webvh_blocks_cross_context_admin() {
let dir = TempDir::new().expect("tempdir");
let store = Store::open(&VtiStoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
let webvh_ks = store.keyspace(crate::keyspaces::WEBVH).expect("keyspace");
let now = Utc::now();
let did = "did:webvh:QmTest:example.com:abc";
webvh_store::store_did(
&webvh_ks,
&WebvhDidRecord {
did: did.to_string(),
server_id: "prod".to_string(),
mnemonic: "fixture-mnemonic".to_string(),
scid: "QmTest".to_string(),
context_id: "ctx-b".to_string(),
portable: false,
log_entry_count: 1,
pre_rotation_count: 0,
next_fragment_id: 1,
created_at: now,
updated_at: now,
},
)
.await
.expect("plant did record");
let auth_a = AuthClaims {
did: "did:key:z6MkCtxAAdmin".to_string(),
role: Role::Admin,
allowed_contexts: vec!["ctx-a".to_string()],
session_id: "test-session".into(),
access_expires_at: 0,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
};
auth_a.require_admin().expect("admin floor passes");
let record = webvh_store::get_did(&webvh_ks, did)
.await
.expect("get_did ok")
.expect("record present");
let err = auth_a
.require_context(&record.context_id)
.expect_err("context-A admin must not pass require_context for ctx-b");
assert!(
matches!(err, AppError::Forbidden(_)),
"expected Forbidden, got: {err:?}"
);
let auth_b = AuthClaims {
did: "did:key:z6MkCtxBAdmin".to_string(),
role: Role::Admin,
allowed_contexts: vec!["ctx-b".to_string()],
session_id: "test-session".into(),
access_expires_at: 0,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
};
auth_b
.require_context(&record.context_id)
.expect("ctx-B admin passes require_context for ctx-b");
}
async fn sample_did_log_for_refresh() -> (String, String, serde_json::Value) {
use didwebvh_rs::parameters::Parameters as WebVHParameters;
let mut signing =
affinidi_tdk::secrets_resolver::secrets::Secret::generate_ed25519(None, None);
let pub_mb = signing
.get_public_keymultibase()
.expect("public key multibase");
signing.id = format!("did:key:{pub_mb}#{pub_mb}");
let did_document = json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": "{DID}",
"verificationMethod": [{
"id": "{DID}#key-0",
"type": "Multikey",
"controller": "{DID}",
"publicKeyMultibase": pub_mb,
}],
"authentication": ["{DID}#key-0"],
"assertionMethod": ["{DID}#key-0"],
});
let parameters = WebVHParameters {
update_keys: Some(Arc::new(vec![pub_mb.clone().into()])),
..Default::default()
};
let cfg = CreateDIDConfig::builder()
.address("https://example.invalid/.well-known/did/did.jsonl")
.authorization_key(signing)
.did_document(did_document)
.parameters(parameters)
.build()
.expect("create did config");
let result = create_did(cfg).await.expect("create did");
let did = result.did().to_string();
let did_log = serde_json::to_string(result.log_entry()).expect("serialize did log entry");
let expected_doc_value =
crate::operations::protocol::document::current_document_from_log(&did_log)
.expect("current document from log");
(did, did_log, expected_doc_value)
}
#[tokio::test]
async fn refresh_resolver_doc_from_log_seeds_cache_from_log() {
let resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.expect("resolver");
let (did, did_log, expected_doc_value) = sample_did_log_for_refresh().await;
refresh_resolver_doc_from_log(&resolver, &did, &did_log, "test").await;
let resolved = resolver
.resolve(&did)
.await
.expect("resolve from refreshed cache");
assert!(resolved.cache_hit, "expected cache hit after refresh");
let expected_doc =
serde_json::from_value(expected_doc_value).expect("deserialize expected did document");
assert_eq!(resolved.doc, expected_doc);
}
#[tokio::test]
async fn refresh_resolver_doc_from_log_preserves_cache_on_parse_failure() {
let resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.expect("resolver");
let mut signing =
affinidi_tdk::secrets_resolver::secrets::Secret::generate_ed25519(None, None);
let pub_mb = signing
.get_public_keymultibase()
.expect("public key multibase");
signing.id = format!("did:key:{pub_mb}#{pub_mb}");
let did = format!("did:key:{pub_mb}");
let did_log = serde_json::to_string(&json!({
"versionId": "1-test",
"versionTime": "2026-01-01T00:00:00Z",
"parameters": {},
"state": {
"@context": ["https://www.w3.org/ns/did/v1"],
"id": did,
}
}))
.expect("serialize did log");
refresh_resolver_doc_from_log(&resolver, &did, &did_log, "test").await;
let seeded = resolver
.resolve(&did)
.await
.expect("resolve seeded DID from cache");
assert!(
seeded.cache_hit,
"sanity: DID should be served from cache after the good refresh"
);
refresh_resolver_doc_from_log(&resolver, &did, "not-a-valid-did-log", "test").await;
let after = resolver
.resolve(&did)
.await
.expect("resolve after failed refresh");
assert!(
after.cache_hit,
"after a failed refresh the prior cache entry must be preserved (still a cache hit)"
);
}
}
#[cfg(test)]
mod delete_cascade_tests {
use super::*;
use crate::operations::credentials::IssuedCredentialRecord;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn credential_ks() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
let ks = store
.keyspace(crate::keyspaces::ISSUED_CREDENTIALS)
.expect("issued_credentials ks");
(ks, dir)
}
async fn seed(ks: &KeyspaceHandle, id: &str, holder: &str, revoked: Option<&str>) {
let record = IssuedCredentialRecord {
id: id.to_string(),
holder: holder.to_string(),
credential: serde_json::json!({ "id": id }),
issued_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: "2027-01-01T00:00:00Z".to_string(),
revoked_at: revoked.map(str::to_string),
revocation_reason: None,
};
ks.insert_raw(
format!("cred:{id}").into_bytes(),
serde_json::to_vec(&record).unwrap(),
)
.await
.unwrap();
}
async fn load(ks: &KeyspaceHandle, id: &str) -> IssuedCredentialRecord {
let raw = ks
.get_raw(format!("cred:{id}").into_bytes())
.await
.unwrap()
.expect("record present");
serde_json::from_slice(&raw).unwrap()
}
#[tokio::test]
async fn the_plan_names_the_credentials_the_deletion_would_revoke() {
let (ks, _dir) = credential_ks();
seed(&ks, "a", "did:key:zGone", None).await;
seed(&ks, "b", "did:key:zGone", None).await;
seed(&ks, "c", "did:key:zStays", None).await;
seed(&ks, "d", "did:key:zGone", Some("2026-02-02T00:00:00Z")).await;
let mut planned = credentials_issued_to(&ks, "did:key:zGone").await.unwrap();
planned.sort();
assert_eq!(
planned,
vec!["a".to_string(), "b".to_string()],
"an already-revoked credential is not offered up for revoking again, and \
another holder's is not in scope at all"
);
let revoked = revoke_credentials_for_did(&ks, "did:key:zGone")
.await
.unwrap();
assert_eq!(revoked, planned.len());
}
#[test]
fn a_plan_that_touches_nothing_else_needs_no_confirmation() {
assert!(!DidDeletionPlan::default().touches_anything_else());
assert!(
DidDeletionPlan {
has_acl_entry: true,
..Default::default()
}
.touches_anything_else()
);
assert!(
DidDeletionPlan {
credentials_to_revoke: vec!["a".into()],
..Default::default()
}
.touches_anything_else()
);
assert!(
DidDeletionPlan {
sessions_to_revoke: 1,
..Default::default()
}
.touches_anything_else()
);
}
#[tokio::test]
async fn only_the_deleted_dids_credentials_are_revoked() {
let (ks, _dir) = credential_ks();
seed(&ks, "a", "did:key:zGone", None).await;
seed(&ks, "b", "did:key:zGone", None).await;
seed(&ks, "c", "did:key:zStays", None).await;
let revoked = revoke_credentials_for_did(&ks, "did:key:zGone")
.await
.expect("revoke");
assert_eq!(revoked, 2);
assert!(load(&ks, "a").await.revoked_at.is_some());
assert!(load(&ks, "b").await.revoked_at.is_some());
assert!(
load(&ks, "c").await.revoked_at.is_none(),
"another holder's credential must be untouched"
);
}
#[tokio::test]
async fn revocation_is_a_tombstone_not_a_deletion() {
let (ks, _dir) = credential_ks();
seed(&ks, "a", "did:key:zGone", None).await;
revoke_credentials_for_did(&ks, "did:key:zGone")
.await
.expect("revoke");
let record = load(&ks, "a").await;
assert!(record.revoked_at.is_some());
assert_eq!(
record.revocation_reason.as_deref(),
Some("holder DID deleted")
);
assert_eq!(record.credential["id"], "a");
}
#[tokio::test]
async fn an_already_revoked_credential_keeps_its_original_timestamp() {
let (ks, _dir) = credential_ks();
seed(&ks, "a", "did:key:zGone", Some("2026-02-02T00:00:00Z")).await;
let revoked = revoke_credentials_for_did(&ks, "did:key:zGone")
.await
.expect("revoke");
assert_eq!(revoked, 0, "already-revoked records are not counted again");
assert_eq!(
load(&ks, "a").await.revoked_at.as_deref(),
Some("2026-02-02T00:00:00Z")
);
}
#[tokio::test]
async fn a_did_with_no_credentials_revokes_none() {
let (ks, _dir) = credential_ks();
seed(&ks, "c", "did:key:zStays", None).await;
assert_eq!(
revoke_credentials_for_did(&ks, "did:key:zNothing")
.await
.expect("revoke"),
0
);
}
}
#[cfg(test)]
mod key_slot_plan_tests {
use super::*;
use std::collections::BTreeMap;
use vta_sdk::did_templates::{KeyPurpose, KeySlot};
fn slot(purpose: KeyPurpose, algorithms: &[&str]) -> KeySlot {
KeySlot {
purpose,
algorithms: algorithms.iter().map(|a| (*a).to_string()).collect(),
}
}
#[test]
fn no_slots_means_the_historical_pair() {
let plan = plan_key_slots(&BTreeMap::new()).expect("empty is the default");
assert_eq!(plan.signing, vec![KeyType::Ed25519]);
assert!(plan.additional_signing.is_empty());
}
#[test]
fn a_signing_slot_keeps_its_declared_order() {
let slots = BTreeMap::from([
(
"signing".to_string(),
slot(KeyPurpose::Signing, &["mldsa44", "ed25519"]),
),
(
"ka".to_string(),
slot(KeyPurpose::KeyAgreement, &["x25519"]),
),
]);
let plan = plan_key_slots(&slots).expect("valid");
assert_eq!(plan.signing, vec![KeyType::MlDsa44, KeyType::Ed25519]);
assert!(plan.additional_signing.is_empty());
}
#[test]
fn an_extra_signing_slot_is_planned_separately() {
let slots = BTreeMap::from([
(
"signing".to_string(),
slot(KeyPurpose::Signing, &["ed25519"]),
),
(
"ka".to_string(),
slot(KeyPurpose::KeyAgreement, &["x25519"]),
),
(
"pq-signing".to_string(),
slot(KeyPurpose::Signing, &["mldsa44"]),
),
]);
let plan = plan_key_slots(&slots).expect("valid");
assert_eq!(plan.signing, vec![KeyType::Ed25519]);
assert_eq!(
plan.additional_signing,
vec![("pq-signing".to_string(), vec![KeyType::MlDsa44])]
);
}
#[test]
fn a_second_key_agreement_slot_is_refused() {
let slots = BTreeMap::from([
(
"signing".to_string(),
slot(KeyPurpose::Signing, &["ed25519"]),
),
(
"ka".to_string(),
slot(KeyPurpose::KeyAgreement, &["x25519"]),
),
(
"ka-2".to_string(),
slot(KeyPurpose::KeyAgreement, &["x25519"]),
),
]);
let err = plan_key_slots(&slots).expect_err("a second KA slot cannot be minted");
assert!(
err.to_string().contains("ka-2"),
"the refusal must name the slot: {err}"
);
}
#[test]
fn an_unknown_algorithm_is_refused_with_its_slot() {
let slots = BTreeMap::from([(
"signing".to_string(),
slot(KeyPurpose::Signing, &["falcon512"]),
)]);
let err = plan_key_slots(&slots).expect_err("unknown algorithm");
let msg = err.to_string();
assert!(
msg.contains("signing") && msg.contains("falcon512"),
"must name both the slot and the algorithm: {msg}"
);
}
#[test]
fn slots_without_a_primary_signing_slot_are_refused() {
let slots = BTreeMap::from([(
"ka".to_string(),
slot(KeyPurpose::KeyAgreement, &["x25519"]),
)]);
let err = plan_key_slots(&slots).expect_err("no primary signing slot");
assert!(err.to_string().contains("signing"), "{err}");
}
#[test]
fn a_webvh_primary_signing_preference_falls_back_to_ed25519() {
assert_eq!(
log_entry_capable_signing(&[KeyType::MlDsa44, KeyType::Ed25519]).expect("falls back"),
vec![KeyType::Ed25519]
);
let err = log_entry_capable_signing(&[KeyType::MlDsa44, KeyType::MlDsa65])
.expect_err("no algorithm here can sign a log entry");
let msg = err.to_string();
assert!(
msg.contains("ed25519") && msg.contains("additional signing slot"),
"the refusal must say why and what to do instead: {msg}"
);
}
}