An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(relay): replace POST /v1/dids with device-signing handler (MM-90 Phase 2, step 1)

authored by malpercio.dev and committed by

Tangled 0157fd59 23b82c80

+97 -121
+97 -121
crates/relay/src/routes/create_did.rs
··· 1 1 // pattern: Imperative Shell 2 2 // 3 - // POST /v1/dids — DID creation and account promotion 3 + // POST /v1/dids — Device-signed DID ceremony and account promotion 4 4 // 5 5 // Inputs: 6 6 // - Authorization: Bearer <pending_session_token> 7 - // - JSON body: { "signingKey": "did:key:z...", "rotationKey": "did:key:z..." } 7 + // - JSON body: { 8 + // "rotationKeyPublic": "did:key:z...", 9 + // "signedCreationOp": { ...genesis op fields... } 10 + // } 8 11 // 9 12 // Processing steps: 10 13 // 1. require_pending_session → PendingSessionInfo { account_id, device_id } 11 - // 2. SELECT handle, pending_did FROM pending_accounts WHERE id = account_id 12 - // 3. SELECT private_key_encrypted FROM relay_signing_keys WHERE id = signing_key 13 - // 4. decrypt_private_key(encrypted, master_key) 14 - // 5. build_did_plc_genesis_op(rotation_key, signing_key, private_key, handle, public_url) 15 - // 6. If pending_did IS NULL: UPDATE pending_accounts SET pending_did = did (pre-store resilience) 16 - // 7. If pending_did IS NOT NULL (retry): skip step 8 17 - // 8. POST {plc_directory_url}/{did} with signed_op_json 18 - // 9. Atomic transaction: 14 + // 2. SELECT handle, pending_did, email FROM pending_accounts WHERE id = account_id 15 + // 3. Validate rotationKeyPublic starts with "did:key:z" → DidKeyUri 16 + // 4. serde_json::to_string(signedCreationOp) → signed_op_str 17 + // 5. crypto::verify_genesis_op(signed_op_str, rotation_key) → VerifiedGenesisOp 18 + // 6. Semantic validation: 19 + // verified.rotation_keys[0] == rotationKeyPublic 20 + // verified.also_known_as[0] == "at://{handle}" 21 + // verified.atproto_pds_endpoint == config.public_url 22 + // 7. If pending_did IS NULL: UPDATE pending_accounts SET pending_did = verified.did 23 + // If pending_did IS NOT NULL: verify match, set skip_plc_directory = true 24 + // 8. SELECT EXISTS(SELECT 1 FROM accounts WHERE did = verified.did) → 409 if true 25 + // 9. If !skip_plc_directory: POST {plc_directory_url}/{did} with signed_op_str 26 + // 10. build_did_document(&verified) → serde_json::Value 27 + // 11. Atomic transaction: 19 28 // INSERT accounts (did, email, password_hash=NULL) 20 29 // INSERT did_documents (did, document) 21 30 // INSERT handles (handle, did) 22 31 // DELETE pending_sessions WHERE account_id = ? 32 + // DELETE devices WHERE account_id = ? 23 33 // DELETE pending_accounts WHERE id = ? 24 - // 10. Return { "did": "did:plc:...", "status": "active" } 34 + // 12. Return { "did": "did:plc:...", "did_document": {...}, "status": "active" } 25 35 // 26 - // Outputs (success): 200 { "did": "did:plc:...", "status": "active" } 27 - // Outputs (error): 401 UNAUTHORIZED, 404 NOT_FOUND, 409 DID_ALREADY_EXISTS, 36 + // Outputs (success): 200 { "did": "did:plc:...", "did_document": {...}, "status": "active" } 37 + // Outputs (error): 400 INVALID_CLAIM, 401 UNAUTHORIZED, 409 DID_ALREADY_EXISTS, 28 38 // 502 PLC_DIRECTORY_ERROR, 500 INTERNAL_ERROR 29 39 30 40 use axum::{extract::State, http::HeaderMap, Json}; ··· 37 47 #[derive(Deserialize)] 38 48 #[serde(rename_all = "camelCase")] 39 49 pub struct CreateDidRequest { 40 - pub signing_key: String, 41 - pub rotation_key: String, 50 + pub rotation_key_public: String, 51 + pub signed_creation_op: serde_json::Value, 42 52 } 43 53 44 54 #[derive(Serialize)] 45 55 pub struct CreateDidResponse { 46 56 pub did: String, 57 + pub did_document: serde_json::Value, 47 58 pub status: &'static str, 48 59 } 49 60 ··· 67 78 })? 68 79 .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "account not found"))?; 69 80 70 - // Step 3: Look up signing key in relay_signing_keys. 71 - let (private_key_encrypted,): (String,) = 72 - sqlx::query_as("SELECT private_key_encrypted FROM relay_signing_keys WHERE id = ?") 73 - .bind(&payload.signing_key) 74 - .fetch_optional(&state.db) 75 - .await 76 - .map_err(|e| { 77 - tracing::error!(error = %e, "failed to query relay signing key"); 78 - ApiError::new(ErrorCode::InternalError, "key lookup failed") 79 - })? 80 - .ok_or_else(|| { 81 - ApiError::new( 82 - ErrorCode::NotFound, 83 - "signing key not found in relay_signing_keys", 84 - ) 85 - })?; 81 + // Step 3: Validate rotationKeyPublic format. 82 + if !payload.rotation_key_public.starts_with("did:key:z") { 83 + return Err(ApiError::new( 84 + ErrorCode::InvalidClaim, 85 + "rotationKeyPublic must be a did:key: URI starting with 'did:key:z'", 86 + )); 87 + } 88 + let rotation_key = crypto::DidKeyUri(payload.rotation_key_public.clone()); 86 89 87 - // Step 4: Decrypt the private key using the master key from config. 88 - let master_key: &[u8; 32] = state 89 - .config 90 - .signing_key_master_key 91 - .as_ref() 92 - .map(|s| &*s.0) 93 - .ok_or_else(|| { 94 - ApiError::new( 95 - ErrorCode::InternalError, 96 - "signing key master key not configured", 97 - ) 98 - })?; 90 + // Step 4: Serialize the submitted signed op to a JSON string for crypto verification. 91 + let signed_op_str = serde_json::to_string(&payload.signed_creation_op).map_err(|e| { 92 + tracing::error!(error = %e, "failed to serialize signedCreationOp"); 93 + ApiError::new(ErrorCode::InternalError, "failed to process signed op") 94 + })?; 99 95 100 - let private_key_bytes = crypto::decrypt_private_key(&private_key_encrypted, master_key) 101 - .map_err(|e| { 102 - tracing::error!(error = %e, "failed to decrypt signing key"); 103 - ApiError::new(ErrorCode::InternalError, "failed to decrypt signing key") 96 + // Step 5: Verify the ECDSA signature and derive the DID. 97 + let verified = 98 + crypto::verify_genesis_op(&signed_op_str, &rotation_key).map_err(|e| { 99 + tracing::warn!(error = %e, "genesis op verification failed"); 100 + ApiError::new(ErrorCode::InvalidClaim, format!("invalid signed genesis op: {e}")) 104 101 })?; 105 102 106 - // Step 5: Build the genesis operation and derive the DID. 107 - // Validate that both keys are proper did:key: URIs before wrapping. 108 - if !payload.signing_key.starts_with("did:key:z") { 103 + // Step 6: Semantic validation — ensure op fields match account and server config. 104 + if verified.rotation_keys.first().map(String::as_str) != Some(&payload.rotation_key_public) { 109 105 return Err(ApiError::new( 110 106 ErrorCode::InvalidClaim, 111 - "signingKey must be a did:key: URI starting with 'did:key:z'", 107 + "rotationKeys[0] in op does not match rotationKeyPublic", 112 108 )); 113 109 } 114 - if !payload.rotation_key.starts_with("did:key:z") { 110 + if verified.also_known_as.first().map(String::as_str) != Some(&format!("at://{handle}")) { 115 111 return Err(ApiError::new( 116 112 ErrorCode::InvalidClaim, 117 - "rotationKey must be a did:key: URI starting with 'did:key:z'", 113 + "alsoKnownAs[0] in op does not match account handle", 118 114 )); 119 115 } 120 - 121 - let rotation_key = crypto::DidKeyUri(payload.rotation_key.clone()); 122 - let signing_key_uri = crypto::DidKeyUri(payload.signing_key.clone()); 123 - 124 - let genesis = crypto::build_did_plc_genesis_op( 125 - &rotation_key, 126 - &signing_key_uri, 127 - &private_key_bytes, 128 - &handle, 129 - &state.config.public_url, 130 - ) 131 - .map_err(|e| { 132 - tracing::error!(error = %e, "failed to build genesis op"); 133 - ApiError::new( 134 - ErrorCode::InternalError, 135 - "failed to build genesis operation", 136 - ) 137 - })?; 116 + if verified.atproto_pds_endpoint.as_deref() != Some(&state.config.public_url) { 117 + return Err(ApiError::new( 118 + ErrorCode::InvalidClaim, 119 + "services.atproto_pds.endpoint in op does not match server public URL", 120 + )); 121 + } 138 122 139 - let did = genesis.did.clone(); 140 - let signed_op_json = genesis.signed_op_json; 123 + let did = &verified.did; 141 124 142 - // Step 6: Pre-store the DID for retry resilience. 143 - // If pending_did is already set, we are on a retry path — skip the plc.directory call. 125 + // Step 7: Pre-store the DID for retry resilience. 144 126 let skip_plc_directory = if let Some(pre_stored_did) = &pending_did { 145 - // Retry: verify that the crypto-derived DID matches the pre-stored value. 146 - // If inputs changed between attempts, a different DID would be derived, 147 - // which indicates a mismatch error that must be caught. 148 - if did != *pre_stored_did { 127 + if did != pre_stored_did { 149 128 tracing::error!( 150 129 derived_did = %did, 151 130 stored_did = %pre_stored_did, ··· 159 138 tracing::info!(did = %pre_stored_did, "retry detected: pending_did already set, skipping plc.directory"); 160 139 true 161 140 } else { 162 - // First attempt: write the DID before calling plc.directory. 163 141 sqlx::query("UPDATE pending_accounts SET pending_did = ? WHERE id = ?") 164 - .bind(&did) 142 + .bind(did) 165 143 .bind(&session.account_id) 166 144 .execute(&state.db) 167 145 .await ··· 172 150 false 173 151 }; 174 152 175 - // Step 7: Check if the account is already fully promoted (idempotency guard for AC2.10). 153 + // Step 8: Check if the account is already fully promoted (idempotency guard). 176 154 let already_promoted: bool = 177 155 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM accounts WHERE did = ?)") 178 - .bind(&did) 156 + .bind(did) 179 157 .fetch_one(&state.db) 180 158 .await 181 159 .map_err(|e| { ··· 190 168 )); 191 169 } 192 170 193 - // Step 8: POST the genesis operation to plc.directory (skipped on retry). 171 + // Step 9: POST the signed genesis operation to plc.directory (skipped on retry). 194 172 if !skip_plc_directory { 195 173 let plc_url = format!("{}/{}", state.config.plc_directory_url, did); 196 174 let response = state 197 175 .http_client 198 176 .post(&plc_url) 199 - .body(signed_op_json.clone()) 177 + .body(signed_op_str.clone()) 200 178 .header("Content-Type", "application/json") 201 179 .send() 202 180 .await 203 181 .map_err(|e| { 204 182 tracing::error!(error = %e, plc_url = %plc_url, "failed to contact plc.directory"); 205 - ApiError::new( 206 - ErrorCode::PlcDirectoryError, 207 - "failed to contact plc.directory", 208 - ) 183 + ApiError::new(ErrorCode::PlcDirectoryError, "failed to contact plc.directory") 209 184 })?; 210 185 211 186 if !response.status().is_success() { 212 187 let status = response.status(); 213 - // Consume the response body to include in logs, ignoring errors if body read fails. 214 188 let body_text = response 215 189 .text() 216 190 .await ··· 227 201 } 228 202 } 229 203 230 - // Step 9: Build the DID document for local storage. 231 - let did_document = build_did_document( 232 - &did, 233 - &handle, 234 - &payload.signing_key, 235 - &state.config.public_url, 236 - )?; 204 + // Step 10: Build the DID document from verified op fields. 205 + let did_document = build_did_document(&verified)?; 206 + let did_document_str = serde_json::to_string(&did_document).map_err(|e| { 207 + tracing::error!(error = %e, "failed to serialize DID document"); 208 + ApiError::new(ErrorCode::InternalError, "failed to serialize DID document") 209 + })?; 237 210 238 - // Step 10: Atomically promote the account. 211 + // Step 11: Atomically promote the account. 239 212 let mut tx = state 240 213 .db 241 214 .begin() ··· 247 220 "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 248 221 VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 249 222 ) 250 - .bind(&did) 223 + .bind(did) 251 224 .bind(&email) 252 225 .execute(&mut *tx) 253 226 .await ··· 258 231 "INSERT INTO did_documents (did, document, created_at, updated_at) \ 259 232 VALUES (?, ?, datetime('now'), datetime('now'))", 260 233 ) 261 - .bind(&did) 262 - .bind(&did_document) 234 + .bind(did) 235 + .bind(&did_document_str) 263 236 .execute(&mut *tx) 264 237 .await 265 238 .inspect_err(|e| tracing::error!(error = %e, "failed to insert did_document")) ··· 267 240 268 241 sqlx::query("INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))") 269 242 .bind(&handle) 270 - .bind(&did) 243 + .bind(did) 271 244 .execute(&mut *tx) 272 245 .await 273 246 .inspect_err(|e| tracing::error!(error = %e, "failed to insert handle")) ··· 299 272 .inspect_err(|e| tracing::error!(error = %e, "failed to commit promotion transaction")) 300 273 .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to commit transaction"))?; 301 274 275 + // Step 12: Return the result. 302 276 Ok(Json(CreateDidResponse { 303 - did, 277 + did: did.clone(), 278 + did_document, 304 279 status: "active", 305 280 })) 306 281 } 307 282 308 - /// Construct a minimal DID Core document from known fields. 283 + /// Construct a minimal DID Core document from a verified genesis operation. 309 284 /// 310 - /// No I/O — pure construction from parameters. 285 + /// No I/O — pure construction from [`crypto::VerifiedGenesisOp`] fields. 311 286 /// 312 287 /// # Errors 313 - /// Returns an error if signing_key_did is not a did:key: URI (e.g., missing the prefix). 314 - fn build_did_document( 315 - did: &str, 316 - handle: &str, 317 - signing_key_did: &str, 318 - service_endpoint: &str, 319 - ) -> Result<String, ApiError> { 320 - // Extract the multibase-encoded public key from the did:key URI. 288 + /// Returns `InternalError` if `verificationMethods["atproto"]` is absent or is not a did:key: URI. 289 + fn build_did_document(verified: &crypto::VerifiedGenesisOp) -> Result<serde_json::Value, ApiError> { 290 + let did = &verified.did; 291 + 292 + // Extract the multibase key from did:key URI for publicKeyMultibase. 321 293 // did:key:zAbcDef... → publicKeyMultibase = "zAbcDef..." 322 - let public_key_multibase = signing_key_did.strip_prefix("did:key:").ok_or_else(|| { 323 - ApiError::new( 324 - ErrorCode::InternalError, 325 - "signing key is not a did:key: URI", 326 - ) 294 + let atproto_did_key = verified 295 + .verification_methods 296 + .get("atproto") 297 + .ok_or_else(|| { 298 + ApiError::new(ErrorCode::InternalError, "atproto verification method not found in op") 299 + })?; 300 + let public_key_multibase = atproto_did_key.strip_prefix("did:key:").ok_or_else(|| { 301 + ApiError::new(ErrorCode::InternalError, "atproto key is not a did:key: URI") 327 302 })?; 328 303 304 + let service_endpoint = verified.atproto_pds_endpoint.as_deref().unwrap_or_default(); 305 + 329 306 Ok(serde_json::json!({ 330 307 "@context": [ 331 308 "https://www.w3.org/ns/did/v1" 332 309 ], 333 310 "id": did, 334 - "alsoKnownAs": [format!("at://{handle}")], 311 + "alsoKnownAs": &verified.also_known_as, 335 312 "verificationMethod": [{ 336 313 "id": format!("{did}#atproto"), 337 314 "type": "Multikey", ··· 343 320 "type": "AtprotoPersonalDataServer", 344 321 "serviceEndpoint": service_endpoint 345 322 }] 346 - }) 347 - .to_string()) 323 + })) 348 324 } 349 325 350 326 // ── Tests ────────────────────────────────────────────────────────────────────