#![allow(clippy::result_large_err)]
use super::helpers::TrustTaskOutcome;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use trust_tasks_rs::TrustTask;
use uuid::Uuid;
use vti_common::acl::{Capability, role_has_capability};
use vti_common::vault::{
LifecycleError, SecretKind, SiteTarget, StoredVaultEntry, VaultEntry, VaultListFilter,
VaultSecret, VaultStatus, delete_vault_entry, get_stored_vault_entry, get_vault_entry,
list_vault_entries as list_entries_store, put_stored_vault_entry,
};
use crate::auth::AuthClaims;
use crate::error::AppError;
use crate::server::AppState;
use super::helpers::{app_error_to_reject, parse_payload, reject_with, success_response};
use trust_tasks_rs::RejectReason;
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "lowercase")]
enum VaultListStatusFilter {
Active,
Archived,
Deleted,
All,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VaultListBody {
context_id: Option<String>,
target_origin_prefix: Option<String>,
target_did: Option<String>,
target_ios_bundle_id: Option<String>,
target_android_package: Option<String>,
secret_kind: Option<SecretKind>,
tag: Option<String>,
used_since: Option<String>,
never_used: Option<bool>,
expires_before: Option<String>,
breached: Option<bool>,
#[serde(default)]
status: Option<VaultListStatusFilter>,
page_size: Option<u32>,
#[serde(default)]
#[allow(dead_code)]
cursor: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VaultListResponseBody {
entries: Vec<VaultEntry>,
truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
redacted_fields: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VaultGetBody {
id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VaultGetResponseBody {
entry: VaultEntry,
#[serde(skip_serializing_if = "Option::is_none")]
redacted_fields: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VaultUpsertBody {
id: Option<String>,
expected_version: Option<u32>,
context_id: String,
targets: Vec<SiteTarget>,
label: String,
secret_kind: SecretKind,
#[serde(default)]
tags: Vec<String>,
notes: Option<String>,
favicon: Option<String>,
#[serde(default)]
selectors: Vec<String>,
#[serde(default)]
custom_field_names: Vec<String>,
expires_at: Option<String>,
sealed_secret: Option<SealedEnvelope>,
#[serde(default)]
clear_fields: Vec<ClearableField>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VaultUpsertResponseBody {
entry: VaultEntry,
created: bool,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "envelope", rename_all = "kebab-case")]
enum SealedEnvelope {
#[serde(alias = "didcommAuthcrypt")]
DidcommAuthcrypt { jwe: String },
#[serde(alias = "hpkeArmored")]
HpkeArmored {
#[serde(default)]
#[allow(dead_code)]
armored: String,
#[serde(default)]
#[allow(dead_code)]
recipient_key_id: String,
},
#[serde(alias = "tspMessage")]
TspMessage {
#[serde(default)]
#[allow(dead_code)]
message: String,
},
}
impl SealedEnvelope {
fn kind_name(&self) -> &'static str {
match self {
SealedEnvelope::DidcommAuthcrypt { .. } => "didcomm-authcrypt",
SealedEnvelope::HpkeArmored { .. } => "hpke-armored",
SealedEnvelope::TspMessage { .. } => "tsp-message",
}
}
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
enum ClearableField {
Notes,
Favicon,
ExpiresAt,
Tags,
Selectors,
CustomFieldNames,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VaultDeleteBody {
id: String,
expected_version: Option<u32>,
#[serde(default)]
#[allow(dead_code)] reason: Option<String>,
#[serde(default)]
force: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VaultDeleteResponseBody {
id: String,
deleted_at: String,
grace_until: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VaultLifecycleBody {
id: String,
#[serde(default)]
expected_version: Option<u32>,
#[serde(default)]
#[allow(dead_code)] reason: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VaultLifecycleResponseBody {
id: String,
status: VaultStatus,
version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
grace_until: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VaultPurgeResponseBody {
id: String,
purged: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VaultReleaseBody {
entry_id: String,
#[serde(default)]
#[allow(dead_code)]
target: Option<SiteTarget>,
#[serde(default)]
#[allow(dead_code)]
consumer_context: Option<Value>,
#[serde(default)]
ttl_seconds_hint: Option<u32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VaultReleaseResponseBody {
sealed_secret: SealedEnvelopeWire,
secret_kind: SecretKind,
ttl_seconds: u32,
}
#[derive(Debug, Serialize)]
#[serde(tag = "envelope", rename_all = "kebab-case")]
enum SealedEnvelopeWire {
DidcommAuthcrypt { jwe: String },
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VaultProxyLoginBody {
entry_id: String,
#[serde(default)]
target: Option<SiteTarget>,
#[serde(default)]
#[allow(dead_code)]
consumer_context: Option<Value>,
#[serde(default)]
nonce: Option<String>,
#[serde(default)]
ttl_seconds_hint: Option<u32>,
}
const NONCE_MAX_LEN: usize = 512;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VaultProxyLoginResponseBody {
sealed_session_blob: SealedEnvelopeWire,
session_id: String,
expires_at: String,
}
fn require_capability(
auth: &AuthClaims,
doc: &TrustTask<Value>,
cap: Capability,
action: &str,
) -> Result<(), TrustTaskOutcome> {
if role_has_capability(&auth.role, cap) {
Ok(())
} else {
Err(reject_with(
doc,
RejectReason::PermissionDenied {
reason: format!(
"vault {action} denied: role {} does not carry {cap:?} capability",
auth.role
),
},
))
}
}
fn enforce_context_scope(
auth: &AuthClaims,
context_id: Option<&str>,
doc: &TrustTask<Value>,
) -> Result<(), TrustTaskOutcome> {
let Some(ctx) = context_id else {
return Ok(()); };
if auth.allowed_contexts.is_empty() {
return Ok(()); }
if auth.allowed_contexts.iter().any(|c| c == ctx) {
return Ok(());
}
Err(reject_with(
doc,
RejectReason::PermissionDenied {
reason: format!("vault scope denied: caller is not authorised for context {ctx}"),
},
))
}
fn vault_not_found(doc: &TrustTask<Value>, verb: &str, id: &str) -> TrustTaskOutcome {
reject_with(
doc,
RejectReason::TaskFailed {
reason: format!("vault/{verb}:not_found — no entry at id {id}"),
details: None,
},
)
}
fn check_expected_version(
doc: &TrustTask<Value>,
verb: &str,
current: u32,
expected: Option<u32>,
) -> Option<TrustTaskOutcome> {
match expected {
Some(v) if v != current => Some(reject_with(
doc,
RejectReason::TaskFailed {
reason: format!(
"vault/{verb}:version_conflict — expectedVersion {v} != current version {current}"
),
details: Some(serde_json::json!({ "currentVersion": current })),
},
)),
_ => None,
}
}
fn lifecycle_reject(
doc: &TrustTask<Value>,
verb: &str,
id: &str,
err: LifecycleError,
) -> TrustTaskOutcome {
let hint = match err {
LifecycleError::NotActive => "entry is not active (already archived or deleted)",
LifecycleError::NotArchived => "entry is not archived",
LifecycleError::AlreadyDeleted => {
"entry is already in the trash — restore it (vault/restore) or purge it (vault/purge)"
}
LifecycleError::NotDeleted => "entry is not in the trash",
LifecycleError::GraceExpired => {
"the grace window has elapsed — the entry has been (or is about to be) purged"
}
};
reject_with(
doc,
RejectReason::TaskFailed {
reason: format!("vault/{verb}:{} — {hint} (id {id})", err.code()),
details: None,
},
)
}
fn lifecycle_response(entry: &VaultEntry) -> VaultLifecycleResponseBody {
VaultLifecycleResponseBody {
id: entry.id.clone(),
status: entry.status,
version: entry.version,
grace_until: entry.grace_until.clone(),
}
}
fn refuse_if_not_active(
doc: &TrustTask<Value>,
op: &str,
entry: &VaultEntry,
) -> Option<TrustTaskOutcome> {
if entry.status.is_active() {
return None;
}
tracing::info!(
entry_id = %entry.id,
status = ?entry.status,
op = %op,
"vault: refusing a non-active entry on a use path (reported to caller as not_found)"
);
Some(reject_with(
doc,
RejectReason::TaskFailed {
reason: format!("vault/{op}:not_found — no entry at id {}", entry.id),
details: None,
},
))
}
pub(super) async fn handle_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_capability(auth, &doc, Capability::VaultRead, "read") {
return r;
}
let req: VaultListBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if req.used_since.is_some() && req.never_used == Some(true) {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "vault/list: usedSince and neverUsed are mutually exclusive".into(),
},
);
}
if let Err(r) = enforce_context_scope(auth, req.context_id.as_deref(), &doc) {
return r;
}
let (status, any_status) = match req.status.unwrap_or(VaultListStatusFilter::Active) {
VaultListStatusFilter::Active => (Some(VaultStatus::Active), false),
VaultListStatusFilter::Archived => (Some(VaultStatus::Archived), false),
VaultListStatusFilter::Deleted => (Some(VaultStatus::Deleted), false),
VaultListStatusFilter::All => (None, true),
};
let filter = VaultListFilter {
context_id: req.context_id.as_deref(),
target_origin_prefix: req.target_origin_prefix.as_deref(),
target_did: req.target_did.as_deref(),
target_ios_bundle_id: req.target_ios_bundle_id.as_deref(),
target_android_package: req.target_android_package.as_deref(),
secret_kind: req.secret_kind,
tag: req.tag.as_deref(),
used_since: req.used_since.as_deref(),
never_used: req.never_used,
expires_before: req.expires_before.as_deref(),
breached: req.breached,
status,
any_status,
};
let mut entries = match list_entries_store(&state.vault_ks, &filter).await {
Ok(v) => v,
Err(e) => return app_error_to_reject(&doc, e),
};
if !auth.allowed_contexts.is_empty() && req.context_id.is_none() {
entries.retain(|e| auth.allowed_contexts.iter().any(|c| c == &e.context_id));
}
let page_size = req.page_size.unwrap_or(100) as usize;
let truncated = entries.len() > page_size;
entries.truncate(page_size);
success_response(
&doc,
VaultListResponseBody {
entries,
truncated,
cursor: None,
redacted_fields: None,
},
)
}
pub(super) async fn handle_get(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_capability(auth, &doc, Capability::VaultRead, "read") {
return r;
}
let req: VaultGetBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let entry = match get_vault_entry(&state.vault_ks, &req.id).await {
Ok(Some(e)) => e,
Ok(None) => {
return app_error_to_reject(
&doc,
AppError::NotFound(format!("vault entry {} not found", req.id)),
);
}
Err(e) => return app_error_to_reject(&doc, e),
};
if let Err(r) = enforce_context_scope(auth, Some(&entry.context_id), &doc) {
return r;
}
success_response(
&doc,
VaultGetResponseBody {
entry,
redacted_fields: None,
},
)
}
pub(super) async fn handle_upsert(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_capability(auth, &doc, Capability::VaultWrite, "write") {
return r;
}
let req: VaultUpsertBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(r) = enforce_context_scope(auth, Some(&req.context_id), &doc) {
return r;
}
let existing: Option<StoredVaultEntry> = if let Some(id) = req.id.as_deref() {
match get_stored_vault_entry(&state.vault_ks, id).await {
Ok(e) => e,
Err(e) => return app_error_to_reject(&doc, e),
}
} else {
None
};
if existing.is_none() && req.expected_version.is_some() && req.id.is_some() {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/upsert:not_found — no entry at id {}",
req.id.as_deref().unwrap_or("(none)")
),
details: None,
},
);
}
if let Some(e) = existing.as_ref()
&& e.entry.status != VaultStatus::Active
{
let (state_word, hint) = match e.entry.status {
VaultStatus::Archived => (
"archived",
"unarchive it first (vault/unarchive) before editing",
),
VaultStatus::Deleted => (
"deleted",
"restore it first (vault/restore), or vault/purge and recreate",
),
VaultStatus::Active => unreachable!("guarded by the != Active check above"),
};
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/upsert:entry_{state_word} — entry {} is {state_word}; {hint}",
e.entry.id
),
details: Some(serde_json::json!({ "status": e.entry.status })),
},
);
}
if let Some(e) = existing.as_ref()
&& e.entry.context_id != req.context_id
{
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/upsert:context_change_forbidden — entry {} is in context {}; cannot move to {}. Delete and recreate instead.",
e.entry.id, e.entry.context_id, req.context_id
),
details: Some(serde_json::json!({
"currentContext": e.entry.context_id,
"requestedContext": req.context_id,
})),
},
);
}
if let (Some(e), Some(v)) = (existing.as_ref(), req.expected_version)
&& e.entry.version != v
{
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/upsert:version_conflict — expectedVersion {v} != current version {}",
e.entry.version
),
details: Some(serde_json::json!({ "currentVersion": e.entry.version })),
},
);
}
let secret: VaultSecret = match (&req.sealed_secret, existing.as_ref()) {
(Some(env), _) => {
let jwe = match env {
SealedEnvelope::DidcommAuthcrypt { jwe } => jwe,
other => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/upsert:envelope_unsupported — received {kind}; this maintainer accepts only didcomm-authcrypt in M2A",
kind = other.kind_name()
),
details: Some(serde_json::json!({
"receivedEnvelope": other.kind_name(),
"supportedEnvelopes": ["didcomm-authcrypt"],
})),
},
);
}
};
let atm = match state.atm.as_ref() {
Some(atm) => atm,
None => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: "ATM not configured — server cannot unpack DIDComm envelopes"
.into(),
},
);
}
};
use crate::operations::vault::upsert::UnsealError;
match crate::operations::vault::upsert::unseal_secret(atm, &auth.did, jwe).await {
Ok(s) => s,
Err(UnsealError::SenderMismatch { sender, caller }) => {
return reject_with(
&doc,
RejectReason::PermissionDenied {
reason: format!(
"vault/upsert:sealed_secret_invalid — JWE sender {sender} does not match authenticated caller {caller}"
),
},
);
}
Err(UnsealError::UnpackFailed(e)) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/upsert:sealed_secret_invalid — DIDComm unpack: {e}"
),
details: Some(serde_json::json!({ "reason": "unpack_failed" })),
},
);
}
Err(UnsealError::MissingSender) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: "vault/upsert:sealed_secret_invalid — JWE has no sender (from)"
.into(),
details: Some(serde_json::json!({ "reason": "missing_sender" })),
},
);
}
Err(UnsealError::CleartextInvalid(e)) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/upsert:sealed_secret_invalid — cleartext not a VaultSecret: {e}"
),
details: Some(
serde_json::json!({ "reason": "cleartext_schema_invalid" }),
),
},
);
}
}
}
(None, Some(e)) => e.secret.clone(),
(None, None) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/upsert:secret_required — secretKind {:?} needs `sealedSecret` on create",
req.secret_kind
),
details: None,
},
);
}
};
if !secret.matches_kind(req.secret_kind) {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/upsert:sealed_secret_invalid — declared secretKind {:?} does not match secret variant {:?}",
req.secret_kind,
secret.kind()
),
details: Some(serde_json::json!({
"declaredKind": serde_json::to_value(req.secret_kind).ok(),
"secretVariant": serde_json::to_value(secret.kind()).ok(),
})),
},
);
}
let now = chrono::Utc::now().to_rfc3339();
let is_create = existing.is_none();
let secret_rotated_password =
req.sealed_secret.is_some() && matches!(req.secret_kind, SecretKind::Password);
let entry = VaultEntry {
id: existing
.as_ref()
.map(|e| e.entry.id.clone())
.or(req.id.clone())
.unwrap_or_else(|| format!("vault_{}", Uuid::new_v4().simple())),
context_id: req.context_id,
targets: req.targets,
label: req.label,
secret_kind: req.secret_kind,
tags: if req.clear_fields.contains(&ClearableField::Tags) {
Vec::new()
} else {
req.tags
},
notes: if req.clear_fields.contains(&ClearableField::Notes) {
None
} else {
req.notes
},
favicon: if req.clear_fields.contains(&ClearableField::Favicon) {
None
} else {
req.favicon
},
selectors: if req.clear_fields.contains(&ClearableField::Selectors) {
Vec::new()
} else {
req.selectors
},
custom_field_names: if req.clear_fields.contains(&ClearableField::CustomFieldNames) {
Vec::new()
} else {
req.custom_field_names
},
attachments: existing
.as_ref()
.map(|e| e.entry.attachments.clone())
.unwrap_or_default(),
expires_at: if req.clear_fields.contains(&ClearableField::ExpiresAt) {
None
} else {
req.expires_at
},
breached_at: existing.as_ref().and_then(|e| e.entry.breached_at.clone()),
password_changed_at: if (is_create && matches!(req.secret_kind, SecretKind::Password))
|| secret_rotated_password
{
Some(now.clone())
} else {
existing
.as_ref()
.and_then(|e| e.entry.password_changed_at.clone())
},
created_at: existing
.as_ref()
.map(|e| e.entry.created_at.clone())
.unwrap_or_else(|| now.clone()),
created_by: existing
.as_ref()
.and_then(|e| e.entry.created_by.clone())
.or_else(|| Some(auth.did.clone())),
updated_at: now,
updated_by: Some(auth.did.clone()),
last_used_at: existing.as_ref().and_then(|e| e.entry.last_used_at.clone()),
version: existing.as_ref().map(|e| e.entry.version + 1).unwrap_or(1),
principal_did: VaultEntry::principal_did_from_secret(&secret),
status: VaultStatus::Active,
archived_at: None,
deleted_at: None,
grace_until: None,
};
let record = StoredVaultEntry {
entry: entry.clone(),
secret,
};
if let Err(e) = put_stored_vault_entry(&state.vault_ks, &record).await {
return app_error_to_reject(&doc, e);
}
success_response(
&doc,
VaultUpsertResponseBody {
entry,
created: is_create,
},
)
}
pub(super) async fn handle_delete(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_capability(auth, &doc, Capability::VaultWrite, "write") {
return r;
}
let req: VaultDeleteBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let mut existing = match get_stored_vault_entry(&state.vault_ks, &req.id).await {
Ok(Some(e)) => e,
Ok(None) => return vault_not_found(&doc, "delete", &req.id),
Err(e) => return app_error_to_reject(&doc, e),
};
if let Err(r) = enforce_context_scope(auth, Some(&existing.entry.context_id), &doc) {
return r;
}
if let Some(r) =
check_expected_version(&doc, "delete", existing.entry.version, req.expected_version)
{
return r;
}
let now = chrono::Utc::now();
let now_str = now.to_rfc3339();
if req.force {
if let Err(e) = delete_vault_entry(&state.vault_ks, &req.id).await {
return app_error_to_reject(&doc, e);
}
return success_response(
&doc,
VaultDeleteResponseBody {
id: req.id,
deleted_at: now_str.clone(),
grace_until: now_str, },
);
}
let grace_days = state.config.read().await.vault.grace_days;
let grace_until = (now + chrono::Duration::days(grace_days as i64)).to_rfc3339();
if let Err(e) = existing
.entry
.soft_delete(&now_str, &grace_until, Some(&auth.did))
{
return lifecycle_reject(&doc, "delete", &req.id, e);
}
if let Err(e) = put_stored_vault_entry(&state.vault_ks, &existing).await {
return app_error_to_reject(&doc, e);
}
success_response(
&doc,
VaultDeleteResponseBody {
id: req.id,
deleted_at: now_str,
grace_until,
},
)
}
pub(super) async fn handle_archive(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
handle_lifecycle_transition(state, auth, doc, "archive", |entry, now, actor| {
entry.archive(now, actor)
})
.await
}
pub(super) async fn handle_unarchive(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
handle_lifecycle_transition(state, auth, doc, "unarchive", |entry, now, actor| {
entry.unarchive(now, actor)
})
.await
}
pub(super) async fn handle_restore(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
handle_lifecycle_transition(state, auth, doc, "restore", |entry, now, actor| {
entry.restore(now, actor)
})
.await
}
async fn handle_lifecycle_transition(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
verb: &str,
transition: impl Fn(&mut VaultEntry, &str, Option<&str>) -> Result<(), LifecycleError>,
) -> TrustTaskOutcome {
if let Err(r) = require_capability(auth, &doc, Capability::VaultWrite, verb) {
return r;
}
let req: VaultLifecycleBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let mut existing = match get_stored_vault_entry(&state.vault_ks, &req.id).await {
Ok(Some(e)) => e,
Ok(None) => return vault_not_found(&doc, verb, &req.id),
Err(e) => return app_error_to_reject(&doc, e),
};
if let Err(r) = enforce_context_scope(auth, Some(&existing.entry.context_id), &doc) {
return r;
}
if let Some(r) =
check_expected_version(&doc, verb, existing.entry.version, req.expected_version)
{
return r;
}
let now = chrono::Utc::now().to_rfc3339();
if let Err(e) = transition(&mut existing.entry, &now, Some(&auth.did)) {
return lifecycle_reject(&doc, verb, &req.id, e);
}
if let Err(e) = put_stored_vault_entry(&state.vault_ks, &existing).await {
return app_error_to_reject(&doc, e);
}
success_response(&doc, lifecycle_response(&existing.entry))
}
pub(super) async fn handle_purge(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_capability(auth, &doc, Capability::VaultWrite, "purge") {
return r;
}
let req: VaultLifecycleBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let existing = match get_stored_vault_entry(&state.vault_ks, &req.id).await {
Ok(Some(e)) => e,
Ok(None) => return vault_not_found(&doc, "purge", &req.id),
Err(e) => return app_error_to_reject(&doc, e),
};
if let Err(r) = enforce_context_scope(auth, Some(&existing.entry.context_id), &doc) {
return r;
}
if let Some(r) =
check_expected_version(&doc, "purge", existing.entry.version, req.expected_version)
{
return r;
}
if let Err(e) = delete_vault_entry(&state.vault_ks, &req.id).await {
return app_error_to_reject(&doc, e);
}
success_response(
&doc,
VaultPurgeResponseBody {
id: req.id,
purged: true,
},
)
}
pub(super) async fn handle_release(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_capability(auth, &doc, Capability::FillRelease, "release") {
return r;
}
let req: VaultReleaseBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let stored = match get_stored_vault_entry(&state.vault_ks, &req.entry_id).await {
Ok(Some(e)) => e,
Ok(None) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!("vault/release:not_found — no entry at id {}", req.entry_id),
details: None,
},
);
}
Err(e) => return app_error_to_reject(&doc, e),
};
if let Err(r) = enforce_context_scope(auth, Some(&stored.entry.context_id), &doc) {
return r;
}
if let Some(reject) = refuse_if_not_active(&doc, "release", &stored.entry) {
return reject;
}
if let Some(reject) =
super::step_up::require_step_up(state, auth, super::step_up::op::VAULT_RELEASE, &doc).await
{
return reject;
}
let atm = match state.atm.as_ref() {
Some(atm) => atm,
None => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: "ATM not configured — server cannot pack DIDComm envelopes".into(),
},
);
}
};
let vta_did = match state.config.read().await.vta_did.clone() {
Some(d) => d,
None => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: "vta_did not configured — server cannot identify itself as signer"
.into(),
},
);
}
};
match crate::operations::vault::release::release_secret(
atm,
&state.vault_ks,
&vta_did,
&auth.did,
stored,
req.ttl_seconds_hint,
super::wire_v0_2::current_wire_version(),
)
.await
{
Ok(out) => success_response(
&doc,
VaultReleaseResponseBody {
sealed_secret: SealedEnvelopeWire::DidcommAuthcrypt { jwe: out.jwe },
secret_kind: out.secret_kind,
ttl_seconds: out.ttl_seconds,
},
),
Err(e) => app_error_to_reject(&doc, e),
}
}
pub(super) async fn handle_proxy_login(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_capability(auth, &doc, Capability::ProxyLogin, "proxy-login") {
return r;
}
let req: VaultProxyLoginBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Some(n) = req.nonce.as_deref()
&& (n.is_empty() || n.len() > NONCE_MAX_LEN)
{
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!(
"vault/proxy-login: nonce length {} outside [1, {NONCE_MAX_LEN}]",
n.len()
),
},
);
}
let stored = match get_stored_vault_entry(&state.vault_ks, &req.entry_id).await {
Ok(Some(e)) => e,
Ok(None) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/proxy-login:not_found — no entry at id {}",
req.entry_id
),
details: None,
},
);
}
Err(e) => return app_error_to_reject(&doc, e),
};
if let Err(r) = enforce_context_scope(auth, Some(&stored.entry.context_id), &doc) {
return r;
}
if let Some(reject) = refuse_if_not_active(&doc, "proxy-login", &stored.entry) {
return reject;
}
if let Some(reject) =
super::step_up::require_step_up(state, auth, super::step_up::op::VAULT_PROXY_LOGIN, &doc)
.await
{
return reject;
}
let atm = match state.atm.as_ref() {
Some(atm) => atm,
None => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: "ATM not configured — server cannot pack DIDComm envelopes".into(),
},
);
}
};
let vta_did = match state.config.read().await.vta_did.clone() {
Some(d) => d,
None => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: "vta_did not configured — server cannot identify itself as signer"
.into(),
},
);
}
};
use crate::operations::vault::proxy_login::ProxyLoginError;
match crate::operations::vault::proxy_login::proxy_login(
atm,
&state.vault_ks,
&state.keys_ks,
&state.imported_ks,
&state.audit_ks,
&*state.seed_store,
&vta_did,
&auth.did,
stored,
req.target,
req.nonce,
req.ttl_seconds_hint,
super::wire_v0_2::current_wire_version(),
)
.await
{
Ok(out) => success_response(
&doc,
VaultProxyLoginResponseBody {
sealed_session_blob: SealedEnvelopeWire::DidcommAuthcrypt { jwe: out.jwe },
session_id: out.session_id,
expires_at: out.expires_at,
},
),
Err(ProxyLoginError::NoAudience { entry_targets }) => reject_with(
&doc,
RejectReason::TaskFailed {
reason:
"vault/proxy-login:no_audience — entry has no DID or web-origin target to use as SIOP audience"
.into(),
details: Some(serde_json::json!({ "entryTargets": entry_targets })),
},
),
Err(ProxyLoginError::NotProxyable) => reject_with(
&doc,
RejectReason::TaskFailed {
reason:
"vault/proxy-login:not_proxyable — password entry has no loginConfig; use vault/release for browser-fill"
.into(),
details: Some(serde_json::json!({
"secretKind": "password",
"remediation": "fall back to vault/release/0.1",
})),
},
),
Err(ProxyLoginError::NotImplemented { kind }) => reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/proxy-login:not_implemented — entry secretKind {kind} has no proxy-login driver yet"
),
details: Some(serde_json::json!({
"secretKind": kind,
"supportedKinds": ["did-self-issued", "password"],
})),
},
),
#[cfg(feature = "webvh")]
Err(ProxyLoginError::PasswordPost(e)) => {
reject_with(&doc, password_post_error_to_reject(&e, &req.entry_id))
}
Err(ProxyLoginError::App(e)) => app_error_to_reject(&doc, e),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VaultSignTrustTaskBody {
entry_id: String,
unsigned_envelope: Value,
#[serde(default)]
#[allow(dead_code)]
consumer_context: Option<Value>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VaultSignTrustTaskResponseBody {
signed_envelope: Value,
}
pub(super) async fn handle_sign_trust_task(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_capability(auth, &doc, Capability::SignTrustTask, "sign-trust-task") {
return r;
}
let req: VaultSignTrustTaskBody = match parse_payload(&doc) {
Ok(v) => v,
Err(r) => return r,
};
let stored = match get_stored_vault_entry(&state.vault_ks, &req.entry_id).await {
Ok(Some(e)) => e,
Ok(None) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/sign-trust-task:not_found — no entry at id {}",
req.entry_id
),
details: None,
},
);
}
Err(e) => return app_error_to_reject(&doc, e),
};
if let Err(r) = enforce_context_scope(auth, Some(&stored.entry.context_id), &doc) {
return r;
}
if let Some(reject) = refuse_if_not_active(&doc, "sign-trust-task", &stored.entry) {
return reject;
}
if let Some(reject) = super::step_up::require_step_up(
state,
auth,
super::step_up::op::VAULT_SIGN_TRUST_TASK,
&doc,
)
.await
{
return reject;
}
use crate::operations::vault::sign_trust_task::SignTrustTaskError;
let signed = match crate::operations::vault::sign_trust_task::sign_envelope(
&state.keys_ks,
&state.imported_ks,
&state.audit_ks,
&*state.seed_store,
&stored.secret,
&req.unsigned_envelope,
)
.await
{
Ok(s) => s,
Err(SignTrustTaskError::NotSignable { kind }) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/sign-trust-task:not_signable — entry kind '{kind}' has no DID-based signing identity"
),
details: Some(serde_json::json!({ "secretKind": kind })),
},
);
}
Err(SignTrustTaskError::EnvelopeNotObject) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: "vault/sign-trust-task:envelope_invalid — unsignedEnvelope must be a JSON object".into(),
details: None,
},
);
}
Err(SignTrustTaskError::EnvelopeMissingField { field }) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/sign-trust-task:envelope_invalid — missing required field '{field}'"
),
details: Some(serde_json::json!({ "missing": field })),
},
);
}
Err(SignTrustTaskError::IssuerNotString) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: "vault/sign-trust-task:envelope_invalid — issuer must be a string"
.into(),
details: None,
},
);
}
Err(SignTrustTaskError::AlreadyProofed) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: "vault/sign-trust-task:envelope_already_proofed — strip the existing proof and resubmit".into(),
details: None,
},
);
}
Err(SignTrustTaskError::IssuerMismatch {
envelope_issuer,
expected,
}) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: "vault/sign-trust-task:envelope_issuer_mismatch — envelope.issuer must equal the entry's principalDid".into(),
details: Some(serde_json::json!({
"envelopeIssuer": envelope_issuer,
"expectedIssuer": expected,
})),
},
);
}
Err(SignTrustTaskError::ExpiresAtNotRfc3339 { value }) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: "vault/sign-trust-task:envelope_invalid — expiresAt must be an RFC 3339 timestamp".into(),
details: Some(serde_json::json!({ "expiresAt": value })),
},
);
}
Err(SignTrustTaskError::Expired { value }) => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason:
"vault/sign-trust-task:envelope_expired — envelope.expiresAt is in the past"
.into(),
details: Some(serde_json::json!({ "expiresAt": value })),
},
);
}
Err(SignTrustTaskError::App(e)) => return app_error_to_reject(&doc, e),
};
let str_field = |k: &str| {
signed
.signed
.get(k)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
};
tracing::info!(
actor = %auth.did,
entry_id = %req.entry_id,
envelope_id = %str_field("id"),
envelope_type = %str_field("type"),
envelope_recipient = %str_field("recipient"),
principal_did = %signed.principal_did,
"vault/sign-trust-task: signed"
);
success_response(
&doc,
VaultSignTrustTaskResponseBody {
signed_envelope: signed.signed,
},
)
}
#[cfg(feature = "webvh")]
fn password_post_error_to_reject(
err: &crate::operations::vault::password_post::PasswordPostError,
entry_id: &str,
) -> RejectReason {
use crate::operations::vault::password_post::PasswordPostError;
match err {
PasswordPostError::NonSuccessStatus { status } if (400..500).contains(status) => {
RejectReason::TaskFailed {
reason: format!(
"vault/proxy-login:credential_rejected — third party returned HTTP {status} for entry {entry_id}"
),
details: Some(serde_json::json!({
"status": status,
"remediation": "rotate the entry's password via vault/upsert/0.1",
})),
}
}
PasswordPostError::NonSuccessStatus { status } => RejectReason::TaskFailed {
reason: format!(
"vault/proxy-login:target_unreachable — third party returned HTTP {status}"
),
details: Some(serde_json::json!({ "status": status, "retryable": true })),
},
PasswordPostError::Transport { url, source } => RejectReason::TaskFailed {
reason: format!("vault/proxy-login:target_unreachable — {source} ({url})"),
details: Some(serde_json::json!({ "url": url, "retryable": true })),
},
PasswordPostError::InvalidLoginUrl(msg) => RejectReason::MalformedRequest {
reason: format!("vault/proxy-login:invalid_login_url — {msg}"),
},
PasswordPostError::TotpNotImplemented(msg) => RejectReason::TaskFailed {
reason: format!("vault/proxy-login:not_implemented — {msg}"),
details: None,
},
PasswordPostError::ResponseParse(msg) => RejectReason::InternalError {
reason: format!("vault/proxy-login: response parse failure — {msg}"),
},
}
}
#[cfg(test)]
mod sealed_envelope_tests {
use super::SealedEnvelope;
#[test]
fn sealed_envelope_accepts_both_tag_casings() {
let kebab: SealedEnvelope =
serde_json::from_str(r#"{"envelope":"didcomm-authcrypt","jwe":"x"}"#).unwrap();
assert_eq!(kebab.kind_name(), "didcomm-authcrypt");
let camel: SealedEnvelope =
serde_json::from_str(r#"{"envelope":"didcommAuthcrypt","jwe":"x"}"#).unwrap();
assert_eq!(camel.kind_name(), "didcomm-authcrypt");
assert!(serde_json::from_str::<SealedEnvelope>(r#"{"envelope":"hpkeArmored"}"#).is_ok());
assert!(serde_json::from_str::<SealedEnvelope>(r#"{"envelope":"tspMessage"}"#).is_ok());
}
}