Skip to main content

didbot_serve/tree/
credentials.rs

1//! `bot.did.updateCredentials`: what logs in as an account, changed after
2//! it is created.
3//!
4//! Two callers. The account replaces the key that signed the request with
5//! one new key, which signs `addProof`. The operator adds a credential,
6//! removes one, or both, on any account but the server's own. Either way
7//! the change is one commit, made by `Registry::update_credentials`.
8
9use axum::extract::State;
10use axum::http::{header, HeaderMap, StatusCode};
11use axum::response::{IntoResponse, Response};
12use axum::Json;
13use didbot_pds::{ChangedBy, Credential, CredentialChange, EstopRefusal};
14use serde::Deserialize;
15use serde_json::{json, Value};
16use time::OffsetDateTime;
17use tracing::info;
18
19use crate::error::ApiError;
20use crate::rate_limit::{caller_key, Peer};
21use crate::routes::{blocking_task_failed, AppState, XrpcBody};
22
23use super::proof::{account_keys, decode_jwt, split_jwt, verify_key_jwt};
24
25/// The method, as a key proof's `lxm` names it.
26const METHOD: &str = "bot.did.updateCredentials";
27
28/// Body of `bot.did.updateCredentials`.
29#[derive(Debug, Deserialize)]
30struct UpdateCredentialsRequest {
31    did: String,
32    #[serde(flatten)]
33    change: ChangeRequest,
34}
35
36/// The change a body asks for. The dashboard's route names the account in
37/// its path, so its body is this alone.
38#[derive(Debug, Deserialize)]
39#[serde(rename_all = "camelCase")]
40struct ChangeRequest {
41    #[serde(default)]
42    add: Option<Value>,
43    #[serde(default)]
44    add_proof: Option<String>,
45    #[serde(default)]
46    parked_key: Option<String>,
47    #[serde(default)]
48    remove: Option<String>,
49}
50
51/// `POST /xrpc/bot.did.updateCredentials`
52///
53/// An operator session cookie makes the caller the operator. Otherwise the
54/// bearer is the account's own JWT, and the account's change passes the
55/// gates a new session passes: the e-stop's issuance gate, readiness, and
56/// the per-address budget `createAccount` and `createSession` share. The
57/// operator's change passes none of them, so a leaked key is replaced
58/// while the server is stopped.
59pub(crate) async fn update_credentials(
60    State(state): State<AppState>,
61    peer: Peer,
62    headers: HeaderMap,
63    XrpcBody(body): XrpcBody,
64) -> Response {
65    match update_credentials_inner(&state, peer, &headers, &body).await {
66        Ok(response) => response,
67        Err(error) => error.into_response(),
68    }
69}
70
71async fn update_credentials_inner(
72    state: &AppState,
73    peer: Peer,
74    headers: &HeaderMap,
75    body: &axum::body::Bytes,
76) -> Result<Response, ApiError> {
77    let by_operator = crate::surface::session_cookie(headers).is_some();
78    if !by_operator {
79        state.estop.check_issue(EstopRefusal::Token)?;
80        crate::routes::require_lifecycle(state, |p| p.provisions_accounts, METHOD)?;
81        let key = caller_key(headers, peer.0, state.trust_forwarded_headers);
82        if !state.provision_rate_limiter.check(&key) {
83            info!(caller = %key, "credential change refused: too many attempts from this address");
84            return Err(ApiError::rate_limited(
85                "too many createAccount, createSession and updateCredentials calls; slow down",
86            ));
87        }
88    }
89
90    let request: UpdateCredentialsRequest = crate::routes::parse_body(body)?;
91    change_credentials(state, headers, by_operator, &request.did, &request.change).await
92}
93
94/// `POST /dashboard/api/accounts/{did}/credentials`: the operator's change,
95/// at a path the dashboard's session cookie reaches. That cookie is scoped
96/// to `/dashboard`, so a browser never carries it to `/xrpc`.
97pub(crate) async fn update_credentials_from_dashboard(
98    state: &AppState,
99    headers: &HeaderMap,
100    did: &str,
101    body: &axum::body::Bytes,
102) -> Response {
103    let changed = match crate::routes::parse_body::<ChangeRequest>(body) {
104        Ok(request) => change_credentials(state, headers, true, did, &request).await,
105        Err(error) => Err(error),
106    };
107    changed.unwrap_or_else(IntoResponse::into_response)
108}
109
110/// The change `request` asks of `did`, by the operator when `by_operator`.
111async fn change_credentials(
112    state: &AppState,
113    headers: &HeaderMap,
114    by_operator: bool,
115    did: &str,
116    request: &ChangeRequest,
117) -> Result<Response, ApiError> {
118    let add = parse_add(request)?;
119    if let Some(Credential::PublicKey {
120        public_key_multibase,
121    }) = &add
122    {
123        didbot_pds::account_key_curve(public_key_multibase).map_err(|error| {
124            ApiError::new(
125                StatusCode::BAD_REQUEST,
126                "InvalidKey",
127                format!("add: {error}"),
128            )
129        })?;
130    }
131    if did == state.registry.service_did() {
132        return Err(ApiError::new(
133            StatusCode::FORBIDDEN,
134            "ServerAccount",
135            format!("{did} is this server's own account, and its credentials do not change"),
136        ));
137    }
138    let Some(account) = state.registry.account(did) else {
139        return Err(ApiError::repo_not_found(did));
140    };
141    let now = OffsetDateTime::now_utc();
142
143    let operator = if by_operator {
144        if let Err(refusal) = crate::surface::signed_in(state, headers, true) {
145            info!("credential change refused: no live operator session");
146            return Ok(*refusal);
147        }
148        Some(state.registry.operator_did())
149    } else {
150        let signed_by = prove_account(state, headers, did, now)?;
151        require_replace(state, did, request, add.as_ref(), &signed_by)?;
152        None
153    };
154
155    // The new key signed for this account, this server and this method:
156    // required of the account, checked for the operator when given.
157    if let (
158        Some(Credential::PublicKey {
159            public_key_multibase,
160        }),
161        Some(proof),
162    ) = (&add, request.add_proof.as_deref())
163    {
164        check_add_proof(state, did, public_key_multibase, proof, now)?;
165    } else if operator.is_none() {
166        return Err(add_proof_invalid("`addProof` is missing"));
167    }
168
169    let add = match (&operator, request.parked_key.as_deref()) {
170        (Some(_), Some(fingerprint)) => Some(take_parked(state, &account, fingerprint, now)?),
171        _ => add,
172    };
173
174    let change = CredentialChange {
175        add,
176        remove: request.remove.clone(),
177        by: match &operator {
178            Some(operator) => ChangedBy::Operator(operator),
179            None => ChangedBy::Account,
180        },
181    };
182    let registry = state.registry.clone();
183    let owned_did = did.to_owned();
184    let owned_change = OwnedChange::from(&change);
185    let outcome = tokio::task::spawn_blocking(move || {
186        registry.update_credentials(&owned_did, owned_change.borrow())
187    })
188    .await;
189    let changed = match outcome {
190        Ok(Ok(changed)) => changed,
191        Ok(Err(error)) => {
192            info!(did, %error, "credential change refused");
193            return Err(ApiError::from(&error));
194        }
195        Err(join) => return Ok(blocking_task_failed(METHOD, join).into_response()),
196    };
197    let collection = didbot_lexicon::nsid::CREDENTIAL;
198    let mut answer = json!({
199        "did": did,
200        "commit": { "cid": changed.commit, "rev": changed.rev },
201    });
202    if let Some(added) = &changed.added {
203        answer["added"] = json!({
204            "uri": format!("at://{did}/{collection}/{}", added.rkey),
205            "cid": added.cid.to_string(),
206        });
207    }
208    if let Some(removed) = &changed.removed {
209        answer["removed"] = json!(format!("at://{did}/{collection}/{removed}"));
210    }
211    Ok(Json(answer).into_response())
212}
213
214/// The request's `add`, `parkedKey` and `remove`, checked for shape.
215fn parse_add(request: &ChangeRequest) -> Result<Option<Credential>, ApiError> {
216    if request.add.is_none() && request.parked_key.is_none() && request.remove.is_none() {
217        return Err(ApiError::bad_request(
218            "name a credential to add, a parked key, a record to remove, or two of those",
219        ));
220    }
221    if request.add.is_some() && request.parked_key.is_some() {
222        return Err(ApiError::bad_request(
223            "name one of `add` and `parkedKey`, not both",
224        ));
225    }
226    if let Some(remove) = request.remove.as_deref() {
227        didbot_schema::tid::validate(remove)
228            .map_err(|error| ApiError::bad_request(format!("remove: {error}")))?;
229    }
230    let Some(add) = &request.add else {
231        return Ok(None);
232    };
233    let credential = Credential::from_json(add)
234        .ok_or_else(|| ApiError::bad_request("add: not a credential kind this server knows"))?;
235    if let Credential::OidcIdentity(identity) = &credential {
236        if let Some(claim) = identity.repeated_claim() {
237            return Err(ApiError::bad_request(format!(
238                "add: the identity names the claim {} more than once; name each claim once",
239                claim.name
240            )));
241        }
242    }
243    Ok(Some(credential))
244}
245
246/// The account's own JWT: a key proof whose `iss` is `did`, for this
247/// server and this method, spent here. Answers the key that signed it.
248///
249/// A session and an ID token are refused before anything is fetched: a
250/// new key is a way to take sessions, and only a key the account holds
251/// may ask for one.
252fn prove_account(
253    state: &AppState,
254    headers: &HeaderMap,
255    did: &str,
256    now: OffsetDateTime,
257) -> Result<String, ApiError> {
258    let token = crate::auth::bearer(headers)?;
259    let key_proof_required = || {
260        ApiError::new(
261            StatusCode::UNAUTHORIZED,
262            "KeyProofRequired",
263            "an account changes its credentials with a JWT one of its keys signed, not a \
264             session or an OpenID Connect token",
265        )
266        .with_header(header::WWW_AUTHENTICATE, "Bearer")
267    };
268    if !matches!(
269        state.registry.verify_agent_token(&token),
270        Err(didbot_pds::TokenError::Unknown)
271    ) || split_jwt(&token).is_none()
272    {
273        return Err(key_proof_required());
274    }
275    let (_, claims) = decode_jwt(&token)?;
276    let iss = claims
277        .get("iss")
278        .and_then(Value::as_str)
279        .filter(|iss| iss.starts_with("did:"))
280        .ok_or_else(key_proof_required)?;
281    if iss != did {
282        return Err(ApiError::repo_mismatch(iss, did));
283    }
284    let keys = account_keys(state, did);
285    let proof = verify_key_jwt(state, &token, did, METHOD, &keys, now)?;
286    let signed_by = proof.key.to_owned();
287    proof.spend(state, now)?;
288    Ok(signed_by)
289}
290
291/// Refuses an account's change that is not one key replacing the key that
292/// signed the request.
293fn require_replace(
294    state: &AppState,
295    did: &str,
296    request: &ChangeRequest,
297    add: Option<&Credential>,
298    signed_by: &str,
299) -> Result<(), ApiError> {
300    let signing_record = state
301        .registry
302        .credential_records(did)
303        .into_iter()
304        .find(|(_, credential)| credential.public_key_multibase() == Some(signed_by))
305        .map(|(rkey, _)| rkey);
306    let replaces = request.parked_key.is_none()
307        && matches!(add, Some(Credential::PublicKey { .. }))
308        && request.remove.is_some()
309        && request.remove == signing_record;
310    if replaces {
311        return Ok(());
312    }
313    Err(ApiError::new(
314        StatusCode::FORBIDDEN,
315        "ReplaceOnly",
316        "an account replaces the key that signed the request with one new key: `add` a key, \
317         and `remove` the record of the key that signed",
318    ))
319}
320
321fn add_proof_invalid(why: impl std::fmt::Display) -> ApiError {
322    ApiError::new(
323        StatusCode::BAD_REQUEST,
324        "AddProofInvalid",
325        format!("addProof: {why}"),
326    )
327}
328
329/// `addProof`: a JWT the added key signed as the account, for this server
330/// and this method, spent here.
331fn check_add_proof(
332    state: &AppState,
333    did: &str,
334    key: &str,
335    proof: &str,
336    now: OffsetDateTime,
337) -> Result<(), ApiError> {
338    let keys = [key.to_owned()];
339    let checked = verify_key_jwt(state, proof, did, METHOD, &keys, now)
340        .map_err(|error| add_proof_invalid(error.message()))?;
341    checked.spend(state, now).map_err(|error| {
342        if error.error() == "InvalidToken" {
343            add_proof_invalid(error.message())
344        } else {
345            error
346        }
347    })
348}
349
350/// The key parked under the account's hostname with `fingerprint`, taken,
351/// for the account's kind.
352fn take_parked(
353    state: &AppState,
354    account: &didbot_pds::HostedAccount,
355    fingerprint: &str,
356    now: OffsetDateTime,
357) -> Result<Credential, ApiError> {
358    let name = account.did.did().authority();
359    let parked = state
360        .tree
361        .parked
362        .take(&name, fingerprint, now)
363        .ok_or_else(|| {
364            ApiError::new(
365                StatusCode::NOT_FOUND,
366                "ParkedKeyNotFound",
367                format!("no key with fingerprint {fingerprint} is parked for {name:?}"),
368            )
369        })?;
370    if parked.kind != account.kind {
371        return Err(ApiError::new(
372            StatusCode::BAD_REQUEST,
373            "ParkedKeyKindMismatch",
374            format!(
375                "the key was parked for {}, and {} is {}",
376                parked.kind.with_article(),
377                account.did.as_str(),
378                account.kind.with_article()
379            ),
380        ));
381    }
382    Ok(Credential::key(&parked.key))
383}
384
385/// A [`CredentialChange`] that can cross into a blocking task.
386struct OwnedChange {
387    add: Option<Credential>,
388    remove: Option<String>,
389    operator: Option<String>,
390}
391
392impl From<&CredentialChange<'_>> for OwnedChange {
393    fn from(change: &CredentialChange<'_>) -> Self {
394        Self {
395            add: change.add.clone(),
396            remove: change.remove.clone(),
397            operator: match change.by {
398                ChangedBy::Operator(operator) => Some(operator.to_owned()),
399                ChangedBy::Account => None,
400            },
401        }
402    }
403}
404
405impl OwnedChange {
406    fn borrow(&self) -> CredentialChange<'_> {
407        CredentialChange {
408            add: self.add.clone(),
409            remove: self.remove.clone(),
410            by: match &self.operator {
411                Some(operator) => ChangedBy::Operator(operator),
412                None => ChangedBy::Account,
413            },
414        }
415    }
416}