use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::store::KeyspaceHandle;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VaultStatus {
#[default]
Active,
Archived,
Deleted,
}
impl VaultStatus {
pub fn is_active(&self) -> bool {
matches!(self, VaultStatus::Active)
}
}
pub fn default_active() -> VaultStatus {
VaultStatus::Active
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VaultEntry {
pub id: String,
pub context_id: String,
pub targets: Vec<SiteTarget>,
pub label: String,
pub secret_kind: SecretKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub favicon: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub selectors: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub custom_field_names: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<AttachmentRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub breached_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_changed_at: Option<String>,
pub created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_used_at: Option<String>,
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub principal_did: Option<String>,
#[serde(
default = "default_active",
skip_serializing_if = "VaultStatus::is_active"
)]
pub status: VaultStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archived_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grace_until: Option<String>,
}
impl VaultEntry {
pub fn principal_did_from_secret(secret: &VaultSecret) -> Option<String> {
match secret {
VaultSecret::DidSelfIssued { did, .. } => Some(did.clone()),
VaultSecret::DidcommPeer { peer_did, .. } => Some(peer_did.clone()),
VaultSecret::Password { .. }
| VaultSecret::Passkey { .. }
| VaultSecret::OauthTokens { .. }
| VaultSecret::BearerToken { .. }
| VaultSecret::SshKey { .. }
| VaultSecret::Custom { .. } => None,
}
}
fn bump_revision(&mut self, now: &str, actor: Option<&str>) {
self.version = self.version.saturating_add(1);
self.updated_at = now.to_string();
if let Some(a) = actor {
self.updated_by = Some(a.to_string());
}
}
pub fn archive(&mut self, now: &str, actor: Option<&str>) -> Result<(), LifecycleError> {
if self.status != VaultStatus::Active {
return Err(LifecycleError::NotActive);
}
self.status = VaultStatus::Archived;
self.archived_at = Some(now.to_string());
self.bump_revision(now, actor);
Ok(())
}
pub fn unarchive(&mut self, now: &str, actor: Option<&str>) -> Result<(), LifecycleError> {
if self.status != VaultStatus::Archived {
return Err(LifecycleError::NotArchived);
}
self.status = VaultStatus::Active;
self.archived_at = None;
self.bump_revision(now, actor);
Ok(())
}
pub fn soft_delete(
&mut self,
now: &str,
grace_until: &str,
actor: Option<&str>,
) -> Result<(), LifecycleError> {
if self.status == VaultStatus::Deleted {
return Err(LifecycleError::AlreadyDeleted);
}
self.status = VaultStatus::Deleted;
self.archived_at = None;
self.deleted_at = Some(now.to_string());
self.grace_until = Some(grace_until.to_string());
self.bump_revision(now, actor);
Ok(())
}
pub fn restore(&mut self, now: &str, actor: Option<&str>) -> Result<(), LifecycleError> {
if self.status != VaultStatus::Deleted {
return Err(LifecycleError::NotDeleted);
}
if let Some(grace) = self.grace_until.as_deref()
&& now >= grace
{
return Err(LifecycleError::GraceExpired);
}
self.status = VaultStatus::Active;
self.deleted_at = None;
self.grace_until = None;
self.bump_revision(now, actor);
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LifecycleError {
NotActive,
NotArchived,
AlreadyDeleted,
NotDeleted,
GraceExpired,
}
impl LifecycleError {
pub fn code(&self) -> &'static str {
match self {
LifecycleError::NotActive => "not_active",
LifecycleError::NotArchived => "not_archived",
LifecycleError::AlreadyDeleted => "already_deleted",
LifecycleError::NotDeleted => "not_deleted",
LifecycleError::GraceExpired => "grace_expired",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum SiteTarget {
WebOrigin {
origin: String,
},
Did {
did: String,
},
#[serde(rename_all = "camelCase")]
IosApp {
bundle_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
team_id: Option<String>,
},
#[serde(rename_all = "camelCase")]
AndroidApp {
package_name: String,
sha256_cert_fingerprints: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum SecretKind {
Password,
Passkey,
#[serde(alias = "oauthTokens")]
OauthTokens,
#[serde(alias = "didSelfIssued")]
DidSelfIssued,
#[serde(alias = "didcommPeer")]
DidcommPeer,
#[serde(alias = "bearerToken")]
BearerToken,
#[serde(alias = "sshKey")]
SshKey,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachmentRef {
pub id: String,
pub name: String,
pub size_bytes: u64,
pub sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
}
#[derive(Debug, Default)]
pub struct VaultListFilter<'a> {
pub context_id: Option<&'a str>,
pub target_origin_prefix: Option<&'a str>,
pub target_did: Option<&'a str>,
pub target_ios_bundle_id: Option<&'a str>,
pub target_android_package: Option<&'a str>,
pub secret_kind: Option<SecretKind>,
pub tag: Option<&'a str>,
pub used_since: Option<&'a str>,
pub never_used: Option<bool>,
pub expires_before: Option<&'a str>,
pub breached: Option<bool>,
pub status: Option<VaultStatus>,
pub any_status: bool,
}
impl VaultListFilter<'_> {
fn status_selector(&self) -> Option<VaultStatus> {
if self.any_status {
None
} else {
Some(self.status.unwrap_or(VaultStatus::Active))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredVaultEntry {
pub entry: VaultEntry,
pub secret: VaultSecret,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum VaultSecret {
#[serde(rename_all = "camelCase")]
Password {
#[serde(default, skip_serializing_if = "Option::is_none")]
username: Option<String>,
password: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
totp: Option<TotpSeed>,
#[serde(default, skip_serializing_if = "Option::is_none")]
login_config: Option<PasswordLoginConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
secure_notes: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
custom_fields: Vec<CustomField>,
},
#[serde(rename_all = "camelCase")]
Passkey {
credential_id: String,
private_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
algorithm: Option<String>,
rp_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
user_handle: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
secure_notes: Option<String>,
},
#[serde(rename_all = "camelCase", alias = "oauthTokens")]
OauthTokens {
provider: String,
refresh_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
access_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
access_token_expires_at: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
secure_notes: Option<String>,
},
#[serde(rename_all = "camelCase", alias = "didSelfIssued")]
DidSelfIssued {
did: String,
signing_key_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
secure_notes: Option<String>,
},
#[serde(rename_all = "camelCase", alias = "didcommPeer")]
DidcommPeer {
peer_did: String,
signing_key_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
secure_notes: Option<String>,
},
#[serde(rename_all = "camelCase", alias = "bearerToken")]
BearerToken {
token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
header_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
header_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
secure_notes: Option<String>,
},
#[serde(rename_all = "camelCase", alias = "sshKey")]
SshKey {
private_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
passphrase: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
secure_notes: Option<String>,
},
#[serde(rename_all = "camelCase")]
Custom {
fields: Vec<CustomField>,
#[serde(default, skip_serializing_if = "Option::is_none")]
secure_notes: Option<String>,
},
}
impl VaultSecret {
pub fn kind(&self) -> SecretKind {
match self {
VaultSecret::Password { .. } => SecretKind::Password,
VaultSecret::Passkey { .. } => SecretKind::Passkey,
VaultSecret::OauthTokens { .. } => SecretKind::OauthTokens,
VaultSecret::DidSelfIssued { .. } => SecretKind::DidSelfIssued,
VaultSecret::DidcommPeer { .. } => SecretKind::DidcommPeer,
VaultSecret::BearerToken { .. } => SecretKind::BearerToken,
VaultSecret::SshKey { .. } => SecretKind::SshKey,
VaultSecret::Custom { .. } => SecretKind::Custom,
}
}
pub fn matches_kind(&self, expected: SecretKind) -> bool {
self.kind() == expected
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TotpSeed {
pub secret: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub algorithm: Option<TotpAlgorithm>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digits: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period: Option<u16>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum TotpAlgorithm {
#[serde(rename = "SHA1")]
Sha1,
#[serde(rename = "SHA256")]
Sha256,
#[serde(rename = "SHA512")]
Sha512,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasswordLoginConfig {
pub login_url: String,
#[serde(default)]
pub format: PasswordLoginFormat,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub username_field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub totp_field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra_fields: Option<std::collections::BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub success_status: Option<Vec<u16>>,
}
impl PasswordLoginConfig {
pub fn effective_success_status(&self) -> Vec<u16> {
self.success_status
.clone()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| vec![200, 204])
}
pub fn effective_username_field(&self) -> &str {
self.username_field.as_deref().unwrap_or("username")
}
pub fn effective_password_field(&self) -> &str {
self.password_field.as_deref().unwrap_or("password")
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum PasswordLoginFormat {
#[default]
Json,
#[serde(alias = "formUrlencoded")]
FormUrlencoded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomField {
pub name: String,
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<CustomFieldKind>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CustomFieldKind {
Text,
Url,
Email,
Phone,
Number,
Date,
}
fn vault_key(id: &str) -> String {
format!("vault:{id}")
}
pub async fn get_vault_entry(
vault: &KeyspaceHandle,
id: &str,
) -> Result<Option<VaultEntry>, AppError> {
Ok(get_stored_vault_entry(vault, id).await?.map(|s| s.entry))
}
pub async fn get_stored_vault_entry(
vault: &KeyspaceHandle,
id: &str,
) -> Result<Option<StoredVaultEntry>, AppError> {
vault.get(vault_key(id)).await
}
pub async fn put_stored_vault_entry(
vault: &KeyspaceHandle,
record: &StoredVaultEntry,
) -> Result<(), AppError> {
debug_assert!(
record.secret.matches_kind(record.entry.secret_kind),
"StoredVaultEntry mismatch: entry.secret_kind={:?} but secret.kind()={:?}",
record.entry.secret_kind,
record.secret.kind()
);
vault.insert(vault_key(&record.entry.id), record).await
}
pub async fn delete_vault_entry(vault: &KeyspaceHandle, id: &str) -> Result<(), AppError> {
vault.remove(vault_key(id)).await
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionBlob {
pub session_id: String,
pub expires_at: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cookies: Vec<CookieJarEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub headers: Vec<RequestHeader>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub local_storage: Vec<StorageEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub session_storage: Vec<StorageEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bind_origin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_hint: Option<RefreshHint>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum RefreshHint {
#[serde(alias = "maintainerOnly")]
MaintainerOnly,
On401,
#[serde(alias = "beforeExpiry")]
BeforeExpiry,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CookieJarEntry {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secure: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub same_site: Option<SameSite>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum SameSite {
Strict,
Lax,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestHeader {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageEntry {
pub key: String,
pub value: String,
}
pub async fn list_vault_entries(
vault: &KeyspaceHandle,
filter: &VaultListFilter<'_>,
) -> Result<Vec<VaultEntry>, AppError> {
let raw = vault.prefix_iter_raw("vault:").await?;
let mut out = Vec::with_capacity(raw.len());
for (_, bytes) in raw {
let stored: StoredVaultEntry = serde_json::from_slice(&bytes)?;
if !matches_filter(&stored.entry, filter) {
continue;
}
out.push(stored.entry);
}
out.sort_by(|a, b| {
match (b.last_used_at.as_deref(), a.last_used_at.as_deref()) {
(Some(x), Some(y)) => x.cmp(y),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
Ok(out)
}
fn matches_filter(entry: &VaultEntry, filter: &VaultListFilter<'_>) -> bool {
if let Some(want) = filter.status_selector()
&& entry.status != want
{
return false;
}
if let Some(ctx) = filter.context_id
&& entry.context_id != ctx
{
return false;
}
if let Some(kind) = filter.secret_kind
&& entry.secret_kind != kind
{
return false;
}
if let Some(tag) = filter.tag
&& !entry.tags.iter().any(|t| t == tag)
{
return false;
}
if let Some(since) = filter.used_since {
match entry.last_used_at.as_deref() {
Some(last) if last >= since => {}
_ => return false,
}
}
if filter.never_used == Some(true) && entry.last_used_at.is_some() {
return false;
}
if let Some(before) = filter.expires_before {
match entry.expires_at.as_deref() {
Some(ts) if ts < before => {}
_ => return false,
}
}
if let Some(want_breached) = filter.breached {
let is_breached = entry.breached_at.is_some();
if is_breached != want_breached {
return false;
}
}
if let Some(prefix) = filter.target_origin_prefix {
let ok = entry.targets.iter().any(|t| match t {
SiteTarget::WebOrigin { origin } => origin.starts_with(prefix),
_ => false,
});
if !ok {
return false;
}
}
if let Some(did) = filter.target_did {
let ok = entry.targets.iter().any(|t| match t {
SiteTarget::Did { did: d } => d == did,
_ => false,
});
if !ok {
return false;
}
}
if let Some(bid) = filter.target_ios_bundle_id {
let ok = entry.targets.iter().any(|t| match t {
SiteTarget::IosApp { bundle_id, .. } => bundle_id == bid,
_ => false,
});
if !ok {
return false;
}
}
if let Some(pkg) = filter.target_android_package {
let ok = entry.targets.iter().any(|t| match t {
SiteTarget::AndroidApp { package_name, .. } => package_name == pkg,
_ => false,
});
if !ok {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(id: &str, ctx: &str, last_used: Option<&str>) -> VaultEntry {
VaultEntry {
id: id.to_string(),
context_id: ctx.to_string(),
targets: vec![SiteTarget::WebOrigin {
origin: "https://github.com".to_string(),
}],
label: format!("entry {id}"),
secret_kind: SecretKind::Password,
tags: vec!["work".to_string()],
notes: None,
favicon: None,
selectors: vec![],
custom_field_names: vec![],
attachments: vec![],
expires_at: None,
breached_at: None,
password_changed_at: None,
created_at: "2026-05-26T10:00:00Z".to_string(),
created_by: None,
updated_at: "2026-05-26T10:00:00Z".to_string(),
updated_by: None,
last_used_at: last_used.map(String::from),
version: 1,
principal_did: None,
status: VaultStatus::Active,
archived_at: None,
deleted_at: None,
grace_until: None,
}
}
#[test]
fn site_target_round_trip_matches_canonical_wire_form() {
let cases = vec![
(
SiteTarget::WebOrigin {
origin: "https://github.com".to_string(),
},
r#"{"kind":"web-origin","origin":"https://github.com"}"#,
),
(
SiteTarget::Did {
did: "did:web:rp.example".to_string(),
},
r#"{"kind":"did","did":"did:web:rp.example"}"#,
),
(
SiteTarget::IosApp {
bundle_id: "com.example.app".to_string(),
team_id: Some("ABCD123456".to_string()),
},
r#"{"kind":"ios-app","bundleId":"com.example.app","teamId":"ABCD123456"}"#,
),
(
SiteTarget::AndroidApp {
package_name: "com.example.app".to_string(),
sha256_cert_fingerprints: vec!["AA:BB".to_string()],
},
r#"{"kind":"android-app","packageName":"com.example.app","sha256CertFingerprints":["AA:BB"]}"#,
),
];
for (val, expected) in cases {
let json = serde_json::to_string(&val).unwrap();
assert_eq!(json, expected, "encode {val:?}");
let back: SiteTarget = serde_json::from_str(expected).unwrap();
assert_eq!(back, val, "round-trip {expected}");
}
}
#[test]
fn secret_kind_serialises_to_canonical_kebab_case() {
let cases = vec![
(SecretKind::Password, "\"password\""),
(SecretKind::OauthTokens, "\"oauth-tokens\""),
(SecretKind::DidSelfIssued, "\"did-self-issued\""),
(SecretKind::DidcommPeer, "\"didcomm-peer\""),
(SecretKind::BearerToken, "\"bearer-token\""),
(SecretKind::SshKey, "\"ssh-key\""),
];
for (val, expected) in cases {
assert_eq!(serde_json::to_string(&val).unwrap(), expected);
let back: SecretKind = serde_json::from_str(expected).unwrap();
assert_eq!(back, val);
}
}
#[test]
fn secret_kind_also_accepts_spec_0_2_camel_case() {
let cases = vec![
("\"oauthTokens\"", SecretKind::OauthTokens),
("\"didSelfIssued\"", SecretKind::DidSelfIssued),
("\"didcommPeer\"", SecretKind::DidcommPeer),
("\"bearerToken\"", SecretKind::BearerToken),
("\"sshKey\"", SecretKind::SshKey),
];
for (camel, expected) in cases {
let back: SecretKind = serde_json::from_str(camel).expect(camel);
assert_eq!(back, expected, "camelCase {camel}");
}
}
#[test]
fn filter_matches_intersection_of_criteria() {
let entry = sample("v1", "ctx_a", Some("2026-05-20T00:00:00Z"));
assert!(matches_filter(&entry, &VaultListFilter::default()));
assert!(matches_filter(
&entry,
&VaultListFilter {
context_id: Some("ctx_a"),
..Default::default()
}
));
assert!(!matches_filter(
&entry,
&VaultListFilter {
context_id: Some("ctx_b"),
..Default::default()
}
));
assert!(!matches_filter(
&entry,
&VaultListFilter {
never_used: Some(true),
..Default::default()
}
));
assert!(matches_filter(
&entry,
&VaultListFilter {
used_since: Some("2026-05-19T00:00:00Z"),
..Default::default()
}
));
assert!(!matches_filter(
&entry,
&VaultListFilter {
used_since: Some("2026-05-21T00:00:00Z"),
..Default::default()
}
));
assert!(matches_filter(
&entry,
&VaultListFilter {
target_origin_prefix: Some("https://github."),
..Default::default()
}
));
assert!(!matches_filter(
&entry,
&VaultListFilter {
target_origin_prefix: Some("https://gitlab."),
..Default::default()
}
));
}
#[test]
fn password_secret_round_trips_camel_case_wire_form() {
let camel = r#"{"kind":"password","password":"x","secureNotes":"hi"}"#;
let v: VaultSecret = serde_json::from_str(camel).expect("parse camelCase");
match &v {
VaultSecret::Password { secure_notes, .. } => {
assert_eq!(secure_notes.as_deref(), Some("hi"));
}
_ => panic!("expected Password variant"),
}
let re = serde_json::to_string(&v).expect("re-emit");
assert!(
re.contains("\"secureNotes\":\"hi\""),
"re-emitted JSON must use camelCase secureNotes; got {re}"
);
}
#[test]
fn did_self_issued_secret_round_trips_camel_case_wire_form() {
let camel = r#"{"kind":"did-self-issued","did":"did:webvh:foo","signingKeyId":"did:webvh:foo#key-0"}"#;
let v: VaultSecret = serde_json::from_str(camel).expect("parse camelCase");
match &v {
VaultSecret::DidSelfIssued {
did,
signing_key_id,
..
} => {
assert_eq!(did, "did:webvh:foo");
assert_eq!(signing_key_id, "did:webvh:foo#key-0");
}
_ => panic!("expected DidSelfIssued variant"),
}
let re = serde_json::to_string(&v).expect("re-emit");
assert!(
re.contains("\"signingKeyId\":\"did:webvh:foo#key-0\""),
"re-emitted JSON must use camelCase signingKeyId; got {re}"
);
}
#[test]
fn vault_secret_discriminator_accepts_both_casings() {
let cases: Vec<(&str, &str, SecretKind)> = vec![
(
r#"{"kind":"oauth-tokens","provider":"google","refreshToken":"r"}"#,
r#"{"kind":"oauthTokens","provider":"google","refreshToken":"r"}"#,
SecretKind::OauthTokens,
),
(
r#"{"kind":"did-self-issued","did":"did:webvh:x","signingKeyId":"did:webvh:x#k"}"#,
r#"{"kind":"didSelfIssued","did":"did:webvh:x","signingKeyId":"did:webvh:x#k"}"#,
SecretKind::DidSelfIssued,
),
(
r#"{"kind":"didcomm-peer","peerDid":"did:peer:2","signingKeyId":"did:peer:2#k"}"#,
r#"{"kind":"didcommPeer","peerDid":"did:peer:2","signingKeyId":"did:peer:2#k"}"#,
SecretKind::DidcommPeer,
),
(
r#"{"kind":"bearer-token","token":"t"}"#,
r#"{"kind":"bearerToken","token":"t"}"#,
SecretKind::BearerToken,
),
(
r#"{"kind":"ssh-key","privateKey":"p"}"#,
r#"{"kind":"sshKey","privateKey":"p"}"#,
SecretKind::SshKey,
),
];
for (kebab, camel, expected) in cases {
let from_kebab: VaultSecret = serde_json::from_str(kebab).expect(kebab);
assert_eq!(from_kebab.kind(), expected, "kebab {kebab}");
let from_camel: VaultSecret = serde_json::from_str(camel).expect(camel);
assert_eq!(from_camel.kind(), expected, "camel {camel}");
let re = serde_json::to_string(&from_camel).unwrap();
let kebab_kind = serde_json::to_string(&expected).unwrap();
assert!(
re.contains(&format!("\"kind\":{kebab_kind}")),
"emitted form must keep kebab kind {kebab_kind}; got {re}"
);
}
}
#[test]
fn password_login_format_and_refresh_hint_accept_both_casings() {
assert_eq!(
serde_json::from_str::<PasswordLoginFormat>("\"form-urlencoded\"").unwrap(),
PasswordLoginFormat::FormUrlencoded
);
assert_eq!(
serde_json::from_str::<PasswordLoginFormat>("\"formUrlencoded\"").unwrap(),
PasswordLoginFormat::FormUrlencoded
);
assert_eq!(
serde_json::to_string(&PasswordLoginFormat::FormUrlencoded).unwrap(),
"\"form-urlencoded\""
);
for (kebab, camel, val) in [
(
"\"maintainer-only\"",
"\"maintainerOnly\"",
RefreshHint::MaintainerOnly,
),
("\"on401\"", "\"on401\"", RefreshHint::On401),
(
"\"before-expiry\"",
"\"beforeExpiry\"",
RefreshHint::BeforeExpiry,
),
] {
assert_eq!(serde_json::from_str::<RefreshHint>(kebab).unwrap(), val);
assert_eq!(serde_json::from_str::<RefreshHint>(camel).unwrap(), val);
}
assert_eq!(
serde_json::to_string(&RefreshHint::On401).unwrap(),
"\"on401\""
);
}
#[test]
fn principal_did_from_secret_for_each_kind() {
let did = "did:webvh:Q1:rp.example:alice";
let s = VaultSecret::DidSelfIssued {
did: did.to_string(),
signing_key_id: format!("{did}#key-0"),
secure_notes: None,
};
assert_eq!(
VaultEntry::principal_did_from_secret(&s),
Some(did.to_string())
);
let peer = "did:peer:2.Ez6LSc...";
let s = VaultSecret::DidcommPeer {
peer_did: peer.to_string(),
signing_key_id: format!("{peer}#key-0"),
secure_notes: None,
};
assert_eq!(
VaultEntry::principal_did_from_secret(&s),
Some(peer.to_string())
);
for s in [
VaultSecret::Password {
username: Some("u".into()),
password: "p".into(),
totp: None,
login_config: None,
secure_notes: None,
custom_fields: vec![],
},
VaultSecret::Passkey {
credential_id: "c".into(),
private_key: "pk".into(),
algorithm: None,
rp_id: "rp".into(),
user_handle: None,
secure_notes: None,
},
VaultSecret::BearerToken {
token: "t".into(),
header_name: None,
header_prefix: None,
secure_notes: None,
},
VaultSecret::SshKey {
private_key: "p".into(),
public_key: None,
comment: None,
passphrase: None,
secure_notes: None,
},
VaultSecret::Custom {
fields: vec![],
secure_notes: None,
},
] {
assert_eq!(
VaultEntry::principal_did_from_secret(&s),
None,
"non-DID kind {:?} must yield None",
s.kind()
);
}
}
#[test]
fn entry_without_status_field_deserialises_as_active() {
let legacy = r#"{
"id":"v1","contextId":"ctx","targets":[],"label":"x",
"secretKind":"password","createdAt":"2026-01-01T00:00:00Z",
"updatedAt":"2026-01-01T00:00:00Z","version":1
}"#;
let entry: VaultEntry = serde_json::from_str(legacy).expect("parse legacy");
assert_eq!(entry.status, VaultStatus::Active);
assert!(entry.archived_at.is_none());
assert!(entry.deleted_at.is_none());
assert!(entry.grace_until.is_none());
let re = serde_json::to_string(&entry).unwrap();
assert!(
!re.contains("status"),
"active entry must not emit status; got {re}"
);
}
#[test]
fn list_filter_excludes_non_active_by_default() {
let active = sample("a", "ctx", None);
let mut archived = sample("b", "ctx", None);
archived.status = VaultStatus::Archived;
let mut deleted = sample("c", "ctx", None);
deleted.status = VaultStatus::Deleted;
let f = VaultListFilter::default();
assert!(matches_filter(&active, &f));
assert!(!matches_filter(&archived, &f));
assert!(!matches_filter(&deleted, &f));
let f = VaultListFilter {
status: Some(VaultStatus::Deleted),
..Default::default()
};
assert!(!matches_filter(&active, &f));
assert!(matches_filter(&deleted, &f));
let f = VaultListFilter {
any_status: true,
..Default::default()
};
assert!(matches_filter(&active, &f));
assert!(matches_filter(&archived, &f));
assert!(matches_filter(&deleted, &f));
}
#[test]
fn lifecycle_transitions_follow_the_state_table() {
let t0 = "2026-06-18T10:00:00+00:00";
let t1 = "2026-06-18T11:00:00+00:00";
let grace = "2026-07-18T10:00:00+00:00";
let mut e = sample("v", "ctx", None);
let v0 = e.version;
e.archive(t0, Some("did:key:op")).unwrap();
assert_eq!(e.status, VaultStatus::Archived);
assert_eq!(e.archived_at.as_deref(), Some(t0));
assert_eq!(e.version, v0 + 1);
assert_eq!(e.updated_by.as_deref(), Some("did:key:op"));
assert_eq!(e.archive(t1, None), Err(LifecycleError::NotActive));
e.unarchive(t1, None).unwrap();
assert_eq!(e.status, VaultStatus::Active);
assert!(e.archived_at.is_none());
assert_eq!(e.unarchive(t1, None), Err(LifecycleError::NotArchived));
let mut e = sample("v", "ctx", None);
e.soft_delete(t0, grace, None).unwrap();
assert_eq!(e.status, VaultStatus::Deleted);
assert_eq!(e.deleted_at.as_deref(), Some(t0));
assert_eq!(e.grace_until.as_deref(), Some(grace));
assert_eq!(
e.soft_delete(t1, grace, None),
Err(LifecycleError::AlreadyDeleted)
);
e.restore(t1, None).unwrap();
assert_eq!(e.status, VaultStatus::Active);
assert!(e.deleted_at.is_none() && e.grace_until.is_none());
assert_eq!(e.restore(t1, None), Err(LifecycleError::NotDeleted));
let mut e = sample("v", "ctx", None);
e.soft_delete(t0, grace, None).unwrap();
let after_grace = "2026-08-01T00:00:00+00:00";
assert_eq!(
e.restore(after_grace, None),
Err(LifecycleError::GraceExpired)
);
assert_eq!(e.status, VaultStatus::Deleted, "failed restore is a no-op");
let mut e = sample("v", "ctx", None);
e.archive(t0, None).unwrap();
e.soft_delete(t1, grace, None).unwrap();
assert_eq!(e.status, VaultStatus::Deleted);
assert!(e.archived_at.is_none(), "archived_at cleared on delete");
}
}