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: &'a vta_audit::SharedAuditSink,
pub acl_ks: &'a KeyspaceHandle,
#[cfg(feature = "webvh")]
pub webvh_ks: &'a KeyspaceHandle,
pub seed_store: &'a Arc<dyn SeedStore>,
}
pub async fn get_context_secrets(
deps: &ExportDeps<'_>,
auth: &AuthClaims,
context_id: &str,
channel: &str,
) -> Result<DidSecretsBundle, AppError> {
auth.require_write()?;
let bundle = build_did_secrets_bundle(deps, auth, context_id, channel).await?;
tracing::info!(
channel,
context = %context_id,
did = %bundle.did,
keys = bundle.secrets.len(),
"context secrets released to the service that operates them"
);
Ok(bundle)
}
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,
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,
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(deps.webvh_ks, auth, did, channel, true).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: vta_audit::SharedAuditSink,
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: vta_audit::shared_keyspace_sink(
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: &env.audit,
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,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
fn scoped(role: crate::acl::Role, contexts: &[&str]) -> AuthClaims {
AuthClaims {
did: "did:key:zTestCaller".into(),
role,
allowed_contexts: contexts.iter().map(|c| (*c).to_string()).collect(),
session_id: "test-session".into(),
access_expires_at: 0,
issued_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
}
const SEEDED_DID: &str = "did:webvh:QmScid:mediator.example.com:med";
async fn seed_context_with_keys(env: &TestEnv, context_id: &str) -> String {
use crate::keys::paths::allocate_path;
use crate::keys::{KeyRecord, store_key};
use chrono::Utc;
use vta_sdk::keys::{KeyOrigin, KeyStatus, KeyType};
env.seed_store
.set(&[0xABu8; 32])
.await
.expect("seed the store");
crate::contexts::create_context(&env.contexts_ks, context_id, "Mediator Ctx")
.await
.expect("create context");
let mut rec = crate::contexts::get_context(&env.contexts_ks, context_id)
.await
.expect("get context")
.expect("context exists");
rec.did = Some(SEEDED_DID.to_string());
crate::contexts::store_context(&env.contexts_ks, &rec)
.await
.expect("store did on context");
async fn mint_internal(
env: &TestEnv,
context_id: &str,
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 {
exportable: None,
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(context_id.to_string()),
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");
}
for (suffix, kt) in [("#key-0", KeyType::Ed25519), ("#key-1", KeyType::X25519)] {
mint_internal(
env,
context_id,
&rec.base_path,
&format!("{SEEDED_DID}{suffix}"),
kt,
None,
)
.await;
}
mint_internal(
env,
context_id,
&rec.base_path,
&format!("{ADMIN_DID_KEY}#z6Mkt6eNM38RhFfjSdmXBtT1SRL7sPgPZD1MkXZbwjYBhTLf"),
KeyType::Ed25519,
Some("admin DID for context med-ctx"),
)
.await;
SEEDED_DID.to_string()
}
const ADMIN_DID_KEY: &str = "did:key:z6Mkt6eNM38RhFfjSdmXBtT1SRL7sPgPZD1MkXZbwjYBhTLf";
#[tokio::test]
async fn build_did_secrets_excludes_non_vm_admin_did_key() {
let env = open_env().await;
let did = seed_context_with_keys(&env, "med-ctx").await;
let bundle = build_did_secrets_bundle(&deps_of(&env), &super_admin(), "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_DID_KEY)),
"admin did:key must not appear in the operating-secret bundle"
);
}
#[tokio::test]
async fn an_application_fetches_its_own_contexts_secrets() {
let env = open_env().await;
let did = seed_context_with_keys(&env, "med-ctx").await;
let auth = scoped(crate::acl::Role::Application, &["med-ctx"]);
let bundle = get_context_secrets(&deps_of(&env), &auth, "med-ctx", "test")
.await
.expect("an application may read the keys of the context it operates");
assert_eq!(bundle.did, did);
let mut kids: Vec<&str> = bundle.secrets.iter().map(|s| s.key_id.as_str()).collect();
kids.sort_unstable();
assert_eq!(kids, vec![format!("{did}#key-0"), format!("{did}#key-1")]);
}
#[tokio::test]
async fn an_application_cannot_reach_another_contexts_secrets() {
let env = open_env().await;
seed_context_with_keys(&env, "med-ctx").await;
let auth = scoped(crate::acl::Role::Application, &["some-other-ctx"]);
let err = get_context_secrets(&deps_of(&env), &auth, "med-ctx", "test")
.await
.expect_err("a context you do not hold is not yours to read keys from");
assert!(matches!(err, AppError::Forbidden(_)), "got: {err:?}");
}
#[tokio::test]
async fn an_admin_of_another_context_cannot_either() {
let env = open_env().await;
seed_context_with_keys(&env, "med-ctx").await;
let auth = scoped(crate::acl::Role::Admin, &["some-other-ctx"]);
let err = get_context_secrets(&deps_of(&env), &auth, "med-ctx", "test")
.await
.expect_err("admin of another context is still not this context's service");
assert!(matches!(err, AppError::Forbidden(_)), "got: {err:?}");
}
#[tokio::test]
async fn a_reader_of_this_very_context_is_below_the_role_floor() {
let env = open_env().await;
seed_context_with_keys(&env, "med-ctx").await;
let auth = scoped(crate::acl::Role::Reader, &["med-ctx"]);
let err = get_context_secrets(&deps_of(&env), &auth, "med-ctx", "test")
.await
.expect_err("a reader may see that keys exist, never hold one");
assert!(matches!(err, AppError::Forbidden(_)), "got: {err:?}");
}
#[tokio::test]
async fn entitlement_is_checked_before_existence() {
let env = open_env().await;
seed_context_with_keys(&env, "med-ctx").await;
let auth = scoped(crate::acl::Role::Application, &["some-other-ctx"]);
let real = get_context_secrets(&deps_of(&env), &auth, "med-ctx", "test")
.await
.expect_err("exists, not yours");
let imaginary = get_context_secrets(&deps_of(&env), &auth, "no-such-ctx", "test")
.await
.expect_err("does not exist, and not yours either");
assert!(matches!(real, AppError::Forbidden(_)), "got: {real:?}");
assert!(
matches!(imaginary, AppError::Forbidden(_)),
"an id that does not exist must not be distinguishable from one that \
does but is not the caller's — got: {imaginary:?}"
);
assert_eq!(
real.to_string().replace("med-ctx", "<id>"),
imaginary.to_string().replace("no-such-ctx", "<id>"),
"the two refusals must differ only in the id echoed back"
);
}
}