Skip to main content

vti_common/auth/
step_up.rs

1//! Pending step-up store.
2//!
3//! When an AAL1 session hits a step-up-gated operation, the relying party
4//! (the VTA) mints a **pending step-up**: a short-lived, single-use record
5//! binding a fresh `challenge` to the `session_id`/`subject` being elevated and
6//! the `targetAcr` requested. It is keyed by the challenge so the matching
7//! `auth/step-up/approve-response/0.1` can be located by its echoed challenge.
8//!
9//! Stored under `stepup:{challenge}` in the sessions keyspace, mirroring the
10//! `nonce:`/`refresh:` index conventions in [`crate::auth::session`]. Records
11//! are consumed exactly once on a successful (or expired) match so an
12//! approve-response cannot be replayed.
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::AppError;
17use crate::store::KeyspaceHandle;
18
19use super::session::now_epoch;
20
21/// A pending AAL step-up awaiting an `approve-response`.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct PendingStepUp {
24    /// base64url challenge the approver echoes + signs/asserts over. The
25    /// store key is `stepup:{challenge}`.
26    pub challenge: String,
27    /// The session being elevated.
28    pub session_id: String,
29    /// The VID whose session is being elevated; the approve-response's
30    /// `subject` MUST equal this.
31    pub subject: String,
32    /// The VID authorized to *sign* the approve-response — the document
33    /// `issuer` / proof VM DID (or credential subject) the relying party will
34    /// accept. Equals [`Self::subject`] for **self** step-up; the delegated
35    /// `AclEntry.stepUp.approver` the request was addressed to for
36    /// **delegated** step-up. The relying party elevates only when the signer
37    /// equals this.
38    ///
39    /// `#[serde(default)]` so an in-flight record written before this field
40    /// existed deserializes with an empty approver; the handler treats an empty
41    /// approver as self (issuer MUST equal subject), preserving the prior
42    /// contract for the ≤TTL window after a deploy.
43    #[serde(default)]
44    pub approver: String,
45    /// `true` for **`delegated-any`** mode: the approve-response is authorized
46    /// not against a single bound [`Self::approver`] but against the relying
47    /// party's approver *criterion* (the issuer must be an admin covering the
48    /// subject's contexts — see `acl::delegated_any_approver_covers`).
49    /// [`Self::approver`] is empty in this mode. `#[serde(default)]` so older
50    /// records deserialize as `false` (the self/delegated single-approver path).
51    #[serde(default)]
52    pub approver_any: bool,
53    /// The acr the relying party requested. The elevated session MUST reach
54    /// at least this, else `acr_unsatisfied`.
55    pub target_acr: String,
56    /// Evidence kinds the relying party will accept (`did-signed`,
57    /// `webauthn`). Empty = any supported kind.
58    #[serde(default)]
59    pub acceptable_evidence: Vec<String>,
60    pub created_at: u64,
61    /// Unix seconds after which the step-up is no longer valid.
62    pub expires_at: u64,
63}
64
65/// Canonical operation-class slugs the VTA gates with a step-up floor.
66///
67/// A policy `floor.operation` MUST be one of these or `*` (the catch-all);
68/// anything else is rejected as `unknownOperation` when a policy is set. Single
69/// source of truth shared by the gate (`routes::trust_tasks::step_up::op`
70/// re-exports these) and the policy-management validation.
71pub mod op_class {
72    pub const ACL_GRANT: &str = "acl/grant";
73    pub const ACL_CHANGE_ROLE: &str = "acl/change-role";
74    pub const ACL_REVOKE: &str = "acl/revoke";
75    pub const ACL_SWAP_KEY: &str = "acl/swap-key";
76    pub const CONTEXT_DELETE: &str = "context/delete";
77    pub const KEY_REVOKE: &str = "key/revoke";
78    /// Disclose a stored vault secret to the caller (`vault/release/0.1`).
79    pub const VAULT_RELEASE: &str = "vault/release";
80    /// Mint a proxy-login session credential for a vault site
81    /// (`vault/proxy-login/0.1`).
82    pub const VAULT_PROXY_LOGIN: &str = "vault/proxy-login";
83    /// Sign a Trust Task envelope as a vault entry's principal DID
84    /// (`vault/sign-trust-task/0.1`).
85    pub const VAULT_SIGN_TRUST_TASK: &str = "vault/sign-trust-task";
86    /// Mint a new VTA-signed Verifiable Credential for a holder
87    /// (`vta/credentials/issue/0.1`).
88    pub const CREDENTIALS_ISSUE: &str = "credentials/issue";
89    /// Revoke a previously-issued VTA credential
90    /// (`vta/credentials/revoke/0.1`).
91    pub const CREDENTIALS_REVOKE: &str = "credentials/revoke";
92
93    /// Every recognized operation-class (excludes the `*` catch-all).
94    pub const ALL: &[&str] = &[
95        ACL_GRANT,
96        ACL_CHANGE_ROLE,
97        ACL_REVOKE,
98        ACL_SWAP_KEY,
99        CONTEXT_DELETE,
100        KEY_REVOKE,
101        VAULT_RELEASE,
102        VAULT_PROXY_LOGIN,
103        VAULT_SIGN_TRUST_TASK,
104        CREDENTIALS_ISSUE,
105        CREDENTIALS_REVOKE,
106    ];
107
108    /// Whether `operation` is a floor target the maintainer recognizes: a known
109    /// op-class or the `*` catch-all.
110    pub fn is_recognized(operation: &str) -> bool {
111        operation == "*" || ALL.contains(&operation)
112    }
113}
114
115/// Step-up enforcement mode for an operation-class — the assurance the
116/// relying party requires before the operation runs. Mirrors the
117/// `auth/step-up/policy/0.1` `FloorMode`. Strictness (least → most):
118/// `None` < `SelfApprove` < `DelegatedAny` < `Delegated`.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
120#[serde(rename_all = "kebab-case")]
121pub enum StepUpMode {
122    /// AAL1 permitted — no step-up required.
123    #[default]
124    None,
125    /// The caller elevates its own session (AAL2 via its own authenticator).
126    #[serde(rename = "self")]
127    SelfApprove,
128    /// A specific approver (the caller's `AclEntry.stepUp.approver`) must
129    /// ratify the elevation.
130    Delegated,
131    /// Any VID meeting the maintainer's approver criterion may ratify.
132    DelegatedAny,
133}
134
135impl StepUpMode {
136    /// Strictness rank for floor/override composition (higher = stricter).
137    fn rank(self) -> u8 {
138        match self {
139            StepUpMode::None => 0,
140            StepUpMode::SelfApprove => 1,
141            StepUpMode::DelegatedAny => 2,
142            StepUpMode::Delegated => 3,
143        }
144    }
145
146    /// Whether this mode demands AAL2 (anything stricter than `None`).
147    pub fn requires_aal2(self) -> bool {
148        self != StepUpMode::None
149    }
150
151    /// The stricter of two modes — used to compose a system floor with a
152    /// per-entry override (additive-only: an override may raise, never lower).
153    pub fn strictest(self, other: StepUpMode) -> StepUpMode {
154        if other.rank() > self.rank() {
155            other
156        } else {
157            self
158        }
159    }
160}
161
162/// A per-operation-class step-up floor. Mirrors `auth/step-up/policy/0.1`
163/// `Floor`.
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub struct StepUpFloor {
166    /// Operation-class this floor governs: a stable op-class id (e.g.
167    /// `acl/grant`, `acl/swap-key`, `context/delete`, `key/revoke`,
168    /// `vault/release`) or `*` for the catch-all default.
169    pub operation: String,
170    /// Minimum mode required to perform the operation.
171    pub mode: StepUpMode,
172    /// Admit a non-escalating self-service request at AAL1 even when `mode`
173    /// requires AAL2 — the rotation/enrolment carve-out. Default `false`
174    /// (fail-closed for escalating operations).
175    #[serde(default)]
176    pub allow_aal1_if_non_escalating: bool,
177}
178
179/// The relying party's system-wide step-up policy.
180///
181/// **Ships disabled.** A freshly-provisioned VTA has no registered approver
182/// and could not otherwise be administered (it could not even register the
183/// first approver), so until an operator turns it on every operation proceeds
184/// at AAL1. Mirrors the `auth/step-up/policy/0.1` payload; the VTA serializes
185/// it under `[auth.step_up]` in its config.
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
187pub struct StepUpPolicy {
188    /// Master switch. `false` (the default) ⇒ step-up is NOT enforced
189    /// anywhere; every operation proceeds at AAL1 regardless of `floors`.
190    #[serde(default)]
191    pub enabled: bool,
192    /// Per-operation-class floors. Empty ⇒ nothing is gated even when
193    /// `enabled`.
194    #[serde(default)]
195    pub floors: Vec<StepUpFloor>,
196}
197
198impl StepUpPolicy {
199    /// Resolve the system-floor mode for an operation-class: the most
200    /// specific matching floor, else the `*` catch-all, else `None`. Always
201    /// `None` when the policy is disabled.
202    pub fn floor_for(&self, operation: &str) -> StepUpMode {
203        self.floor_record(operation)
204            .map(|f| f.mode)
205            .unwrap_or(StepUpMode::None)
206    }
207
208    /// The matching floor record (exact match preferred over the `*`
209    /// catch-all), or `None` when disabled or unmatched. Carries the
210    /// `allow_aal1_if_non_escalating` flag for the carve-out.
211    pub fn floor_record(&self, operation: &str) -> Option<&StepUpFloor> {
212        if !self.enabled {
213            return None;
214        }
215        self.floors
216            .iter()
217            .find(|f| f.operation == operation)
218            .or_else(|| self.floors.iter().find(|f| f.operation == "*"))
219    }
220}
221
222fn step_up_key(challenge: &str) -> String {
223    format!("stepup:{challenge}")
224}
225
226/// Outcome of consuming a pending step-up by challenge.
227#[derive(Debug, PartialEq)]
228pub enum ConsumeOutcome {
229    /// No pending step-up matched the challenge (`challenge_unknown`).
230    NotFound,
231    /// A match existed but had expired (`challenge_expired`). The stale
232    /// record is removed as a side effect.
233    Expired,
234    /// A live match; the record was removed (single-use).
235    Found(Box<PendingStepUp>),
236}
237
238/// Store a pending step-up keyed by its challenge.
239pub async fn store_pending_step_up(
240    sessions: &KeyspaceHandle,
241    pending: &PendingStepUp,
242) -> Result<(), AppError> {
243    sessions
244        .insert(step_up_key(&pending.challenge), pending)
245        .await
246}
247
248/// Read a pending step-up by challenge without consuming it. Returns the raw
249/// record (no expiry filtering) — callers that want single-use semantics
250/// should use [`consume_pending_step_up`].
251pub async fn get_pending_step_up(
252    sessions: &KeyspaceHandle,
253    challenge: &str,
254) -> Result<Option<PendingStepUp>, AppError> {
255    sessions.get(step_up_key(challenge)).await
256}
257
258/// Locate and **consume** the pending step-up matching `challenge` (single
259/// use). On a live match the record is removed and returned; on an expired
260/// match the stale record is removed and [`ConsumeOutcome::Expired`] returned;
261/// a miss yields [`ConsumeOutcome::NotFound`].
262///
263/// Typed records are stored encrypted-aware via `insert`, so consumption is a
264/// `get` (which decrypts) + `remove`, matching how the rest of the session
265/// layer handles typed rows. The remove makes the challenge single-use.
266pub async fn consume_pending_step_up(
267    sessions: &KeyspaceHandle,
268    challenge: &str,
269    now: u64,
270) -> Result<ConsumeOutcome, AppError> {
271    let key = step_up_key(challenge);
272    let Some(pending): Option<PendingStepUp> = sessions.get(key.clone()).await? else {
273        return Ok(ConsumeOutcome::NotFound);
274    };
275    // Single-use either way: remove before returning so neither a live nor an
276    // expired challenge can be presented twice.
277    sessions.remove(key).await?;
278    if now >= pending.expires_at {
279        return Ok(ConsumeOutcome::Expired);
280    }
281    Ok(ConsumeOutcome::Found(Box::new(pending)))
282}
283
284/// Convenience: build a pending step-up expiring `ttl_secs` from now.
285pub fn new_pending_step_up(
286    challenge: impl Into<String>,
287    session_id: impl Into<String>,
288    subject: impl Into<String>,
289    approver: impl Into<String>,
290    approver_any: bool,
291    target_acr: impl Into<String>,
292    acceptable_evidence: Vec<String>,
293    ttl_secs: u64,
294) -> PendingStepUp {
295    let created_at = now_epoch();
296    PendingStepUp {
297        challenge: challenge.into(),
298        session_id: session_id.into(),
299        subject: subject.into(),
300        approver: approver.into(),
301        approver_any,
302        target_acr: target_acr.into(),
303        acceptable_evidence,
304        created_at,
305        expires_at: created_at.saturating_add(ttl_secs),
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::config::StoreConfig;
313    use crate::store::Store;
314
315    #[test]
316    fn vault_op_classes_are_recognized_floor_targets() {
317        // P0.13: vault ops must be settable as policy floors so step-up can be
318        // enforced on them; an unrecognized op-class is rejected at policy-set
319        // time as `unknownOperation`.
320        for op in [
321            op_class::VAULT_RELEASE,
322            op_class::VAULT_PROXY_LOGIN,
323            op_class::VAULT_SIGN_TRUST_TASK,
324        ] {
325            assert!(
326                op_class::is_recognized(op),
327                "{op} must be a valid floor target"
328            );
329            assert!(op_class::ALL.contains(&op), "{op} must be in ALL");
330        }
331    }
332
333    async fn ks() -> KeyspaceHandle {
334        let dir = tempfile::tempdir().expect("tempdir");
335        // Leak the tempdir for the test's lifetime so the fjall files survive.
336        let dir = Box::leak(Box::new(dir));
337        let store = Store::open(&StoreConfig {
338            data_dir: dir.path().to_path_buf(),
339        })
340        .expect("open store");
341        store.keyspace("sessions").expect("keyspace")
342    }
343
344    fn sample(challenge: &str, expires_at: u64) -> PendingStepUp {
345        PendingStepUp {
346            challenge: challenge.to_string(),
347            session_id: "sess-1".to_string(),
348            subject: "did:key:zHolder".to_string(),
349            approver: "did:key:zHolder".to_string(),
350            approver_any: false,
351            target_acr: "aal2".to_string(),
352            acceptable_evidence: vec!["did-signed".into(), "webauthn".into()],
353            created_at: 1000,
354            expires_at,
355        }
356    }
357
358    #[tokio::test]
359    async fn round_trips_and_consumes_once() {
360        let ks = ks().await;
361        let p = sample("VHJhbnNmZXJDb25maXJtTm9uY2VYWQ", now_epoch() + 300);
362        store_pending_step_up(&ks, &p).await.unwrap();
363
364        // get does not consume
365        assert_eq!(
366            get_pending_step_up(&ks, &p.challenge).await.unwrap(),
367            Some(p.clone())
368        );
369
370        // first consume returns it
371        match consume_pending_step_up(&ks, &p.challenge, now_epoch())
372            .await
373            .unwrap()
374        {
375            ConsumeOutcome::Found(found) => assert_eq!(*found, p),
376            other => panic!("expected Found, got {other:?}"),
377        }
378        // second consume is a miss (single-use)
379        assert_eq!(
380            consume_pending_step_up(&ks, &p.challenge, now_epoch())
381                .await
382                .unwrap(),
383            ConsumeOutcome::NotFound
384        );
385    }
386
387    #[tokio::test]
388    async fn unknown_challenge_is_not_found() {
389        let ks = ks().await;
390        assert_eq!(
391            consume_pending_step_up(&ks, "no-such-challenge", now_epoch())
392                .await
393                .unwrap(),
394            ConsumeOutcome::NotFound
395        );
396    }
397
398    #[tokio::test]
399    async fn expired_challenge_is_consumed_and_reported_expired() {
400        let ks = ks().await;
401        let p = sample("RXhwaXJlZENoYWxsZW5nZVZhbHVlWA", 1000); // expires_at in the past
402        store_pending_step_up(&ks, &p).await.unwrap();
403        assert_eq!(
404            consume_pending_step_up(&ks, &p.challenge, now_epoch())
405                .await
406                .unwrap(),
407            ConsumeOutcome::Expired
408        );
409        // expired record was removed
410        assert_eq!(get_pending_step_up(&ks, &p.challenge).await.unwrap(), None);
411    }
412
413    #[test]
414    fn new_pending_sets_expiry() {
415        let p = new_pending_step_up(
416            "VHJhbnNmZXJDb25maXJtTm9uY2VYWQ",
417            "sess-1",
418            "did:key:zHolder",
419            "did:key:zApprover",
420            false,
421            "aal2",
422            vec!["webauthn".into()],
423            300,
424        );
425        assert_eq!(p.expires_at, p.created_at + 300);
426        assert_eq!(p.target_acr, "aal2");
427        assert_eq!(p.approver, "did:key:zApprover");
428        assert!(!p.approver_any);
429    }
430
431    #[test]
432    fn legacy_record_without_approver_defaults_empty() {
433        // A record serialized before `approver` existed must still deserialize
434        // (serde default) with an empty approver — the handler treats that as
435        // self (issuer MUST equal subject), preserving the prior contract.
436        let legacy = r#"{
437            "challenge":"VHJhbnNmZXJDb25maXJtTm9uY2VYWQ",
438            "session_id":"sess-1",
439            "subject":"did:key:zHolder",
440            "target_acr":"aal2",
441            "acceptable_evidence":["did-signed"],
442            "created_at":1000,
443            "expires_at":2000
444        }"#;
445        let p: PendingStepUp = serde_json::from_str(legacy).expect("legacy record deserializes");
446        assert_eq!(p.approver, "");
447        assert_eq!(p.subject, "did:key:zHolder");
448    }
449
450    fn floor(op: &str, mode: StepUpMode) -> StepUpFloor {
451        StepUpFloor {
452            operation: op.to_string(),
453            mode,
454            allow_aal1_if_non_escalating: false,
455        }
456    }
457
458    #[test]
459    fn default_policy_is_disabled_and_never_gates() {
460        let p = StepUpPolicy::default();
461        assert!(!p.enabled);
462        // Disabled ⇒ every operation resolves to None regardless of floors.
463        assert_eq!(p.floor_for("acl/grant"), StepUpMode::None);
464        assert_eq!(p.floor_for("*"), StepUpMode::None);
465        assert!(!p.floor_for("anything").requires_aal2());
466    }
467
468    #[test]
469    fn disabled_policy_ignores_configured_floors() {
470        let p = StepUpPolicy {
471            enabled: false,
472            floors: vec![floor("*", StepUpMode::Delegated)],
473        };
474        assert_eq!(p.floor_for("acl/grant"), StepUpMode::None);
475        assert!(p.floor_record("acl/grant").is_none());
476    }
477
478    #[test]
479    fn enabled_resolves_exact_then_catch_all() {
480        let p = StepUpPolicy {
481            enabled: true,
482            floors: vec![
483                floor("*", StepUpMode::SelfApprove),
484                floor("acl/grant", StepUpMode::Delegated),
485            ],
486        };
487        // Exact match wins over `*`.
488        assert_eq!(p.floor_for("acl/grant"), StepUpMode::Delegated);
489        // Unlisted op falls back to the catch-all.
490        assert_eq!(p.floor_for("context/delete"), StepUpMode::SelfApprove);
491    }
492
493    #[test]
494    fn enabled_without_catch_all_is_none_for_unlisted() {
495        let p = StepUpPolicy {
496            enabled: true,
497            floors: vec![floor("acl/grant", StepUpMode::Delegated)],
498        };
499        assert_eq!(p.floor_for("acl/swap-key"), StepUpMode::None);
500        assert_eq!(p.floor_for("acl/grant"), StepUpMode::Delegated);
501    }
502
503    #[test]
504    fn mode_strictness_is_additive() {
505        // Override may raise, never lower (strictest wins).
506        assert_eq!(
507            StepUpMode::SelfApprove.strictest(StepUpMode::Delegated),
508            StepUpMode::Delegated
509        );
510        assert_eq!(
511            StepUpMode::Delegated.strictest(StepUpMode::SelfApprove),
512            StepUpMode::Delegated
513        );
514        assert_eq!(
515            StepUpMode::None.strictest(StepUpMode::SelfApprove),
516            StepUpMode::SelfApprove
517        );
518        assert!(!StepUpMode::None.requires_aal2());
519        assert!(StepUpMode::SelfApprove.requires_aal2());
520        assert!(StepUpMode::DelegatedAny.requires_aal2());
521    }
522
523    #[test]
524    fn mode_serde_uses_spec_wire_tokens() {
525        assert_eq!(
526            serde_json::to_string(&StepUpMode::SelfApprove).unwrap(),
527            "\"self\""
528        );
529        assert_eq!(
530            serde_json::to_string(&StepUpMode::DelegatedAny).unwrap(),
531            "\"delegated-any\""
532        );
533        assert_eq!(
534            serde_json::from_str::<StepUpMode>("\"none\"").unwrap(),
535            StepUpMode::None
536        );
537    }
538}