pub(crate) mod auth_cache;
mod concurrency;
mod document;
mod lifecycle;
mod register_server;
mod servers;
mod transport;
mod update;
mod webvh_keys;
pub use auth_cache::WebvhAuthLocks;
pub(crate) use auth_cache::{
delete_log_on_server, publish_log_to_server, register_did_atomic_on_server,
};
pub(crate) use concurrency::{RaceDetected, RecordSnapshot};
pub(crate) use document::build_did_document_with_options;
pub use document::{build_did_document, build_vta_did_document_with_sealed_transfer};
pub use lifecycle::{GetDidWebvhLogResult, get_did_webvh, get_did_webvh_log, list_dids_webvh};
pub use register_server::{
RegisterDidWithServerError, RegisterDidWithServerParams, RegisterDidWithServerResult,
register_did_with_server,
};
pub use servers::{
add_webvh_server, list_webvh_server_domains, list_webvh_servers, remove_webvh_server,
update_webvh_server,
};
pub use update::{
RotateDidWebvhKeysOptions, UpdateDidWebvhError, UpdateDidWebvhOptions, UpdateDidWebvhResult,
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;
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::paths::allocate_path;
use crate::keys::seed_store::SeedStore;
use crate::keys::seeds::{get_active_seed_id, load_seed_bytes};
use crate::keys::{self, KeyType as SdkKeyType, PreRotationKeyData, encode_private_multibase};
use crate::store::KeyspaceHandle;
use crate::webvh_client::{RequestUriResponse, WebvhClient};
use crate::webvh_didcomm::WebvhDIDCommClient;
use crate::webvh_store;
use vta_sdk::keys::{KeyOrigin, KeyRecord, KeyStatus, KeyType};
use zeroize::Zeroize;
use ed25519_dalek_bip32::{DerivationPath, ExtendedSigningKey};
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 audit_ks: &'a KeyspaceHandle,
pub seed_store: &'a dyn SeedStore,
pub did_resolver: &'a DIDCacheClient,
pub didcomm_bridge: &'a Arc<DIDCommBridge>,
pub auth_locks: &'a WebvhAuthLocks,
}
impl<'a> WebvhDeps<'a> {
#[cfg(all(feature = "webvh", feature = "didcomm"))]
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,
audit_ks: &s.audit_ks,
seed_store: &*s.seed_store,
did_resolver,
didcomm_bridge: &s.didcomm_bridge,
auth_locks: &s.webvh_auth_locks,
}
}
#[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,
audit_ks: &s.audit_ks,
seed_store: &*s.seed_store,
did_resolver,
didcomm_bridge: &s.didcomm_bridge,
auth_locks: &s.webvh_auth_locks,
}
}
}
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 seed_store: &'a dyn SeedStore,
pub config: &'a AppConfig,
pub did_resolver: &'a DIDCacheClient,
pub didcomm_bridge: &'a Arc<DIDCommBridge>,
}
impl<'a> CreateDidWebvhDeps<'a> {
#[cfg(all(feature = "webvh", feature = "didcomm"))]
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,
seed_store: &*s.seed_store,
config,
did_resolver,
didcomm_bridge: &s.didcomm_bridge,
}
}
#[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,
seed_store: &*s.seed_store,
config,
did_resolver,
didcomm_bridge: &s.didcomm_bridge,
}
}
}
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 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 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),
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,
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::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"))
})
})
})
}
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,
seed_store,
config,
did_resolver,
didcomm_bridge,
} = *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 {
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 !serverless {
let server_id = params.server_id.as_ref().ok_or_else(|| {
AppError::Validation(
"server_id is required when serverless=false (final-mode publish path)".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 =
WebvhTransport::from_server(&server, did_resolver, didcomm_bridge).await?;
transport.publish_did(&scid, did_log, None).await?;
}
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?;
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(),
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 (derived, active_seed_id) = 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) = 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")),
)
} else {
(
Secret::generate_ed25519(None, None),
String::new(),
String::new(),
String::new(),
)
};
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")),
ka_secret,
ka_path,
ka_pub,
ka_priv: String::new(),
ka_label,
};
(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(
&seed,
&ctx.base_path,
&format!("{label} signing key"),
&format!("{label} key-agreement key"),
keys_ks,
)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
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 = WebvhTransport::from_server(&server, did_resolver, didcomm_bridge).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(ref template_name) = params.template {
let record = resolve_template_for_render(
did_templates_ks,
template_name,
params.template_context.as_deref(),
)
.await?;
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());
}
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 rendered = record.template.render(&vars).map_err(|e| {
AppError::Validation(format!("template '{template_name}' render failed: {e}"))
})?;
params.did_document = Some(rendered);
}
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,
¶ms.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,
¶ms.additional_services,
),
None => build_did_document(
&derived,
config,
params.add_mediator_service,
¶ms.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)
.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}")))?;
if !user_specified_keys {
keys::save_entity_key_records(
&final_did,
&derived,
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,
&format!("{final_did}#key-0"),
&derived.signing_path,
SdkKeyType::Ed25519,
&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,
&format!("{final_did}#key-1"),
&derived.ka_path,
SdkKeyType::X25519,
&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: 2,
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?;
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: format!("{final_did}#key-0"),
ka_key_id: format!("{final_did}#key-1"),
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 = WebvhTransport::from_server(&server, did_resolver, didcomm_bridge).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: 2,
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?;
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: format!("{final_did}#key-0"),
ka_key_id: format!("{final_did}#key-1"),
pre_rotation_key_count: pre_rotation_keys.len() as u32,
created_at: now,
did_document: Some(final_did_document),
log_entry: Some(log_content),
})
}
}
pub async fn delete_did_webvh(
deps: &WebvhDeps<'_>,
auth: &AuthClaims,
did: &str,
vta_did: Option<&str>,
channel: &str,
) -> 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 mut daemon_cleanup_error: Option<String> = None;
let server = webvh_store::get_server(deps.webvh_ks, &record.server_id).await?;
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?;
for key_id in &[format!("{did}#key-0"), format!("{did}#key-1")] {
let _ = deps.keys_ks.remove(keys::store_key(key_id)).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;
}
info!(channel, did = %did, "webvh DID deleted");
Ok(DeleteDidWebvhResultBody {
did: did.to_string(),
deleted: true,
daemon_cleanup_error,
})
}
pub(super) enum WebvhTransport<'a> {
Rest(WebvhClient),
DIDComm {
bridge: &'a DIDCommBridge,
server_did: String,
},
}
impl<'a> WebvhTransport<'a> {
pub(super) async fn from_server(
server: &WebvhServerRecord,
did_resolver: &DIDCacheClient,
didcomm_bridge: &'a Arc<DIDCommBridge>,
) -> 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::DIDComm) => {
info!(server_did = %server.did, transport = "didcomm", "resolved webvh server endpoint");
Ok(Self::DIDComm {
bridge: didcomm_bridge,
server_did: server.did.clone(),
})
}
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::DIDComm { bridge, server_did } => {
WebvhDIDCommClient::new(bridge, server_did)
.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::DIDComm { bridge, server_did } => {
WebvhDIDCommClient::new(bridge, server_did)
.publish_did(mnemonic, log_content, domain)
.await
}
}
}
pub(super) async fn from_server_authenticated(
server: &WebvhServerRecord,
did_resolver: &DIDCacheClient,
didcomm_bridge: &'a Arc<DIDCommBridge>,
auth_ctx: &auth_cache::AuthContext<'_>,
) -> Result<Self, AppError> {
let mut transport = Self::from_server(server, did_resolver, didcomm_bridge).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::DIDComm { bridge, server_did } => {
WebvhDIDCommClient::new(bridge, server_did)
.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::DIDComm { bridge, server_did } => {
WebvhDIDCommClient::new(bridge, server_did)
.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::DIDComm { bridge, server_did } => {
WebvhDIDCommClient::new(bridge, server_did)
.register_did_atomic(path, did_log, force, domain)
.await
}
}
}
}
pub(crate) async fn derive_pre_rotation_keys(
seed: &[u8],
base: &str,
label: &str,
keys_ks: &KeyspaceHandle,
count: u32,
) -> Result<(Vec<String>, Vec<PreRotationKeyData>), AppError> {
if count == 0 {
return Ok((vec![], vec![]));
}
let root = ExtendedSigningKey::from_seed(seed)
.map_err(|e| AppError::Internal(format!("failed to create BIP-32 root key: {e}")))?;
let mut hashes = Vec::with_capacity(count as usize);
let mut key_data = Vec::with_capacity(count as usize);
for i in 0..count {
let path = allocate_path(keys_ks, base)
.await
.map_err(|e| AppError::Internal(format!("{e}")))?;
let derivation_path: DerivationPath = path
.parse()
.map_err(|e| AppError::Internal(format!("invalid derivation path: {e}")))?;
let derived_key = root
.derive(&derivation_path)
.map_err(|e| AppError::Internal(format!("key derivation failed: {e}")))?;
let secret = Secret::generate_ed25519(None, Some(derived_key.signing_key.as_bytes()));
let pub_mb = secret
.get_public_keymultibase()
.map_err(|e| AppError::Internal(format!("{e}")))?;
let hash = secret
.get_public_keymultibase_hash()
.map_err(|e| AppError::Internal(format!("{e}")))?;
key_data.push(PreRotationKeyData {
path,
public_key: pub_mb,
label: format!("{label} pre-rotation key {i}"),
});
hashes.push(hash);
}
Ok((hashes, key_data))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use crate::webvh_store;
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,
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,
amr: Vec::new(),
acr: String::new(),
};
auth_b
.require_context(&record.context_id)
.expect("ctx-B admin passes require_context for ctx-b");
}
}