1use 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
25pub const MAX_PROOF_LIFETIME: Duration = Duration::minutes(10);
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum NotSpent {
34 Window(Spent),
36 NotKept,
43}
44
45#[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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum Proof {
115 Server,
117 Session(String),
119 Key(String),
121 Oidc(String),
124}
125
126impl Proof {
127 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#[derive(Debug, Clone, Copy)]
143pub(crate) struct Named<'a> {
144 pub(crate) did: Option<&'a str>,
146 pub(crate) field: NamedBy,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub(crate) enum NamedBy {
153 Did,
155 Parent,
157}
158
159impl NamedBy {
160 fn field(self) -> &'static str {
162 match self {
163 NamedBy::Did => "did",
164 NamedBy::Parent => "parent",
165 }
166 }
167
168 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
187pub(super) fn account_mismatch(message: String) -> ApiError {
189 ApiError::new(StatusCode::FORBIDDEN, "AccountMismatch", message)
190}
191
192pub(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
209const MAX_NAMED_DID_BYTES: usize = 512;
211
212pub(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
229fn 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
242pub(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
249pub(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
259fn 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
295pub(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
305pub(super) struct KeyJwt<'k> {
307 pub(super) key: &'k str,
309 replay_key: String,
311 exp: OffsetDateTime,
313}
314
315impl KeyJwt<'_> {
316 pub(super) fn spend(self, state: &AppState, now: OffsetDateTime) -> Result<(), ApiError> {
318 spend(state, self.replay_key, self.exp, now)
319 }
320}
321
322pub(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
390pub(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
400fn 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
428pub(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
455pub(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}