Gearing up for migration endpoints

This commit is contained in:
lewis
2025-12-09 23:35:06 +02:00
parent 0c57965638
commit 8cfea3b480
9 changed files with 1071 additions and 9 deletions
+2 -2
View File
@@ -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`)
@@ -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;
+68 -6
View File
@@ -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<String>,
pub did: Option<String>,
pub signing_key: Option<String>,
}
#[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<u8>, Option<uuid::Uuid>) =
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,
+209
View File
@@ -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<bool>,
pub token: Option<String>,
}
pub async fn update_email(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(input): Json<UpdateEmailInput>,
) -> 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()
}
}
}
+3 -1
View File
@@ -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;
+90
View File
@@ -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<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReserveSigningKeyOutput {
pub signing_key: String,
}
pub async fn reserve_signing_key(
State(state): State<AppState>,
Json(input): Json<ReserveSigningKeyInput>,
) -> 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()
}
}
}
+8
View File
@@ -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),
+324
View File
@@ -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");
}
+355
View File
@@ -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);
}