did-hosting-control 0.10.0

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
//! The one browser sign-in route that has no Trust Task form: the
//! challenge nonce (`auth/challenge/0.1` has a Trust Task form too, served
//! identically from `trust_tasks_auth`, but the console always reaches it
//! over plain REST before it holds any credential to sign a Trust Task
//! envelope with). See `routes/mod.rs` for why it alone stays plain HTTP —
//! authenticate and refresh both moved onto `auth/authenticate/0.3` and
//! `auth/refresh/0.2` over `/api/trust-tasks` (`trust_tasks_auth`).

use std::net::SocketAddr;

use axum::Json;
use axum::extract::{ConnectInfo, State};
use axum::http::HeaderMap;
use tracing::warn;

use did_hosting_common::{ChallengeRequest, ChallengeResponse};

use crate::auth::session::now_epoch;
use crate::error::AppError;
use crate::rate_limit::resolve_client_ip;
use crate::server::AppState;

/// Default maximum concurrent pending challenges per DID. Combined with the
/// global cap on the `pending_challenges` tracker on `AppState`, this
/// bounds the unauthenticated challenge-endpoint surface against both
/// per-DID floods and DID-sweep attacks. Shared with the
/// `auth/challenge/0.1` Trust Task handler, which applies the same caps.
///
/// This is the compiled-in default; the effective value is
/// `AuthConfig::max_pending_challenges_per_did`, which defaults to this and is
/// read per call by [`issue_challenge`]. The default and the config default
/// are pinned equal by `pending_challenges::tests::config_defaults_match_constants`.
pub(crate) const MAX_PENDING_CHALLENGES_PER_DID: usize = 10;

// Keep the constant referenced in non-test builds (it is otherwise used only
// from the config-default pin test and the TSP suite) and pin its value, so a
// change is deliberate and travels with the config default above.
const _: () = assert!(MAX_PENDING_CHALLENGES_PER_DID == 10);

/// POST /api/auth/challenge — request a challenge nonce.
///
/// Two layers of rate-limit defence:
/// 1. Per-IP fixed-window counter (`IpRateLimiter`) — caps requests
///    from any single IP regardless of which DID they're issuing
///    challenges for. Trusted-proxy XFF resolution per
///    `server.trusted_proxies` config.
/// 2. Per-DID + global cap on live challenges (`PendingChallengeTracker`).
///    A slot frees when its challenge authenticates or its
///    `challenge_ttl` runs out. At the per-DID cap the oldest bound challenge
///    is evicted to make room, once the new one is minted; a refusal's
///    `Retry-After` is the moment the oldest counted challenge expires.
///
/// Replaced an earlier O(N) `prefix_iter_raw("session:")` scan with
/// the O(1) in-memory tracker (review SM3).
pub async fn challenge(
    State(state): State<AppState>,
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Json(req): Json<ChallengeRequest>,
) -> Result<Json<ChallengeResponse>, AppError> {
    // Input validation (route-layer concern).
    if req.did.len() > 512 {
        return Err(AppError::Validation("DID exceeds maximum length".into()));
    }

    // IP rate limit (defence in depth before any session-storage I/O).
    let xff = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok());
    let client_ip = resolve_client_ip(addr.ip(), xff, &state.config.server.trusted_proxies);
    state
        .ip_rate_limiter
        .try_consume(client_ip, now_epoch())
        .inspect_err(|e| {
            warn!(ip = %client_ip, error = %e, "challenge IP rate limited");
        })?;

    let canonical = issue_challenge(&state, req.did).await?;
    // did-hosting's ChallengeResponse drops the canonical
    // `teeAttestation` field — did-hosting doesn't run in a TEE.
    Ok(Json(ChallengeResponse {
        challenge: canonical.challenge,
        session_id: canonical.session_id,
        expires_at: canonical.expires_at,
    }))
}

/// Reserve a pending-challenge slot for `did`, run the canonical challenge
/// handler, and bind the slot to the session it minted — the one issuance path
/// for every binding (REST here, DIDComm/TSP/HTTPS-trust-task in
/// `trust_tasks_auth`).
///
/// The canonical handler's own per-DID limit is disabled in this backend; the
/// tracker is the single source of truth. The slot is taken whether or not the
/// DID holds an ACL entry: the canonical handler answers an unenrolled subject
/// without persisting anything, and counting only enrolled subjects would turn
/// the cap into an enumeration oracle (VTI-SES-007).
///
/// The per-DID cap is `AuthConfig::max_pending_challenges_per_did`
/// (default [`MAX_PENDING_CHALLENGES_PER_DID`]); the global cap is baked into
/// the tracker at construction from `max_global_pending_challenges`.
pub(crate) async fn issue_challenge(
    state: &AppState,
    did: String,
) -> Result<vta_sdk::protocols::auth::ChallengeResponse, AppError> {
    let per_did_cap = state.config.auth.max_pending_challenges_per_did;
    let reservation = state
        .pending_challenges
        .try_issue(&did, per_did_cap)
        .inspect_err(|e| {
            warn!(did = %did, error = %e, "challenge rate limited");
        })?;

    let minted = match crate::auth::DidHostingControlAuthBackend::from_state(state) {
        Ok(backend) => {
            vti_common::auth::handlers::handle_challenge(
                &backend,
                vti_common::auth::ChallengeInput {
                    did: did.clone(),
                    session_pubkey_b58btc: None,
                },
            )
            .await
        }
        Err(e) => Err(e),
    };
    let resp = match minted {
        Ok(resp) => resp,
        Err(e) => {
            // Nothing was issued, so nothing is evicted either.
            state.pending_challenges.cancel(reservation);
            return Err(e);
        }
    };

    let eviction = state.pending_challenges.bind(reservation, &resp.session_id);
    if let Err(e) = claim_evicted(state, eviction).await {
        // A victim's row could not be claimed, so it is still counted, and
        // handing out the new challenge as well would put the DID over its
        // cap. Take the new one back; if even that fails it stays counted —
        // over the cap until it expires, but never redeemable uncounted.
        warn!(did = %did, error = %e, "could not claim an evicted challenge");
        match did_hosting_common::server::auth::session::take_session(
            &state.sessions_ks,
            &resp.session_id,
        )
        .await
        {
            Ok(_) => {
                state.pending_challenges.release_session(&resp.session_id);
            }
            Err(e) => {
                warn!(did = %did, error = %e, "could not withdraw the new challenge");
            }
        }
        return Err(e);
    }
    Ok(resp)
}

/// Claim the `ChallengeSent` row of each challenge evicted for a new one,
/// then drop it from the tracker. The claim is the same atomic take the
/// authenticate handler consumes a challenge with, so a victim racing its own
/// authentication is either claimed here (and its authentication refused) or
/// authenticated (and the claim finds nothing) — never both. On a failed claim
/// that victim and every unclaimed one stay counted: a row the tracker stops
/// counting must be one that can no longer be redeemed (VTI-SES-005).
async fn claim_evicted(
    state: &AppState,
    eviction: crate::pending_challenges::Eviction,
) -> Result<(), AppError> {
    let mut victims = eviction.into_victims().into_iter();
    while let Some(victim) = victims.next() {
        match did_hosting_common::server::auth::session::take_session(
            &state.sessions_ks,
            victim.session_id(),
        )
        .await
        {
            Ok(_) => state.pending_challenges.complete_eviction(&victim),
            Err(e) => {
                state.pending_challenges.restore_eviction(&victim);
                for rest in victims {
                    state.pending_challenges.restore_eviction(&rest);
                }
                return Err(e);
            }
        }
    }
    Ok(())
}

/// Refuse a renewal for a session that has gone quiet longer than
/// `auth.admin_idle_timeout`. Shared by `trust_tasks_auth`'s
/// `auth/refresh/0.2` arm — the only surviving refresh path since the REST
/// `/api/auth/refresh` route was retired.
///
/// Checked separately from — and before — the shared `handle_refresh`, so
/// the policy is ours and needs no vti-common release to change, and so an
/// idled-out session is refused before anything is spent: the handler's
/// first act is to claim-and-delete the refresh-token index, and refusing
/// afterwards would burn the caller's token on the way to telling them no.
///
/// A session whose `last_seen` predates the field (`0`) falls back to
/// `created_at`, so rows written before idle tracking existed are judged
/// from when they began rather than being refused outright.
pub(crate) fn refuse_if_idle_session(
    session: &did_hosting_common::server::auth::session::Session,
    idle_ttl: u64,
) -> Result<(), AppError> {
    use did_hosting_common::server::auth::session::now_epoch;

    let last_activity = if session.last_seen == 0 {
        session.created_at
    } else {
        session.last_seen
    };
    let idle_for = now_epoch().saturating_sub(last_activity);
    if idle_for > idle_ttl {
        warn!(
            session_id = %session.session_id,
            did = %session.did,
            idle_for,
            idle_ttl,
            "refresh rejected: session idle past the timeout",
        );
        return Err(AppError::Authentication(
            "session signed out after the configured period of inactivity".into(),
        ));
    }
    Ok(())
}

/// Bind a refresh to the device that logged in.
///
/// A refresh token proves nothing about the party presenting it beyond
/// possession, so without a check here a stolen token alone would authorise
/// a rotation from anywhere.
///
/// The binding reuses what the console already has: the login flow
/// generates an ephemeral Ed25519 keypair, sends its public multikey as
/// `session_pubkey_b58btc`, and the server stores it on the session row.
/// Requiring a Data Integrity proof from that key (SPEC `auth/refresh/0.2`
/// item 3: "the located session carries a bound `sessionKey`... MUST
/// require that `proof` be present and its `verificationMethod` resolve to
/// exactly that `sessionKey`") means a stolen refresh token is not enough on
/// its own — the attacker also needs a key that never left the browser that
/// logged in.
///
/// **Sessions with no bound key are unchanged.** Wallet and
/// machine-to-machine sessions sign with their own DID's verification
/// methods and never supplied a session pubkey; demanding a proof from them
/// would break a path this change does not otherwise touch. That arm is not
/// a downgrade an attacker can choose: whether a session has a bound key is
/// a property of the stored row, not of the request.
///
/// Mirrors the case (a) / case (b) split in
/// `routes::trust_tasks::dispatch_trust_task`, where the same binding is
/// enforced for every other document the console signs.
pub(crate) async fn verify_session_bound_proof(
    doc: &trust_tasks_rs::TrustTask<serde_json::Value>,
    session: &did_hosting_common::server::auth::session::Session,
) -> Result<(), AppError> {
    use affinidi_data_integrity::DidKeyResolver;
    use did_hosting_common::server::trust_tasks::TransportBoundVerifier;
    use trust_tasks_rs::ProofVerifier;

    let Some(pk) = session.session_pubkey_b58btc.as_deref() else {
        return Ok(());
    };

    let Some(proof) = doc.proof.as_ref() else {
        return Err(AppError::Authentication(
            "refresh document must carry a proof from this session's key".into(),
        ));
    };

    // Check the binding before verifying the signature, so a proof signed by
    // some other resolvable key is refused for the reason that actually
    // applies rather than verifying cleanly and being wrong.
    let expected_vm = format!("did:key:{pk}#{pk}");
    if proof.verification_method != expected_vm {
        warn!(
            session_id = %session.session_id,
            actual_vm = %proof.verification_method,
            "refresh proof verificationMethod is not this session's key",
        );
        return Err(AppError::Authentication(
            "refresh proof is not bound to this session".into(),
        ));
    }

    // `did:key` resolves locally, so this needs no DID cache and no DIDComm
    // configuration — which matters, because the point of this dialect is to
    // serve a client that has neither.
    TransportBoundVerifier::with_resolver(std::sync::Arc::new(DidKeyResolver))
        .verify(doc)
        .await
        .map_err(|e| {
            warn!(
                session_id = %session.session_id,
                error = %e,
                "refresh proof failed verification",
            );
            AppError::Authentication("refresh proof failed verification".into())
        })
}

#[cfg(test)]
mod tests {
    use super::*;

    use serde_json::json;

    use did_hosting_common::server::auth::session::{Session, SessionState};

    /// A stored session, optionally carrying a bound session key.
    fn session_with(pubkey: Option<&str>, last_seen: u64) -> Session {
        Session {
            session_id: "sess-refresh".into(),
            did: "did:example:alice".into(),
            challenge: String::new(),
            state: SessionState::Authenticated,
            created_at: last_seen,
            last_seen,
            refresh_token: Some("tok".into()),
            refresh_expires_at: Some(u64::MAX),
            tee_attested: false,
            amr: vec!["passkey".into()],
            acr: "aal1".into(),
            acr_expires_at: None,
            not_after: None,
            token_id: Some("jti".into()),
            session_pubkey_b58btc: pubkey.map(str::to_string),
        }
    }

    fn refresh_doc(
        proof: Option<serde_json::Value>,
    ) -> trust_tasks_rs::TrustTask<serde_json::Value> {
        let mut v = json!({
            "type": "https://trusttasks.org/spec/auth/refresh/0.2",
            "id": "urn:uuid:11111111-1111-4111-8111-111111111111",
            "payload": { "refreshToken": "tok" },
        });
        if let Some(p) = proof {
            v.as_object_mut().unwrap().insert("proof".into(), p);
        }
        serde_json::from_value(v).expect("refresh document parses")
    }

    /// A session that registered a key must prove possession of it. Without
    /// this, a stolen refresh token alone would rotate the session.
    #[tokio::test]
    async fn a_bound_session_refresh_without_a_proof_is_refused() {
        let session = session_with(Some("z6MkExampleKeyMaterialNotReal"), 0);
        let err = verify_session_bound_proof(&refresh_doc(None), &session)
            .await
            .expect_err("a bound session must demand a proof");
        assert!(format!("{err}").contains("proof"), "{err}");
    }

    /// …and it must be *that* key, not merely a resolvable one.
    #[tokio::test]
    async fn a_proof_from_another_key_is_refused() {
        let (other_did, signer) = crate::signing::test_util::did_key_signer(&[7u8; 32]);
        let _ = signer;
        let session = session_with(Some("z6MkTheSessionsOwnKeyNotThatOne"), 0);
        let proof = json!({
            "type": "DataIntegrityProof",
            "cryptosuite": "eddsa-jcs-2022",
            "verificationMethod": format!("{other_did}#irrelevant"),
            "created": "2026-01-01T00:00:00Z",
            "proofPurpose": "assertionMethod",
            "proofValue": "z2fake",
        });
        let err = verify_session_bound_proof(&refresh_doc(Some(proof)), &session)
            .await
            .expect_err("a proof from a foreign key must be refused");
        assert!(
            format!("{err}").contains("not bound to this session"),
            "the refusal should name the binding, got: {err}"
        );
    }

    /// Wallet and machine-to-machine sessions never supplied a session key.
    /// They are unchanged — and this is not a downgrade an attacker can
    /// pick, because it is a property of the stored row, not of the request.
    #[tokio::test]
    async fn a_session_with_no_bound_key_needs_no_proof() {
        let session = session_with(None, 0);
        verify_session_bound_proof(&refresh_doc(None), &session)
            .await
            .expect("an unbound session keeps its previous behaviour");
    }

    #[test]
    fn an_idle_session_is_refused_and_a_busy_one_is_not() {
        let now = did_hosting_common::server::auth::session::now_epoch();
        let fresh = session_with(None, now - 60);
        refuse_if_idle_session(&fresh, 900).expect("60s idle is inside a 900s window");

        let stale = session_with(None, now - 1_200);
        let err =
            refuse_if_idle_session(&stale, 900).expect_err("1200s idle is outside a 900s window");
        assert!(format!("{err}").contains("inactivity"), "{err}");
    }
}