1use serde::{Deserialize, Serialize};
20
21use crate::error::AppError;
22use crate::store::KeyspaceHandle;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum VaultStatus {
39 #[default]
40 Active,
41 Archived,
42 Deleted,
43}
44
45impl VaultStatus {
46 pub fn is_active(&self) -> bool {
49 matches!(self, VaultStatus::Active)
50 }
51}
52
53pub fn default_active() -> VaultStatus {
58 VaultStatus::Active
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct VaultEntry {
66 pub id: String,
68 pub context_id: String,
70 pub targets: Vec<SiteTarget>,
72 pub label: String,
74 pub secret_kind: SecretKind,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 pub tags: Vec<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub notes: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub favicon: Option<String>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub selectors: Vec<String>,
88 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub custom_field_names: Vec<String>,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub attachments: Vec<AttachmentRef>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub expires_at: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub breached_at: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub password_changed_at: Option<String>,
103 pub created_at: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub created_by: Option<String>,
108 pub updated_at: String,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub updated_by: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub last_used_at: Option<String>,
116 pub version: u32,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub principal_did: Option<String>,
128 #[serde(
132 default = "default_active",
133 skip_serializing_if = "VaultStatus::is_active"
134 )]
135 pub status: VaultStatus,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub archived_at: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub deleted_at: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub grace_until: Option<String>,
146}
147
148impl VaultEntry {
149 pub fn principal_did_from_secret(secret: &VaultSecret) -> Option<String> {
155 match secret {
156 VaultSecret::DidSelfIssued { did, .. } => Some(did.clone()),
157 VaultSecret::DidcommPeer { peer_did, .. } => Some(peer_did.clone()),
158 VaultSecret::Password { .. }
159 | VaultSecret::Passkey { .. }
160 | VaultSecret::OauthTokens { .. }
161 | VaultSecret::BearerToken { .. }
162 | VaultSecret::SshKey { .. }
163 | VaultSecret::Custom { .. } => None,
164 }
165 }
166
167 fn bump_revision(&mut self, now: &str, actor: Option<&str>) {
171 self.version = self.version.saturating_add(1);
172 self.updated_at = now.to_string();
173 if let Some(a) = actor {
174 self.updated_by = Some(a.to_string());
175 }
176 }
177
178 pub fn archive(&mut self, now: &str, actor: Option<&str>) -> Result<(), LifecycleError> {
180 if self.status != VaultStatus::Active {
181 return Err(LifecycleError::NotActive);
182 }
183 self.status = VaultStatus::Archived;
184 self.archived_at = Some(now.to_string());
185 self.bump_revision(now, actor);
186 Ok(())
187 }
188
189 pub fn unarchive(&mut self, now: &str, actor: Option<&str>) -> Result<(), LifecycleError> {
191 if self.status != VaultStatus::Archived {
192 return Err(LifecycleError::NotArchived);
193 }
194 self.status = VaultStatus::Active;
195 self.archived_at = None;
196 self.bump_revision(now, actor);
197 Ok(())
198 }
199
200 pub fn soft_delete(
206 &mut self,
207 now: &str,
208 grace_until: &str,
209 actor: Option<&str>,
210 ) -> Result<(), LifecycleError> {
211 if self.status == VaultStatus::Deleted {
212 return Err(LifecycleError::AlreadyDeleted);
213 }
214 self.status = VaultStatus::Deleted;
215 self.archived_at = None;
216 self.deleted_at = Some(now.to_string());
217 self.grace_until = Some(grace_until.to_string());
218 self.bump_revision(now, actor);
219 Ok(())
220 }
221
222 pub fn restore(&mut self, now: &str, actor: Option<&str>) -> Result<(), LifecycleError> {
229 if self.status != VaultStatus::Deleted {
230 return Err(LifecycleError::NotDeleted);
231 }
232 if let Some(grace) = self.grace_until.as_deref()
233 && now >= grace
234 {
235 return Err(LifecycleError::GraceExpired);
236 }
237 self.status = VaultStatus::Active;
238 self.deleted_at = None;
239 self.grace_until = None;
240 self.bump_revision(now, actor);
241 Ok(())
242 }
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum LifecycleError {
250 NotActive,
252 NotArchived,
254 AlreadyDeleted,
256 NotDeleted,
258 GraceExpired,
260}
261
262impl LifecycleError {
263 pub fn code(&self) -> &'static str {
265 match self {
266 LifecycleError::NotActive => "not_active",
267 LifecycleError::NotArchived => "not_archived",
268 LifecycleError::AlreadyDeleted => "already_deleted",
269 LifecycleError::NotDeleted => "not_deleted",
270 LifecycleError::GraceExpired => "grace_expired",
271 }
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
279#[serde(tag = "kind", rename_all = "kebab-case")]
280pub enum SiteTarget {
281 WebOrigin {
282 origin: String,
283 },
284 Did {
285 did: String,
286 },
287 #[serde(rename_all = "camelCase")]
288 IosApp {
289 bundle_id: String,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 team_id: Option<String>,
292 },
293 #[serde(rename_all = "camelCase")]
294 AndroidApp {
295 package_name: String,
296 sha256_cert_fingerprints: Vec<String>,
297 },
298}
299
300#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
308#[serde(rename_all = "kebab-case")]
309pub enum SecretKind {
310 Password,
311 Passkey,
312 #[serde(alias = "oauthTokens")]
313 OauthTokens,
314 #[serde(alias = "didSelfIssued")]
315 DidSelfIssued,
316 #[serde(alias = "didcommPeer")]
317 DidcommPeer,
318 #[serde(alias = "bearerToken")]
319 BearerToken,
320 #[serde(alias = "sshKey")]
321 SshKey,
322 Custom,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330pub struct AttachmentRef {
331 pub id: String,
332 pub name: String,
333 pub size_bytes: u64,
334 pub sha256: String,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub content_type: Option<String>,
338}
339
340#[derive(Debug, Default)]
345pub struct VaultListFilter<'a> {
346 pub context_id: Option<&'a str>,
347 pub target_origin_prefix: Option<&'a str>,
348 pub target_did: Option<&'a str>,
349 pub target_ios_bundle_id: Option<&'a str>,
350 pub target_android_package: Option<&'a str>,
351 pub secret_kind: Option<SecretKind>,
352 pub tag: Option<&'a str>,
353 pub used_since: Option<&'a str>,
354 pub never_used: Option<bool>,
357 pub expires_before: Option<&'a str>,
358 pub breached: Option<bool>,
359 pub status: Option<VaultStatus>,
365 pub any_status: bool,
369}
370
371impl VaultListFilter<'_> {
372 fn status_selector(&self) -> Option<VaultStatus> {
375 if self.any_status {
376 None
377 } else {
378 Some(self.status.unwrap_or(VaultStatus::Active))
379 }
380 }
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct StoredVaultEntry {
396 pub entry: VaultEntry,
398 pub secret: VaultSecret,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
422#[serde(tag = "kind", rename_all = "kebab-case")]
423pub enum VaultSecret {
424 #[serde(rename_all = "camelCase")]
435 Password {
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 username: Option<String>,
438 password: String,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 totp: Option<TotpSeed>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
448 login_config: Option<PasswordLoginConfig>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
450 secure_notes: Option<String>,
451 #[serde(default, skip_serializing_if = "Vec::is_empty")]
452 custom_fields: Vec<CustomField>,
453 },
454 #[serde(rename_all = "camelCase")]
455 Passkey {
456 credential_id: String,
457 private_key: String,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
459 algorithm: Option<String>,
460 rp_id: String,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 user_handle: Option<String>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
464 secure_notes: Option<String>,
465 },
466 #[serde(rename_all = "camelCase", alias = "oauthTokens")]
467 OauthTokens {
468 provider: String,
469 refresh_token: String,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 access_token: Option<String>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
473 access_token_expires_at: Option<String>,
474 #[serde(default, skip_serializing_if = "Vec::is_empty")]
475 scopes: Vec<String>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 secure_notes: Option<String>,
478 },
479 #[serde(rename_all = "camelCase", alias = "didSelfIssued")]
480 DidSelfIssued {
481 did: String,
482 signing_key_id: String,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
484 secure_notes: Option<String>,
485 },
486 #[serde(rename_all = "camelCase", alias = "didcommPeer")]
487 DidcommPeer {
488 peer_did: String,
489 signing_key_id: String,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
491 secure_notes: Option<String>,
492 },
493 #[serde(rename_all = "camelCase", alias = "bearerToken")]
494 BearerToken {
495 token: String,
496 #[serde(default, skip_serializing_if = "Option::is_none")]
497 header_name: Option<String>,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 header_prefix: Option<String>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
501 secure_notes: Option<String>,
502 },
503 #[serde(rename_all = "camelCase", alias = "sshKey")]
504 SshKey {
505 private_key: String,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
507 public_key: Option<String>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
509 comment: Option<String>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 passphrase: Option<String>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
513 secure_notes: Option<String>,
514 },
515 #[serde(rename_all = "camelCase")]
516 Custom {
517 fields: Vec<CustomField>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
519 secure_notes: Option<String>,
520 },
521}
522
523impl VaultSecret {
524 pub fn kind(&self) -> SecretKind {
530 match self {
531 VaultSecret::Password { .. } => SecretKind::Password,
532 VaultSecret::Passkey { .. } => SecretKind::Passkey,
533 VaultSecret::OauthTokens { .. } => SecretKind::OauthTokens,
534 VaultSecret::DidSelfIssued { .. } => SecretKind::DidSelfIssued,
535 VaultSecret::DidcommPeer { .. } => SecretKind::DidcommPeer,
536 VaultSecret::BearerToken { .. } => SecretKind::BearerToken,
537 VaultSecret::SshKey { .. } => SecretKind::SshKey,
538 VaultSecret::Custom { .. } => SecretKind::Custom,
539 }
540 }
541
542 pub fn matches_kind(&self, expected: SecretKind) -> bool {
546 self.kind() == expected
547 }
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
552#[serde(rename_all = "camelCase")]
553pub struct TotpSeed {
554 pub secret: String,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub algorithm: Option<TotpAlgorithm>,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub digits: Option<u8>,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub period: Option<u16>,
562}
563
564#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
565pub enum TotpAlgorithm {
566 #[serde(rename = "SHA1")]
567 Sha1,
568 #[serde(rename = "SHA256")]
569 Sha256,
570 #[serde(rename = "SHA512")]
571 Sha512,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
584#[serde(rename_all = "camelCase")]
585pub struct PasswordLoginConfig {
586 pub login_url: String,
590 #[serde(default)]
593 pub format: PasswordLoginFormat,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub username_field: Option<String>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub password_field: Option<String>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub totp_field: Option<String>,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
609 pub extra_fields: Option<std::collections::BTreeMap<String, String>>,
610 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub success_status: Option<Vec<u16>>,
615}
616
617impl PasswordLoginConfig {
618 pub fn effective_success_status(&self) -> Vec<u16> {
622 self.success_status
623 .clone()
624 .filter(|v| !v.is_empty())
625 .unwrap_or_else(|| vec![200, 204])
626 }
627
628 pub fn effective_username_field(&self) -> &str {
629 self.username_field.as_deref().unwrap_or("username")
630 }
631
632 pub fn effective_password_field(&self) -> &str {
633 self.password_field.as_deref().unwrap_or("password")
634 }
635}
636
637#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
642#[serde(rename_all = "kebab-case")]
643pub enum PasswordLoginFormat {
644 #[default]
645 Json,
646 #[serde(alias = "formUrlencoded")]
647 FormUrlencoded,
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize)]
652#[serde(rename_all = "camelCase")]
653pub struct CustomField {
654 pub name: String,
655 pub value: String,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub hidden: Option<bool>,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub kind: Option<CustomFieldKind>,
660}
661
662#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
663#[serde(rename_all = "kebab-case")]
664pub enum CustomFieldKind {
665 Text,
666 Url,
667 Email,
668 Phone,
669 Number,
670 Date,
671}
672
673fn vault_key(id: &str) -> String {
676 format!("vault:{id}")
677}
678
679pub async fn get_vault_entry(
685 vault: &KeyspaceHandle,
686 id: &str,
687) -> Result<Option<VaultEntry>, AppError> {
688 Ok(get_stored_vault_entry(vault, id).await?.map(|s| s.entry))
689}
690
691pub async fn get_stored_vault_entry(
695 vault: &KeyspaceHandle,
696 id: &str,
697) -> Result<Option<StoredVaultEntry>, AppError> {
698 vault.get(vault_key(id)).await
699}
700
701pub async fn put_stored_vault_entry(
705 vault: &KeyspaceHandle,
706 record: &StoredVaultEntry,
707) -> Result<(), AppError> {
708 debug_assert!(
709 record.secret.matches_kind(record.entry.secret_kind),
710 "StoredVaultEntry mismatch: entry.secret_kind={:?} but secret.kind()={:?}",
711 record.entry.secret_kind,
712 record.secret.kind()
713 );
714 vault.insert(vault_key(&record.entry.id), record).await
715}
716
717pub async fn delete_vault_entry(vault: &KeyspaceHandle, id: &str) -> Result<(), AppError> {
721 vault.remove(vault_key(id)).await
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize)]
740#[serde(rename_all = "camelCase")]
741pub struct SessionBlob {
742 pub session_id: String,
746 pub expires_at: String,
749 #[serde(default, skip_serializing_if = "Vec::is_empty")]
753 pub cookies: Vec<CookieJarEntry>,
754 #[serde(default, skip_serializing_if = "Vec::is_empty")]
758 pub headers: Vec<RequestHeader>,
759 #[serde(default, skip_serializing_if = "Vec::is_empty")]
762 pub local_storage: Vec<StorageEntry>,
763 #[serde(default, skip_serializing_if = "Vec::is_empty")]
765 pub session_storage: Vec<StorageEntry>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub bind_origin: Option<String>,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub refresh_hint: Option<RefreshHint>,
773}
774
775#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
780#[serde(rename_all = "kebab-case")]
781pub enum RefreshHint {
782 #[serde(alias = "maintainerOnly")]
784 MaintainerOnly,
785 On401,
789 #[serde(alias = "beforeExpiry")]
791 BeforeExpiry,
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize)]
795#[serde(rename_all = "camelCase")]
796pub struct CookieJarEntry {
797 pub name: String,
798 pub value: String,
799 pub domain: String,
800 pub path: String,
801 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub expires: Option<String>,
803 #[serde(default, skip_serializing_if = "Option::is_none")]
804 pub secure: Option<bool>,
805 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub http_only: Option<bool>,
807 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub same_site: Option<SameSite>,
809}
810
811#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
812pub enum SameSite {
813 Strict,
814 Lax,
815 None,
816}
817
818#[derive(Debug, Clone, Serialize, Deserialize)]
819pub struct RequestHeader {
820 pub name: String,
821 pub value: String,
822}
823
824#[derive(Debug, Clone, Serialize, Deserialize)]
825pub struct StorageEntry {
826 pub key: String,
827 pub value: String,
828}
829
830pub async fn list_vault_entries(
834 vault: &KeyspaceHandle,
835 filter: &VaultListFilter<'_>,
836) -> Result<Vec<VaultEntry>, AppError> {
837 let raw = vault.prefix_iter_raw("vault:").await?;
838 let mut out = Vec::with_capacity(raw.len());
839 for (_, bytes) in raw {
840 let stored: StoredVaultEntry = serde_json::from_slice(&bytes)?;
841 if !matches_filter(&stored.entry, filter) {
842 continue;
843 }
844 out.push(stored.entry);
845 }
846 out.sort_by(|a, b| {
847 match (b.last_used_at.as_deref(), a.last_used_at.as_deref()) {
849 (Some(x), Some(y)) => x.cmp(y),
850 (Some(_), None) => std::cmp::Ordering::Less,
851 (None, Some(_)) => std::cmp::Ordering::Greater,
852 (None, None) => std::cmp::Ordering::Equal,
853 }
854 });
855 Ok(out)
856}
857
858fn matches_filter(entry: &VaultEntry, filter: &VaultListFilter<'_>) -> bool {
859 if let Some(want) = filter.status_selector()
862 && entry.status != want
863 {
864 return false;
865 }
866 if let Some(ctx) = filter.context_id
867 && entry.context_id != ctx
868 {
869 return false;
870 }
871 if let Some(kind) = filter.secret_kind
872 && entry.secret_kind != kind
873 {
874 return false;
875 }
876 if let Some(tag) = filter.tag
877 && !entry.tags.iter().any(|t| t == tag)
878 {
879 return false;
880 }
881 if let Some(since) = filter.used_since {
882 match entry.last_used_at.as_deref() {
883 Some(last) if last >= since => {}
884 _ => return false,
885 }
886 }
887 if filter.never_used == Some(true) && entry.last_used_at.is_some() {
888 return false;
889 }
890 if let Some(before) = filter.expires_before {
891 match entry.expires_at.as_deref() {
892 Some(ts) if ts < before => {}
893 _ => return false,
894 }
895 }
896 if let Some(want_breached) = filter.breached {
897 let is_breached = entry.breached_at.is_some();
898 if is_breached != want_breached {
899 return false;
900 }
901 }
902 if let Some(prefix) = filter.target_origin_prefix {
907 let ok = entry.targets.iter().any(|t| match t {
908 SiteTarget::WebOrigin { origin } => origin.starts_with(prefix),
909 _ => false,
910 });
911 if !ok {
912 return false;
913 }
914 }
915 if let Some(did) = filter.target_did {
916 let ok = entry.targets.iter().any(|t| match t {
917 SiteTarget::Did { did: d } => d == did,
918 _ => false,
919 });
920 if !ok {
921 return false;
922 }
923 }
924 if let Some(bid) = filter.target_ios_bundle_id {
925 let ok = entry.targets.iter().any(|t| match t {
926 SiteTarget::IosApp { bundle_id, .. } => bundle_id == bid,
927 _ => false,
928 });
929 if !ok {
930 return false;
931 }
932 }
933 if let Some(pkg) = filter.target_android_package {
934 let ok = entry.targets.iter().any(|t| match t {
935 SiteTarget::AndroidApp { package_name, .. } => package_name == pkg,
936 _ => false,
937 });
938 if !ok {
939 return false;
940 }
941 }
942 true
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948
949 fn sample(id: &str, ctx: &str, last_used: Option<&str>) -> VaultEntry {
950 VaultEntry {
951 id: id.to_string(),
952 context_id: ctx.to_string(),
953 targets: vec![SiteTarget::WebOrigin {
954 origin: "https://github.com".to_string(),
955 }],
956 label: format!("entry {id}"),
957 secret_kind: SecretKind::Password,
958 tags: vec!["work".to_string()],
959 notes: None,
960 favicon: None,
961 selectors: vec![],
962 custom_field_names: vec![],
963 attachments: vec![],
964 expires_at: None,
965 breached_at: None,
966 password_changed_at: None,
967 created_at: "2026-05-26T10:00:00Z".to_string(),
968 created_by: None,
969 updated_at: "2026-05-26T10:00:00Z".to_string(),
970 updated_by: None,
971 last_used_at: last_used.map(String::from),
972 version: 1,
973 principal_did: None,
974 status: VaultStatus::Active,
975 archived_at: None,
976 deleted_at: None,
977 grace_until: None,
978 }
979 }
980
981 #[test]
982 fn site_target_round_trip_matches_canonical_wire_form() {
983 let cases = vec![
984 (
985 SiteTarget::WebOrigin {
986 origin: "https://github.com".to_string(),
987 },
988 r#"{"kind":"web-origin","origin":"https://github.com"}"#,
989 ),
990 (
991 SiteTarget::Did {
992 did: "did:web:rp.example".to_string(),
993 },
994 r#"{"kind":"did","did":"did:web:rp.example"}"#,
995 ),
996 (
997 SiteTarget::IosApp {
998 bundle_id: "com.example.app".to_string(),
999 team_id: Some("ABCD123456".to_string()),
1000 },
1001 r#"{"kind":"ios-app","bundleId":"com.example.app","teamId":"ABCD123456"}"#,
1002 ),
1003 (
1004 SiteTarget::AndroidApp {
1005 package_name: "com.example.app".to_string(),
1006 sha256_cert_fingerprints: vec!["AA:BB".to_string()],
1007 },
1008 r#"{"kind":"android-app","packageName":"com.example.app","sha256CertFingerprints":["AA:BB"]}"#,
1009 ),
1010 ];
1011 for (val, expected) in cases {
1012 let json = serde_json::to_string(&val).unwrap();
1013 assert_eq!(json, expected, "encode {val:?}");
1014 let back: SiteTarget = serde_json::from_str(expected).unwrap();
1015 assert_eq!(back, val, "round-trip {expected}");
1016 }
1017 }
1018
1019 #[test]
1020 fn secret_kind_serialises_to_canonical_kebab_case() {
1021 let cases = vec![
1022 (SecretKind::Password, "\"password\""),
1023 (SecretKind::OauthTokens, "\"oauth-tokens\""),
1024 (SecretKind::DidSelfIssued, "\"did-self-issued\""),
1025 (SecretKind::DidcommPeer, "\"didcomm-peer\""),
1026 (SecretKind::BearerToken, "\"bearer-token\""),
1027 (SecretKind::SshKey, "\"ssh-key\""),
1028 ];
1029 for (val, expected) in cases {
1030 assert_eq!(serde_json::to_string(&val).unwrap(), expected);
1031 let back: SecretKind = serde_json::from_str(expected).unwrap();
1032 assert_eq!(back, val);
1033 }
1034 }
1035
1036 #[test]
1037 fn secret_kind_also_accepts_spec_0_2_camel_case() {
1038 let cases = vec![
1042 ("\"oauthTokens\"", SecretKind::OauthTokens),
1043 ("\"didSelfIssued\"", SecretKind::DidSelfIssued),
1044 ("\"didcommPeer\"", SecretKind::DidcommPeer),
1045 ("\"bearerToken\"", SecretKind::BearerToken),
1046 ("\"sshKey\"", SecretKind::SshKey),
1047 ];
1048 for (camel, expected) in cases {
1049 let back: SecretKind = serde_json::from_str(camel).expect(camel);
1050 assert_eq!(back, expected, "camelCase {camel}");
1051 }
1052 }
1053
1054 #[test]
1055 fn filter_matches_intersection_of_criteria() {
1056 let entry = sample("v1", "ctx_a", Some("2026-05-20T00:00:00Z"));
1057
1058 assert!(matches_filter(&entry, &VaultListFilter::default()));
1060
1061 assert!(matches_filter(
1063 &entry,
1064 &VaultListFilter {
1065 context_id: Some("ctx_a"),
1066 ..Default::default()
1067 }
1068 ));
1069
1070 assert!(!matches_filter(
1072 &entry,
1073 &VaultListFilter {
1074 context_id: Some("ctx_b"),
1075 ..Default::default()
1076 }
1077 ));
1078
1079 assert!(!matches_filter(
1081 &entry,
1082 &VaultListFilter {
1083 never_used: Some(true),
1084 ..Default::default()
1085 }
1086 ));
1087
1088 assert!(matches_filter(
1090 &entry,
1091 &VaultListFilter {
1092 used_since: Some("2026-05-19T00:00:00Z"),
1093 ..Default::default()
1094 }
1095 ));
1096 assert!(!matches_filter(
1097 &entry,
1098 &VaultListFilter {
1099 used_since: Some("2026-05-21T00:00:00Z"),
1100 ..Default::default()
1101 }
1102 ));
1103
1104 assert!(matches_filter(
1106 &entry,
1107 &VaultListFilter {
1108 target_origin_prefix: Some("https://github."),
1109 ..Default::default()
1110 }
1111 ));
1112 assert!(!matches_filter(
1113 &entry,
1114 &VaultListFilter {
1115 target_origin_prefix: Some("https://gitlab."),
1116 ..Default::default()
1117 }
1118 ));
1119 }
1120
1121 #[test]
1122 fn password_secret_round_trips_camel_case_wire_form() {
1123 let camel = r#"{"kind":"password","password":"x","secureNotes":"hi"}"#;
1132 let v: VaultSecret = serde_json::from_str(camel).expect("parse camelCase");
1133 match &v {
1134 VaultSecret::Password { secure_notes, .. } => {
1135 assert_eq!(secure_notes.as_deref(), Some("hi"));
1136 }
1137 _ => panic!("expected Password variant"),
1138 }
1139 let re = serde_json::to_string(&v).expect("re-emit");
1140 assert!(
1141 re.contains("\"secureNotes\":\"hi\""),
1142 "re-emitted JSON must use camelCase secureNotes; got {re}"
1143 );
1144 }
1145
1146 #[test]
1147 fn did_self_issued_secret_round_trips_camel_case_wire_form() {
1148 let camel = r#"{"kind":"did-self-issued","did":"did:webvh:foo","signingKeyId":"did:webvh:foo#key-0"}"#;
1149 let v: VaultSecret = serde_json::from_str(camel).expect("parse camelCase");
1150 match &v {
1151 VaultSecret::DidSelfIssued {
1152 did,
1153 signing_key_id,
1154 ..
1155 } => {
1156 assert_eq!(did, "did:webvh:foo");
1157 assert_eq!(signing_key_id, "did:webvh:foo#key-0");
1158 }
1159 _ => panic!("expected DidSelfIssued variant"),
1160 }
1161 let re = serde_json::to_string(&v).expect("re-emit");
1162 assert!(
1163 re.contains("\"signingKeyId\":\"did:webvh:foo#key-0\""),
1164 "re-emitted JSON must use camelCase signingKeyId; got {re}"
1165 );
1166 }
1167
1168 #[test]
1169 fn vault_secret_discriminator_accepts_both_casings() {
1170 let cases: Vec<(&str, &str, SecretKind)> = vec![
1178 (
1179 r#"{"kind":"oauth-tokens","provider":"google","refreshToken":"r"}"#,
1180 r#"{"kind":"oauthTokens","provider":"google","refreshToken":"r"}"#,
1181 SecretKind::OauthTokens,
1182 ),
1183 (
1184 r#"{"kind":"did-self-issued","did":"did:webvh:x","signingKeyId":"did:webvh:x#k"}"#,
1185 r#"{"kind":"didSelfIssued","did":"did:webvh:x","signingKeyId":"did:webvh:x#k"}"#,
1186 SecretKind::DidSelfIssued,
1187 ),
1188 (
1189 r#"{"kind":"didcomm-peer","peerDid":"did:peer:2","signingKeyId":"did:peer:2#k"}"#,
1190 r#"{"kind":"didcommPeer","peerDid":"did:peer:2","signingKeyId":"did:peer:2#k"}"#,
1191 SecretKind::DidcommPeer,
1192 ),
1193 (
1194 r#"{"kind":"bearer-token","token":"t"}"#,
1195 r#"{"kind":"bearerToken","token":"t"}"#,
1196 SecretKind::BearerToken,
1197 ),
1198 (
1199 r#"{"kind":"ssh-key","privateKey":"p"}"#,
1200 r#"{"kind":"sshKey","privateKey":"p"}"#,
1201 SecretKind::SshKey,
1202 ),
1203 ];
1204 for (kebab, camel, expected) in cases {
1205 let from_kebab: VaultSecret = serde_json::from_str(kebab).expect(kebab);
1206 assert_eq!(from_kebab.kind(), expected, "kebab {kebab}");
1207 let from_camel: VaultSecret = serde_json::from_str(camel).expect(camel);
1208 assert_eq!(from_camel.kind(), expected, "camel {camel}");
1209 let re = serde_json::to_string(&from_camel).unwrap();
1212 let kebab_kind = serde_json::to_string(&expected).unwrap();
1213 assert!(
1214 re.contains(&format!("\"kind\":{kebab_kind}")),
1215 "emitted form must keep kebab kind {kebab_kind}; got {re}"
1216 );
1217 }
1218 }
1219
1220 #[test]
1221 fn password_login_format_and_refresh_hint_accept_both_casings() {
1222 assert_eq!(
1224 serde_json::from_str::<PasswordLoginFormat>("\"form-urlencoded\"").unwrap(),
1225 PasswordLoginFormat::FormUrlencoded
1226 );
1227 assert_eq!(
1228 serde_json::from_str::<PasswordLoginFormat>("\"formUrlencoded\"").unwrap(),
1229 PasswordLoginFormat::FormUrlencoded
1230 );
1231 assert_eq!(
1233 serde_json::to_string(&PasswordLoginFormat::FormUrlencoded).unwrap(),
1234 "\"form-urlencoded\""
1235 );
1236
1237 for (kebab, camel, val) in [
1238 (
1239 "\"maintainer-only\"",
1240 "\"maintainerOnly\"",
1241 RefreshHint::MaintainerOnly,
1242 ),
1243 ("\"on401\"", "\"on401\"", RefreshHint::On401),
1245 (
1246 "\"before-expiry\"",
1247 "\"beforeExpiry\"",
1248 RefreshHint::BeforeExpiry,
1249 ),
1250 ] {
1251 assert_eq!(serde_json::from_str::<RefreshHint>(kebab).unwrap(), val);
1252 assert_eq!(serde_json::from_str::<RefreshHint>(camel).unwrap(), val);
1253 }
1254 assert_eq!(
1255 serde_json::to_string(&RefreshHint::On401).unwrap(),
1256 "\"on401\""
1257 );
1258 }
1259
1260 #[test]
1261 fn principal_did_from_secret_for_each_kind() {
1262 let did = "did:webvh:Q1:rp.example:alice";
1264 let s = VaultSecret::DidSelfIssued {
1265 did: did.to_string(),
1266 signing_key_id: format!("{did}#key-0"),
1267 secure_notes: None,
1268 };
1269 assert_eq!(
1270 VaultEntry::principal_did_from_secret(&s),
1271 Some(did.to_string())
1272 );
1273
1274 let peer = "did:peer:2.Ez6LSc...";
1275 let s = VaultSecret::DidcommPeer {
1276 peer_did: peer.to_string(),
1277 signing_key_id: format!("{peer}#key-0"),
1278 secure_notes: None,
1279 };
1280 assert_eq!(
1281 VaultEntry::principal_did_from_secret(&s),
1282 Some(peer.to_string())
1283 );
1284
1285 for s in [
1288 VaultSecret::Password {
1289 username: Some("u".into()),
1290 password: "p".into(),
1291 totp: None,
1292 login_config: None,
1293 secure_notes: None,
1294 custom_fields: vec![],
1295 },
1296 VaultSecret::Passkey {
1297 credential_id: "c".into(),
1298 private_key: "pk".into(),
1299 algorithm: None,
1300 rp_id: "rp".into(),
1301 user_handle: None,
1302 secure_notes: None,
1303 },
1304 VaultSecret::BearerToken {
1305 token: "t".into(),
1306 header_name: None,
1307 header_prefix: None,
1308 secure_notes: None,
1309 },
1310 VaultSecret::SshKey {
1311 private_key: "p".into(),
1312 public_key: None,
1313 comment: None,
1314 passphrase: None,
1315 secure_notes: None,
1316 },
1317 VaultSecret::Custom {
1318 fields: vec![],
1319 secure_notes: None,
1320 },
1321 ] {
1322 assert_eq!(
1323 VaultEntry::principal_did_from_secret(&s),
1324 None,
1325 "non-DID kind {:?} must yield None",
1326 s.kind()
1327 );
1328 }
1329 }
1330
1331 #[test]
1332 fn entry_without_status_field_deserialises_as_active() {
1333 let legacy = r#"{
1337 "id":"v1","contextId":"ctx","targets":[],"label":"x",
1338 "secretKind":"password","createdAt":"2026-01-01T00:00:00Z",
1339 "updatedAt":"2026-01-01T00:00:00Z","version":1
1340 }"#;
1341 let entry: VaultEntry = serde_json::from_str(legacy).expect("parse legacy");
1342 assert_eq!(entry.status, VaultStatus::Active);
1343 assert!(entry.archived_at.is_none());
1344 assert!(entry.deleted_at.is_none());
1345 assert!(entry.grace_until.is_none());
1346 let re = serde_json::to_string(&entry).unwrap();
1348 assert!(
1349 !re.contains("status"),
1350 "active entry must not emit status; got {re}"
1351 );
1352 }
1353
1354 #[test]
1355 fn list_filter_excludes_non_active_by_default() {
1356 let active = sample("a", "ctx", None);
1357 let mut archived = sample("b", "ctx", None);
1358 archived.status = VaultStatus::Archived;
1359 let mut deleted = sample("c", "ctx", None);
1360 deleted.status = VaultStatus::Deleted;
1361
1362 let f = VaultListFilter::default();
1364 assert!(matches_filter(&active, &f));
1365 assert!(!matches_filter(&archived, &f));
1366 assert!(!matches_filter(&deleted, &f));
1367
1368 let f = VaultListFilter {
1370 status: Some(VaultStatus::Deleted),
1371 ..Default::default()
1372 };
1373 assert!(!matches_filter(&active, &f));
1374 assert!(matches_filter(&deleted, &f));
1375
1376 let f = VaultListFilter {
1378 any_status: true,
1379 ..Default::default()
1380 };
1381 assert!(matches_filter(&active, &f));
1382 assert!(matches_filter(&archived, &f));
1383 assert!(matches_filter(&deleted, &f));
1384 }
1385
1386 #[test]
1387 fn lifecycle_transitions_follow_the_state_table() {
1388 let t0 = "2026-06-18T10:00:00+00:00";
1389 let t1 = "2026-06-18T11:00:00+00:00";
1390 let grace = "2026-07-18T10:00:00+00:00";
1391
1392 let mut e = sample("v", "ctx", None);
1394 let v0 = e.version;
1395 e.archive(t0, Some("did:key:op")).unwrap();
1396 assert_eq!(e.status, VaultStatus::Archived);
1397 assert_eq!(e.archived_at.as_deref(), Some(t0));
1398 assert_eq!(e.version, v0 + 1);
1399 assert_eq!(e.updated_by.as_deref(), Some("did:key:op"));
1400 assert_eq!(e.archive(t1, None), Err(LifecycleError::NotActive));
1402 e.unarchive(t1, None).unwrap();
1404 assert_eq!(e.status, VaultStatus::Active);
1405 assert!(e.archived_at.is_none());
1406 assert_eq!(e.unarchive(t1, None), Err(LifecycleError::NotArchived));
1408
1409 let mut e = sample("v", "ctx", None);
1411 e.soft_delete(t0, grace, None).unwrap();
1412 assert_eq!(e.status, VaultStatus::Deleted);
1413 assert_eq!(e.deleted_at.as_deref(), Some(t0));
1414 assert_eq!(e.grace_until.as_deref(), Some(grace));
1415 assert_eq!(
1417 e.soft_delete(t1, grace, None),
1418 Err(LifecycleError::AlreadyDeleted)
1419 );
1420 e.restore(t1, None).unwrap();
1422 assert_eq!(e.status, VaultStatus::Active);
1423 assert!(e.deleted_at.is_none() && e.grace_until.is_none());
1424 assert_eq!(e.restore(t1, None), Err(LifecycleError::NotDeleted));
1426
1427 let mut e = sample("v", "ctx", None);
1429 e.soft_delete(t0, grace, None).unwrap();
1430 let after_grace = "2026-08-01T00:00:00+00:00";
1431 assert_eq!(
1432 e.restore(after_grace, None),
1433 Err(LifecycleError::GraceExpired)
1434 );
1435 assert_eq!(e.status, VaultStatus::Deleted, "failed restore is a no-op");
1436
1437 let mut e = sample("v", "ctx", None);
1439 e.archive(t0, None).unwrap();
1440 e.soft_delete(t1, grace, None).unwrap();
1441 assert_eq!(e.status, VaultStatus::Deleted);
1442 assert!(e.archived_at.is_none(), "archived_at cleared on delete");
1443 }
1444}