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

feat(relay): implement POST /v1/relay/keys endpoint (MM-92)

authored by malpercio.dev and committed by

Tangled c54d8b71 fc9fbce4

+433 -1
+6
crates/common/src/error.rs
··· 21 21 WeakPassword, 22 22 RateLimited, 23 23 ExportInProgress, 24 + ServiceUnavailable, 25 + InternalError, 24 26 /// Returned for any XRPC NSID that has no registered handler. 25 27 /// 26 28 /// Serialized as `"MethodNotImplemented"` (PascalCase) to match the AT Protocol XRPC ··· 51 53 ErrorCode::WeakPassword => 422, 52 54 ErrorCode::RateLimited => 429, 53 55 ErrorCode::ExportInProgress => 503, 56 + ErrorCode::ServiceUnavailable => 503, 57 + ErrorCode::InternalError => 500, 54 58 ErrorCode::MethodNotImplemented => 501, 55 59 } 56 60 } ··· 196 200 (ErrorCode::WeakPassword, 422), 197 201 (ErrorCode::RateLimited, 429), 198 202 (ErrorCode::ExportInProgress, 503), 203 + (ErrorCode::ServiceUnavailable, 503), 204 + (ErrorCode::InternalError, 500), 199 205 (ErrorCode::MethodNotImplemented, 501), 200 206 ]; 201 207 for (code, expected) in cases {
+1
crates/relay/Cargo.toml
··· 26 26 tower-http = { workspace = true } 27 27 serde = { workspace = true } 28 28 sqlx = { workspace = true } 29 + crypto = { workspace = true } 29 30 30 31 [dev-dependencies] 31 32 tower = { workspace = true }
+8 -1
crates/relay/src/app.rs
··· 1 1 use std::sync::Arc; 2 2 3 - use axum::{extract::Path, http::Request, routing::get, Router}; 3 + use axum::{ 4 + extract::Path, 5 + http::Request, 6 + routing::{get, post}, 7 + Router, 8 + }; 4 9 use common::{ApiError, Config, ErrorCode}; 5 10 use opentelemetry::propagation::Extractor; 6 11 use tower_http::{cors::CorsLayer, trace::TraceLayer}; 7 12 use tracing_opentelemetry::OpenTelemetrySpanExt; 8 13 14 + use crate::routes::create_signing_key::create_signing_key; 9 15 use crate::routes::describe_server::describe_server; 10 16 use crate::routes::health::health; 11 17 ··· 80 86 get(describe_server), 81 87 ) 82 88 .route("/xrpc/:method", get(xrpc_handler).post(xrpc_handler)) 89 + .route("/v1/relay/keys", post(create_signing_key)) 83 90 .layer(CorsLayer::permissive()) 84 91 .layer(TraceLayer::new_for_http().make_span_with(OtelMakeSpan)) 85 92 .with_state(state)
+417
crates/relay/src/routes/create_signing_key.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: Bearer token from Authorization header, JSON request body, config, DB pool 4 + // Processes: auth check → algorithm check → master key check → key generation → encryption → DB insert 5 + // Returns: JSON { key_id, public_key, algorithm } on success; ApiError on all failure paths 6 + 7 + use axum::{extract::State, http::HeaderMap, response::Json}; 8 + use serde::{Deserialize, Serialize}; 9 + 10 + use common::{ApiError, ErrorCode}; 11 + 12 + use crate::app::AppState; 13 + 14 + #[derive(Deserialize)] 15 + #[serde(rename_all = "camelCase")] 16 + pub struct CreateSigningKeyRequest { 17 + #[serde(default)] 18 + algorithm: Option<String>, 19 + } 20 + 21 + // Response uses camelCase per JSON API convention (keyId, publicKey). 22 + // The design document shows snake_case field names; this is a deliberate 23 + // deviation — camelCase is standard for JSON responses and matches ATProto conventions. 24 + #[derive(Serialize)] 25 + #[serde(rename_all = "camelCase")] 26 + pub struct CreateSigningKeyResponse { 27 + key_id: String, 28 + public_key: String, 29 + algorithm: String, 30 + } 31 + 32 + pub async fn create_signing_key( 33 + State(state): State<AppState>, 34 + headers: HeaderMap, 35 + Json(payload): Json<CreateSigningKeyRequest>, 36 + ) -> Result<Json<CreateSigningKeyResponse>, ApiError> { 37 + // --- Auth: require matching Bearer token --- 38 + // Check this first so unauthenticated callers cannot probe server configuration. 39 + let expected_token = state 40 + .config 41 + .admin_token 42 + .as_deref() 43 + .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "admin token not configured"))?; 44 + 45 + let auth_value = headers 46 + .get(axum::http::header::AUTHORIZATION) 47 + .and_then(|v| v.to_str().ok()) 48 + .unwrap_or(""); 49 + 50 + let provided_token = auth_value.strip_prefix("Bearer ").ok_or_else(|| { 51 + ApiError::new( 52 + ErrorCode::Unauthorized, 53 + "missing or invalid Authorization header", 54 + ) 55 + })?; 56 + 57 + if provided_token != expected_token { 58 + return Err(ApiError::new( 59 + ErrorCode::Unauthorized, 60 + "invalid admin token", 61 + )); 62 + } 63 + 64 + // --- Algorithm: only p256 is supported --- 65 + match payload.algorithm.as_deref() { 66 + Some("p256") => {} 67 + _ => { 68 + return Err(ApiError::new( 69 + ErrorCode::InvalidClaim, 70 + "unsupported algorithm; expected \"p256\"", 71 + )) 72 + } 73 + } 74 + 75 + // --- Master key: return 503 if not configured --- 76 + let master_key: &[u8; 32] = state 77 + .config 78 + .signing_key_master_key 79 + .as_ref() 80 + .ok_or_else(|| { 81 + ApiError::new( 82 + ErrorCode::ServiceUnavailable, 83 + "signing key master key not configured", 84 + ) 85 + })?; 86 + 87 + // --- Generate P-256 keypair --- 88 + let keypair = crypto::generate_p256_keypair().map_err(|e| { 89 + tracing::error!(error = %e, "failed to generate P-256 keypair"); 90 + ApiError::new(ErrorCode::InternalError, "key generation failed") 91 + })?; 92 + 93 + // --- Encrypt private key with AES-256-GCM --- 94 + // private_key_bytes is Zeroizing<[u8; 32]>; deref coercion to &[u8; 32] applies. 95 + let private_key_encrypted = crypto::encrypt_private_key(&keypair.private_key_bytes, master_key) 96 + .map_err(|e| { 97 + tracing::error!(error = %e, "failed to encrypt private key"); 98 + ApiError::new(ErrorCode::InternalError, "key encryption failed") 99 + })?; 100 + 101 + // --- Persist to relay_signing_keys --- 102 + // created_at uses SQLite's datetime('now') to produce ISO 8601 UTC without a chrono dep. 103 + sqlx::query( 104 + "INSERT INTO relay_signing_keys \ 105 + (id, algorithm, public_key, private_key_encrypted, created_at) \ 106 + VALUES (?, ?, ?, ?, datetime('now'))", 107 + ) 108 + .bind(&keypair.key_id) 109 + .bind("p256") 110 + .bind(&keypair.public_key) 111 + .bind(&private_key_encrypted) 112 + .execute(&state.db) 113 + .await 114 + .map_err(|e| { 115 + tracing::error!(error = %e, "failed to insert relay signing key"); 116 + ApiError::new(ErrorCode::InternalError, "failed to store signing key") 117 + })?; 118 + 119 + Ok(Json(CreateSigningKeyResponse { 120 + key_id: keypair.key_id, 121 + public_key: keypair.public_key, 122 + algorithm: "p256".to_string(), 123 + })) 124 + } 125 + 126 + #[cfg(test)] 127 + mod tests { 128 + use std::sync::Arc; 129 + 130 + use axum::{ 131 + body::Body, 132 + http::{Request, StatusCode}, 133 + }; 134 + use tower::ServiceExt; 135 + 136 + use crate::app::{app, test_state, AppState}; 137 + 138 + /// Build an AppState with both admin_token and signing_key_master_key configured. 139 + async fn test_state_with_keys() -> AppState { 140 + let base = test_state().await; 141 + let mut config = (*base.config).clone(); 142 + config.admin_token = Some("test-admin-token".to_string()); 143 + config.signing_key_master_key = Some([ 144 + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 145 + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 146 + 0x1d, 0x1e, 0x1f, 0x20, 147 + ]); 148 + AppState { 149 + config: Arc::new(config), 150 + db: base.db, 151 + } 152 + } 153 + 154 + /// Build a POST /v1/relay/keys request with JSON body and optional Bearer token. 155 + fn post_keys(body: &str, bearer: Option<&str>) -> Request<Body> { 156 + let mut builder = Request::builder() 157 + .method("POST") 158 + .uri("/v1/relay/keys") 159 + .header("Content-Type", "application/json"); 160 + if let Some(token) = bearer { 161 + builder = builder.header("Authorization", format!("Bearer {token}")); 162 + } 163 + builder.body(Body::from(body.to_string())).unwrap() 164 + } 165 + 166 + // --- Happy path --- 167 + 168 + #[tokio::test] 169 + async fn create_signing_key_returns_200_with_key_fields() { 170 + // MM-92.AC1.1 171 + let response = app(test_state_with_keys().await) 172 + .oneshot(post_keys( 173 + r#"{"algorithm": "p256"}"#, 174 + Some("test-admin-token"), 175 + )) 176 + .await 177 + .unwrap(); 178 + 179 + assert_eq!(response.status(), StatusCode::OK); 180 + let body = axum::body::to_bytes(response.into_body(), 4096) 181 + .await 182 + .unwrap(); 183 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 184 + assert!(json["keyId"].is_string(), "keyId must be present"); 185 + assert!(json["publicKey"].is_string(), "publicKey must be present"); 186 + assert_eq!(json["algorithm"], "p256"); // MM-92.AC1.4 187 + } 188 + 189 + #[tokio::test] 190 + async fn key_id_is_did_key_uri() { 191 + // MM-92.AC1.2 192 + let response = app(test_state_with_keys().await) 193 + .oneshot(post_keys( 194 + r#"{"algorithm": "p256"}"#, 195 + Some("test-admin-token"), 196 + )) 197 + .await 198 + .unwrap(); 199 + 200 + let body = axum::body::to_bytes(response.into_body(), 4096) 201 + .await 202 + .unwrap(); 203 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 204 + let key_id = json["keyId"].as_str().unwrap(); 205 + assert!( 206 + key_id.starts_with("did:key:z"), 207 + "keyId must start with did:key:z, got: {key_id}" 208 + ); 209 + } 210 + 211 + #[tokio::test] 212 + async fn public_key_is_multibase_base58btc() { 213 + // MM-92.AC1.3 214 + let response = app(test_state_with_keys().await) 215 + .oneshot(post_keys( 216 + r#"{"algorithm": "p256"}"#, 217 + Some("test-admin-token"), 218 + )) 219 + .await 220 + .unwrap(); 221 + 222 + let body = axum::body::to_bytes(response.into_body(), 4096) 223 + .await 224 + .unwrap(); 225 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 226 + let public_key = json["publicKey"].as_str().unwrap(); 227 + assert!( 228 + public_key.starts_with('z'), 229 + "publicKey must start with 'z' (multibase base58btc prefix), got: {public_key}" 230 + ); 231 + assert!( 232 + !public_key.starts_with("did:key:"), 233 + "publicKey must not include did:key: prefix" 234 + ); 235 + } 236 + 237 + #[tokio::test] 238 + async fn response_has_no_private_key_field() { 239 + // MM-92.AC2.1 240 + let response = app(test_state_with_keys().await) 241 + .oneshot(post_keys( 242 + r#"{"algorithm": "p256"}"#, 243 + Some("test-admin-token"), 244 + )) 245 + .await 246 + .unwrap(); 247 + 248 + let body = axum::body::to_bytes(response.into_body(), 4096) 249 + .await 250 + .unwrap(); 251 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 252 + assert!( 253 + json.get("privateKey").is_none(), 254 + "privateKey must not appear in response" 255 + ); 256 + assert!( 257 + json.get("private_key").is_none(), 258 + "private_key must not appear in response" 259 + ); 260 + } 261 + 262 + #[tokio::test] 263 + async fn row_persisted_in_db_with_encrypted_private_key() { 264 + // MM-92.AC1.5, MM-92.AC2.2 265 + let state = test_state_with_keys().await; 266 + let db = state.db.clone(); 267 + 268 + let response = app(state) 269 + .oneshot(post_keys( 270 + r#"{"algorithm": "p256"}"#, 271 + Some("test-admin-token"), 272 + )) 273 + .await 274 + .unwrap(); 275 + 276 + assert_eq!(response.status(), StatusCode::OK); 277 + let body = axum::body::to_bytes(response.into_body(), 4096) 278 + .await 279 + .unwrap(); 280 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 281 + let key_id = json["keyId"].as_str().unwrap(); 282 + 283 + // Verify the row exists and has the expected fields. 284 + let row: (String, String, String, String) = sqlx::query_as( 285 + "SELECT id, algorithm, public_key, private_key_encrypted \ 286 + FROM relay_signing_keys WHERE id = ?", 287 + ) 288 + .bind(key_id) 289 + .fetch_one(&db) 290 + .await 291 + .expect("row must exist in relay_signing_keys after successful creation"); 292 + 293 + assert_eq!(row.0, key_id, "db id must match response keyId"); 294 + assert_eq!(row.1, "p256", "db algorithm must be p256"); 295 + assert_eq!( 296 + row.2, 297 + json["publicKey"].as_str().unwrap(), 298 + "db public_key must match response publicKey" 299 + ); 300 + // base64(12-byte nonce || 32-byte ciphertext || 16-byte tag) = base64(60 bytes) = 80 chars 301 + assert_eq!( 302 + row.3.len(), 303 + 80, 304 + "private_key_encrypted must be 80 base64 chars (nonce 12 + ciphertext 32 + tag 16)" 305 + ); 306 + } 307 + 308 + // --- Auth tests --- 309 + 310 + #[tokio::test] 311 + async fn missing_authorization_header_returns_401() { 312 + // MM-92.AC4.1 313 + let response = app(test_state_with_keys().await) 314 + .oneshot(post_keys(r#"{"algorithm": "p256"}"#, None)) 315 + .await 316 + .unwrap(); 317 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 318 + } 319 + 320 + #[tokio::test] 321 + async fn wrong_bearer_token_returns_401() { 322 + // MM-92.AC4.2 323 + let response = app(test_state_with_keys().await) 324 + .oneshot(post_keys(r#"{"algorithm": "p256"}"#, Some("wrong-token"))) 325 + .await 326 + .unwrap(); 327 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 328 + } 329 + 330 + #[tokio::test] 331 + async fn bare_token_without_bearer_prefix_returns_401() { 332 + // MM-92.AC4.3: Authorization header present but "Bearer " prefix missing 333 + let request = Request::builder() 334 + .method("POST") 335 + .uri("/v1/relay/keys") 336 + .header("Content-Type", "application/json") 337 + .header("Authorization", "test-admin-token") // no "Bearer " prefix 338 + .body(Body::from(r#"{"algorithm": "p256"}"#)) 339 + .unwrap(); 340 + 341 + let response = app(test_state_with_keys().await) 342 + .oneshot(request) 343 + .await 344 + .unwrap(); 345 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 346 + } 347 + 348 + // --- Algorithm tests --- 349 + 350 + #[tokio::test] 351 + async fn unsupported_algorithm_returns_400() { 352 + // MM-92.AC5.1 353 + let response = app(test_state_with_keys().await) 354 + .oneshot(post_keys( 355 + r#"{"algorithm": "k256"}"#, 356 + Some("test-admin-token"), 357 + )) 358 + .await 359 + .unwrap(); 360 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 361 + } 362 + 363 + #[tokio::test] 364 + async fn empty_algorithm_returns_400() { 365 + // MM-92.AC5.2 366 + let response = app(test_state_with_keys().await) 367 + .oneshot(post_keys(r#"{"algorithm": ""}"#, Some("test-admin-token"))) 368 + .await 369 + .unwrap(); 370 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 371 + } 372 + 373 + #[tokio::test] 374 + async fn missing_algorithm_field_returns_400() { 375 + // MM-92.AC5.3: empty JSON body — algorithm field absent, defaults to None → 400 376 + let response = app(test_state_with_keys().await) 377 + .oneshot(post_keys(r#"{}"#, Some("test-admin-token"))) 378 + .await 379 + .unwrap(); 380 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 381 + } 382 + 383 + // --- Master key test --- 384 + 385 + #[tokio::test] 386 + async fn missing_master_key_returns_503() { 387 + // MM-92.AC6.1: valid Bearer token, but signing_key_master_key not configured → 503 388 + let base = test_state().await; 389 + let mut config = (*base.config).clone(); 390 + config.admin_token = Some("test-admin-token".to_string()); 391 + // signing_key_master_key intentionally left as None 392 + let state = AppState { 393 + config: Arc::new(config), 394 + db: base.db, 395 + }; 396 + 397 + let response = app(state) 398 + .oneshot(post_keys( 399 + r#"{"algorithm": "p256"}"#, 400 + Some("test-admin-token"), 401 + )) 402 + .await 403 + .unwrap(); 404 + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); 405 + } 406 + 407 + #[tokio::test] 408 + async fn admin_token_not_configured_returns_401() { 409 + // Operator has not set EZPDS_ADMIN_TOKEN; any request to the endpoint returns 401. 410 + // test_state() leaves admin_token as None by default. 411 + let response = app(test_state().await) 412 + .oneshot(post_keys(r#"{"algorithm": "p256"}"#, Some("any-token"))) 413 + .await 414 + .unwrap(); 415 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 416 + } 417 + }
+1
crates/relay/src/routes/mod.rs
··· 1 + pub mod create_signing_key; 1 2 pub mod describe_server; 2 3 pub mod health;