did-hosting-control 0.10.1

DID Hosting Control Plane — Unified management UI and service registry
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
//! Step-up (`auth/step-up/start`, `auth/step-up/approve-response/0.5`),
//! and passkey login (`auth/passkey/login/{start,finish}/0.2`). Enrolment is
//! `enrol`.
//!
//! The login ceremony reads login credentials only — the `KS_SESSIONS`
//! store. A login passkey can never satisfy a step-up, and a step-up-only
//! passkey can never sign anyone in: the two stores are opened by different
//! code paths, and neither reads the other. `step_up_start`/`approve_response`
//! below are the ones that open `KS_PASSKEY_STEP_UP` — when the session's
//! subject holds a step-up passkey, the approve-request offers `webauthn` as
//! an `acceptableEvidence` alongside `didSigned`, gated on that store; the
//! login family (`login_start`/`login_finish`) never opens it.

use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracing::{info, warn};
use trust_tasks_rs::specs::auth::{
    passkey::login::{finish as login_finish, start as login_start},
    revoke_session,
    step_up::{approve_response, start as step_up_start},
};

use did_hosting_common::server::auth::session::{
    Session, SessionState, create_authenticated_session, delete_session, get_session, now_epoch,
    store_session,
};
use did_hosting_common::server::passkey::store::{self as passkey_store, cred_id_hex};
use did_hosting_common::server::store::KS_PASSKEY_STEP_UP;

use super::{Cx, TaskError, at, new_id, typed};
use crate::acl::check_acl;
use crate::error::AppError;
use crate::server::AppState;

/// How long a step-up challenge or a passkey ceremony stays open.
const CEREMONY_TTL_SECS: u64 = 300;

/// The assurance level a step-up grants.
const STEP_UP_ACR: &str = "aal2";

fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}

fn random_hex(bytes: usize) -> String {
    (0..bytes)
        .map(|_| format!("{:02x}", rand::random::<u8>()))
        .collect()
}

/// The canonical `Session` shape the auth family's responses carry.
fn wire_session(session: &Session, expires_at: u64) -> Value {
    json!({
        "id": session.session_id,
        "subject": session.did,
        "issuedAt": at(session.created_at),
        "expiresAt": at(expires_at),
        "amr": session.amr,
        "acr": session.acr,
    })
}

// ---------------------------------------------------------------------------
// Step-up
// ---------------------------------------------------------------------------

/// A step-up challenge bound to one session.
///
/// The WebAuthn ceremony (when one was offered) lives inside this record,
/// not under a key of its own: the one atomic `take` that consumes the
/// step-up consumes its ceremony with it, so the ceremony can only ever
/// answer the exact challenge, subject and session it was opened for, and
/// exactly once.
#[derive(Serialize, Deserialize)]
struct PendingStepUp {
    challenge: String,
    subject: String,
    expires_at: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    webauthn: Option<webauthn_rs::prelude::PasskeyAuthentication>,
}

fn step_up_key(session_id: &str) -> String {
    format!("stepup-task:{session_id}")
}

/// `auth/revoke-session/0.2`: end one of the caller's own sessions. This is
/// the console's logout.
///
/// The session row goes, and with it its refresh token and any session key
/// bound at login. The bearer extractor reads that row on every request, so
/// the access token and the key stop working at once, not at expiry
/// (`auth/authenticate/0.2`: the binding ends on revoke or logout).
///
/// A session key may sign this, since revoking only reduces authority, but
/// only for its own session: it speaks for its subject in that session and no
/// other (`auth/authenticate/0.2` Conformance item 10), so a stolen key cannot
/// sign its subject out of their other devices.
///
/// A session that does not exist, was already revoked, belongs to someone
/// else, or is out of reach of the session key that signed, answers
/// `revokedCount: 0`, identically. That discloses nothing, and a retried
/// logout succeeds (`auth/revoke-session/0.2` Conformance item 2, the
/// RECOMMENDED form). `all` and `subject` are not offered: the console only
/// ever ends the session it holds.
pub(crate) async fn revoke_session(
    cx: &Cx<'_>,
    p: revoke_session::v0_2::Payload,
) -> Result<revoke_session::v0_2::Response, TaskError> {
    let caller = cx.caller()?.to_string();
    if p.all.is_some() || p.subject.is_some() {
        return Err(AppError::Validation(
            "this relying party revokes one session, named by sessionId".into(),
        )
        .into());
    }
    let session_id = p
        .session_id
        .as_ref()
        .map(|s| s.to_string())
        .ok_or_else(|| AppError::Validation("sessionId is required".into()))?;
    let revoked = |count: u32| typed(json!({ "revokedCount": count }), "revoke-session response");

    // Signed by the bearer session's bound key: that session only.
    let by_session_key_elsewhere = cx.bearer_session().is_some_and(|b| {
        b.session_id != session_id
            && b.session_pubkey_b58btc.as_deref().is_some_and(|pk| {
                cx.proof_vm.as_deref() == Some(format!("did:key:{pk}#{pk}").as_str())
            })
    });
    if by_session_key_elsewhere {
        return revoked(0);
    }
    let Some(session) = get_session(&cx.state.sessions_ks, &session_id).await? else {
        return revoked(0);
    };
    if session.did != caller {
        return revoked(0);
    }
    delete_session(&cx.state.sessions_ks, &session_id).await?;
    // The row a proxied `auth/authenticate/0.3` login left (its `actor` and
    // absolute-lifetime cap) outlives neither the session nor its refresh
    // token: a direct 0.2 login never wrote one, so this is a no-op there.
    crate::trust_tasks_auth::delete_auth_proxy_meta(cx.state, &session_id).await?;
    info!(
        did = %caller,
        session_id = %session_id,
        reason = p.reason.as_deref().map(String::as_str).unwrap_or(""),
        "session revoked",
    );
    revoked(1)
}

/// `auth/step-up/start/0.1`: bind a fresh challenge to the caller's own
/// session and answer the signed `approve-request/0.3` the approver will
/// answer. Elevates nothing: only a verified `approve-response` does.
///
/// The session must be live and belong to the proven caller, or the proof must
/// have been made with the session's recorded key; the three failing cases
/// (no such session, someone else's, not live) answer alike.
pub(crate) async fn step_up_start(
    cx: &Cx<'_>,
    p: step_up_start::v0_1::Payload,
) -> Result<step_up_start::v0_1::Response, TaskError> {
    use step_up_start::v0_1::error_codes;

    let caller = cx.caller()?.to_string();
    let state = cx.state;
    let session_id = p.session_id.to_string();
    let unknown = || {
        TaskError::Declared(
            error_codes::SESSION_UNKNOWN,
            "no live session of yours has that id".into(),
        )
    };
    let session = get_session(&state.sessions_ks, &session_id)
        .await?
        .ok_or_else(unknown)?;
    let key_matches = session
        .session_pubkey_b58btc
        .as_deref()
        .is_some_and(|pk| cx.proof_vm.as_deref() == Some(format!("did:key:{pk}#{pk}").as_str()));
    if session.state != SessionState::Authenticated || (session.did != caller && !key_matches) {
        return Err(unknown());
    }
    let target = p
        .target_acr
        .as_ref()
        .map(|t| t.to_string())
        .unwrap_or_else(|| STEP_UP_ACR.to_string());
    if target != STEP_UP_ACR && target != "aal1" {
        return Err(AppError::Validation(format!(
            "this relying party offers step-up to {STEP_UP_ACR} only"
        ))
        .into());
    }
    if session.acr == STEP_UP_ACR || target == "aal1" {
        return Err(TaskError::Declared(
            error_codes::NOT_NEEDED,
            "the session is already at the requested assurance level".into(),
        ));
    }

    let expires_at = now_epoch() + CEREMONY_TTL_SECS;

    // Offer a WebAuthn gate when the session's subject holds a step-up
    // passkey. Its own challenge — not an independently-generated nonce —
    // becomes the step-up's `challenge`, so the two are the same value: the
    // spec requires the assertion's `clientDataJSON` challenge to equal
    // `payload.challenge`, and webauthn-rs, not this service, is what
    // chooses a passkey ceremony's challenge.
    let step_up_ks = state.store.keyspace(KS_PASSKEY_STEP_UP)?;
    let step_up_creds = passkey_store::get_passkey_user_by_did(&step_up_ks, &session.did)
        .await?
        .map(|u| u.credentials)
        .unwrap_or_default();
    let mut gate = match (&state.webauthn, step_up_creds.is_empty()) {
        (Some(webauthn), false) => Some(
            webauthn
                .start_passkey_authentication(&step_up_creds)
                .map_err(|e| AppError::Internal(format!("webauthn step-up start failed: {e}")))?,
        ),
        _ => None,
    };
    let (challenge, acceptable_evidence, webauthn_options) = match &gate {
        Some((rcr, _)) => {
            let options = request_options(rcr)?;
            let challenge = options
                .get("challenge")
                .and_then(Value::as_str)
                .ok_or_else(|| AppError::Internal("webauthn options missing challenge".into()))?
                .to_string();
            // Once the subject holds a step-up passkey, only a webauthn
            // assertion from it may satisfy step-up — `didSigned` (a
            // self- or wallet/VTA-signed decision made without touching
            // the passkey) is no longer offered.
            (challenge, json!(["webauthn"]), Some(options))
        }
        None => (random_hex(32), json!(["didSigned"]), None),
    };

    let my_vid = state
        .config
        .server_did
        .clone()
        .ok_or_else(|| AppError::Config("server_did not configured".into()))?;
    let secret = crate::signing::control_signing_secret(state, &my_vid)?;
    let mut payload = json!({
        "subject": session.did,
        "sessionId": session_id,
        "challenge": challenge,
        "reason": "Elevate this session to aal2",
        "targetAcr": STEP_UP_ACR,
        "acceptableEvidence": acceptable_evidence,
    });
    if let Some(options) = webauthn_options {
        payload["webauthn"] = options;
    }
    let request: trust_tasks_rs::TrustTask<Value> = serde_json::from_value(json!({
        "id": new_id(),
        "type": <trust_tasks_rs::specs::auth::step_up::approve_request::v0_3::Payload as trust_tasks_rs::Payload>::TYPE_URI,
        "issuer": my_vid,
        "recipient": session.did,
        "issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
        "expiresAt": at(expires_at).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
        "payload": payload,
    }))?;
    // The approve-request is the approver's to verify on its own terms before
    // surfacing its reason, so it is checked here against its own schema too.
    typed::<trust_tasks_rs::specs::auth::step_up::approve_request::v0_3::Payload>(
        request.payload.clone(),
        "approve-request payload",
    )?;
    let signed = did_hosting_common::server::trust_tasks::sign_document(&request, &secret)
        .await
        .map_err(|e| AppError::Internal(e.to_string()))?;

    // Bound only once the request is signed, so a signing failure leaves no
    // orphaned challenge.
    let offered_webauthn = gate.is_some();
    state
        .sessions_ks
        .insert(
            step_up_key(&session_id),
            &PendingStepUp {
                challenge,
                subject: session.did.clone(),
                expires_at,
                webauthn: gate.take().map(|(_, auth_state)| auth_state),
            },
        )
        .await?;
    info!(did = %session.did, session_id = %session_id, webauthn = offered_webauthn, "step-up started");
    typed(
        json!({ "approveRequest": serde_json::to_value(&signed)? }),
        "step-up start response",
    )
}

/// `auth/step-up/approve-response/0.5`: the approver's signed decision. The
/// gate has verified it as an attestation (`assertionMethod`); here it must
/// answer the challenge bound to its session, be made by that session's
/// subject, and — approved — it raises the session's assurance level. Tokens
/// carrying the new level come from `auth/refresh`, which reads the session's
/// current level.
///
/// `evidence.kind: webauthn` additionally requires a user-verified assertion
/// from the subject's `KS_PASSKEY_STEP_UP` credentials, over the WebAuthn
/// ceremony `step_up_start` opened for exactly this challenge — a login
/// passkey can't answer it (it's a different store) and a step-up passkey
/// can't sign anyone in (this is the only place that verifies one).
///
/// **Once the subject holds any step-up passkey, that is the only route.** A
/// `didSigned` decision (no evidence, or `evidence.kind: didSigned` — a
/// self- or wallet/VTA-signed approval) is refused with `noGate` for a
/// subject with at least one `KS_PASSKEY_STEP_UP` credential, checked fresh
/// here rather than against what `step_up_start` advertised, so enrolling a
/// passkey between start and this decision still closes the route. A
/// subject with none keeps `didSigned`.
pub(crate) async fn approve_response(
    cx: &Cx<'_>,
    p: approve_response::v0_5::Payload,
) -> Result<approve_response::v0_5::Response, TaskError> {
    use approve_response::v0_5::{Evidence, PayloadDecision, error_codes};

    let approver = cx.caller()?.to_string();
    let state = cx.state;
    let session_id = p
        .session_id
        .as_ref()
        .map(|s| s.to_string())
        .ok_or_else(|| {
            TaskError::Declared(
                error_codes::CHALLENGE_UNKNOWN,
                "this relying party binds every step-up to a session".into(),
            )
        })?;
    let unknown = || {
        TaskError::Declared(
            error_codes::CHALLENGE_UNKNOWN,
            "no pending step-up has that challenge".into(),
        )
    };
    // A wrong challenge is refused before anything is consumed, so it can't
    // be used to cancel someone's pending step-up.
    let peeked: PendingStepUp = state
        .sessions_ks
        .get(step_up_key(&session_id))
        .await?
        .ok_or_else(unknown)?;
    if !constant_time_eq(peeked.challenge.as_bytes(), p.challenge.as_bytes()) {
        return Err(unknown());
    }
    // Single use: claimed with one atomic `take` — whatever the decision,
    // and with its WebAuthn ceremony inside it. Of two racing responses
    // only one gets it back; and the challenge is re-checked on what was
    // taken, since a fresh `step_up_start` may have replaced the record
    // between the peek and the take.
    let pending: PendingStepUp = state
        .sessions_ks
        .take(step_up_key(&session_id))
        .await?
        .ok_or_else(unknown)?;
    if !constant_time_eq(pending.challenge.as_bytes(), p.challenge.as_bytes()) {
        return Err(unknown());
    }
    if now_epoch() > pending.expires_at {
        return Err(TaskError::Declared(
            error_codes::CHALLENGE_EXPIRED,
            "the step-up challenge has expired".into(),
        ));
    }
    if p.subject.as_str() != pending.subject {
        return Err(TaskError::Declared(
            error_codes::SUBJECT_MISMATCH,
            "the response names a different subject".into(),
        ));
    }
    // Self step-up: the approver is the subject. No delegate is recognised.
    if approver != pending.subject {
        return Err(TaskError::Declared(
            error_codes::APPROVER_UNAUTHORIZED,
            "only the session's subject may approve its step-up".into(),
        ));
    }
    // The framework proof already established the approver's DID (verified
    // as `assertionMethod` before this handler ran) — a self step-up, so
    // that's the session's own subject, as checked above. `webauthn`
    // evidence is an *additional* factor on top of that proof, not a
    // replacement for it: the browser signs with the did:key its session is
    // already bound to (the same key every other request in the session
    // uses), and the human-facing action is touching the step-up passkey.
    // Looked up once, fresh, and reused by both the webauthn-evidence branch
    // below and the passkey-only gate after it — decided at the subject's
    // *current* enrolment, not at what `step_up_start` offered, since a
    // step-up passkey can land between start and this decision.
    let step_up_ks = state.store.keyspace(KS_PASSKEY_STEP_UP)?;
    if let Some(Evidence::Webauthn(assertion)) = &p.evidence {
        let webauthn_state = pending.webauthn.as_ref().ok_or_else(|| {
            TaskError::Declared(
                error_codes::NO_GATE,
                "this step-up was not offered a passkey gate".into(),
            )
        })?;
        let webauthn = state.webauthn.as_deref().ok_or_else(|| {
            TaskError::Declared(
                error_codes::NO_GATE,
                "passkeys are not configured on this service".into(),
            )
        })?;
        let credential: webauthn_rs::prelude::PublicKeyCredential =
            serde_json::from_value(serde_json::to_value(assertion)?).map_err(|e| {
                TaskError::Declared(
                    error_codes::ASSERTION_INVALID,
                    format!("unreadable assertion: {e}"),
                )
            })?;
        let result = webauthn
            .finish_passkey_authentication(&credential, webauthn_state)
            .map_err(|e| {
                warn!(error = %e, "step-up passkey assertion failed");
                TaskError::Declared(
                    error_codes::ASSERTION_INVALID,
                    "the assertion did not verify".into(),
                )
            })?;
        if !result.user_verified() {
            return Err(TaskError::Declared(
                error_codes::ASSERTION_INVALID,
                "step-up requires a user-verified passkey assertion".into(),
            ));
        }
        // Defence in depth: the assertion must be from one of the pending
        // step-up's own subject's step-up-only credentials — which is
        // already everything `webauthn_state` could answer, since it was
        // built from exactly that subject's `KS_PASSKEY_STEP_UP` passkeys,
        // never the login store.
        let _guard = super::passkey::credentials_guard(
            state,
            did_hosting_common::server::passkey::invite::Purpose::StepUp,
            &pending.subject,
        )
        .await;
        let cred_id = cred_id_hex(result.cred_id());
        let mut step_up_user = passkey_store::get_passkey_user_by_cred(&step_up_ks, &cred_id)
            .await?
            .filter(|u| u.did == pending.subject)
            .ok_or_else(|| {
                TaskError::Declared(
                    error_codes::ASSERTION_INVALID,
                    "the assertion is not from the subject's step-up passkey".into(),
                )
            })?;
        for c in &mut step_up_user.credentials {
            c.update_credential(&result);
        }
        passkey_store::store_passkey_user(&step_up_ks, &step_up_user).await?;
    }

    match p.decision {
        PayloadDecision::Denied => {
            info!(did = %approver, session_id = %session_id, "step-up denied by the approver");
            return typed(json!({ "status": "recorded" }), "approve-response response");
        }
        PayloadDecision::Approved => {}
        _ => return Err(AppError::Validation("unsupported decision".into()).into()),
    }
    // Once this subject holds a step-up passkey, only a webauthn assertion
    // from it may grant step-up: a wallet/VTA-signed `didSigned` decision
    // (or one carrying no evidence at all) no longer counts, however
    // properly it is signed. Evaluated fresh, at this decision, not against
    // what `step_up_start` offered.
    if !matches!(p.evidence, Some(Evidence::Webauthn(_)))
        && passkey_store::get_passkey_user_by_did(&step_up_ks, &pending.subject)
            .await?
            .is_some_and(|u| !u.credentials.is_empty())
    {
        return Err(TaskError::Declared(
            error_codes::NO_GATE,
            "the subject has a step-up passkey; only a webauthn assertion from it may grant step-up"
                .into(),
        ));
    }
    if let Some(granted) = p.granted_acr.as_deref()
        && granted != STEP_UP_ACR
    {
        return Err(TaskError::Declared(
            error_codes::ACR_UNSATISFIED,
            format!("the approver granted {granted}; the step-up requires {STEP_UP_ACR}"),
        ));
    }

    let mut session = get_session(&state.sessions_ks, &session_id)
        .await?
        .filter(|s| s.state == SessionState::Authenticated && s.did == pending.subject)
        .ok_or_else(|| {
            TaskError::Declared(
                error_codes::CHALLENGE_UNKNOWN,
                "the session has ended".into(),
            )
        })?;
    // The ACL still gates: elevation does not outlive an ACL entry.
    check_acl(&state.acl_ks, &session.did).await?;
    let factor = if matches!(p.evidence, Some(Evidence::Webauthn(_))) {
        "passkey"
    } else {
        "did"
    };
    if !session.amr.iter().any(|m| m == factor) {
        session.amr.push(factor.into());
    }
    session.acr = STEP_UP_ACR.to_string();
    session.acr_expires_at = None;
    store_session(&state.sessions_ks, &session).await?;
    info!(did = %session.did, session_id = %session_id, "step-up approved: session elevated to aal2");
    let expires_at = session.refresh_expires_at.unwrap_or(session.created_at);
    typed(
        json!({ "status": "elevated", "session": wire_session(&session, expires_at) }),
        "approve-response response",
    )
}

// ---------------------------------------------------------------------------
// Passkey login
// ---------------------------------------------------------------------------

/// What a passkey ceremony was opened for.
#[derive(Serialize, Deserialize)]
struct CeremonyMeta {
    step_up: bool,
    subject: Option<String>,
    expires_at: u64,
}

fn ceremony_key(auth_id: &str) -> String {
    format!("pk_task_meta:{auth_id}")
}

fn require_webauthn(state: &AppState) -> Result<&webauthn_rs::Webauthn, TaskError> {
    state.webauthn.as_deref().ok_or_else(|| {
        TaskError::Standard(
            trust_tasks_rs::StandardCode::Unavailable,
            "passkeys are not configured on this service".into(),
        )
    })
}

/// The members of a WebAuthn request-options object the shared schema knows.
/// The library adds hints the schema does not (and the platform API does not
/// need); they are dropped rather than sent off-schema.
fn request_options(
    options: &webauthn_rs::prelude::RequestChallengeResponse,
) -> Result<Value, TaskError> {
    let value = serde_json::to_value(options)?;
    let public_key = value.get("publicKey").cloned().unwrap_or(value);
    let mut out = serde_json::Map::new();
    for k in [
        "challenge",
        "timeout",
        "rpId",
        "allowCredentials",
        "userVerification",
        "extensions",
    ] {
        if let Some(v) = public_key.get(k).filter(|v| !v.is_null()) {
            out.insert(k.into(), v.clone());
        }
    }
    Ok(Value::Object(out))
}

/// `auth/passkey/login/start/0.2`: open a login (or, `purpose: stepUp`, a
/// step-up) ceremony. Accepted unsigned — the assertion at finish is the gate.
pub(crate) async fn login_start(
    cx: &Cx<'_>,
    p: login_start::v0_2::Payload,
) -> Result<login_start::v0_2::Response, TaskError> {
    use login_start::v0_2::{PayloadPurpose, error_codes};

    let state = cx.state;
    let webauthn = require_webauthn(state)?;
    let step_up = matches!(p.purpose, Some(PayloadPurpose::StepUp));
    let subject = p.subject.as_ref().map(|s| s.to_string());
    let passkeys = match subject.as_deref() {
        Some(did) => {
            let user = passkey_store::get_passkey_user_by_did(&state.sessions_ks, did)
                .await?
                .ok_or_else(|| {
                    TaskError::Declared(
                        error_codes::SUBJECT_NOT_RECOGNIZED,
                        "unknown subject".into(),
                    )
                })?;
            if user.credentials.is_empty() {
                return Err(TaskError::Declared(
                    error_codes::NO_CREDENTIALS,
                    "the subject has no passkey".into(),
                ));
            }
            user.credentials
        }
        None => {
            let all = passkey_store::get_all_passkeys(&state.sessions_ks).await?;
            if all.is_empty() {
                return Err(TaskError::Declared(
                    error_codes::NO_CREDENTIALS,
                    "no passkeys are registered here".into(),
                ));
            }
            all
        }
    };
    let (options, auth_state) = webauthn
        .start_passkey_authentication(&passkeys)
        .map_err(|e| AppError::Internal(format!("webauthn start failed: {e}")))?;
    let auth_id = uuid::Uuid::new_v4().to_string();
    passkey_store::store_auth_state(&state.sessions_ks, &auth_id, &auth_state).await?;
    state
        .sessions_ks
        .insert(
            ceremony_key(&auth_id),
            &CeremonyMeta {
                step_up,
                subject,
                expires_at: now_epoch() + CEREMONY_TTL_SECS,
            },
        )
        .await?;
    typed(
        json!({ "authId": auth_id, "options": request_options(&options)? }),
        "login start response",
    )
}

/// `auth/passkey/login/finish/0.2`: verify the assertion.
///
/// A login mints a session for the credential's subject, bound to the
/// `did:key` that signed this document — the session key every later request
/// in the session is signed with. A step-up elevates the bearer session the
/// request carries, which must belong to the credential's subject, and
/// issues no tokens (`auth/refresh` reads the new level).
pub(crate) async fn login_finish(
    cx: &Cx<'_>,
    p: login_finish::v0_2::Payload,
) -> Result<login_finish::v0_2::Response, TaskError> {
    use login_finish::v0_2::error_codes;

    let state = cx.state;
    let webauthn = require_webauthn(state)?;
    let auth_id = p.auth_id.to_string();
    let not_found = || {
        TaskError::Declared(
            error_codes::AUTH_NOT_FOUND,
            "no open ceremony has that id".into(),
        )
    };
    let meta: CeremonyMeta = state
        .sessions_ks
        .take(ceremony_key(&auth_id))
        .await?
        .ok_or_else(not_found)?;
    let auth_state = passkey_store::take_auth_state(&state.sessions_ks, &auth_id)
        .await?
        .ok_or_else(not_found)?;
    if now_epoch() > meta.expires_at {
        return Err(TaskError::Declared(
            error_codes::AUTH_EXPIRED,
            "the ceremony has expired".into(),
        ));
    }
    let credential: webauthn_rs::prelude::PublicKeyCredential =
        serde_json::from_value(serde_json::to_value(&p.credential)?).map_err(|e| {
            TaskError::Declared(
                error_codes::ASSERTION_INVALID,
                format!("unreadable assertion: {e}"),
            )
        })?;
    let result = webauthn
        .finish_passkey_authentication(&credential, &auth_state)
        .map_err(|e| {
            warn!(error = %e, "passkey assertion failed");
            TaskError::Declared(
                error_codes::ASSERTION_INVALID,
                "the assertion did not verify".into(),
            )
        })?;
    let cred_id: String = AsRef::<[u8]>::as_ref(result.cred_id())
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect();
    let mut user = passkey_store::get_passkey_user_by_cred(&state.sessions_ks, &cred_id)
        .await?
        .ok_or_else(|| {
            TaskError::Declared(error_codes::CREDENTIAL_UNKNOWN, "unknown credential".into())
        })?;
    if meta.subject.as_deref().is_some_and(|s| s != user.did) {
        return Err(TaskError::Declared(
            error_codes::ASSERTION_INVALID,
            "the credential belongs to another subject".into(),
        ));
    }
    for c in &mut user.credentials {
        c.update_credential(&result);
    }
    passkey_store::store_passkey_user(&state.sessions_ks, &user).await?;
    let role = check_acl(&state.acl_ks, &user.did).await?;

    if meta.step_up {
        let caller = cx.caller()?;
        let bearer = cx
            .bearer_session()
            .filter(|b| b.did == caller && b.did == user.did)
            .ok_or_else(|| {
                TaskError::Declared(
                    error_codes::STEP_UP_SESSION_NOT_FOUND,
                    "a step-up needs the subject's own session".into(),
                )
            })?;
        let mut session = get_session(&state.sessions_ks, &bearer.session_id)
            .await?
            .filter(|s| s.state == SessionState::Authenticated)
            .ok_or_else(|| {
                TaskError::Declared(
                    error_codes::STEP_UP_SESSION_NOT_FOUND,
                    "no live session".into(),
                )
            })?;
        if !session.amr.iter().any(|a| a == "passkey") {
            session.amr.push("passkey".into());
        }
        session.acr = STEP_UP_ACR.to_string();
        session.acr_expires_at = None;
        store_session(&state.sessions_ks, &session).await?;
        info!(did = %user.did, "passkey step-up complete: session elevated to aal2");
        let expires_at = session.refresh_expires_at.unwrap_or(session.created_at);
        return typed(
            json!({ "purpose": "stepUp", "session": wire_session(&session, expires_at) }),
            "login finish response",
        );
    }

    // A login: the session is bound to the key that signed this document.
    let session_key = cx
        .caller()?
        .strip_prefix("did:key:")
        .filter(|pk| pk.starts_with("z6Mk"))
        .map(str::to_string)
        .ok_or_else(|| {
            TaskError::Standard(
                trust_tasks_rs::StandardCode::PermissionDenied,
                "a login is signed by the Ed25519 did:key its session will be bound to".into(),
            )
        })?;
    let jwt_keys = state
        .jwt_keys
        .as_deref()
        .ok_or_else(|| AppError::Config("auth not configured".into()))?;
    let tokens = create_authenticated_session(
        &state.sessions_ks,
        jwt_keys,
        &user.did,
        &role,
        state.config.auth.access_token_expiry,
        state.config.auth.refresh_token_expiry,
        Some(session_key),
        Some((vec!["passkey".to_string()], STEP_UP_ACR.to_string())),
    )
    .await?;
    info!(did = %user.did, "passkey login complete");
    let canonical = tokens.into_canonical();
    let mut tokens = serde_json::to_value(&canonical.tokens)?;
    if tokens
        .get("scope")
        .is_some_and(|s| s.as_array().is_some_and(Vec::is_empty))
    {
        tokens.as_object_mut().map(|t| t.remove("scope"));
    }
    typed(
        json!({
            "purpose": "login",
            "session": serde_json::to_value(&canonical.session)?,
            "tokens": tokens,
        }),
        "login finish response",
    )
}