1use 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
25const METHOD: &str = "bot.did.updateCredentials";
27
28#[derive(Debug, Deserialize)]
30struct UpdateCredentialsRequest {
31 did: String,
32 #[serde(flatten)]
33 change: ChangeRequest,
34}
35
36#[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
51pub(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
94pub(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
110async 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 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
214fn 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
246fn 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
291fn 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
329fn 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
350fn 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
385struct 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}