#![allow(clippy::result_large_err)]
use affinidi_messaging_didcomm::Message;
use axum::response::Response;
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::{
RequestHeader, SecretKind, SessionBlob, SiteTarget, StoredVaultEntry, VaultEntry,
VaultListFilter, VaultSecret, 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;
#[allow(dead_code, deprecated)]
pub(super) const DISPATCHED_URIS: &[&str] = &[
vta_sdk::trust_tasks::TASK_VAULT_LIST_0_1,
vta_sdk::trust_tasks::TASK_VAULT_GET_0_1,
vta_sdk::trust_tasks::TASK_VAULT_UPSERT_0_1,
vta_sdk::trust_tasks::TASK_VAULT_DELETE_0_1,
vta_sdk::trust_tasks::TASK_VAULT_RELEASE_0_1,
vta_sdk::trust_tasks::TASK_VAULT_PROXY_LOGIN_0_1,
vta_sdk::trust_tasks::TASK_VAULT_SIGN_TRUST_TASK_0_1,
];
#[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>,
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 {
DidcommAuthcrypt {
jwe: String,
},
HpkeArmored {
#[serde(default)]
#[allow(dead_code)]
armored: String,
#[serde(default)]
#[allow(dead_code)]
recipient_key_id: String,
},
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>,
}
#[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 VaultReleaseBody {
entry_id: String,
#[serde(default)]
#[allow(dead_code)]
target: Option<SiteTarget>,
#[serde(default)]
#[allow(dead_code)]
consumer_context: Option<Value>,
#[serde(default)]
#[allow(dead_code)]
step_up_proof: 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 },
}
const RELEASE_INNER_MSG_TYPE: &str = "https://openvtc.org/vault/release/secret-envelope/1.0";
const PROXY_LOGIN_INNER_MSG_TYPE: &str =
"https://openvtc.org/vault/proxy-login/session-envelope/1.0";
#[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)]
#[allow(dead_code)]
step_up_proof: 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_vault_read(auth: &AuthClaims, doc: &TrustTask<Value>) -> Result<(), Response> {
if role_has_capability(&auth.role, Capability::VaultRead) {
Ok(())
} else {
Err(reject_with(
doc,
RejectReason::PermissionDenied {
reason: format!(
"vault read denied: role {} does not carry VaultRead capability",
auth.role
),
},
))
}
}
fn require_vault_write(auth: &AuthClaims, doc: &TrustTask<Value>) -> Result<(), Response> {
if role_has_capability(&auth.role, Capability::VaultWrite) {
Ok(())
} else {
Err(reject_with(
doc,
RejectReason::PermissionDenied {
reason: format!(
"vault write denied: role {} does not carry VaultWrite capability",
auth.role
),
},
))
}
}
fn require_fill_release(auth: &AuthClaims, doc: &TrustTask<Value>) -> Result<(), Response> {
if role_has_capability(&auth.role, Capability::FillRelease) {
Ok(())
} else {
Err(reject_with(
doc,
RejectReason::PermissionDenied {
reason: format!(
"vault release denied: role {} does not carry FillRelease capability",
auth.role
),
},
))
}
}
fn require_proxy_login(auth: &AuthClaims, doc: &TrustTask<Value>) -> Result<(), Response> {
if role_has_capability(&auth.role, Capability::ProxyLogin) {
Ok(())
} else {
Err(reject_with(
doc,
RejectReason::PermissionDenied {
reason: format!(
"vault proxy-login denied: role {} does not carry ProxyLogin capability",
auth.role
),
},
))
}
}
fn require_sign_trust_task(auth: &AuthClaims, doc: &TrustTask<Value>) -> Result<(), Response> {
if role_has_capability(&auth.role, Capability::SignTrustTask) {
Ok(())
} else {
Err(reject_with(
doc,
RejectReason::PermissionDenied {
reason: format!(
"vault sign-trust-task denied: role {} does not carry SignTrustTask capability",
auth.role
),
},
))
}
}
async fn unseal_secret(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
envelope: &SealedEnvelope,
) -> Result<VaultSecret, Response> {
let jwe = match envelope {
SealedEnvelope::DidcommAuthcrypt { jwe } => jwe,
other => {
return Err(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 = state.atm.as_ref().ok_or_else(|| {
reject_with(
doc,
RejectReason::InternalError {
reason: "ATM not configured — server cannot unpack DIDComm envelopes".into(),
},
)
})?;
let (msg, _metadata) = atm.unpack(jwe).await.map_err(|e| {
reject_with(
doc,
RejectReason::TaskFailed {
reason: format!("vault/upsert:sealed_secret_invalid — DIDComm unpack: {e}"),
details: Some(serde_json::json!({ "reason": "unpack_failed" })),
},
)
})?;
let sender = msg
.from
.as_deref()
.map(|s| s.split('#').next().unwrap_or(s).to_string())
.ok_or_else(|| {
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" })),
},
)
})?;
if sender != auth.did {
return Err(reject_with(
doc,
RejectReason::PermissionDenied {
reason: format!(
"vault/upsert:sealed_secret_invalid — JWE sender {sender} does not match authenticated caller {}",
auth.did
),
},
));
}
let secret: VaultSecret = serde_json::from_value(msg.body).map_err(|e| {
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" })),
},
)
})?;
Ok(secret)
}
fn enforce_context_scope(
auth: &AuthClaims,
context_id: Option<&str>,
doc: &TrustTask<Value>,
) -> Result<(), Response> {
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}"),
},
))
}
pub(super) async fn handle_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> Response {
if let Err(r) = require_vault_read(auth, &doc) {
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 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,
};
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>,
) -> Response {
if let Err(r) = require_vault_read(auth, &doc) {
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>,
) -> Response {
if let Err(r) = require_vault_write(auth, &doc) {
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.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), _) => match unseal_secret(state, auth, &doc, env).await {
Ok(s) => s,
Err(resp) => return resp,
},
(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),
};
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>,
) -> Response {
if let Err(r) = require_vault_write(auth, &doc) {
return r;
}
let req: VaultDeleteBody = 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 reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!("vault/delete:not_found — no entry at id {}", req.id),
details: None,
},
);
}
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(v) = req.expected_version
&& v != existing.entry.version
{
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/delete:version_conflict — expectedVersion {v} != current version {}",
existing.entry.version
),
details: Some(serde_json::json!({ "currentVersion": existing.entry.version })),
},
);
}
if let Err(e) = delete_vault_entry(&state.vault_ks, &req.id).await {
return app_error_to_reject(&doc, e);
}
let now = chrono::Utc::now().to_rfc3339();
success_response(
&doc,
VaultDeleteResponseBody {
id: req.id,
deleted_at: now.clone(),
grace_until: now,
},
)
}
pub(super) async fn handle_release(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> Response {
if let Err(r) = require_fill_release(auth, &doc) {
return r;
}
let req: VaultReleaseBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let mut 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;
}
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 = {
let config = state.config.read().await;
match config.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(),
},
);
}
}
};
const TTL_CEILING: u32 = 60;
let ttl_seconds = req
.ttl_seconds_hint
.map(|t| t.min(TTL_CEILING))
.unwrap_or(TTL_CEILING);
let secret_body = match serde_json::to_value(&stored.secret) {
Ok(v) => v,
Err(e) => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("vault/release: failed to serialise secret: {e}"),
},
);
}
};
let msg = Message::build(
Uuid::new_v4().to_string(),
RELEASE_INNER_MSG_TYPE.to_string(),
secret_body,
)
.from(vta_did.clone())
.to(auth.did.clone())
.finalize();
let (jwe, _metadata) = match atm
.pack_encrypted(&msg, &auth.did, Some(&vta_did), Some(&vta_did))
.await
{
Ok(packed) => packed,
Err(e) => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("vault/release: pack_encrypted failed: {e}"),
},
);
}
};
let now = chrono::Utc::now().to_rfc3339();
stored.entry.last_used_at = Some(now);
if let Err(e) = put_stored_vault_entry(&state.vault_ks, &stored).await {
tracing::warn!(
entry_id = %stored.entry.id,
error = %e,
"vault/release: lastUsedAt update failed; secret release proceeded"
);
}
let secret_kind = stored.entry.secret_kind;
success_response(
&doc,
VaultReleaseResponseBody {
sealed_secret: SealedEnvelopeWire::DidcommAuthcrypt { jwe },
secret_kind,
ttl_seconds,
},
)
}
#[cfg(feature = "webvh")]
const PASSWORD_POST_TTL_CEILING_SECS: u64 = 900;
pub(super) async fn handle_proxy_login(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> Response {
if let Err(r) = require_proxy_login(auth, &doc) {
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 mut 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;
}
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 = {
let config = state.config.read().await;
match config.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(),
},
);
}
}
};
let (session_blob, session_id, expires_at) = match &stored.secret {
VaultSecret::DidSelfIssued {
did: siop_did,
signing_key_id,
..
} => {
let (audience, bind_origin) = match resolve_siop_audience(
&req.target,
&stored.entry.targets,
) {
Some(pair) => pair,
None => {
return 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": &stored.entry.targets,
})),
},
);
}
};
let ttl_secs = req
.ttl_seconds_hint
.map(|t| (t as u64).min(crate::operations::vault::PROXY_LOGIN_ID_TOKEN_TTL_SECS))
.unwrap_or(crate::operations::vault::PROXY_LOGIN_ID_TOKEN_TTL_SECS);
let signing_key = match crate::operations::vault::load_signing_key_by_id(
&state.keys_ks,
&state.imported_ks,
&*state.seed_store,
&state.audit_ks,
signing_key_id,
)
.await
{
Ok(k) => k,
Err(e) => return app_error_to_reject(&doc, e),
};
let iat = chrono::Utc::now().timestamp().max(0) as u64;
let id_token = match crate::operations::vault::build_siop_id_token(
siop_did,
signing_key_id,
&audience,
req.nonce.as_deref(),
iat,
ttl_secs,
&signing_key,
) {
Ok(t) => t,
Err(e) => return app_error_to_reject(&doc, e),
};
build_session_blob_with_bearer(id_token, bind_origin, ttl_secs)
}
VaultSecret::Password {
username,
password,
totp,
login_config: Some(login_config),
..
} => {
#[cfg(feature = "webvh")]
{
let cookies = match crate::operations::vault::password_post::run_password_post(
login_config,
username.as_deref(),
password,
totp.as_ref(),
)
.await
{
Ok(c) => c,
Err(e) => {
return reject_with(
&doc,
password_post_error_to_reject(&e, &stored.entry.id),
);
}
};
let ttl_secs = req
.ttl_seconds_hint
.map(|t| (t as u64).min(PASSWORD_POST_TTL_CEILING_SECS))
.unwrap_or(PASSWORD_POST_TTL_CEILING_SECS);
let bind_origin = first_web_origin(&stored.entry.targets).or_else(|| {
url::Url::parse(&login_config.login_url)
.ok()
.and_then(|u| u.origin().ascii_serialization().into())
});
build_session_blob_with_cookies(cookies, bind_origin, ttl_secs)
}
#[cfg(not(feature = "webvh"))]
{
let _ = (username, password, totp, login_config);
return reject_with(
&doc,
RejectReason::TaskFailed {
reason:
"vault/proxy-login:not_implemented — password driver requires the `webvh` feature"
.into(),
details: None,
},
);
}
}
VaultSecret::Password {
login_config: None, ..
} => {
return 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",
})),
},
);
}
other => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/proxy-login:not_implemented — entry secretKind {kind} has no proxy-login driver yet",
kind = secret_kind_label(other.kind())
),
details: Some(serde_json::json!({
"secretKind": other.kind(),
"supportedKinds": ["did-self-issued", "password"],
})),
},
);
}
};
let session_body = match serde_json::to_value(&session_blob) {
Ok(v) => v,
Err(e) => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("vault/proxy-login: failed to serialise SessionBlob: {e}"),
},
);
}
};
let msg = Message::build(
Uuid::new_v4().to_string(),
PROXY_LOGIN_INNER_MSG_TYPE.to_string(),
session_body,
)
.from(vta_did.clone())
.to(auth.did.clone())
.finalize();
let (jwe, _metadata) = match atm
.pack_encrypted(&msg, &auth.did, Some(&vta_did), Some(&vta_did))
.await
{
Ok(packed) => packed,
Err(e) => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("vault/proxy-login: pack_encrypted failed: {e}"),
},
);
}
};
let now = chrono::Utc::now().to_rfc3339();
stored.entry.last_used_at = Some(now);
if let Err(e) = put_stored_vault_entry(&state.vault_ks, &stored).await {
tracing::warn!(
entry_id = %stored.entry.id,
error = %e,
"vault/proxy-login: lastUsedAt update failed; session release proceeded"
);
}
success_response(
&doc,
VaultProxyLoginResponseBody {
sealed_session_blob: SealedEnvelopeWire::DidcommAuthcrypt { jwe },
session_id,
expires_at,
},
)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct VaultSignTrustTaskBody {
entry_id: String,
unsigned_envelope: Value,
#[serde(default)]
#[allow(dead_code)]
consumer_context: Option<Value>,
#[serde(default)]
#[allow(dead_code)]
step_up_proof: 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>,
) -> Response {
if let Err(r) = require_sign_trust_task(auth, &doc) {
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;
}
let (principal_did, signing_key_id) = match &stored.secret {
VaultSecret::DidSelfIssued {
did,
signing_key_id,
..
}
| VaultSecret::DidcommPeer {
peer_did: did,
signing_key_id,
..
} => (did.clone(), signing_key_id.clone()),
other => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: format!(
"vault/sign-trust-task:not_signable — entry kind '{}' has no DID-based signing identity",
secret_kind_label(other.kind()),
),
details: Some(serde_json::json!({
"secretKind": secret_kind_label(other.kind()),
})),
},
);
}
};
let envelope_obj = match req.unsigned_envelope.as_object() {
Some(o) => o,
None => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: "vault/sign-trust-task:envelope_invalid — unsignedEnvelope must be a JSON object".into(),
details: None,
},
);
}
};
for field in ["id", "type", "issuer", "recipient", "issuedAt", "payload"] {
if !envelope_obj.contains_key(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 })),
},
);
}
}
if envelope_obj.contains_key("proof") {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: "vault/sign-trust-task:envelope_already_proofed — strip the existing proof and resubmit".into(),
details: None,
},
);
}
let envelope_issuer = match envelope_obj.get("issuer").and_then(|v| v.as_str()) {
Some(s) => s,
None => {
return reject_with(
&doc,
RejectReason::TaskFailed {
reason: "vault/sign-trust-task:envelope_invalid — issuer must be a string"
.into(),
details: None,
},
);
}
};
if envelope_issuer != principal_did {
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": principal_did,
})),
},
);
}
if let Some(exp_v) = envelope_obj.get("expiresAt") {
let exp_str = exp_v.as_str().unwrap_or_default();
match chrono::DateTime::parse_from_rfc3339(exp_str) {
Ok(exp) if exp < chrono::Utc::now() => {
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": exp_str })),
},
);
}
Ok(_) => {} Err(_) => {
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": exp_str })),
},
);
}
}
}
let secret = match crate::operations::vault::load_signing_secret_by_id(
&state.keys_ks,
&state.imported_ks,
&*state.seed_store,
&state.audit_ks,
&signing_key_id,
)
.await
{
Ok(s) => s,
Err(e) => return app_error_to_reject(&doc, e),
};
let proof = match affinidi_data_integrity::DataIntegrityProof::sign(
&req.unsigned_envelope,
&secret,
affinidi_data_integrity::SignOptions::new(),
)
.await
{
Ok(p) => p,
Err(e) => {
return app_error_to_reject(
&doc,
AppError::Internal(format!("DataIntegrityProof sign failed: {e}")),
);
}
};
let proof_value = match serde_json::to_value(&proof) {
Ok(v) => v,
Err(e) => {
return app_error_to_reject(&doc, AppError::Internal(format!("serialize proof: {e}")));
}
};
let mut signed = req.unsigned_envelope.clone();
signed
.as_object_mut()
.expect("envelope is an object — checked above")
.insert("proof".to_string(), proof_value);
let envelope_id = envelope_obj
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("");
let envelope_type = envelope_obj
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
let envelope_recipient = envelope_obj
.get("recipient")
.and_then(|v| v.as_str())
.unwrap_or("");
tracing::info!(
actor = %auth.did,
entry_id = %req.entry_id,
envelope_id,
envelope_type,
envelope_recipient,
principal_did = %principal_did,
"vault/sign-trust-task: signed"
);
success_response(
&doc,
VaultSignTrustTaskResponseBody {
signed_envelope: signed,
},
)
}
fn build_session_blob_with_bearer(
bearer: String,
bind_origin: Option<String>,
ttl_secs: u64,
) -> (SessionBlob, String, String) {
let session_id = Uuid::new_v4().to_string();
let expires_at = (chrono::Utc::now() + chrono::Duration::seconds(ttl_secs as i64)).to_rfc3339();
let blob = SessionBlob {
session_id: session_id.clone(),
expires_at: expires_at.clone(),
cookies: Vec::new(),
headers: vec![RequestHeader {
name: "Authorization".to_string(),
value: format!("Bearer {bearer}"),
}],
local_storage: Vec::new(),
session_storage: Vec::new(),
bind_origin,
refresh_hint: None,
};
(blob, session_id, expires_at)
}
#[cfg(feature = "webvh")]
fn build_session_blob_with_cookies(
cookies: Vec<vti_common::vault::CookieJarEntry>,
bind_origin: Option<String>,
ttl_secs: u64,
) -> (SessionBlob, String, String) {
let session_id = Uuid::new_v4().to_string();
let expires_at = (chrono::Utc::now() + chrono::Duration::seconds(ttl_secs as i64)).to_rfc3339();
let blob = SessionBlob {
session_id: session_id.clone(),
expires_at: expires_at.clone(),
cookies,
headers: Vec::new(),
local_storage: Vec::new(),
session_storage: Vec::new(),
bind_origin,
refresh_hint: Some(vti_common::vault::RefreshHint::On401),
};
(blob, session_id, expires_at)
}
#[cfg(feature = "webvh")]
fn first_web_origin(targets: &[SiteTarget]) -> Option<String> {
targets.iter().find_map(|t| match t {
SiteTarget::WebOrigin { origin } => Some(origin.clone()),
_ => None,
})
}
fn secret_kind_label(kind: SecretKind) -> &'static str {
match kind {
SecretKind::Password => "password",
SecretKind::Passkey => "passkey",
SecretKind::OauthTokens => "oauth-tokens",
SecretKind::DidSelfIssued => "did-self-issued",
SecretKind::DidcommPeer => "didcomm-peer",
SecretKind::BearerToken => "bearer-token",
SecretKind::SshKey => "ssh-key",
SecretKind::Custom => "custom",
}
}
#[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}"),
},
}
}
fn resolve_siop_audience(
explicit: &Option<SiteTarget>,
entry_targets: &[SiteTarget],
) -> Option<(String, Option<String>)> {
let entry_origin: Option<String> = entry_targets.iter().find_map(|t| match t {
SiteTarget::WebOrigin { origin } => Some(origin.clone()),
_ => None,
});
if let Some(t) = explicit {
return match t {
SiteTarget::Did { did } => Some((did.clone(), entry_origin)),
SiteTarget::WebOrigin { origin } => Some((origin.clone(), Some(origin.clone()))),
_ => None,
};
}
let entry_did: Option<String> = entry_targets.iter().find_map(|t| match t {
SiteTarget::Did { did } => Some(did.clone()),
_ => None,
});
if let Some(did) = entry_did {
return Some((did, entry_origin));
}
entry_origin.clone().map(|o| (o, entry_origin))
}
#[allow(dead_code)]
type _SiteTargetReexport = SiteTarget;
#[allow(dead_code)]
const _: &() = &();
#[cfg(test)]
mod tests {
use super::*;
fn web(o: &str) -> SiteTarget {
SiteTarget::WebOrigin {
origin: o.to_string(),
}
}
fn did(d: &str) -> SiteTarget {
SiteTarget::Did { did: d.to_string() }
}
fn ios() -> SiteTarget {
SiteTarget::IosApp {
bundle_id: "com.example.app".into(),
team_id: None,
}
}
#[test]
fn explicit_did_target_uses_did_as_audience_with_entry_origin_as_bind() {
let entry_targets = vec![did("did:web:rp.example"), web("https://rp.example")];
let (aud, bind) = resolve_siop_audience(&Some(did("did:web:rp.example")), &entry_targets)
.expect("audience");
assert_eq!(aud, "did:web:rp.example");
assert_eq!(bind.as_deref(), Some("https://rp.example"));
}
#[test]
fn explicit_web_origin_target_audience_equals_bind() {
let entry_targets = vec![web("https://rp.example")];
let (aud, bind) = resolve_siop_audience(&Some(web("https://rp.example")), &entry_targets)
.expect("audience");
assert_eq!(aud, "https://rp.example");
assert_eq!(bind.as_deref(), Some("https://rp.example"));
}
#[test]
fn explicit_app_target_rejects_for_siop() {
let entry_targets = vec![did("did:web:rp.example")];
assert!(
resolve_siop_audience(&Some(ios()), &entry_targets).is_none(),
"app targets aren't SIOP audiences"
);
}
#[test]
fn no_explicit_target_prefers_first_did_on_entry() {
let entry_targets = vec![
web("https://rp.example"),
did("did:web:rp.example"),
did("did:web:other"),
];
let (aud, bind) = resolve_siop_audience(&None, &entry_targets).expect("audience");
assert_eq!(aud, "did:web:rp.example", "first DID wins over later DIDs");
assert_eq!(bind.as_deref(), Some("https://rp.example"));
}
#[test]
fn no_explicit_target_falls_back_to_first_web_origin_when_no_did() {
let entry_targets = vec![web("https://rp.example")];
let (aud, bind) = resolve_siop_audience(&None, &entry_targets).expect("audience");
assert_eq!(aud, "https://rp.example");
assert_eq!(bind.as_deref(), Some("https://rp.example"));
}
#[test]
fn no_audience_when_entry_has_only_app_targets() {
let entry_targets = vec![ios()];
assert!(
resolve_siop_audience(&None, &entry_targets).is_none(),
"app-only entry yields no SIOP audience"
);
}
}