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// The `op_class` module lived here: eleven slugs (`acl/grant`,
66// `context/delete`, `vault/release`, …) that a `[auth.step_up]` floor keyed on,
67// plus the `*` catch-all. It is retired along with the floors it addressed. A
68// rule names the task URI itself, so there is no closed list to keep in step
69// with the dispatch table — and no way for a gated task to fall outside it.
70
71/// Step-up enforcement mode.
72///
73/// Retained **only** as the type of `AclEntry.stepUp.require`, a published wire
74/// field. Nothing reads it to make a decision any more: the floors that
75/// composed a system mode with this per-entry override are gone, and the gate
76/// asks the rules. Removing the field is a separate slice with its own wire
77/// consequences (~35 vta-sdk sites and the CLI flags that set it), so it stays
78/// serialisable and inert rather than half-removed.
79///
80/// Strictness (least → most): `None` < `SelfApprove` < `DelegatedAny` <
81/// `Delegated`.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
83#[serde(rename_all = "kebab-case")]
84pub enum StepUpMode {
85    /// AAL1 permitted — no step-up required.
86    #[default]
87    None,
88    /// The caller elevates its own session (AAL2 via its own authenticator).
89    #[serde(rename = "self")]
90    SelfApprove,
91    /// A specific approver (the caller's `AclEntry.stepUp.approver`) must
92    /// ratify the elevation.
93    Delegated,
94    /// Any VID meeting the maintainer's approver criterion may ratify.
95    DelegatedAny,
96}
97
98// `rank`, `requires_aal2`, and `strictest` lived here. They composed a system
99// floor with a per-entry override — "an override may raise, never lower" — and
100// there is no system floor left to compose with. The enum survives as a wire
101// shape, not as a decision procedure.
102
103// `StepUpFloor` and `StepUpPolicy` lived here — the `{enabled, floors[]}` shape
104// the VTA serialised under `[auth.step_up]`, and the resolution that picked the
105// most specific floor for an op-class. Both are retired; `AuthConfig` now
106// refuses a config that still carries the section rather than parse one nothing
107// reads.
108
109fn step_up_key(challenge: &str) -> String {
110    format!("stepup:{challenge}")
111}
112
113/// Outcome of consuming a pending step-up by challenge.
114#[derive(Debug, PartialEq)]
115pub enum ConsumeOutcome {
116    /// No pending step-up matched the challenge (`challenge_unknown`).
117    NotFound,
118    /// A match existed but had expired (`challenge_expired`). The stale
119    /// record is removed as a side effect.
120    Expired,
121    /// A live match; the record was removed (single-use).
122    Found(Box<PendingStepUp>),
123}
124
125/// Store a pending step-up keyed by its challenge.
126pub async fn store_pending_step_up(
127    sessions: &KeyspaceHandle,
128    pending: &PendingStepUp,
129) -> Result<(), AppError> {
130    sessions
131        .insert(step_up_key(&pending.challenge), pending)
132        .await
133}
134
135/// Read a pending step-up by challenge without consuming it. Returns the raw
136/// record (no expiry filtering) — callers that want single-use semantics
137/// should use [`consume_pending_step_up`].
138pub async fn get_pending_step_up(
139    sessions: &KeyspaceHandle,
140    challenge: &str,
141) -> Result<Option<PendingStepUp>, AppError> {
142    sessions.get(step_up_key(challenge)).await
143}
144
145/// Locate and **consume** the pending step-up matching `challenge` (single
146/// use). On a live match the record is removed and returned; on an expired
147/// match the stale record is removed and [`ConsumeOutcome::Expired`] returned;
148/// a miss yields [`ConsumeOutcome::NotFound`].
149///
150/// Typed records are stored encrypted-aware via `insert`, so consumption is a
151/// `get` (which decrypts) + `remove`, matching how the rest of the session
152/// layer handles typed rows. The remove makes the challenge single-use.
153pub async fn consume_pending_step_up(
154    sessions: &KeyspaceHandle,
155    challenge: &str,
156    now: u64,
157) -> Result<ConsumeOutcome, AppError> {
158    let key = step_up_key(challenge);
159    let Some(pending): Option<PendingStepUp> = sessions.get(key.clone()).await? else {
160        return Ok(ConsumeOutcome::NotFound);
161    };
162    // Single-use either way: remove before returning so neither a live nor an
163    // expired challenge can be presented twice.
164    sessions.remove(key).await?;
165    if now >= pending.expires_at {
166        return Ok(ConsumeOutcome::Expired);
167    }
168    Ok(ConsumeOutcome::Found(Box::new(pending)))
169}
170
171/// Convenience: build a pending step-up expiring `ttl_secs` from now.
172pub fn new_pending_step_up(
173    challenge: impl Into<String>,
174    session_id: impl Into<String>,
175    subject: impl Into<String>,
176    approver: impl Into<String>,
177    approver_any: bool,
178    target_acr: impl Into<String>,
179    acceptable_evidence: Vec<String>,
180    ttl_secs: u64,
181) -> PendingStepUp {
182    let created_at = now_epoch();
183    PendingStepUp {
184        challenge: challenge.into(),
185        session_id: session_id.into(),
186        subject: subject.into(),
187        approver: approver.into(),
188        approver_any,
189        target_acr: target_acr.into(),
190        acceptable_evidence,
191        created_at,
192        expires_at: created_at.saturating_add(ttl_secs),
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::config::StoreConfig;
200    use crate::store::Store;
201
202    async fn ks() -> KeyspaceHandle {
203        let dir = tempfile::tempdir().expect("tempdir");
204        // Leak the tempdir for the test's lifetime so the fjall files survive.
205        let dir = Box::leak(Box::new(dir));
206        let store = Store::open(&StoreConfig {
207            data_dir: dir.path().to_path_buf(),
208        })
209        .expect("open store");
210        store.keyspace("sessions").expect("keyspace")
211    }
212
213    fn sample(challenge: &str, expires_at: u64) -> PendingStepUp {
214        PendingStepUp {
215            challenge: challenge.to_string(),
216            session_id: "sess-1".to_string(),
217            subject: "did:key:zHolder".to_string(),
218            approver: "did:key:zHolder".to_string(),
219            approver_any: false,
220            target_acr: "aal2".to_string(),
221            acceptable_evidence: vec!["did-signed".into(), "webauthn".into()],
222            created_at: 1000,
223            expires_at,
224        }
225    }
226
227    #[tokio::test]
228    async fn round_trips_and_consumes_once() {
229        let ks = ks().await;
230        let p = sample("VHJhbnNmZXJDb25maXJtTm9uY2VYWQ", now_epoch() + 300);
231        store_pending_step_up(&ks, &p).await.unwrap();
232
233        // get does not consume
234        assert_eq!(
235            get_pending_step_up(&ks, &p.challenge).await.unwrap(),
236            Some(p.clone())
237        );
238
239        // first consume returns it
240        match consume_pending_step_up(&ks, &p.challenge, now_epoch())
241            .await
242            .unwrap()
243        {
244            ConsumeOutcome::Found(found) => assert_eq!(*found, p),
245            other => panic!("expected Found, got {other:?}"),
246        }
247        // second consume is a miss (single-use)
248        assert_eq!(
249            consume_pending_step_up(&ks, &p.challenge, now_epoch())
250                .await
251                .unwrap(),
252            ConsumeOutcome::NotFound
253        );
254    }
255
256    #[tokio::test]
257    async fn unknown_challenge_is_not_found() {
258        let ks = ks().await;
259        assert_eq!(
260            consume_pending_step_up(&ks, "no-such-challenge", now_epoch())
261                .await
262                .unwrap(),
263            ConsumeOutcome::NotFound
264        );
265    }
266
267    #[tokio::test]
268    async fn expired_challenge_is_consumed_and_reported_expired() {
269        let ks = ks().await;
270        let p = sample("RXhwaXJlZENoYWxsZW5nZVZhbHVlWA", 1000); // expires_at in the past
271        store_pending_step_up(&ks, &p).await.unwrap();
272        assert_eq!(
273            consume_pending_step_up(&ks, &p.challenge, now_epoch())
274                .await
275                .unwrap(),
276            ConsumeOutcome::Expired
277        );
278        // expired record was removed
279        assert_eq!(get_pending_step_up(&ks, &p.challenge).await.unwrap(), None);
280    }
281
282    #[test]
283    fn new_pending_sets_expiry() {
284        let p = new_pending_step_up(
285            "VHJhbnNmZXJDb25maXJtTm9uY2VYWQ",
286            "sess-1",
287            "did:key:zHolder",
288            "did:key:zApprover",
289            false,
290            "aal2",
291            vec!["webauthn".into()],
292            300,
293        );
294        assert_eq!(p.expires_at, p.created_at + 300);
295        assert_eq!(p.target_acr, "aal2");
296        assert_eq!(p.approver, "did:key:zApprover");
297        assert!(!p.approver_any);
298    }
299
300    #[test]
301    fn legacy_record_without_approver_defaults_empty() {
302        // A record serialized before `approver` existed must still deserialize
303        // (serde default) with an empty approver — the handler treats that as
304        // self (issuer MUST equal subject), preserving the prior contract.
305        let legacy = r#"{
306            "challenge":"VHJhbnNmZXJDb25maXJtTm9uY2VYWQ",
307            "session_id":"sess-1",
308            "subject":"did:key:zHolder",
309            "target_acr":"aal2",
310            "acceptable_evidence":["did-signed"],
311            "created_at":1000,
312            "expires_at":2000
313        }"#;
314        let p: PendingStepUp = serde_json::from_str(legacy).expect("legacy record deserializes");
315        assert_eq!(p.approver, "");
316        assert_eq!(p.subject, "did:key:zHolder");
317    }
318
319    /// The wire tokens are the reason [`StepUpMode`] survives at all —
320    /// `AclEntry.stepUp.require` is a published field, and its spellings have to
321    /// keep round-tripping even though nothing acts on the value any more.
322    #[test]
323    fn mode_serde_uses_spec_wire_tokens() {
324        assert_eq!(
325            serde_json::to_string(&StepUpMode::SelfApprove).unwrap(),
326            "\"self\""
327        );
328        assert_eq!(
329            serde_json::to_string(&StepUpMode::DelegatedAny).unwrap(),
330            "\"delegated-any\""
331        );
332        assert_eq!(
333            serde_json::from_str::<StepUpMode>("\"none\"").unwrap(),
334            StepUpMode::None
335        );
336    }
337}