Skip to main content

vti_common/vault/
mod.rs

1//! Vault entries — third-party credentials the holder has stored on the
2//! VTA, used by Companions and Services to authenticate against external
3//! sites and apps. M1 ships the metadata view + read-only store helpers;
4//! upsert, delete, sync, proxy-login, and release land in later milestones.
5//!
6//! Wire format mirrors the canonical Trust Task spec
7//! `https://trusttasks.org/spec/vault/_shared/0.1/vault-entry` field-for-field
8//! — `#[serde(rename_all = "camelCase")]` lines the JSON up with the
9//! schema's camelCase wire form. Timestamps are RFC 3339 strings rather
10//! than Unix epoch (unlike [`crate::acl::AclEntry`]); this matches the spec
11//! directly and avoids a separate wire/domain conversion. The slight
12//! ergonomic loss versus `u64` is fine for v0.1.
13//!
14//! **No secret material lives in this module.** [`VaultEntry`] is the
15//! metadata projection — the `secret_kind` discriminator is present, but
16//! the bytes only ever transit through HPKE-sealed envelopes carried by
17//! the vault/release/0.1 task (which lands in M2).
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::AppError;
22use crate::store::KeyspaceHandle;
23
24/// Lifecycle state of a vault entry (and, reused, of a stored credential).
25/// This is **archival** state — orthogonal to a credential's *validity*
26/// (`CredentialStatus`) and to a password entry's `breached_at`/`expires_at`.
27///
28/// - `Active` — the normal, usable state. Default for any record persisted
29///   before this field existed (`#[serde(default)]` → [`default_active`]).
30/// - `Archived` — hidden from default listing and refused for use
31///   (release / proxy-login / sign / present), but fully restorable.
32/// - `Deleted` — a recoverable tombstone: the row (and its secret) is
33///   retained but blocked from use, restorable until `grace_until`, after
34///   which the vault sweeper hard-purges it. `delete --force` / `purge`
35///   skip this state and erase immediately.
36#[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    /// `true` for the normal usable state — the only state from which a
47    /// secret may be released / a credential presented.
48    pub fn is_active(&self) -> bool {
49        matches!(self, VaultStatus::Active)
50    }
51}
52
53/// Serde default for the `status` field so records persisted before the
54/// lifecycle existed (which lack the key) deserialize as [`VaultStatus::Active`].
55/// Named rather than relying on `#[serde(default)]` + `Default` so the intent
56/// is explicit at the field.
57pub fn default_active() -> VaultStatus {
58    VaultStatus::Active
59}
60
61/// Public metadata view of a single vault entry. Direct wire-form match for
62/// the `VaultEntry` `$def` in the canonical Trust Task shared schema.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct VaultEntry {
66    /// Opaque maintainer-assigned id (ULID recommended).
67    pub id: String,
68    /// Trust context (persona) this entry belongs to.
69    pub context_id: String,
70    /// Binding targets. A request from any matching target uses this entry.
71    pub targets: Vec<SiteTarget>,
72    /// User-facing display name.
73    pub label: String,
74    /// Discriminator for the kind of secret bytes; never the bytes themselves.
75    pub secret_kind: SecretKind,
76    /// User-defined tags for filtering.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub tags: Vec<String>,
79    /// Non-sensitive notes (sensitive notes live inside the secret payload).
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub notes: Option<String>,
82    /// Optional icon URI.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub favicon: Option<String>,
85    /// Opaque policy-engine selector strings.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub selectors: Vec<String>,
88    /// Names of custom fields (values live in the secret payload).
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub custom_field_names: Vec<String>,
91    /// References to encrypted blobs (recovery codes, key files, etc.).
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub attachments: Vec<AttachmentRef>,
94    /// Expected expiry (e.g. OAuth refresh-token expiry, time-limited tokens).
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub expires_at: Option<String>,
97    /// Set when HIBP (or equivalent) detects this credential in a breach.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub breached_at: Option<String>,
100    /// Last password rotation timestamp (for password-kind entries).
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub password_changed_at: Option<String>,
103    /// RFC 3339 creation timestamp.
104    pub created_at: String,
105    /// DID of the consumer that created the entry.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub created_by: Option<String>,
108    /// RFC 3339 last-modification timestamp.
109    pub updated_at: String,
110    /// DID of the consumer that last modified the entry.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub updated_by: Option<String>,
113    /// Most recent use (proxy-login or release).
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub last_used_at: Option<String>,
116    /// Monotonic version for optimistic concurrency + sync seq baseline.
117    pub version: u32,
118    /// Cached "principal DID" the entry will act AS for DID-shaped flows.
119    /// Mirrors the `did` field of `did-self-issued` / `didcomm-peer`
120    /// secrets; absent for kinds without a DID concept. MAINTAINER-DERIVED:
121    /// recomputed from the secret at every upsert / rotation; a producer-
122    /// supplied value on the wire is ignored. Exposed so consumers can
123    /// drive RP-side flows (e.g. an RP page fetching `/auth/challenge`
124    /// keyed on the principal DID before requesting a proxy-login)
125    /// without releasing the secret.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub principal_did: Option<String>,
128    /// Archival lifecycle state. Absent on the wire for `Active` entries
129    /// (`skip_serializing_if`) and defaulted in for records written before
130    /// the lifecycle existed. See [`VaultStatus`].
131    #[serde(
132        default = "default_active",
133        skip_serializing_if = "VaultStatus::is_active"
134    )]
135    pub status: VaultStatus,
136    /// RFC 3339 timestamp the entry was archived (set iff `status == Archived`).
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub archived_at: Option<String>,
139    /// RFC 3339 timestamp the entry was (soft-)deleted (set iff `status == Deleted`).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub deleted_at: Option<String>,
142    /// RFC 3339 deadline after which the sweeper hard-purges a `Deleted`
143    /// entry. Restorable while `now < grace_until`. Set iff `status == Deleted`.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub grace_until: Option<String>,
146}
147
148impl VaultEntry {
149    /// Derive `principal_did` from a freshly-unsealed `VaultSecret`. The
150    /// maintainer calls this at every upsert + secret rotation; the
151    /// resulting value overrides whatever the producer wrote on the
152    /// wire (the canonical schema declares the field read-only on the
153    /// upsert path).
154    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    /// Bump the optimistic-concurrency version + modification stamps. Every
168    /// lifecycle transition is a user-visible mutation, so it advances
169    /// `version` (the M5 sync seq baseline) and `updated_at`/`updated_by`.
170    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    /// `Active → Archived`. Refused (`NotActive`) for any other source state.
179    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    /// `Archived → Active`. Refused (`NotArchived`) for any other source state.
190    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    /// `Active|Archived → Deleted` (recoverable tombstone). `grace_until` is
201    /// the caller-computed `now + grace_days` deadline. Refused
202    /// (`AlreadyDeleted`) if the entry is already a tombstone — the operator
203    /// should `restore` or `purge` instead (a hard `delete --force` bypasses
204    /// this method entirely).
205    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    /// `Deleted → Active`, but only while still inside the grace window.
223    /// Refused `NotDeleted` if the entry isn't a tombstone, or `GraceExpired`
224    /// if `now >= grace_until` (the sweeper has purged it or is about to).
225    /// The `now >= grace_until` comparison is lexical over RFC 3339 strings —
226    /// consistent with the rest of this module's timestamp handling (both
227    /// stamps are produced by `chrono::Utc::now().to_rfc3339()`).
228    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/// Why an archival-lifecycle transition on a [`VaultEntry`] (or stored
246/// credential) was refused. Handlers map each variant to a Trust-Task reject
247/// reason; `code()` gives the stable token used in those messages.
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum LifecycleError {
250    /// `archive` on an entry that isn't `Active`.
251    NotActive,
252    /// `unarchive` on an entry that isn't `Archived`.
253    NotArchived,
254    /// soft `delete` on an entry that is already a `Deleted` tombstone.
255    AlreadyDeleted,
256    /// `restore` on an entry that isn't a `Deleted` tombstone.
257    NotDeleted,
258    /// `restore` after the grace window elapsed.
259    GraceExpired,
260}
261
262impl LifecycleError {
263    /// Stable token embedded in handler reject messages (e.g. `not_active`).
264    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/// Binding target for a vault entry. Tagged union over the discriminator
276/// `kind`. Wire form (kebab-case discriminator) matches the canonical
277/// `SiteTarget` shared schema.
278#[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/// Discriminator for the kind of secret stored. Emitted as kebab-case
301/// (`oauth-tokens`, `did-self-issued`, …) for the 0.1 wire form; the
302/// `vault/*/0.2` edge transform up-converts these to the canonical
303/// lowerCamelCase 0.2 values. To stay backwards-compatible while also
304/// accepting a spec-0.2 producer that sends camelCase directly, each
305/// multi-word value carries a camelCase `alias` (Postel's law: liberal in
306/// what we accept, conservative in what we emit). See issue #517.
307#[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/// Descriptor for an encrypted blob associated with a vault entry. The blob
326/// itself is fetched via a separate mechanism; this struct carries only the
327/// metadata projection.
328#[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    /// Hex-encoded SHA-256 of the encrypted blob bytes.
335    pub sha256: String,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub content_type: Option<String>,
338}
339
340/// Filter criteria for [`list_vault_entries`]. All populated fields are
341/// AND-combined. Matches the `payload.schema.json` of `vault/list/0.1`
342/// minus pagination (`cursor` / `page_size`), which is applied in the
343/// route layer rather than the store helper.
344#[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    /// When `Some(true)`, return only entries with no `lastUsedAt`. Mutually
355    /// exclusive with `used_since` at the caller level.
356    pub never_used: Option<bool>,
357    pub expires_before: Option<&'a str>,
358    pub breached: Option<bool>,
359    /// Archival-lifecycle filter. `None` (the default) returns only
360    /// [`VaultStatus::Active`] entries — archived and (soft-)deleted entries
361    /// are hidden from normal listing. Pass `Some(status)` to list a specific
362    /// state (e.g. the trash view via `Some(Deleted)`), or use
363    /// [`VaultListFilter::any_status`] to include every state.
364    pub status: Option<VaultStatus>,
365    /// When `true`, the `status` filter is ignored and entries of every
366    /// lifecycle state are returned (the explicit "show all" view). Defaults
367    /// to `false` so callers get the Active-only behaviour for free.
368    pub any_status: bool,
369}
370
371impl VaultListFilter<'_> {
372    /// The lifecycle state this filter selects, applying the Active-only
373    /// default. Returns `None` when every state is requested (`any_status`).
374    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/// Full record persisted in the `vault:` keyspace. `VaultEntry` is the
384/// metadata projection that ships on the wire via vault/list/0.1 and
385/// vault/get/0.1; the cleartext secret material lives ONLY inside this
386/// stored form and crosses the wire only via vault/release/0.1's pluggable
387/// `sealedSecret` envelope.
388///
389/// Encrypted at rest via the keyspace's transparent AES-256-GCM wrapper
390/// when `storage_encryption_key` is configured (TEE deployments). In
391/// local-dev / non-TEE mode the secret is plaintext on disk — same threat
392/// model as every other secret-bearing keyspace today (the OS account
393/// running the daemon is the security boundary).
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct StoredVaultEntry {
396    /// Metadata view — the only half that ships on the wire by default.
397    pub entry: VaultEntry,
398    /// Cleartext secret material. Per the canonical
399    /// `vault/_shared/0.1/vault-secret` shared schema.
400    pub secret: VaultSecret,
401}
402
403/// Cleartext secret material. Field-for-field mirror of
404/// [`vault/_shared/0.2/vault-secret#/$defs/VaultSecret`](https://trusttasks.org/spec/vault/_shared/0.2/vault-secret).
405/// Discriminated by `kind`. This secret rides **inside** the opaque
406/// authcrypt JWE (`vault/upsert`'s `sealedSecret`), so the `vault/*/0.2`
407/// edge transform cannot reach it — the discriminator is parsed verbatim
408/// here. To accept both a 0.1 producer (kebab `did-self-issued`) and a
409/// spec-0.2 producer (camelCase `didSelfIssued`) without a breaking wire
410/// change, each multi-word variant carries a camelCase `alias`; the
411/// emitted form stays kebab for backwards compatibility (Postel's law).
412/// This is the fix for the half-completed migration in issue #517 — the
413/// variant *fields* were camelCased in `76287a5`, the *discriminator* was
414/// not.
415///
416/// Sensitive fields (`password`, `private_key`, `refresh_token`,
417/// `secure_notes`, `token`, etc.) MUST be zeroised by handlers as soon as
418/// their use is complete; this enum derives `Debug` for diagnostic
419/// convenience but production logs MUST NOT format `VaultSecret` via
420/// `{:?}` — the strings would leak straight in.
421#[derive(Debug, Clone, Serialize, Deserialize)]
422#[serde(tag = "kind", rename_all = "kebab-case")]
423pub enum VaultSecret {
424    // `rename_all = "camelCase"` on each variant aligns Rust's
425    // default snake_case field names with the canonical wire shape
426    // in `vault/_shared/0.1/vault-secret` (which uses camelCase
427    // throughout: `secureNotes`, `loginConfig`, `signingKeyId`,
428    // `credentialId`, etc.). Without these, every camelCase-emitting
429    // consumer (the browser plugin's `vault/upsert` path is the live
430    // example) silently loses optional fields on deserialize and
431    // emits the wrong shape on serialize. The two new tests in this
432    // file's `mod tests` exercise password + did-self-issued
433    // round-trips and would have caught this earlier.
434    #[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        /// Optional driver config for `vault/proxy-login/0.1` against
442        /// this entry. When present, the maintainer performs an HTTP
443        /// POST against `loginConfig.loginUrl` with the entry's
444        /// credentials. When absent, proxy-login returns
445        /// `not_proxyable` and the consumer falls back to vault/release
446        /// for browser-fill. See `vault/_shared/0.1/vault-secret#/$defs/PasswordLoginConfig`.
447        #[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    /// Returns the [`SecretKind`] that matches this variant. The metadata
525    /// view's `secret_kind` field MUST equal this on every persisted
526    /// `StoredVaultEntry`; an inconsistency is a programming error and
527    /// callers can use [`VaultSecret::matches_kind`] to assert at the
528    /// upsert / release boundary.
529    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    /// Convenience: assert that this secret's variant matches `expected`.
543    /// Used by handler code to fail loudly when the metadata view's
544    /// `secret_kind` disagrees with the unsealed secret's discriminator.
545    pub fn matches_kind(&self, expected: SecretKind) -> bool {
546        self.kind() == expected
547    }
548}
549
550/// RFC 6238 TOTP seed for entries that pair a TOTP with a password.
551#[derive(Debug, Clone, Serialize, Deserialize)]
552#[serde(rename_all = "camelCase")]
553pub struct TotpSeed {
554    /// Base32 (RFC 4648) shared secret.
555    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/// Driver config for HTTP-POST proxy-login against a Password-kind
575/// entry. Mirrors `vault/_shared/0.1/vault-secret#/$defs/PasswordLoginConfig`.
576///
577/// When this struct is present on a Password secret, the maintainer
578/// performs an HTTP POST against `login_url` carrying the entry's
579/// credentials and captures the resulting Set-Cookie headers into the
580/// SessionBlob. When absent, vault/proxy-login returns `not_proxyable`
581/// and the consumer falls back to vault/release for a browser-fill
582/// flow.
583#[derive(Debug, Clone, Serialize, Deserialize)]
584#[serde(rename_all = "camelCase")]
585pub struct PasswordLoginConfig {
586    /// Absolute URL the maintainer POSTs credentials to. MUST be
587    /// `https://` for any non-loopback host — see the canonical spec
588    /// for the loopback carve-out.
589    pub login_url: String,
590    /// Request-body encoding. `Json` → `application/json`,
591    /// `FormUrlEncoded` → `application/x-www-form-urlencoded`.
592    #[serde(default)]
593    pub format: PasswordLoginFormat,
594    /// Field name carrying the username. Default `"username"`.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub username_field: Option<String>,
597    /// Field name carrying the password. Default `"password"`.
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub password_field: Option<String>,
600    /// Optional field name carrying the TOTP code. When set AND the
601    /// entry's `totp` is populated, the maintainer computes the
602    /// current code and includes it in the request.
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub totp_field: Option<String>,
605    /// Constant field/value pairs the maintainer MUST include
606    /// alongside the credentials. Useful for fixed selectors the site
607    /// expects (e.g. `grantType: password`).
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    pub extra_fields: Option<std::collections::BTreeMap<String, String>>,
610    /// HTTP status codes the maintainer treats as login success.
611    /// Default `[200, 204]` (set via accessor when None — keeps the
612    /// wire shape clean).
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub success_status: Option<Vec<u16>>,
615}
616
617impl PasswordLoginConfig {
618    /// The set of HTTP statuses the maintainer treats as success,
619    /// falling back to the canonical default `[200, 204]` when the
620    /// caller didn't override.
621    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/// Emitted kebab-case for the 0.1 wire form; accepts the spec-0.2
638/// camelCase `formUrlencoded` via alias. Rides inside the JWE, so the 0.2
639/// edge transform can't reach it — dual-accept here keeps it
640/// backwards-compatible (issue #517).
641#[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/// Free-form user-defined field on Password / Custom variants.
651#[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
673/// Storage key for a vault entry — `"vault:<id>"`. Prefix scans on
674/// `"vault:"` enumerate every entry in this VTA's keyspace.
675fn vault_key(id: &str) -> String {
676    format!("vault:{id}")
677}
678
679/// Read a single vault entry's metadata view by id. Returns `Ok(None)`
680/// for absent ids so callers can map to a not_found / permission_denied
681/// response per their enumeration-resistance policy. Skips the secret —
682/// use [`get_stored_vault_entry`] when the secret bytes are needed
683/// (vault/release/0.1's handler is the only caller in M2A).
684pub 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
691/// Read the full stored record (metadata + secret) by id. Use sparingly —
692/// only the release handler and admin tooling have a legitimate need for
693/// the secret bytes. All other reads go through [`get_vault_entry`].
694pub 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
701/// Store (create or overwrite) a full vault record. Unconditional write —
702/// version + optimistic-concurrency checks are the caller's responsibility
703/// (the upsert handler implements them).
704pub 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
717/// Delete a vault entry by id. Use the upcoming `vault/delete/0.1` handler
718/// (M2A) for the tombstone-aware path; this helper exists for tests and
719/// administrative scripts.
720pub async fn delete_vault_entry(vault: &KeyspaceHandle, id: &str) -> Result<(), AppError> {
721    vault.remove(vault_key(id)).await
722}
723
724// ───────────────────────────────────────────────────────────────────────
725// SessionBlob — the cleartext payload of vault/proxy-login/0.1's sealed
726// response (M2B). Mirrors `vault/_shared/0.1/session-blob` schema field
727// for field. Wallet consumers receive this inside a SealedEnvelope and
728// inject the contents into their browser session for the bound origin.
729//
730// Sensitive fields here are server-managed (the VTA issues the session
731// bytes), so unlike VaultSecret these aren't user-typed — but the
732// `headers[].value` and `cookies[].value` carry bearer tokens / session
733// IDs and MUST be zeroised at TTL by the consumer just like VaultSecret.
734// ───────────────────────────────────────────────────────────────────────
735
736/// Cleartext session material returned by vault/proxy-login/0.1 — the
737/// VTA performs the login at the third party, captures the resulting
738/// session credentials, and ships them in this shape.
739#[derive(Debug, Clone, Serialize, Deserialize)]
740#[serde(rename_all = "camelCase")]
741pub struct SessionBlob {
742    /// Maintainer-assigned opaque id for this session — used by future
743    /// `vault/session/{revoke, refresh}/0.1` tasks (post-M2B) to act on
744    /// the session without re-identifying it by content.
745    pub session_id: String,
746    /// RFC 3339. The consumer MUST discard the blob (cookies + headers)
747    /// at this time even if the user hasn't finished interacting.
748    pub expires_at: String,
749    /// Cookies the consumer injects into the bound origin's cookie jar.
750    /// Order is significant for sites that set multiple cookies with the
751    /// same name on different paths.
752    #[serde(default, skip_serializing_if = "Vec::is_empty")]
753    pub cookies: Vec<CookieJarEntry>,
754    /// HTTP request headers the consumer attaches to outbound requests
755    /// for the bound origin. Typically `Authorization: Bearer …` for
756    /// the SIOP / OAuth paths.
757    #[serde(default, skip_serializing_if = "Vec::is_empty")]
758    pub headers: Vec<RequestHeader>,
759    /// Optional localStorage entries to set on the origin (SPAs that
760    /// store session material there rather than in cookies).
761    #[serde(default, skip_serializing_if = "Vec::is_empty")]
762    pub local_storage: Vec<StorageEntry>,
763    /// Optional sessionStorage entries to set on the origin.
764    #[serde(default, skip_serializing_if = "Vec::is_empty")]
765    pub session_storage: Vec<StorageEntry>,
766    /// The web origin this session is for. Consumers MUST refuse to
767    /// inject the session into any other origin.
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub bind_origin: Option<String>,
770    /// Refresh policy hint for the consumer.
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub refresh_hint: Option<RefreshHint>,
773}
774
775/// Emitted kebab-case for the 0.1 wire form; accepts the spec-0.2
776/// camelCase variants (`maintainerOnly`/`on401`/`beforeExpiry`) via alias.
777/// Inside the sealed session blob, so the 0.2 edge transform can't reach
778/// it — dual-accept keeps it backwards-compatible (issue #517).
779#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
780#[serde(rename_all = "kebab-case")]
781pub enum RefreshHint {
782    /// Don't refresh on your own; the maintainer drives renewal.
783    #[serde(alias = "maintainerOnly")]
784    MaintainerOnly,
785    /// Call back to vault/proxy-login when the third party returns 401.
786    /// (kebab-case renders this `on401` — already equal to the spec-0.2
787    /// camelCase form, so no alias is needed.)
788    On401,
789    /// Pre-emptively refresh shortly before `expiresAt`.
790    #[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
830/// List vault entries matching `filter`, ordered by `last_used_at`
831/// descending (entries without `last_used_at` sort last). Returns the
832/// metadata projection only — secrets stay in the keyspace.
833pub 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        // Most-recently-used first; absent last_used_at sorts last.
848        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    // Archival lifecycle first: by default only Active entries are listed, so
860    // archived / soft-deleted entries never leak into a normal `vault/list`.
861    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    // Target filters: an entry matches when AT LEAST ONE target satisfies the
903    // criterion. Each target filter is independent — passing multiple narrows
904    // the result to entries that have a target matching every criterion (a
905    // single target need not satisfy all of them).
906    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        // Backwards-compat dual-accept (issue #517): the 0.2 spec uses
1039        // lowerCamelCase discriminator values. Emission stays kebab (above),
1040        // but every multi-word camelCase form must deserialize too.
1041        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        // Match-all empty filter
1059        assert!(matches_filter(&entry, &VaultListFilter::default()));
1060
1061        // Single criterion that matches
1062        assert!(matches_filter(
1063            &entry,
1064            &VaultListFilter {
1065                context_id: Some("ctx_a"),
1066                ..Default::default()
1067            }
1068        ));
1069
1070        // Single criterion that misses
1071        assert!(!matches_filter(
1072            &entry,
1073            &VaultListFilter {
1074                context_id: Some("ctx_b"),
1075                ..Default::default()
1076            }
1077        ));
1078
1079        // never_used excludes used entries
1080        assert!(!matches_filter(
1081            &entry,
1082            &VaultListFilter {
1083                never_used: Some(true),
1084                ..Default::default()
1085            }
1086        ));
1087
1088        // used_since accepts a timestamp at or before last_used_at
1089        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        // Origin prefix matches any web-origin target
1105        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        // The canonical spec (vault/_shared/0.1/vault-secret) uses
1124        // camelCase field names (`secureNotes`, `loginConfig`,
1125        // `signingKeyId`). Verify the Rust implementation deserializes
1126        // the spec-form wire input AND re-emits it in the same shape
1127        // — i.e. neither side requires snake_case translation. If
1128        // this test fails, every camelCase-emitting consumer (the
1129        // browser plugin's vault/upsert path is the live example)
1130        // would silently lose optional fields.
1131        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        // Regression for issue #517: the inner sealed `VaultSecret` rides in
1171        // the opaque authcrypt JWE, so the 0.2 edge transform can't camelCase
1172        // its `kind`. A spec-0.2 producer (e.g. the browser plugin) seals
1173        // `{"kind":"didSelfIssued", …}`; before the alias fix this was
1174        // rejected with `unknown variant didSelfIssued`. Both the legacy
1175        // kebab and the spec-0.2 camelCase discriminator must parse, for every
1176        // multi-word kind.
1177        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            // Conservative emission: we still serialize the legacy kebab form
1210            // so existing 0.1 openers keep working.
1211            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        // Both ride inside the JWE / sealed session blob (issue #517).
1223        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        // Emission stays kebab.
1232        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            // kebab-case of `On401` is `on401` — already the spec-0.2 form.
1244            ("\"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        // DID-shaped kinds → Some(did)
1263        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        // No-DID kinds → None (sample each variant so a future
1286        // refactor that misses one trips this test).
1287        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        // Back-compat: a record persisted before the lifecycle existed has no
1334        // `status`/`archivedAt`/`deletedAt`/`graceUntil` keys. It MUST default
1335        // to Active so existing vaults keep working after upgrade.
1336        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        // And an Active entry re-emits WITHOUT the status keys (wire stays clean).
1347        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        // Default filter → Active only.
1363        let f = VaultListFilter::default();
1364        assert!(matches_filter(&active, &f));
1365        assert!(!matches_filter(&archived, &f));
1366        assert!(!matches_filter(&deleted, &f));
1367
1368        // Explicit status selects exactly that state.
1369        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        // any_status returns everything.
1377        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        // archive: Active → Archived, bumps version, stamps archived_at.
1393        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        // double-archive refused.
1401        assert_eq!(e.archive(t1, None), Err(LifecycleError::NotActive));
1402        // unarchive: Archived → Active.
1403        e.unarchive(t1, None).unwrap();
1404        assert_eq!(e.status, VaultStatus::Active);
1405        assert!(e.archived_at.is_none());
1406        // unarchive of Active refused.
1407        assert_eq!(e.unarchive(t1, None), Err(LifecycleError::NotArchived));
1408
1409        // soft delete: Active → Deleted with grace, restorable in-window.
1410        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        // re-delete refused.
1416        assert_eq!(
1417            e.soft_delete(t1, grace, None),
1418            Err(LifecycleError::AlreadyDeleted)
1419        );
1420        // restore inside the window.
1421        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        // restore of a non-deleted entry refused.
1425        assert_eq!(e.restore(t1, None), Err(LifecycleError::NotDeleted));
1426
1427        // restore AFTER grace is refused (the sweeper owns it now).
1428        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        // delete is reachable from Archived too (Archived → Deleted).
1438        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}