diff --git a/backend/src/handlers/api_keys.rs b/backend/src/handlers/api_keys.rs index 5c7bb71..2262853 100644 --- a/backend/src/handlers/api_keys.rs +++ b/backend/src/handlers/api_keys.rs @@ -169,6 +169,34 @@ pub async fn test_key( } } +/// `POST /api/v1/user/api-keys/export` +/// +/// Returns decrypted API keys for settings export backup. +/// Rate-limited to 3 calls per minute. Uses POST to require CSRF token. +pub async fn export_keys( + auth_user: AuthUser, + State(state): State, +) -> Result { + if !state.auth_rate_limiter.check(&format!("key-export:{}", auth_user.id)) { + return Err(AppError::RateLimited("Too many export requests".into())); + } + + let stored_keys = db::api_keys::list_for_user(&state.pool, auth_user.id).await?; + let master_key = encryption::MasterKey::from_hex(&state.config.master_encryption_key)?; + + let mut exported = Vec::new(); + for key in &stored_keys { + let decrypted = encryption::decrypt(&master_key, &key.encrypted_key, &key.nonce)?; + exported.push(serde_json::json!({ + "provider_name": key.provider_name, + "api_key": decrypted, + })); + } + + tracing::info!(user_id = %auth_user.id, key_count = exported.len(), "API keys exported"); + Ok(Json(exported)) +} + /// Get a default model identifier for a provider from the admin config. /// /// Looks up the admin provider configuration for the given provider name diff --git a/backend/src/router.rs b/backend/src/router.rs index 2e62291..402189e 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -49,6 +49,7 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router { .route("/user/api-keys", post(handlers::api_keys::create)) .route("/user/api-keys/{provider}", delete(handlers::api_keys::delete)) .route("/user/api-keys/{provider}/test", post(handlers::api_keys::test_key)) + .route("/user/api-keys/export", post(handlers::api_keys::export_keys)) // Generation routes (authenticated) — registered before /syntheses/{id} // to avoid ambiguity with path parameter matching .route("/syntheses/generate", post(handlers::generation::trigger_generate))