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;
use vti_common::acl::Capability;
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> {
ensure_may_export(deps.acl_ks, auth).await?;
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)
}
async fn ensure_may_export(acl_ks: &KeyspaceHandle, auth: &AuthClaims) -> Result<(), AppError> {
use vti_common::acl::{entry_has_capability, get_acl_entry, role_has_capability};
let entry = match get_acl_entry(acl_ks, &auth.did).await {
Ok(entry) => entry,
Err(e) => {
tracing::error!(
error = %e, did = %auth.did,
"could not read the ACL entry for the KeyExport check; refusing"
);
return Err(AppError::Forbidden(format!(
"vta/contexts/secrets denied: could not confirm that {} carries the \
key-export capability",
auth.did
)));
}
};
let may_export = match &entry {
Some(entry) => entry_has_capability(entry, Capability::KeyExport),
None => role_has_capability(&auth.role, Capability::KeyExport),
};
if may_export {
return Ok(());
}
Err(AppError::Forbidden(format!(
"vta/contexts/secrets denied: {} does not carry the key-export capability. \
Releasing a DID's private keys is an export, and VTI-VTA-003 gates export on a \
capability distinct from using the key; only an admin derives it, so the service \
operating a context's DID must be an admin scoped to that context. {}",
auth.did,
key_export_fix(entry.as_ref(), &auth.did)
)))
}
fn key_export_fix(entry: Option<&vti_common::acl::AclEntry>, did: &str) -> String {
use vta_sdk::acl::ActScope;
let Some(entry) = entry else {
return format!(
"Grant it with: pnm acl create --did {did} --role admin --contexts <CONTEXT>"
);
};
if entry.role != crate::acl::Role::Admin {
let promote = format!(
"pnm acl change-role --did {did} --from {} --to admin",
entry.role
);
let narrowed = restated_narrowing(entry);
return match (entry.act_scope(), narrowed) {
(ActScope::Contexts(_), None) => format!("Grant it with: {promote}"),
(ActScope::Contexts(_), Some(caps)) => {
format!("Grant it with: {promote} && pnm acl update {did} --capabilities {caps}")
}
_ => format!(
"Scope it to the context first, then promote it: \
pnm acl update {did} --contexts <CONTEXT> && {promote}"
),
};
}
match restated_narrowing(entry) {
Some(caps) => format!("Grant it with: pnm acl update {did} --capabilities {caps}"),
None => format!("Inspect the entry with: pnm acl get {did}"),
}
}
fn restated_narrowing(entry: &vti_common::acl::AclEntry) -> Option<String> {
if entry.capabilities.is_empty() {
return None;
}
let name = |c: &Capability| {
serde_json::to_value(c)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
};
let mut names: Vec<String> = entry.capabilities.iter().filter_map(name).collect();
if let Some(key_export) = name(&Capability::KeyExport)
&& !names.contains(&key_export)
{
names.push(key_export);
}
Some(names.join(","))
}
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"
);
}
async fn store_caller(
env: &TestEnv,
role: crate::acl::Role,
contexts: &[&str],
capabilities: Vec<Capability>,
) -> AuthClaims {
let auth = scoped(role.clone(), contexts);
vti_common::acl::store_acl_entry(
&env.acl_ks,
&crate::acl::AclEntry::new(&auth.did, role, "did:key:zRoot")
.with_contexts(auth.allowed_contexts.clone())
.with_capabilities(capabilities),
)
.await
.expect("store the caller's entry");
auth
}
fn forbidden_message(err: AppError) -> String {
match err {
AppError::Forbidden(m) => m,
other => panic!("expected Forbidden, got {other:?}"),
}
}
#[tokio::test]
async fn vti_vta_003_a_context_admin_fetches_its_own_contexts_secrets() {
let env = open_env().await;
let did = seed_context_with_keys(&env, "med-ctx").await;
let auth = store_caller(&env, crate::acl::Role::Admin, &["med-ctx"], vec![]).await;
let bundle = get_context_secrets(&deps_of(&env), &auth, "med-ctx", "test")
.await
.expect("a context admin derives KeyExport");
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 vti_vta_003_an_application_in_the_context_is_refused() {
let env = open_env().await;
seed_context_with_keys(&env, "med-ctx").await;
let auth = store_caller(&env, crate::acl::Role::Application, &["med-ctx"], vec![]).await;
let msg = forbidden_message(
get_context_secrets(&deps_of(&env), &auth, "med-ctx", "test")
.await
.expect_err("Application does not carry KeyExport"),
);
assert!(msg.contains("key-export"), "{msg}");
assert!(
msg.contains(&format!(
"pnm acl change-role --did {} --from application --to admin",
auth.did
)),
"the refusal must print the exact fix: {msg}"
);
}
#[tokio::test]
async fn vti_vta_003_an_initiator_in_the_context_is_refused() {
let env = open_env().await;
seed_context_with_keys(&env, "med-ctx").await;
let auth = store_caller(&env, crate::acl::Role::Initiator, &["med-ctx"], vec![]).await;
let msg = forbidden_message(
get_context_secrets(&deps_of(&env), &auth, "med-ctx", "test")
.await
.expect_err("Initiator does not carry KeyExport"),
);
assert!(msg.contains("--from initiator --to admin"), "{msg}");
}
#[tokio::test]
async fn vti_vta_003_an_admin_narrowed_without_key_export_is_refused() {
let env = open_env().await;
seed_context_with_keys(&env, "med-ctx").await;
let auth = store_caller(
&env,
crate::acl::Role::Admin,
&["med-ctx"],
vec![Capability::Sign, Capability::KeyMint],
)
.await;
let msg = forbidden_message(
get_context_secrets(&deps_of(&env), &auth, "med-ctx", "test")
.await
.expect_err("narrowed away, KeyExport is gone"),
);
assert!(
msg.contains(&format!(
"pnm acl update {} --capabilities sign,key-mint,key-export",
auth.did
)),
"{msg}"
);
}
#[tokio::test]
async fn vti_vta_003_an_admin_of_another_context_is_refused() {
let env = open_env().await;
seed_context_with_keys(&env, "med-ctx").await;
let auth = store_caller(&env, crate::acl::Role::Admin, &["some-other-ctx"], vec![]).await;
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 vti_vta_003_an_unscoped_non_admin_is_told_to_scope_first() {
let env = open_env().await;
seed_context_with_keys(&env, "med-ctx").await;
let auth = store_caller(&env, crate::acl::Role::Application, &[], vec![]).await;
let msg = forbidden_message(
get_context_secrets(&deps_of(&env), &auth, "med-ctx", "test")
.await
.expect_err("no KeyExport"),
);
assert!(
msg.contains(&format!(
"pnm acl update {} --contexts <CONTEXT> &&",
auth.did
)),
"{msg}"
);
}
#[tokio::test]
async fn vti_vta_003_a_reader_of_this_very_context_is_refused() {
let env = open_env().await;
seed_context_with_keys(&env, "med-ctx").await;
let auth = store_caller(&env, crate::acl::Role::Reader, &["med-ctx"], vec![]).await;
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;
for (role, ctx) in [
(crate::acl::Role::Admin, "some-other-ctx"),
(crate::acl::Role::Application, "med-ctx"),
] {
let auth = store_caller(&env, role, &[ctx], vec![]).await;
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"
);
}
}
}