use super::helpers::TrustTaskOutcome;
use base64::Engine as _;
use serde_json::Value;
use trust_tasks_rs::{RejectReason, TrustTask};
use vta_sdk::protocols::key_management::create::CreateKeyBody;
use vta_sdk::protocols::key_management::derive_and_sign::DeriveAndSignBody;
use vta_sdk::protocols::key_management::derive_and_sign_document::DeriveAndSignDocumentBody;
use vta_sdk::protocols::key_management::get::GetKeyBody;
use vta_sdk::protocols::key_management::import::{ImportKeyBody, ImportKeyResponseBody};
use vta_sdk::protocols::key_management::list::ListKeysBody;
use vta_sdk::protocols::key_management::rename::RenameKeyBody;
use vta_sdk::protocols::key_management::revoke::RevokeKeyBody;
use vta_sdk::protocols::key_management::secret::GetKeySecretBody;
use vta_sdk::protocols::key_management::sign::SignRequestBody;
use vta_sdk::protocols::key_management::sign::SigningDomain;
use crate::auth::AuthClaims;
use crate::operations;
use crate::server::AppState;
use super::helpers::{
TRANSPORT_TRUST_TASK, app_error_to_reject, parse_payload, reject_with, success_response,
};
pub(super) async fn handle_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: ListKeysBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match operations::keys::list_keys(
&state.keys_ks,
auth,
operations::keys::ListKeysParams {
offset: req.offset,
limit: req.limit,
status: req.status,
context_id: req.context_id,
},
TRANSPORT_TRUST_TASK,
)
.await
{
Ok(body) => success_response(&doc, body),
Err(e) => app_error_to_reject(&doc, e),
}
}
pub(super) async fn handle_create(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: CreateKeyBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match operations::keys::create_key(
&state.keys_ks,
&state.internal_ks,
&state.contexts_ks,
&state.seed_store,
&state.audit_sink,
&state.acl_ks,
auth,
operations::keys::CreateKeyParams {
internal: req.internal.unwrap_or(false),
key_type: req.key_type,
derivation_path: req.derivation_path,
key_id: req.key_id,
mnemonic: req.mnemonic,
label: req.label,
context_id: req.context_id,
},
TRANSPORT_TRUST_TASK,
)
.await
{
Ok(body) => success_response(
&doc,
vta_sdk::protocols::key_management::create::CreateKeyResponseBody { key: body },
),
Err(e) => app_error_to_reject(&doc, e),
}
}
pub(super) async fn handle_get(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: GetKeyBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match operations::keys::get_key(&state.keys_ks, auth, &req.key_id, TRANSPORT_TRUST_TASK).await {
Ok(record) => success_response(
&doc,
vta_sdk::protocols::key_management::get::GetKeyResponseBody { key: Some(record) },
),
Err(e) => app_error_to_reject(&doc, e),
}
}
pub(super) async fn handle_rename(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(e) = auth.require_admin() {
return app_error_to_reject(&doc, e);
}
let req: RenameKeyBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match operations::keys::rename_key(
&state.keys_ks,
&state.audit_sink,
auth,
&req.key_id,
&req.new_key_id,
TRANSPORT_TRUST_TASK,
)
.await
{
Ok(body) => success_response(&doc, body),
Err(e) => app_error_to_reject(&doc, e),
}
}
pub(super) async fn handle_revoke(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(e) = auth.require_admin() {
return app_error_to_reject(&doc, e);
}
let req: RevokeKeyBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match operations::keys::revoke_key(
&state.keys_ks,
&state.imported_ks,
&state.audit_sink,
auth,
&req.key_id,
TRANSPORT_TRUST_TASK,
)
.await
{
Ok(body) => success_response(&doc, body),
Err(e) => app_error_to_reject(&doc, e),
}
}
pub(super) async fn handle_set_exportability(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: vta_sdk::protocols::key_management::set_exportability::SetKeyExportabilityBody =
match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match operations::keys::set_key_exportability(
&state.keys_ks,
&state.sessions_ks,
&state.audit_sink,
auth,
&req.key_id,
req.exportable,
TRANSPORT_TRUST_TASK,
)
.await
{
Ok(key) => success_response(
&doc,
vta_sdk::protocols::key_management::set_exportability::SetKeyExportabilityResultBody {
key,
},
),
Err(e) => app_error_to_reject(&doc, e),
}
}
pub(super) async fn handle_export_secret(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: GetKeySecretBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match operations::keys::export_key_secret(
&state.keys_ks,
&state.imported_ks,
&state.contexts_ks,
&state.acl_ks,
&state.seed_store,
&state.audit_sink,
auth,
&req.key_id,
export_channel(),
)
.await
{
Ok(body) => success_response(&doc, body),
Err(operations::keys::KeyExportError::Refused(refusal, message)) => {
match trust_tasks_rs::TrustTaskCode::new_extended("keys/export-secret", refusal.code())
{
Ok(code) => super::helpers::reject_with_code(&doc, code, message, None),
Err(_) => app_error_to_reject(&doc, crate::error::AppError::Forbidden(message)),
}
}
Err(operations::keys::KeyExportError::Other(e)) => app_error_to_reject(&doc, e),
}
}
pub(super) fn export_channel() -> operations::keys::ExportChannel<'static> {
let channel = super::transport::audit_channel();
match super::transport::current() {
super::transport::TransportConfidentiality::EndToEnd => {
operations::keys::ExportChannel::EndToEnd(channel)
}
super::transport::TransportConfidentiality::HopByHop => {
operations::keys::ExportChannel::HopByHop(channel)
}
}
}
pub(super) async fn handle_sign(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(e) = auth.require_write() {
return app_error_to_reject(&doc, e);
}
let req: SignRequestBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let payload_bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&req.payload)
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(&req.payload))
{
Ok(b) => b,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("invalid base64url payload: {e}"),
},
);
}
};
match operations::keys::sign_payload(
&state.keys_ks,
&state.imported_ks,
&state.internal_ks,
&state.contexts_ks,
&state.acl_ks,
&state.seed_store,
&state.audit_sink,
auth,
&req.key_id,
&payload_bytes,
&req.algorithm,
SigningDomain::Opaque,
TRANSPORT_TRUST_TASK,
)
.await
{
Ok(body) => success_response(&doc, body),
Err(e) => app_error_to_reject(&doc, e),
}
}
pub(super) async fn handle_derive_and_sign(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: DeriveAndSignBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let payload_bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&req.payload)
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(&req.payload))
{
Ok(b) => b,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("invalid base64url payload: {e}"),
},
);
}
};
match operations::keys::derive_and_sign(
&state.keys_ks,
&state.acl_ks,
&state.seed_store,
auth,
&state.audit_sink,
&req.key_type,
&req.derivation_path,
&payload_bytes,
&req.algorithm,
TRANSPORT_TRUST_TASK,
)
.await
{
Ok(body) => success_response(&doc, body),
Err(e) => app_error_to_reject(&doc, e),
}
}
pub(super) async fn handle_derive_and_sign_document(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: DeriveAndSignDocumentBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match operations::keys::derive_and_sign_document(
&state.keys_ks,
&state.acl_ks,
&state.seed_store,
auth,
&state.audit_sink,
&req.key_type,
&req.derivation_path,
req.document,
req.proof_purpose.as_deref(),
TRANSPORT_TRUST_TASK,
)
.await
{
Ok(body) => success_response(&doc, body),
Err(e) => app_error_to_reject(&doc, e),
}
}
pub(super) async fn handle_import_wrapping_key(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use trust_tasks_rs::specs::keys::import_wrapping_key::v0_1 as spec;
if let Err(e) = auth.require_admin() {
return app_error_to_reject(&doc, e);
}
if let Err(resp) = parse_payload::<spec::Payload>(&doc) {
return resp;
}
let key = state.wrapping_cache.generate().await;
let body = serde_json::json!({
"wrappingKey": key.public_did,
"keyId": key.kid,
"expiresAt": key.expires_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
});
match serde_json::from_value::<spec::Response>(body) {
Ok(r) => success_response(&doc, r),
Err(e) => reject_with(
&doc,
RejectReason::InternalError {
reason: format!("wrapping key does not match its schema: {e}"),
},
),
}
}
pub(super) async fn handle_import(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(e) = auth.require_admin() {
return app_error_to_reject(&doc, e);
}
let req: ImportKeyBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if req.private_key_multibase.is_some()
&& crate::trust_tasks::transport::current()
!= crate::trust_tasks::transport::TransportConfidentiality::EndToEnd
{
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "keys/import: the cleartext `privateKeyMultibase` carrier needs a \
transport that is confidential end to end, and this request did not \
arrive on one — TLS terminates wherever the operator terminates it, so \
the key would exist in plaintext there. Seal the key to this VTA and \
send `privateKeySealed`, or send it over DIDComm or TSP."
.to_string(),
},
);
}
let private_key_bytes = if let Some(sealed) = req.private_key_sealed.as_deref() {
match state.wrapping_cache.unwrap_sealed(sealed).await {
Ok((sealed_type, bytes)) => {
if sealed_type != req.key_type.to_string() {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!(
"sealed keyType `{sealed_type}` does not match the request's `{}`",
req.key_type
),
},
);
}
bytes
}
Err(e) => return app_error_to_reject(&doc, e),
}
} else if let Some(jwe) = req.private_key_jwe.as_deref() {
tracing::warn!("key import via legacy JWE carrier — prefer privateKeySealed");
match state.wrapping_cache.unwrap_jwe(jwe).await {
Ok(bytes) => bytes,
Err(e) => return app_error_to_reject(&doc, e),
}
} else if let Some(mb) = req.private_key_multibase.as_deref() {
match multibase::decode(mb) {
Ok((_, decoded)) => match decoded.len() {
34 => decoded[2..].to_vec(),
32 => decoded,
other => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!(
"keys/import: `privateKeyMultibase` decoded to {other} bytes; \
expected 32 raw or 34 multicodec-prefixed"
),
},
);
}
},
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("keys/import: `privateKeyMultibase` is not multibase: {e}"),
},
);
}
}
} else {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "keys/import: one of `privateKeySealed`, `privateKeyJwe` or \
`privateKeyMultibase` is required"
.to_string(),
},
);
};
match operations::keys::import_key(
&state.keys_ks,
&state.imported_ks,
&state.seed_store,
&state.audit_sink,
auth,
operations::keys::ImportKeyParams {
key_type: req.key_type,
private_key_bytes,
label: req.label,
context_id: req.context_id,
},
TRANSPORT_TRUST_TASK,
)
.await
{
Ok(body) => success_response(&doc, ImportKeyResponseBody { key: body }),
Err(e) => app_error_to_reject(&doc, e),
}
}
#[cfg(test)]
mod key_export_tests {
use super::*;
use crate::acl::Role;
use crate::test_support::build_signing_test_app_state;
use serde_json::json;
use trust_tasks_rs::TypeUri;
use vti_common::acl::{AclEntry, Capability, store_acl_entry};
fn claims(did: &str, role: Role) -> AuthClaims {
AuthClaims {
did: did.into(),
role,
allowed_contexts: vec!["acme".to_string()],
session_id: "test-session".into(),
access_expires_at: 0,
issued_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
fn export_doc() -> TrustTask<Value> {
let uri: TypeUri = vta_sdk::trust_tasks::TASK_KEYS_EXPORT_SECRET_0_1
.parse()
.expect("export-secret uri");
TrustTask::new(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
uri,
json!({ "keyId": "did:key:zNoSuchKey#key-0" }),
)
}
fn refused_by_the_gate(out: &super::super::helpers::TrustTaskOutcome) -> bool {
let doc: Value = serde_json::from_slice(&out.body).expect("response is JSON");
doc.pointer("/payload/code").and_then(Value::as_str) == Some("permissionDenied")
}
#[tokio::test]
async fn an_initiator_cannot_export_a_secret() {
let (state, _dir) = build_signing_test_app_state().await;
let out = handle_export_secret(
&state,
&claims("did:key:zManager", Role::Initiator),
export_doc(),
)
.await;
assert!(
refused_by_the_gate(&out),
"an initiator must be refused at the KeyExport gate"
);
}
#[tokio::test]
async fn an_admin_passes_the_key_export_gate() {
let (state, _dir) = build_signing_test_app_state().await;
let out = super::super::transport::with_confidentiality(
super::super::transport::TransportConfidentiality::EndToEnd,
handle_export_secret(
&state,
&claims("did:key:zOperator", Role::Admin),
export_doc(),
),
)
.await;
let body = String::from_utf8_lossy(&out.body);
assert!(
!body.contains("key-export capability"),
"an admin derives KeyExport and must reach the key lookup: {body}"
);
assert!(body.contains("not within the caller's scope"), "{body}");
}
#[tokio::test]
async fn refusals_about_the_key_carry_the_declared_codes() {
use vta_sdk::keys::{KeyOrigin, KeyRecord, KeyStatus, KeyType};
let (state, _dir) = build_signing_test_app_state().await;
for (id, origin, exportable, code) in [
(
"k-locked",
KeyOrigin::Derived,
Some(false),
"keys/export-secret:notExportable",
),
(
"k-inside",
KeyOrigin::Internal,
None,
"keys/export-secret:neverExportable",
),
] {
let now = chrono::Utc::now();
state
.keys_ks
.insert(
crate::keys::store_key(id),
&KeyRecord {
key_id: id.into(),
derivation_path: "m/26'/0'/0'/0'".into(),
key_type: KeyType::Ed25519,
status: KeyStatus::Active,
public_key: "z6MkUnused".into(),
label: None,
context_id: None,
seed_id: None,
exportable,
origin,
created_at: now,
updated_at: now,
},
)
.await
.unwrap();
let uri: TypeUri = vta_sdk::trust_tasks::TASK_KEYS_EXPORT_SECRET_0_1
.parse()
.unwrap();
let doc = TrustTask::new(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
uri,
json!({ "keyId": id }),
);
let mut super_admin = claims("did:key:zRootAdmin", Role::Admin);
super_admin.allowed_contexts.clear();
let out = super::super::transport::with_confidentiality(
super::super::transport::TransportConfidentiality::EndToEnd,
handle_export_secret(&state, &super_admin, doc),
)
.await;
let body: Value = serde_json::from_slice(&out.body).unwrap();
assert_eq!(
body.pointer("/payload/code").and_then(Value::as_str),
Some(code),
"{body}"
);
}
}
#[tokio::test]
async fn an_export_over_https_is_refused_and_says_which_transports_work() {
let (state, _dir) = build_signing_test_app_state().await;
let out = super::super::transport::with_binding(
"https",
super::super::transport::with_confidentiality(
super::super::transport::TransportConfidentiality::HopByHop,
handle_export_secret(
&state,
&claims("did:key:zOperator", Role::Admin),
export_doc(),
),
),
)
.await;
assert!(refused_by_the_gate(&out));
let body = String::from_utf8_lossy(&out.body);
assert!(body.contains("DIDComm or TSP"), "{body}");
}
#[tokio::test]
async fn an_admin_narrowed_without_key_export_is_refused() {
let (state, _dir) = build_signing_test_app_state().await;
let auth = claims("did:key:zNarrowed", Role::Admin);
let narrowed = AclEntry::new(&auth.did, Role::Admin, "did:key:zRoot")
.with_contexts(vec!["acme".to_string()])
.with_capabilities(vec![Capability::Sign, Capability::KeyMint]);
store_acl_entry(&state.acl_ks, &narrowed)
.await
.expect("store the narrowed entry");
let out = handle_export_secret(&state, &auth, export_doc()).await;
assert!(
refused_by_the_gate(&out),
"a narrowing without key-export removes it, even from an admin"
);
}
}
#[cfg(test)]
mod import_wrapping_key_tests {
use crate::acl::Role;
use crate::auth::AuthClaims;
use crate::test_support::build_signing_test_app_state;
use serde_json::{Value, json};
async fn ask(state: &crate::server::AppState, seed: u8, role: Role) -> Value {
let vta_did = state.config.read().await.vta_did.clone().expect("vta_did");
let (did, _) = crate::test_support::did_for_seed(seed);
let mut doc: trust_tasks_rs::TrustTask<Value> = serde_json::from_value(json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": vta_sdk::trust_tasks::TASK_KEYS_IMPORT_WRAPPING_KEY_0_1,
"issuer": did,
"recipient": vta_did,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": {},
}))
.unwrap();
crate::test_support::sign_as(seed, &mut doc);
let claims = AuthClaims {
did,
role,
allowed_contexts: vec![],
..Default::default()
};
let out = super::super::dispatch_trust_task_core(
state,
&claims,
&serde_json::to_vec(&doc).unwrap(),
super::super::transport::TransportConfidentiality::HopByHop,
)
.await;
serde_json::from_slice(&out.body).expect("a JSON document")
}
#[tokio::test]
async fn an_admin_gets_a_fresh_signed_did_key_that_opens_one_sealed_bundle() {
let (state, _dir) = build_signing_test_app_state().await;
let first = ask(&state, 0x68, Role::Admin).await;
let second = ask(&state, 0x68, Role::Admin).await;
let key = first["payload"]["wrappingKey"].as_str().expect("{first}");
assert!(key.starts_with("did:key:z6Mk"), "{first}");
assert!(first["payload"]["keyId"].is_string(), "{first}");
assert!(first["payload"]["expiresAt"].is_string(), "{first}");
assert!(first["proof"].is_object(), "the answer is signed: {first}");
assert_ne!(
first["payload"]["wrappingKey"],
second["payload"]["wrappingKey"]
);
use base64::Engine as _;
use vta_sdk::sealed_transfer::{
AssertionProof, InMemoryNonceStore, ProducerAssertion, RawPrivateKey, SealedPayloadV1,
armor, generate_ed25519_keypair, seal_payload,
};
let x = affinidi_crypto::did_key::ed25519_pub_to_x25519_bytes(
&affinidi_crypto::did_key::did_key_to_ed25519_pub(key).unwrap(),
)
.unwrap();
let (_s, prod) = generate_ed25519_keypair();
let bundle = seal_payload(
&x,
[9u8; 16],
ProducerAssertion {
producer_did: affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod),
proof: AssertionProof::PinnedOnly,
},
&SealedPayloadV1::RawPrivateKey(RawPrivateKey {
key_type: "ed25519".into(),
key_bytes_b64: base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode([0x33u8; 32]),
}),
&InMemoryNonceStore::new(),
)
.await
.unwrap();
let armored = armor::encode(&bundle);
let (kind, bytes) = state.wrapping_cache.unwrap_sealed(&armored).await.unwrap();
assert_eq!(
(kind.as_str(), bytes.as_slice()),
("ed25519", &[0x33u8; 32][..])
);
assert!(
state.wrapping_cache.unwrap_sealed(&armored).await.is_err(),
"single use"
);
}
#[tokio::test]
async fn a_non_admin_is_refused_a_wrapping_key() {
let (state, _dir) = build_signing_test_app_state().await;
let resp = ask(&state, 0x69, Role::Reader).await;
assert_eq!(resp["payload"]["code"], "permissionDenied", "{resp}");
}
}