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}