Skip to main content

didbot_serve/tree/
proof.rs

1//! Who a caller is, before anything is created for them.
2//!
3//! One verifier, three shapes ([`Proof`]): a session this server issued, a
4//! JWT signed by a key in the caller's credential records, and an OpenID
5//! Connect token (see this module's `oidc` sibling). `createAccount` and
6//! `createSession` both reach it through [`prove`], naming the account the
7//! credential is for ([`Named`]).
8
9use axum::http::{header, HeaderMap, StatusCode};
10use base64::prelude::*;
11use didbot_pds::side_store::SideStoreError;
12use didbot_pds::SpentStore;
13use serde_json::Value;
14use std::sync::Mutex;
15use time::{Duration, OffsetDateTime};
16use tracing::{info, warn};
17
18use crate::auth;
19use crate::error::ApiError;
20use crate::replay::{Spent, SpentKeys, MAX_JTI_BYTES, REPLAY_CAPACITY};
21use crate::routes::AppState;
22
23use super::oidc::prove_by_oidc;
24
25/// The furthest into the future a proof's `exp` may sit.
26///
27/// A proof is minted for one request and presented at once; ten minutes is
28/// clock skew and a slow network, not a credential to keep.
29pub const MAX_PROOF_LIFETIME: Duration = Duration::minutes(10);
30
31/// Why a proof could not be spent.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum NotSpent {
34    /// The window answered; see [`Spent`].
35    Window(Spent),
36    /// The window took it and the data directory would not.
37    ///
38    /// Refused rather than admitted, because admitting it is the whole
39    /// failure this window exists to stop: a proof accepted here and not
40    /// written down is one the next process has never heard of, and it is
41    /// still inside its own lifetime.
42    NotKept,
43}
44
45/// Proofs already spent, each until its own expiry.
46///
47/// Swept on every spend rather than on a schedule: a create is rare enough
48/// that walking the window costs nothing, and the window is then never
49/// holding an entry it has stopped needing.
50///
51/// A deployment with a data directory keeps the window there too, so the
52/// proof a caller spent a second before a restart is still spent after it.
53#[derive(Debug)]
54pub struct Replay {
55    seen: Mutex<SpentKeys>,
56    kept: Option<SpentStore>,
57}
58
59impl Default for Replay {
60    fn default() -> Self {
61        Self {
62            seen: Mutex::new(SpentKeys::new(REPLAY_CAPACITY, OffsetDateTime::now_utc())),
63            kept: None,
64        }
65    }
66}
67
68impl Replay {
69    /// A window kept in `store`, holding what it already had.
70    ///
71    /// # Errors
72    ///
73    /// [`SideStoreError`] for a kept file that is there and cannot be read.
74    /// It stops the boot: see [`didbot_pds::side_store`].
75    pub fn kept_in(store: SpentStore) -> Result<Self, SideStoreError> {
76        let now = OffsetDateTime::now_utc();
77        let mut seen = SpentKeys::new(REPLAY_CAPACITY, now);
78        let held = store.load(now)?;
79        let count = held.len();
80        for (key, until) in held {
81            let _ = seen.spend(key, until, now);
82        }
83        if count > 0 {
84            tracing::debug!(count, "proofs spent before the restart are still spent");
85        }
86        Ok(Self {
87            seen: Mutex::new(seen),
88            kept: Some(store),
89        })
90    }
91
92    /// Spends `key` until `until`.
93    pub fn spend(
94        &self,
95        key: String,
96        until: OffsetDateTime,
97        now: OffsetDateTime,
98    ) -> Result<(), NotSpent> {
99        let mut seen = self.seen.lock().unwrap_or_else(|p| p.into_inner());
100        seen.sweep(now);
101        seen.spend(key, until, now).map_err(NotSpent::Window)?;
102        if let Some(store) = &self.kept {
103            if let Err(error) = store.save(seen.held()) {
104                warn!(%error, "a proof could not be written down and was refused");
105                return Err(NotSpent::NotKept);
106            }
107        }
108        Ok(())
109    }
110}
111
112/// What a credential on the tree's routes resolved to.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum Proof {
115    /// No credential, on a development stack: the deployment itself.
116    Server,
117    /// A session this server issued, for the account it names.
118    Session(String),
119    /// A JWT signed by a key a hosted account's credential records name.
120    Key(String),
121    /// An OpenID Connect token matching an identity the account the request
122    /// named logs in with.
123    Oidc(String),
124}
125
126impl Proof {
127    /// The DID the proof authenticates as, or `None` for the deployment.
128    pub(super) fn did(&self) -> Option<&str> {
129        match self {
130            Proof::Server => None,
131            Proof::Session(did) | Proof::Key(did) | Proof::Oidc(did) => Some(did),
132        }
133    }
134}
135
136pub(super) fn invalid_proof(reason: impl Into<String>) -> ApiError {
137    ApiError::new(StatusCode::UNAUTHORIZED, "InvalidToken", reason.into())
138        .with_header(header::WWW_AUTHENTICATE, "Bearer")
139}
140
141/// The account a request says its credential is for.
142#[derive(Debug, Clone, Copy)]
143pub(crate) struct Named<'a> {
144    /// The DID the request names, if it names one.
145    pub(crate) did: Option<&'a str>,
146    /// The field that names it.
147    pub(crate) field: NamedBy,
148}
149
150/// Which field of a request names the account its credential is for.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub(crate) enum NamedBy {
153    /// `createSession`'s `did` query parameter.
154    Did,
155    /// `createAccount`'s `parent` input field.
156    Parent,
157}
158
159impl NamedBy {
160    /// The field as the lexicon spells it.
161    fn field(self) -> &'static str {
162        match self {
163            NamedBy::Did => "did",
164            NamedBy::Parent => "parent",
165        }
166    }
167
168    /// The refusal of an ID token sent with the field left out.
169    fn required(self) -> ApiError {
170        match self {
171            NamedBy::Did => ApiError::new(
172                StatusCode::BAD_REQUEST,
173                "DidRequired",
174                "an OpenID Connect ID token does not say which account it signs in as; send \
175                 the account's DID as `did`",
176            ),
177            NamedBy::Parent => ApiError::new(
178                StatusCode::BAD_REQUEST,
179                "ParentRequired",
180                "an OpenID Connect ID token does not say which account is creating; send that \
181                 account's DID as `parent`",
182            ),
183        }
184    }
185}
186
187/// A credential for one account, sent with a request naming another.
188pub(super) fn account_mismatch(message: String) -> ApiError {
189    ApiError::new(StatusCode::FORBIDDEN, "AccountMismatch", message)
190}
191
192/// Refuses a named account that is not a DID, before it reaches a
193/// comparison or a lookup.
194pub(super) fn check_named(named: Named<'_>) -> Result<(), ApiError> {
195    let Some(did) = named.did else {
196        return Ok(());
197    };
198    if did.len() > MAX_NAMED_DID_BYTES {
199        return Err(ApiError::bad_request(format!(
200            "`{}` is longer than {MAX_NAMED_DID_BYTES} bytes",
201            named.field.field()
202        )));
203    }
204    didbot_identity::validate_did(did).map_err(|error| {
205        ApiError::bad_request(format!("`{}` is not a DID: {error}", named.field.field()))
206    })
207}
208
209/// The longest `did` or `parent` the lexicons allow.
210const MAX_NAMED_DID_BYTES: usize = 512;
211
212/// `GET` of a small JSON body from a URL this server chose to reach.
213pub(super) async fn fetch_json<T: serde::de::DeserializeOwned>(
214    client: &reqwest::Client,
215    url: &str,
216) -> Result<T, ApiError> {
217    let response = client
218        .get(url)
219        .send()
220        .await
221        .and_then(reqwest::Response::error_for_status)
222        .map_err(|error| invalid_proof(format!("fetching {url}: {error}")))?;
223    let body = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY)
224        .await
225        .map_err(|error| invalid_proof(format!("reading {url}: {error}")))?;
226    serde_json::from_slice(&body).map_err(|error| invalid_proof(format!("parsing {url}: {error}")))
227}
228
229/// Refuses a DID that is neither hosted here nor the operator this
230/// deployment was launched naming, before anything is fetched for it.
231fn not_the_operator(state: &AppState, did: &str) -> ApiError {
232    ApiError::new(
233        StatusCode::FORBIDDEN,
234        "NotTheOperator",
235        format!(
236            "{did} is not this deployment's operator ({}) and holds no account here",
237            state.registry.operator_did()
238        ),
239    )
240}
241
242/// One segment of a compact JWS, decoded.
243pub(super) fn segment(text: &str, what: &str) -> Result<Vec<u8>, ApiError> {
244    BASE64_URL_SAFE_NO_PAD
245        .decode(text)
246        .map_err(|_| invalid_proof(format!("the token's {what} is not base64url")))
247}
248
249/// A JWT's three parts, split but not decoded.
250pub(super) fn split_jwt(token: &str) -> Option<(&str, &str, &str)> {
251    let mut parts = token.split('.');
252    let (header, payload, signature) = (parts.next()?, parts.next()?, parts.next()?);
253    parts
254        .next()
255        .is_none()
256        .then_some((header, payload, signature))
257}
258
259/// Verifies `signing_input`'s `signature` against every multibase key in
260/// `keys` on the curve `alg` names, answering the key that verified it.
261fn verify_with_keys<'a>(
262    keys: impl IntoIterator<Item = &'a str>,
263    alg: &str,
264    signing_input: &[u8],
265    signature: &[u8],
266) -> Result<&'a str, ApiError> {
267    let bytes: [u8; 64] = signature
268        .try_into()
269        .map_err(|_| invalid_proof("the signature is not 64 raw bytes"))?;
270    for multibase in keys {
271        let verified = match alg {
272            "ES256K" => didbot_key::VerifyingKey::from_multibase(multibase)
273                .ok()
274                .zip(didbot_key::Signature::from_bytes(&bytes).ok())
275                .is_some_and(|(key, sig)| key.verify(signing_input, &sig).is_ok()),
276            "ES256" => didbot_key::secp256r1::VerifyingKey::from_multibase(multibase)
277                .ok()
278                .zip(didbot_key::secp256r1::Signature::from_bytes(&bytes).ok())
279                .is_some_and(|(key, sig)| key.verify(signing_input, &sig).is_ok()),
280            other => {
281                return Err(invalid_proof(format!(
282                    "alg {other:?} is not ES256K or ES256"
283                )))
284            }
285        };
286        if verified {
287            return Ok(multibase);
288        }
289    }
290    Err(invalid_proof(
291        "the signature verifies against no key the issuer logs in with",
292    ))
293}
294
295/// A JWT's header and claims, decoded but not verified.
296pub(super) fn decode_jwt(token: &str) -> Result<(Value, Value), ApiError> {
297    let (head, payload, _) = split_jwt(token).ok_or_else(|| invalid_proof("not a JWT"))?;
298    let header: Value = serde_json::from_slice(&segment(head, "header")?)
299        .map_err(|_| invalid_proof("the token's header is not JSON"))?;
300    let claims: Value = serde_json::from_slice(&segment(payload, "payload")?)
301        .map_err(|_| invalid_proof("the token's payload is not JSON"))?;
302    Ok((header, claims))
303}
304
305/// A key-signed JWT that checks out, not yet spent.
306pub(super) struct KeyJwt<'k> {
307    /// The key that verified it.
308    pub(super) key: &'k str,
309    /// What it is spent as.
310    replay_key: String,
311    /// When it may be spent until.
312    exp: OffsetDateTime,
313}
314
315impl KeyJwt<'_> {
316    /// Spends it, so it is refused if it is presented again.
317    pub(super) fn spend(self, state: &AppState, now: OffsetDateTime) -> Result<(), ApiError> {
318        spend(state, self.replay_key, self.exp, now)
319    }
320}
321
322/// Checks a JWT whose `iss` is `iss` against `keys`: for this server, for
323/// `lxm`, `exp` in the future and at most [`MAX_PROOF_LIFETIME`] away, a
324/// `jti` of a bounded length, and signed by one of `keys`.
325pub(super) fn verify_key_jwt<'k>(
326    state: &AppState,
327    token: &str,
328    iss: &str,
329    lxm: &str,
330    keys: &'k [String],
331    now: OffsetDateTime,
332) -> Result<KeyJwt<'k>, ApiError> {
333    let (head, payload, signature) = split_jwt(token).ok_or_else(|| invalid_proof("not a JWT"))?;
334    let (header, claims) = decode_jwt(token)?;
335    let alg = header
336        .get("alg")
337        .and_then(Value::as_str)
338        .ok_or_else(|| invalid_proof("the token names no alg"))?;
339    if claims.get("iss").and_then(Value::as_str) != Some(iss) {
340        return Err(invalid_proof(format!("iss is not {iss}")));
341    }
342    let service_did = state.registry.service_did();
343    if claims.get("aud").and_then(Value::as_str) != Some(service_did.as_str()) {
344        return Err(invalid_proof(format!(
345            "aud is not this server, {service_did}"
346        )));
347    }
348    if claims.get("lxm").and_then(Value::as_str) != Some(lxm) {
349        return Err(invalid_proof(format!("lxm is not {lxm}")));
350    }
351    let exp = claims
352        .get("exp")
353        .and_then(Value::as_i64)
354        .and_then(|exp| OffsetDateTime::from_unix_timestamp(exp).ok())
355        .ok_or_else(|| invalid_proof("the token carries no exp"))?;
356    if exp <= now {
357        return Err(invalid_proof("the token has expired"));
358    }
359    if exp > now + MAX_PROOF_LIFETIME {
360        return Err(invalid_proof(format!(
361            "exp is more than {} seconds away",
362            MAX_PROOF_LIFETIME.whole_seconds()
363        )));
364    }
365    let jti = claims
366        .get("jti")
367        .and_then(Value::as_str)
368        .filter(|jti| !jti.is_empty())
369        .ok_or_else(|| invalid_proof("the token carries no jti"))?;
370    if jti.len() > MAX_JTI_BYTES {
371        return Err(invalid_proof(format!(
372            "the token's jti is longer than {MAX_JTI_BYTES} bytes"
373        )));
374    }
375    let signing_input = format!("{head}.{payload}");
376    let signature = segment(signature, "signature")?;
377    let key = verify_with_keys(
378        keys.iter().map(String::as_str),
379        alg,
380        signing_input.as_bytes(),
381        &signature,
382    )?;
383    Ok(KeyJwt {
384        key,
385        replay_key: format!("jti:{iss}:{jti}"),
386        exp,
387    })
388}
389
390/// The multibase keys `did`'s credential records hold.
391pub(super) fn account_keys(state: &AppState, did: &str) -> Vec<String> {
392    state
393        .registry
394        .credentials(did)
395        .iter()
396        .filter_map(|credential| credential.public_key_multibase().map(str::to_owned))
397        .collect()
398}
399
400/// Verifies a JWT whose `iss` is a DID: signed by a key the DID logs in
401/// with, for this server, for `lxm`, unexpired and unspent. The DID must be
402/// hosted here, and its keys are its credential records.
403fn prove_by_key(
404    state: &AppState,
405    token: &str,
406    iss: &str,
407    lxm: &str,
408    now: OffsetDateTime,
409) -> Result<Proof, ApiError> {
410    if state.registry.account(iss).is_none() {
411        if iss == state.registry.operator_did() {
412            info!(
413                operator = iss,
414                "refused a key proof from the operator's own PDS"
415            );
416            return Err(invalid_proof(
417                "the operator creates with the operator session `didbot login` mints, not a \
418                 token from their own PDS",
419            ));
420        }
421        return Err(not_the_operator(state, iss));
422    }
423    let keys = account_keys(state, iss);
424    verify_key_jwt(state, token, iss, lxm, &keys, now)?.spend(state, now)?;
425    Ok(Proof::Key(iss.to_owned()))
426}
427
428/// Spends a proof, naming why it could not be.
429pub(super) fn spend(
430    state: &AppState,
431    key: String,
432    until: OffsetDateTime,
433    now: OffsetDateTime,
434) -> Result<(), ApiError> {
435    match state.tree.replay.spend(key, until, now) {
436        Ok(()) => Ok(()),
437        Err(NotSpent::Window(Spent::Replayed)) => {
438            Err(invalid_proof("this token has already been presented"))
439        }
440        Err(NotSpent::Window(Spent::WindowFull)) => {
441            warn!("the proof replay window is full; refusing a proof rather than admitting it unchecked");
442            Err(invalid_proof(
443                "this server is holding as many proofs as it will; retry shortly",
444            ))
445        }
446        Err(NotSpent::NotKept) => Err(ApiError::new(
447            StatusCode::SERVICE_UNAVAILABLE,
448            "ProofNotKept",
449            "this server could not write this proof down and will not accept one it cannot \
450             refuse a second time; retry shortly",
451        )),
452    }
453}
454
455/// Resolves the `Authorization` header on a tree route into what it proves.
456///
457/// A session token this server issued is the account it names. A JWT is
458/// told apart by its `iss`: a DID is a key proof for `lxm`, a URL an
459/// OpenID Connect token. No header at all is the deployment itself, and
460/// only where [`AppState::unauthenticated_creates`] says so.
461///
462/// `named` is the account the request says the credential is for. An ID
463/// token is checked against that account's identities alone, so it needs
464/// one; a session or a key proof names its own account, which must be the
465/// one named when the request names one. Both refusals come before
466/// anything is fetched or spent.
467pub(crate) async fn prove(
468    state: &AppState,
469    headers: &HeaderMap,
470    lxm: &str,
471    allow_anonymous: bool,
472    named: Named<'_>,
473) -> Result<Proof, ApiError> {
474    check_named(named)?;
475    if allow_anonymous && !headers.contains_key(header::AUTHORIZATION) {
476        return Ok(Proof::Server);
477    }
478    let token = auth::bearer(headers)?;
479    match state.registry.verify_agent_token(&token) {
480        Ok(did) => {
481            if let Some(other) = named.did.filter(|other| *other != did) {
482                return Err(account_mismatch(format!(
483                    "the session is {did}'s, and `{}` is {other}",
484                    named.field.field()
485                )));
486            }
487            return Ok(Proof::Session(did));
488        }
489        Err(didbot_pds::TokenError::Expired) => return Err(auth::expired_token()),
490        Err(didbot_pds::TokenError::Unknown) => {}
491    }
492    if split_jwt(&token).is_none() {
493        return Err(invalid_proof(
494            "this token is neither a session this server issued nor a JWT",
495        ));
496    }
497    let (_, claims) = decode_jwt(&token)?;
498    let iss = claims
499        .get("iss")
500        .and_then(Value::as_str)
501        .ok_or_else(|| invalid_proof("the token names no iss"))?
502        .to_owned();
503    let now = OffsetDateTime::now_utc();
504    if iss.starts_with("did:") {
505        if let Some(other) = named.did.filter(|other| *other != iss) {
506            info!(
507                iss,
508                named = other,
509                "refused a key proof for another account"
510            );
511            return Err(account_mismatch(format!(
512                "the JWT is signed as {iss}, and the request is for {other}"
513            )));
514        }
515        prove_by_key(state, &token, &iss, lxm, now)
516    } else {
517        let Some(did) = named.did else {
518            info!(issuer = iss, "refused an ID token that names no account");
519            return Err(named.field.required());
520        };
521        prove_by_oidc(state, &token, &iss, did, now).await
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn a_proof_is_spent_until_it_expires() {
531        let replay = Replay::default();
532        let now = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("a clock");
533        assert_eq!(
534            replay.spend("jti:a".to_owned(), now + Duration::minutes(5), now),
535            Ok(())
536        );
537        assert_eq!(
538            replay.spend("jti:a".to_owned(), now + Duration::minutes(5), now),
539            Err(NotSpent::Window(Spent::Replayed))
540        );
541        assert_eq!(
542            replay.spend(
543                "jti:a".to_owned(),
544                now + Duration::minutes(15),
545                now + Duration::minutes(6)
546            ),
547            Ok(())
548        );
549    }
550}