diff --git a/TODO.md b/TODO.md index e747e65..aef40c4 100644 --- a/TODO.md +++ b/TODO.md @@ -37,9 +37,9 @@ Lewis' corrected big boy todofile - [x] Implement `com.atproto.server.requestAccountDelete`. - [x] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`. - [x] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`. - - [ ] Implement `com.atproto.server.reserveSigningKey`. + - [x] Implement `com.atproto.server.reserveSigningKey`. - [x] Implement `com.atproto.server.revokeAppPassword`. - - [ ] Implement `com.atproto.server.updateEmail`. + - [x] Implement `com.atproto.server.updateEmail`. - [x] Implement `com.atproto.server.confirmEmail`. ## Repository Operations (`com.atproto.repo`) diff --git a/migrations/202512211401_reserved_signing_keys.sql b/migrations/202512211401_reserved_signing_keys.sql new file mode 100644 index 0000000..dbda92a --- /dev/null +++ b/migrations/202512211401_reserved_signing_keys.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS reserved_signing_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT, + public_key_did_key TEXT NOT NULL, + private_key_bytes BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours', + used_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_did ON reserved_signing_keys(did) WHERE did IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_reserved_signing_keys_expires ON reserved_signing_keys(expires_at) WHERE used_at IS NULL; diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 88e3445..a0c090a 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -17,13 +17,14 @@ use std::sync::Arc; use tracing::{error, info, warn}; #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] pub struct CreateAccountInput { pub handle: String, pub email: String, pub password: String, - #[serde(rename = "inviteCode")] pub invite_code: Option, pub did: Option, + pub signing_key: Option, } #[derive(Serialize)] @@ -185,12 +186,55 @@ pub async fn create_account( } }; - let secret_key = SecretKey::random(&mut OsRng); - let secret_key_bytes = secret_key.to_bytes(); + let (secret_key_bytes, reserved_key_id): (Vec, Option) = + if let Some(signing_key_did) = &input.signing_key { + let reserved = sqlx::query!( + r#" + SELECT id, private_key_bytes + FROM reserved_signing_keys + WHERE public_key_did_key = $1 + AND used_at IS NULL + AND expires_at > NOW() + FOR UPDATE + "#, + signing_key_did + ) + .fetch_optional(&mut *tx) + .await; - let key_insert = sqlx::query!("INSERT INTO user_keys (user_id, key_bytes) VALUES ($1, $2)", user_id, &secret_key_bytes[..]) - .execute(&mut *tx) - .await; + match reserved { + Ok(Some(row)) => (row.private_key_bytes, Some(row.id)), + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidSigningKey", + "message": "Signing key not found, already used, or expired" + })), + ) + .into_response(); + } + Err(e) => { + error!("Error looking up reserved signing key: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + } else { + let secret_key = SecretKey::random(&mut OsRng); + (secret_key.to_bytes().to_vec(), None) + }; + + let key_insert = sqlx::query!( + "INSERT INTO user_keys (user_id, key_bytes) VALUES ($1, $2)", + user_id, + &secret_key_bytes[..] + ) + .execute(&mut *tx) + .await; if let Err(e) = key_insert { error!("Error inserting user key: {:?}", e); @@ -201,6 +245,24 @@ pub async fn create_account( .into_response(); } + if let Some(key_id) = reserved_key_id { + let mark_used = sqlx::query!( + "UPDATE reserved_signing_keys SET used_at = NOW() WHERE id = $1", + key_id + ) + .execute(&mut *tx) + .await; + + if let Err(e) = mark_used { + error!("Error marking reserved key as used: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + let mst = Mst::new(Arc::new(state.block_store.clone())); let mst_root = match mst.persist().await { Ok(c) => c, diff --git a/src/api/server/email.rs b/src/api/server/email.rs index 882f2de..972c26b 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -286,3 +286,212 @@ pub async fn confirm_email( (StatusCode::OK, Json(json!({}))).into_response() } + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateEmailInput { + pub email: String, + #[serde(default)] + pub email_auth_factor: Option, + pub token: Option, +} + +pub async fn update_email( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let token = auth_header + .unwrap() + .to_str() + .unwrap_or("") + .replace("Bearer ", ""); + + let session = sqlx::query!( + r#" + SELECT s.did, k.key_bytes, u.id as user_id, u.email as current_email, + u.email_confirmation_code, u.email_confirmation_code_expires_at, + u.email_pending_verification + FROM sessions s + JOIN users u ON s.did = u.did + JOIN user_keys k ON u.id = k.user_id + WHERE s.access_jwt = $1 + "#, + token + ) + .fetch_optional(&state.db) + .await; + + let ( + _did, + key_bytes, + user_id, + current_email, + stored_code, + expires_at, + email_pending_verification, + ) = match session { + Ok(Some(row)) => ( + row.did, + row.key_bytes, + row.user_id, + row.current_email, + row.email_confirmation_code, + row.email_confirmation_code_expires_at, + row.email_pending_verification, + ), + Ok(None) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in update_email: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), + ) + .into_response(); + } + + let new_email = input.email.trim().to_lowercase(); + if new_email.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "email is required"})), + ) + .into_response(); + } + + if !new_email.contains('@') || !new_email.contains('.') { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Invalid email format"})), + ) + .into_response(); + } + + if new_email == current_email.to_lowercase() { + return (StatusCode::OK, Json(json!({}))).into_response(); + } + + let email_confirmed = stored_code.is_some() && email_pending_verification.is_some(); + + if email_confirmed { + let confirmation_token = match &input.token { + Some(t) => t.trim(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "TokenRequired", "message": "Token required for confirmed accounts. Call requestEmailUpdate first."})), + ) + .into_response(); + } + }; + + let pending_email = email_pending_verification.unwrap(); + if pending_email.to_lowercase() != new_email { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})), + ) + .into_response(); + } + + if stored_code.unwrap() != confirmation_token { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidToken", "message": "Invalid token"})), + ) + .into_response(); + } + + if let Some(exp) = expires_at { + if Utc::now() > exp { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), + ) + .into_response(); + } + } + } + + let exists = sqlx::query!( + "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", + new_email, + user_id + ) + .fetch_optional(&state.db) + .await; + + if let Ok(Some(_)) = exists { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), + ) + .into_response(); + } + + let update = sqlx::query!( + r#" + UPDATE users + SET email = $1, + email_pending_verification = NULL, + email_confirmation_code = NULL, + email_confirmation_code_expires_at = NULL, + updated_at = NOW() + WHERE id = $2 + "#, + new_email, + user_id + ) + .execute(&state.db) + .await; + + match update { + Ok(_) => { + info!("Email updated to {} for user {}", new_email, user_id); + (StatusCode::OK, Json(json!({}))).into_response() + } + Err(e) => { + error!("DB error finalizing email update: {:?}", e); + if e.as_database_error() + .map(|db_err| db_err.is_unique_violation()) + .unwrap_or(false) + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), + ) + .into_response(); + } + + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index 35ad563..eeb6687 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -5,15 +5,17 @@ pub mod invite; pub mod meta; pub mod password; pub mod session; +pub mod signing_key; pub use account_status::{ activate_account, check_account_status, deactivate_account, request_account_delete, }; pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; -pub use email::{confirm_email, request_email_update}; +pub use email::{confirm_email, request_email_update, update_email}; pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; pub use meta::{describe_server, health}; pub use password::{request_password_reset, reset_password}; pub use session::{ create_session, delete_session, get_service_auth, get_session, refresh_session, }; +pub use signing_key::reserve_signing_key; diff --git a/src/api/server/signing_key.rs b/src/api/server/signing_key.rs new file mode 100644 index 0000000..e5cfc70 --- /dev/null +++ b/src/api/server/signing_key.rs @@ -0,0 +1,90 @@ +use crate::state::AppState; +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use chrono::{Duration, Utc}; +use k256::ecdsa::SigningKey; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::{error, info}; + +const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01]; + +fn public_key_to_did_key(signing_key: &SigningKey) -> String { + let verifying_key = signing_key.verifying_key(); + let compressed_pubkey = verifying_key.to_sec1_bytes(); + + let mut multicodec_key = Vec::with_capacity(2 + compressed_pubkey.len()); + multicodec_key.extend_from_slice(&SECP256K1_MULTICODEC_PREFIX); + multicodec_key.extend_from_slice(&compressed_pubkey); + + let encoded = multibase::encode(multibase::Base::Base58Btc, &multicodec_key); + + format!("did:key:{}", encoded) +} + +#[derive(Deserialize)] +pub struct ReserveSigningKeyInput { + pub did: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReserveSigningKeyOutput { + pub signing_key: String, +} + +pub async fn reserve_signing_key( + State(state): State, + Json(input): Json, +) -> Response { + let signing_key = SigningKey::random(&mut rand::thread_rng()); + let private_key_bytes = signing_key.to_bytes(); + let public_key_did_key = public_key_to_did_key(&signing_key); + + let expires_at = Utc::now() + Duration::hours(24); + + let private_bytes: &[u8] = &private_key_bytes; + + let result = sqlx::query!( + r#" + INSERT INTO reserved_signing_keys (did, public_key_did_key, private_key_bytes, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id + "#, + input.did, + public_key_did_key, + private_bytes, + expires_at + ) + .fetch_one(&state.db) + .await; + + match result { + Ok(row) => { + info!( + "Reserved signing key {} for did {:?}", + row.id, + input.did + ); + ( + StatusCode::OK, + Json(ReserveSigningKeyOutput { + signing_key: public_key_did_key, + }), + ) + .into_response() + } + Err(e) => { + error!("DB error in reserve_signing_key: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 015473c..8fc47de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -171,6 +171,14 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.server.confirmEmail", post(api::server::confirm_email), ) + .route( + "/xrpc/com.atproto.server.updateEmail", + post(api::server::update_email), + ) + .route( + "/xrpc/com.atproto.server.reserveSigningKey", + post(api::server::reserve_signing_key), + ) .route( "/xrpc/com.atproto.identity.updateHandle", post(api::identity::update_handle), diff --git a/tests/email_update.rs b/tests/email_update.rs index 08df505..4adace2 100644 --- a/tests/email_update.rs +++ b/tests/email_update.rs @@ -234,3 +234,327 @@ async fn test_confirm_email_wrong_email() { let body: Value = res.json().await.expect("Invalid JSON"); assert_eq!(body["message"], "Email does not match pending update"); } + +#[tokio::test] +async fn test_update_email_success_no_token_required() { + let client = common::client(); + let base_url = common::base_url().await; + let pool = get_pool().await; + + let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4()); + let email = format!("{}@example.com", handle); + let res = client + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": "password" + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt"); + + let new_email = format!("direct_{}@example.com", handle); + let res = client + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) + .bearer_auth(access_jwt) + .json(&json!({ "email": new_email })) + .send() + .await + .expect("Failed to update email"); + + assert_eq!(res.status(), StatusCode::OK); + + let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) + .fetch_one(&pool) + .await + .expect("User not found"); + assert_eq!(user.email, new_email); +} + +#[tokio::test] +async fn test_update_email_same_email_noop() { + let client = common::client(); + let base_url = common::base_url().await; + + let handle = format!("emailup_same_{}", uuid::Uuid::new_v4()); + let email = format!("{}@example.com", handle); + let res = client + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": "password" + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt"); + + let res = client + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) + .bearer_auth(access_jwt) + .json(&json!({ "email": email })) + .send() + .await + .expect("Failed to update email"); + + assert_eq!(res.status(), StatusCode::OK, "Updating to same email should succeed as no-op"); +} + +#[tokio::test] +async fn test_update_email_requires_token_after_pending() { + let client = common::client(); + let base_url = common::base_url().await; + + let handle = format!("emailup_token_{}", uuid::Uuid::new_v4()); + let email = format!("{}@example.com", handle); + let res = client + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": "password" + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt"); + + let new_email = format!("pending_{}@example.com", handle); + let res = client + .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) + .bearer_auth(access_jwt) + .json(&json!({"email": new_email})) + .send() + .await + .expect("Failed to request email update"); + assert_eq!(res.status(), StatusCode::OK); + + let res = client + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) + .bearer_auth(access_jwt) + .json(&json!({ "email": new_email })) + .send() + .await + .expect("Failed to attempt email update"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["error"], "TokenRequired"); +} + +#[tokio::test] +async fn test_update_email_with_valid_token() { + let client = common::client(); + let base_url = common::base_url().await; + let pool = get_pool().await; + + let handle = format!("emailup_valid_{}", uuid::Uuid::new_v4()); + let email = format!("{}@example.com", handle); + let res = client + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": "password" + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt"); + + let new_email = format!("valid_{}@example.com", handle); + let res = client + .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) + .bearer_auth(access_jwt) + .json(&json!({"email": new_email})) + .send() + .await + .expect("Failed to request email update"); + assert_eq!(res.status(), StatusCode::OK); + + let user = sqlx::query!( + "SELECT email_confirmation_code FROM users WHERE handle = $1", + handle + ) + .fetch_one(&pool) + .await + .expect("User not found"); + let code = user.email_confirmation_code.unwrap(); + + let res = client + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) + .bearer_auth(access_jwt) + .json(&json!({ + "email": new_email, + "token": code + })) + .send() + .await + .expect("Failed to update email"); + + assert_eq!(res.status(), StatusCode::OK); + + let user = sqlx::query!("SELECT email, email_pending_verification FROM users WHERE handle = $1", handle) + .fetch_one(&pool) + .await + .expect("User not found"); + assert_eq!(user.email, new_email); + assert!(user.email_pending_verification.is_none()); +} + +#[tokio::test] +async fn test_update_email_invalid_token() { + let client = common::client(); + let base_url = common::base_url().await; + + let handle = format!("emailup_badtok_{}", uuid::Uuid::new_v4()); + let email = format!("{}@example.com", handle); + let res = client + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": "password" + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt"); + + let new_email = format!("badtok_{}@example.com", handle); + let res = client + .post(format!("{}/xrpc/com.atproto.server.requestEmailUpdate", base_url)) + .bearer_auth(access_jwt) + .json(&json!({"email": new_email})) + .send() + .await + .expect("Failed to request email update"); + assert_eq!(res.status(), StatusCode::OK); + + let res = client + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) + .bearer_auth(access_jwt) + .json(&json!({ + "email": new_email, + "token": "wrong-token-12345" + })) + .send() + .await + .expect("Failed to attempt email update"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["error"], "InvalidToken"); +} + +#[tokio::test] +async fn test_update_email_already_taken() { + let client = common::client(); + let base_url = common::base_url().await; + + let handle1 = format!("emailup_dup1_{}", uuid::Uuid::new_v4()); + let email1 = format!("{}@example.com", handle1); + let res = client + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .json(&json!({ + "handle": handle1, + "email": email1, + "password": "password" + })) + .send() + .await + .expect("Failed to create account 1"); + assert_eq!(res.status(), StatusCode::OK); + + let handle2 = format!("emailup_dup2_{}", uuid::Uuid::new_v4()); + let email2 = format!("{}@example.com", handle2); + let res = client + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .json(&json!({ + "handle": handle2, + "email": email2, + "password": "password" + })) + .send() + .await + .expect("Failed to create account 2"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let access_jwt2 = body["accessJwt"].as_str().expect("No accessJwt"); + + let res = client + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) + .bearer_auth(access_jwt2) + .json(&json!({ "email": email1 })) + .send() + .await + .expect("Failed to attempt email update"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.expect("Invalid JSON"); + assert!(body["message"].as_str().unwrap().contains("already in use") || body["error"] == "InvalidRequest"); +} + +#[tokio::test] +async fn test_update_email_no_auth() { + let client = common::client(); + let base_url = common::base_url().await; + + let res = client + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) + .json(&json!({ "email": "test@example.com" })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["error"], "AuthenticationRequired"); +} + +#[tokio::test] +async fn test_update_email_invalid_format() { + let client = common::client(); + let base_url = common::base_url().await; + + let handle = format!("emailup_fmt_{}", uuid::Uuid::new_v4()); + let email = format!("{}@example.com", handle); + let res = client + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) + .json(&json!({ + "handle": handle, + "email": email, + "password": "password" + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let access_jwt = body["accessJwt"].as_str().expect("No accessJwt"); + + let res = client + .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) + .bearer_auth(access_jwt) + .json(&json!({ "email": "not-an-email" })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["error"], "InvalidRequest"); +} diff --git a/tests/signing_key.rs b/tests/signing_key.rs new file mode 100644 index 0000000..b72183e --- /dev/null +++ b/tests/signing_key.rs @@ -0,0 +1,355 @@ +mod common; + +use reqwest::StatusCode; +use serde_json::{json, Value}; +use sqlx::PgPool; + +async fn get_pool() -> PgPool { + let conn_str = common::get_db_connection_string().await; + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&conn_str) + .await + .expect("Failed to connect to test database") +} + +#[tokio::test] +async fn test_reserve_signing_key_without_did() { + let client = common::client(); + let base_url = common::base_url().await; + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url + )) + .json(&json!({})) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + + assert!(body["signingKey"].is_string()); + let signing_key = body["signingKey"].as_str().unwrap(); + assert!( + signing_key.starts_with("did:key:z"), + "Signing key should be in did:key format with multibase prefix" + ); +} + +#[tokio::test] +async fn test_reserve_signing_key_with_did() { + let client = common::client(); + let base_url = common::base_url().await; + let pool = get_pool().await; + + let target_did = "did:plc:test123456"; + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url + )) + .json(&json!({ "did": target_did })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + + let signing_key = body["signingKey"].as_str().unwrap(); + assert!(signing_key.starts_with("did:key:z")); + + let row = sqlx::query!( + "SELECT did, public_key_did_key FROM reserved_signing_keys WHERE public_key_did_key = $1", + signing_key + ) + .fetch_one(&pool) + .await + .expect("Reserved key not found in database"); + + assert_eq!(row.did.as_deref(), Some(target_did)); + assert_eq!(row.public_key_did_key, signing_key); +} + +#[tokio::test] +async fn test_reserve_signing_key_stores_private_key() { + let client = common::client(); + let base_url = common::base_url().await; + let pool = get_pool().await; + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url + )) + .json(&json!({})) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + let signing_key = body["signingKey"].as_str().unwrap(); + + let row = sqlx::query!( + "SELECT private_key_bytes, expires_at, used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", + signing_key + ) + .fetch_one(&pool) + .await + .expect("Reserved key not found in database"); + + assert_eq!(row.private_key_bytes.len(), 32, "Private key should be 32 bytes for secp256k1"); + assert!(row.used_at.is_none(), "Reserved key should not be marked as used yet"); + assert!(row.expires_at > chrono::Utc::now(), "Key should expire in the future"); +} + +#[tokio::test] +async fn test_reserve_signing_key_unique_keys() { + let client = common::client(); + let base_url = common::base_url().await; + + let res1 = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url + )) + .json(&json!({})) + .send() + .await + .expect("Failed to send request 1"); + assert_eq!(res1.status(), StatusCode::OK); + let body1: Value = res1.json().await.unwrap(); + let key1 = body1["signingKey"].as_str().unwrap(); + + let res2 = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url + )) + .json(&json!({})) + .send() + .await + .expect("Failed to send request 2"); + assert_eq!(res2.status(), StatusCode::OK); + let body2: Value = res2.json().await.unwrap(); + let key2 = body2["signingKey"].as_str().unwrap(); + + assert_ne!(key1, key2, "Each call should generate a unique signing key"); +} + +#[tokio::test] +async fn test_reserve_signing_key_is_public() { + let client = common::client(); + let base_url = common::base_url().await; + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url + )) + .json(&json!({})) + .send() + .await + .expect("Failed to send request"); + + assert_eq!( + res.status(), + StatusCode::OK, + "reserveSigningKey should work without authentication" + ); +} + +#[tokio::test] +async fn test_create_account_with_reserved_signing_key() { + let client = common::client(); + let base_url = common::base_url().await; + let pool = get_pool().await; + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url + )) + .json(&json!({})) + .send() + .await + .expect("Failed to reserve signing key"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let signing_key = body["signingKey"].as_str().unwrap(); + + let handle = format!("reserved_key_user_{}", uuid::Uuid::new_v4()); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) + .json(&json!({ + "handle": handle, + "email": format!("{}@example.com", handle), + "password": "password", + "signingKey": signing_key + })) + .send() + .await + .expect("Failed to create account"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + assert!(body["accessJwt"].is_string()); + assert!(body["did"].is_string()); + + let reserved = sqlx::query!( + "SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", + signing_key + ) + .fetch_one(&pool) + .await + .expect("Reserved key not found"); + assert!( + reserved.used_at.is_some(), + "Reserved key should be marked as used" + ); +} + +#[tokio::test] +async fn test_create_account_with_invalid_signing_key() { + let client = common::client(); + let base_url = common::base_url().await; + + let handle = format!("bad_key_user_{}", uuid::Uuid::new_v4()); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) + .json(&json!({ + "handle": handle, + "email": format!("{}@example.com", handle), + "password": "password", + "signingKey": "did:key:zNonExistentKey12345" + })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "InvalidSigningKey"); +} + +#[tokio::test] +async fn test_create_account_cannot_reuse_signing_key() { + let client = common::client(); + let base_url = common::base_url().await; + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url + )) + .json(&json!({})) + .send() + .await + .expect("Failed to reserve signing key"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let signing_key = body["signingKey"].as_str().unwrap(); + + let handle1 = format!("reuse_key_user1_{}", uuid::Uuid::new_v4()); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) + .json(&json!({ + "handle": handle1, + "email": format!("{}@example.com", handle1), + "password": "password", + "signingKey": signing_key + })) + .send() + .await + .expect("Failed to create first account"); + assert_eq!(res.status(), StatusCode::OK); + + let handle2 = format!("reuse_key_user2_{}", uuid::Uuid::new_v4()); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) + .json(&json!({ + "handle": handle2, + "email": format!("{}@example.com", handle2), + "password": "password", + "signingKey": signing_key + })) + .send() + .await + .expect("Failed to send second request"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "InvalidSigningKey"); + assert!(body["message"] + .as_str() + .unwrap() + .contains("already used")); +} + +#[tokio::test] +async fn test_reserved_key_tokens_work() { + let client = common::client(); + let base_url = common::base_url().await; + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url + )) + .json(&json!({})) + .send() + .await + .expect("Failed to reserve signing key"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let signing_key = body["signingKey"].as_str().unwrap(); + + let handle = format!("token_test_user_{}", uuid::Uuid::new_v4()); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url + )) + .json(&json!({ + "handle": handle, + "email": format!("{}@example.com", handle), + "password": "password", + "signingKey": signing_key + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let access_jwt = body["accessJwt"].as_str().unwrap(); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.server.getSession", + base_url + )) + .bearer_auth(access_jwt) + .send() + .await + .expect("Failed to get session"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["handle"], handle); +}