use std::sync::Arc;
use tracing::debug;
use crate::auth::AuthClaims;
use crate::error::AppError;
use crate::keys::seed_store::SeedStore;
use crate::store::KeyspaceHandle;
use vta_sdk::context_provision::ContextProvisionBundle;
#[cfg(feature = "webvh")]
use vta_sdk::context_provision::ProvisionedDid;
use vta_sdk::credentials::CredentialBundle;
use vta_sdk::did_secrets::{DidSecretsBundle, SecretEntry, select_secret_kid};
use vta_sdk::keys::KeyStatus;
pub struct ExportDeps<'a> {
pub keys_ks: &'a KeyspaceHandle,
pub contexts_ks: &'a KeyspaceHandle,
pub imported_ks: &'a KeyspaceHandle,
pub audit_ks: &'a KeyspaceHandle,
pub acl_ks: &'a KeyspaceHandle,
#[cfg(feature = "webvh")]
pub webvh_ks: &'a KeyspaceHandle,
pub seed_store: &'a Arc<dyn SeedStore>,
}
pub async fn build_did_secrets_bundle(
deps: &ExportDeps<'_>,
auth: &AuthClaims,
context_id: &str,
channel: &str,
) -> Result<DidSecretsBundle, AppError> {
auth.require_context(context_id)?;
let ctx = crate::contexts::get_context(deps.contexts_ks, context_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("context not found: {context_id}")))?;
let did = ctx.did.clone().ok_or_else(|| {
AppError::Validation(format!("context '{context_id}' has no DID assigned"))
})?;
let mut secrets = Vec::new();
let page_size = 100u64;
let mut offset = 0u64;
loop {
let page = super::keys::list_keys(
deps.keys_ks,
auth,
super::keys::ListKeysParams {
offset: Some(offset),
limit: Some(page_size),
status: Some(KeyStatus::Active),
context_id: Some(context_id.to_string()),
},
channel,
)
.await?;
if page.keys.is_empty() {
break;
}
for key in &page.keys {
let secret = super::keys::get_key_secret(
deps.keys_ks,
deps.imported_ks,
deps.seed_store,
deps.audit_ks,
auth,
&key.key_id,
channel,
)
.await?;
match select_secret_kid(&did, &secret.key_id, key.label.as_deref()) {
Some(key_id) => secrets.push(SecretEntry {
key_id,
key_type: secret.key_type,
private_key_multibase: secret.private_key_multibase,
}),
None => {
debug!(
channel,
%context_id,
%did,
key_id = %secret.key_id,
label = key.label.as_deref().unwrap_or(""),
"excluding secret from did-secrets bundle: not a verification \
method of the context DID (e.g. an admin did:key minted into \
this context, or a free-text-labelled key). Including it would \
corrupt the DIDComm operating-secret set and break the \
mediator's exact-match recipient lookup."
);
}
}
}
offset += page.keys.len() as u64;
if offset >= page.total {
break;
}
}
debug!(channel, %context_id, %did, secret_count = secrets.len(), "built did-secrets bundle from local store");
Ok(DidSecretsBundle { did, secrets })
}
pub async fn credential_from_key_offline(
deps: &ExportDeps<'_>,
auth: &AuthClaims,
key_id: &str,
vta_did: &str,
vta_url: Option<&str>,
channel: &str,
) -> Result<(CredentialBundle, String), AppError> {
let secret = super::keys::get_key_secret(
deps.keys_ks,
deps.imported_ks,
deps.seed_store,
deps.audit_ks,
auth,
key_id,
channel,
)
.await?;
CredentialBundle::from_ed25519_seed_multibase(&secret.private_key_multibase, vta_did, vta_url)
.map_err(|e| AppError::Internal(format!("decode admin key secret: {e}")))
}
pub struct ContextReprovisionInputs {
pub context_id: String,
pub key_id: String,
}
pub async fn build_context_provision_bundle(
deps: &ExportDeps<'_>,
auth: &AuthClaims,
inputs: ContextReprovisionInputs,
vta_did: &str,
vta_url: Option<&str>,
channel: &str,
) -> Result<ContextProvisionBundle, AppError> {
let ContextReprovisionInputs { context_id, key_id } = inputs;
auth.require_context(&context_id)?;
let ctx = crate::contexts::get_context(deps.contexts_ks, &context_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("context not found: {context_id}")))?;
let (credential, admin_did) =
credential_from_key_offline(deps, auth, &key_id, vta_did, vta_url, channel).await?;
#[cfg(feature = "webvh")]
let provisioned_did = match ctx.did.as_deref() {
Some(did_id) => {
Some(fetch_did_material_offline(deps, auth, did_id, &context_id, channel).await?)
}
None => None,
};
#[cfg(not(feature = "webvh"))]
let provisioned_did = None;
Ok(ContextProvisionBundle {
context_id,
context_name: ctx.name,
vta_url: vta_url.map(String::from),
vta_did: Some(vta_did.to_string()),
credential,
admin_did,
did: provisioned_did,
})
}
#[cfg(feature = "webvh")]
async fn fetch_did_material_offline(
deps: &ExportDeps<'_>,
auth: &AuthClaims,
did: &str,
context_id: &str,
channel: &str,
) -> Result<ProvisionedDid, AppError> {
let log_result = super::did_webvh::get_did_webvh_log(deps.webvh_ks, auth, did, channel).await?;
let log_entry = log_result.log;
let did_document = log_entry
.as_deref()
.and_then(|log_str| serde_json::from_str::<serde_json::Value>(log_str).ok())
.and_then(|v| v.get("state").cloned());
let secrets_bundle = build_did_secrets_bundle(deps, auth, context_id, channel).await?;
Ok(ProvisionedDid {
id: did.to_string(),
did_document,
log_entry,
secrets: secrets_bundle.secrets,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StoreConfig;
use crate::keys::seed_store::PlaintextSeedStore;
use crate::store::{KeyspaceHandle, Store};
use std::path::PathBuf;
struct TestEnv {
_dir: tempfile::TempDir,
_store: Store,
contexts_ks: KeyspaceHandle,
keys_ks: KeyspaceHandle,
imported_ks: KeyspaceHandle,
audit_ks: KeyspaceHandle,
acl_ks: KeyspaceHandle,
#[cfg(feature = "webvh")]
webvh_ks: KeyspaceHandle,
seed_store: Arc<dyn SeedStore>,
data_dir: PathBuf,
}
async fn open_env() -> TestEnv {
let dir = tempfile::tempdir().expect("temp dir");
let data_dir = dir.path().to_path_buf();
let store = Store::open(&StoreConfig {
data_dir: data_dir.clone(),
})
.expect("open store");
TestEnv {
contexts_ks: store.keyspace(crate::keyspaces::CONTEXTS).unwrap(),
keys_ks: store.keyspace(crate::keyspaces::KEYS).unwrap(),
imported_ks: store.keyspace(crate::keyspaces::IMPORTED_SECRETS).unwrap(),
audit_ks: store.keyspace(crate::keyspaces::AUDIT).unwrap(),
acl_ks: store.keyspace(crate::keyspaces::ACL).unwrap(),
#[cfg(feature = "webvh")]
webvh_ks: store.keyspace(crate::keyspaces::WEBVH).unwrap(),
seed_store: Arc::new(PlaintextSeedStore::new(&data_dir)),
_dir: dir,
_store: store,
data_dir,
}
}
fn deps_of(env: &TestEnv) -> ExportDeps<'_> {
ExportDeps {
keys_ks: &env.keys_ks,
contexts_ks: &env.contexts_ks,
imported_ks: &env.imported_ks,
audit_ks: &env.audit_ks,
acl_ks: &env.acl_ks,
#[cfg(feature = "webvh")]
webvh_ks: &env.webvh_ks,
seed_store: &env.seed_store,
}
}
fn super_admin() -> AuthClaims {
AuthClaims {
did: "did:key:zTestCli".into(),
role: crate::acl::Role::Admin,
allowed_contexts: Vec::new(),
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
#[tokio::test]
async fn build_did_secrets_rejects_missing_context() {
let env = open_env().await;
let auth = super_admin();
let err = build_did_secrets_bundle(&deps_of(&env), &auth, "nope", "test")
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "got: {err:?}");
let msg = err.to_string();
assert!(msg.contains("nope"), "got: {msg}");
}
#[tokio::test]
async fn build_did_secrets_rejects_context_without_did() {
let env = open_env().await;
let auth = super_admin();
crate::contexts::create_context(&env.contexts_ks, "no-did", "No DID Ctx")
.await
.expect("create context");
let err = build_did_secrets_bundle(&deps_of(&env), &auth, "no-did", "test")
.await
.unwrap_err();
assert!(matches!(err, AppError::Validation(_)), "got: {err:?}");
assert!(err.to_string().contains("no DID assigned"));
}
#[tokio::test]
async fn build_context_provision_requires_existing_context() {
let env = open_env().await;
let auth = super_admin();
let err = build_context_provision_bundle(
&deps_of(&env),
&auth,
ContextReprovisionInputs {
context_id: "missing".into(),
key_id: "did:key:zFake#zFake".into(),
},
"did:key:zVta",
None,
"test",
)
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "got: {err:?}");
assert!(err.to_string().contains("missing"));
}
#[allow(dead_code)]
fn _unused_data_dir(env: &TestEnv) -> &PathBuf {
&env.data_dir
}
#[tokio::test]
async fn build_did_secrets_excludes_non_vm_admin_did_key() {
use crate::keys::paths::allocate_path;
use crate::keys::{KeyRecord, store_key};
use chrono::Utc;
use vta_sdk::keys::{KeyOrigin, KeyStatus, KeyType};
let env = open_env().await;
let auth = super_admin();
env.seed_store
.set(&[0xABu8; 32])
.await
.expect("seed the store");
let did = "did:webvh:QmScid:mediator.example.com:med";
crate::contexts::create_context(&env.contexts_ks, "med-ctx", "Mediator Ctx")
.await
.expect("create context");
let mut rec = crate::contexts::get_context(&env.contexts_ks, "med-ctx")
.await
.expect("get context")
.expect("context exists");
rec.did = Some(did.to_string());
crate::contexts::store_context(&env.contexts_ks, &rec)
.await
.expect("store did on context");
async fn mint_internal(
env: &TestEnv,
base_path: &str,
kid: &str,
kt: KeyType,
label: Option<&str>,
) {
let path = allocate_path(&env.keys_ks, base_path)
.await
.expect("allocate path");
let now = Utc::now();
let record = KeyRecord {
key_id: kid.to_string(),
derivation_path: path,
key_type: kt,
status: KeyStatus::Active,
public_key: "zPlaceholderNotUnderTest".into(),
label: label.map(String::from),
context_id: Some("med-ctx".into()),
seed_id: None,
origin: KeyOrigin::Derived,
created_at: now,
updated_at: now,
};
env.keys_ks
.insert(store_key(kid), &record)
.await
.expect("store key record");
}
mint_internal(
&env,
&rec.base_path,
&format!("{did}#key-0"),
KeyType::Ed25519,
None,
)
.await;
mint_internal(
&env,
&rec.base_path,
&format!("{did}#key-1"),
KeyType::X25519,
None,
)
.await;
let admin = "did:key:z6Mkt6eNM38RhFfjSdmXBtT1SRL7sPgPZD1MkXZbwjYBhTLf";
mint_internal(
&env,
&rec.base_path,
&format!("{admin}#z6Mkt6eNM38RhFfjSdmXBtT1SRL7sPgPZD1MkXZbwjYBhTLf"),
KeyType::Ed25519,
Some("admin DID for context med-ctx"),
)
.await;
let bundle = build_did_secrets_bundle(&deps_of(&env), &auth, "med-ctx", "test")
.await
.expect("bundle builds");
assert_eq!(bundle.did, did);
let expect_0 = format!("{did}#key-0");
let expect_1 = format!("{did}#key-1");
let mut kids: Vec<&str> = bundle.secrets.iter().map(|s| s.key_id.as_str()).collect();
kids.sort_unstable();
assert_eq!(
kids,
vec![expect_0.as_str(), expect_1.as_str()],
"only the two VM-id operating keys belong in the bundle; the admin \
did:key minted into the context must be excluded"
);
assert!(
!bundle.secrets.iter().any(|s| s.key_id.contains(admin)),
"admin did:key must not appear in the operating-secret bundle"
);
}
}