diff --git a/crates/tranquil-api/src/identity/account.rs b/crates/tranquil-api/src/identity/account.rs index df9f25a..e02bdc0 100644 --- a/crates/tranquil-api/src/identity/account.rs +++ b/crates/tranquil-api/src/identity/account.rs @@ -1,4 +1,5 @@ use super::did::verify_did_web; +use crate::common; use axum::{ Json, extract::State, @@ -6,11 +7,9 @@ use axum::{ response::{IntoResponse, Response}, }; use bcrypt::{DEFAULT_COST, hash}; -use k256::{SecretKey, ecdsa::SigningKey}; -use rand::rngs::OsRng; use serde::{Deserialize, Serialize}; use serde_json::json; -use tracing::{debug, error, info, warn}; +use tracing::{debug, error, info}; use tranquil_pds::api::error::ApiError; use tranquil_pds::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token}; use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited}; @@ -47,6 +46,138 @@ pub struct CreateAccountOutput { pub verification_channel: tranquil_db_traits::CommsChannel, } +async fn try_reactivate_migration( + state: &AppState, + did: &str, + handle: &str, + email: &Option, + verification_channel: tranquil_db_traits::CommsChannel, + verification_recipient: Option<&str>, +) -> Option { + let did_typed: Did = match did.parse() { + Ok(d) => d, + Err(_) => return Some(ApiError::InternalError(Some("Invalid DID".into())).into_response()), + }; + let handle_typed: Handle = match handle.parse() { + Ok(h) => h, + Err(_) => return Some(ApiError::InvalidHandle(None).into_response()), + }; + let reactivate_input = tranquil_db_traits::MigrationReactivationInput { + did: did_typed.clone(), + new_handle: handle_typed.clone(), + new_email: email.clone(), + }; + match state + .user_repo + .reactivate_migration_account(&reactivate_input) + .await + { + Ok(reactivated) => { + info!(did = %did, old_handle = %reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration"); + let secret_key_bytes = match state + .user_repo + .get_user_key_by_id(reactivated.user_id) + .await + { + Ok(Some(key_info)) => { + match tranquil_pds::config::decrypt_key( + &key_info.key_bytes, + key_info.encryption_version, + ) { + Ok(k) => k, + Err(e) => { + error!("Error decrypting key for reactivated account: {:?}", e); + return Some(ApiError::InternalError(None).into_response()); + } + } + } + _ => { + error!("No signing key found for reactivated account"); + return Some( + ApiError::InternalError(Some("Account signing key not found".into())) + .into_response(), + ); + } + }; + let access_meta = + match tranquil_pds::auth::create_access_token_with_metadata(did, &secret_key_bytes) + { + Ok(m) => m, + Err(e) => { + error!("Error creating access token: {:?}", e); + return Some(ApiError::InternalError(None).into_response()); + } + }; + let refresh_meta = match tranquil_pds::auth::create_refresh_token_with_metadata( + did, + &secret_key_bytes, + ) { + Ok(m) => m, + Err(e) => { + error!("Error creating refresh token: {:?}", e); + return Some(ApiError::InternalError(None).into_response()); + } + }; + let session_data = tranquil_db_traits::SessionTokenCreate { + did: did_typed.clone(), + access_jti: access_meta.jti.clone(), + refresh_jti: refresh_meta.jti.clone(), + access_expires_at: access_meta.expires_at, + refresh_expires_at: refresh_meta.expires_at, + login_type: tranquil_db_traits::LoginType::Modern, + mfa_verified: false, + scope: Some("transition:generic transition:chat.bsky".to_string()), + controller_did: None, + app_password_name: None, + }; + if let Err(e) = state.session_repo.create_session(&session_data).await { + error!("Error creating session: {:?}", e); + return Some(ApiError::InternalError(None).into_response()); + } + let verification_required = match verification_recipient { + Some(recipient) => { + super::provision::enqueue_migration_verification( + state, + reactivated.user_id, + &did_typed, + verification_channel, + recipient, + ) + .await; + true + } + None => false, + }; + Some( + ( + StatusCode::OK, + Json(CreateAccountOutput { + handle: handle.to_string().into(), + did: did_typed.clone(), + did_doc: state.did_resolver.resolve_did_document(did).await, + access_jwt: access_meta.token, + refresh_jwt: refresh_meta.token, + verification_required, + verification_channel, + }), + ) + .into_response(), + ) + } + Err(tranquil_db_traits::MigrationReactivationError::NotFound) => None, + Err(tranquil_db_traits::MigrationReactivationError::NotDeactivated) => { + Some(ApiError::AccountAlreadyExists.into_response()) + } + Err(tranquil_db_traits::MigrationReactivationError::HandleTaken) => { + Some(ApiError::HandleTaken.into_response()) + } + Err(e) => { + error!("Error reactivating migration account: {:?}", e); + Some(ApiError::InternalError(None).into_response()) + } + } +} + pub async fn create_account( State(state): State, _rate_limit: RateLimited, @@ -154,79 +285,37 @@ pub async fn create_account( .verification_channel .unwrap_or(tranquil_db_traits::CommsChannel::Email); let verification_recipient = { - Some(match verification_channel { - tranquil_db_traits::CommsChannel::Email => match &input.email { - Some(email) if !email.trim().is_empty() => email.trim().to_string(), - _ => return ApiError::MissingEmail.into_response(), + Some( + match common::extract_verification_recipient( + verification_channel, + &common::ChannelInput { + email: input.email.as_deref(), + discord_username: input.discord_username.as_deref(), + telegram_username: input.telegram_username.as_deref(), + signal_username: input.signal_username.as_deref(), + }, + ) { + Ok(r) => r, + Err(e) => return e.into_response(), }, - tranquil_db_traits::CommsChannel::Discord => match &input.discord_username { - Some(username) if !username.trim().is_empty() => { - let clean = username.trim().to_lowercase(); - if !tranquil_pds::api::validation::is_valid_discord_username(&clean) { - return ApiError::InvalidRequest( - "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), - ).into_response(); - } - clean - } - _ => return ApiError::MissingDiscordId.into_response(), - }, - tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username { - Some(username) if !username.trim().is_empty() => { - let clean = username.trim().trim_start_matches('@'); - if !tranquil_pds::api::validation::is_valid_telegram_username(clean) { - return ApiError::InvalidRequest( - "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), - ).into_response(); - } - clean.to_string() - } - _ => return ApiError::MissingTelegramUsername.into_response(), - }, - tranquil_db_traits::CommsChannel::Signal => match &input.signal_username { - Some(username) if !username.trim().is_empty() => { - username.trim().trim_start_matches('@').to_lowercase() - } - _ => return ApiError::MissingSignalNumber.into_response(), - }, - }) + ) }; let hostname = &cfg.server.hostname; - let (secret_key_bytes, reserved_key_id): (Vec, Option) = - if let Some(signing_key_did) = &input.signing_key { - match state - .infra_repo - .get_reserved_signing_key(signing_key_did) - .await - { - Ok(Some(key)) => (key.private_key_bytes, Some(key.id)), - Ok(None) => { - return ApiError::InvalidSigningKey.into_response(); - } - Err(e) => { - error!("Error looking up reserved signing key: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - } - } else { - let secret_key = SecretKey::random(&mut OsRng); - (secret_key.to_bytes().to_vec(), None) + let key_result = + match super::provision::resolve_signing_key(&state, input.signing_key.as_deref()).await { + Ok(k) => k, + Err(e) => return e.into_response(), }; - let signing_key = match SigningKey::from_slice(&secret_key_bytes) { - Ok(k) => k, - Err(e) => { - error!("Error creating signing key: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; + let secret_key_bytes = key_result.secret_key_bytes; + let signing_key = key_result.signing_key; + let reserved_key_id = key_result.reserved_key_id; let did_type = input.did_type.as_deref().unwrap_or("plc"); let did = match did_type { "web" => { - if !tranquil_pds::util::is_self_hosted_did_web_enabled() { - return ApiError::SelfHostedDidWebDisabled.into_response(); - } - let encoded_handle = handle.replace(':', "%3A"); - let self_hosted_did = format!("did:web:{}", encoded_handle); + let self_hosted_did = match common::create_self_hosted_did_web(&handle) { + Ok(d) => d, + Err(e) => return e.into_response(), + }; info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)"); self_hosted_did } @@ -287,140 +376,18 @@ pub async fn create_account( } } }; - if is_migration { - let did_typed: Did = match did.parse() { - Ok(d) => d, - Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(), - }; - let handle_typed: Handle = match handle.parse() { - Ok(h) => h, - Err(_) => return ApiError::InvalidHandle(None).into_response(), - }; - let reactivate_input = tranquil_db_traits::MigrationReactivationInput { - did: did_typed.clone(), - new_handle: handle_typed.clone(), - new_email: email.clone(), - }; - match state - .user_repo - .reactivate_migration_account(&reactivate_input) - .await - { - Ok(reactivated) => { - info!(did = %did, old_handle = %reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration"); - let secret_key_bytes = match state - .user_repo - .get_user_key_by_id(reactivated.user_id) - .await - { - Ok(Some(key_info)) => { - match tranquil_pds::config::decrypt_key( - &key_info.key_bytes, - key_info.encryption_version, - ) { - Ok(k) => k, - Err(e) => { - error!("Error decrypting key for reactivated account: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - } - } - _ => { - error!("No signing key found for reactivated account"); - return ApiError::InternalError(Some( - "Account signing key not found".into(), - )) - .into_response(); - } - }; - let access_meta = match tranquil_pds::auth::create_access_token_with_metadata( - &did, - &secret_key_bytes, - ) { - Ok(m) => m, - Err(e) => { - error!("Error creating access token: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; - let refresh_meta = match tranquil_pds::auth::create_refresh_token_with_metadata( - &did, - &secret_key_bytes, - ) { - Ok(m) => m, - Err(e) => { - error!("Error creating refresh token: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; - let session_data = tranquil_db_traits::SessionTokenCreate { - did: did_typed.clone(), - access_jti: access_meta.jti.clone(), - refresh_jti: refresh_meta.jti.clone(), - access_expires_at: access_meta.expires_at, - refresh_expires_at: refresh_meta.expires_at, - login_type: tranquil_db_traits::LoginType::Modern, - mfa_verified: false, - scope: Some("transition:generic transition:chat.bsky".to_string()), - controller_did: None, - app_password_name: None, - }; - if let Err(e) = state.session_repo.create_session(&session_data).await { - error!("Error creating session: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - let hostname = &tranquil_config::get().server.hostname; - let verification_required = if let Some(ref recipient) = verification_recipient { - let token = tranquil_pds::auth::verification_token::generate_migration_token( - &did_typed, - verification_channel, - recipient, - ); - let formatted_token = - tranquil_pds::auth::verification_token::format_token_for_display(&token); - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification( - state.user_repo.as_ref(), - state.infra_repo.as_ref(), - reactivated.user_id, - verification_channel, - recipient, - &formatted_token, - hostname, - ) - .await - { - warn!("Failed to enqueue migration verification: {:?}", e); - } - true - } else { - false - }; - return ( - axum::http::StatusCode::OK, - Json(CreateAccountOutput { - handle: handle.clone().into(), - did: did_typed.clone(), - did_doc: state.did_resolver.resolve_did_document(&did).await, - access_jwt: access_meta.token, - refresh_jwt: refresh_meta.token, - verification_required, - verification_channel, - }), - ) - .into_response(); - } - Err(tranquil_db_traits::MigrationReactivationError::NotFound) => {} - Err(tranquil_db_traits::MigrationReactivationError::NotDeactivated) => { - return ApiError::AccountAlreadyExists.into_response(); - } - Err(tranquil_db_traits::MigrationReactivationError::HandleTaken) => { - return ApiError::HandleTaken.into_response(); - } - Err(e) => { - error!("Error reactivating migration account: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - } + if is_migration + && let Some(response) = try_reactivate_migration( + &state, + &did, + &handle, + &email, + verification_channel, + verification_recipient.as_deref(), + ) + .await + { + return response; } let handle_typed: Handle = match handle.parse() { @@ -528,7 +495,13 @@ pub async fn create_account( None }; + let comms = super::provision::normalize_comms_usernames( + input.discord_username.as_deref(), + input.telegram_username.as_deref(), + input.signal_username.as_deref(), + ); let preferred_comms_channel = verification_channel; + let repo_for_seq = repo.clone(); let create_input = tranquil_db_traits::CreatePasswordAccountInput { handle: handle_typed.clone(), @@ -536,23 +509,9 @@ pub async fn create_account( did: did_for_commit.clone(), password_hash, preferred_comms_channel, - discord_username: input - .discord_username - .as_deref() - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()), - telegram_username: input - .telegram_username - .as_deref() - .map(|s| s.trim().trim_start_matches('@')) - .filter(|s| !s.is_empty()) - .map(String::from), - signal_username: input - .signal_username - .as_deref() - .map(|s| s.trim().trim_start_matches('@')) - .filter(|s| !s.is_empty()) - .map(|s| s.to_lowercase()), + discord_username: comms.discord, + telegram_username: comms.telegram, + signal_username: comms.signal, deactivated_at, encrypted_key_bytes: repo.encrypted_key_bytes, encryption_version: tranquil_pds::config::ENCRYPTION_VERSION, @@ -586,144 +545,50 @@ pub async fn create_account( }; let user_id = create_result.user_id; if !is_migration && !is_did_web_byod { - if let Err(e) = tranquil_pds::repo_ops::sequence_identity_event( + super::provision::sequence_new_account( &state, &did_for_commit, - Some(&handle_typed), + &handle_typed, + &repo_for_seq, + &input.handle, ) - .await - { - warn!("Failed to sequence identity event for {}: {}", did, e); - } - if let Err(e) = tranquil_pds::repo_ops::sequence_account_event( - &state, - &did_for_commit, - tranquil_db_traits::AccountStatus::Active, - ) - .await - { - warn!("Failed to sequence account event for {}: {}", did, e); - } - if let Err(e) = tranquil_pds::repo_ops::sequence_genesis_commit( - &state, - &did_for_commit, - &repo.commit_cid, - &repo.mst_root_cid, - &rev_str, - ) - .await - { - warn!("Failed to sequence commit event for {}: {}", did, e); - } - if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event( - &state, - &did_for_commit, - &commit_cid_str, - Some(&rev_str), - ) - .await - { - warn!("Failed to sequence sync event for {}: {}", did, e); - } - let profile_record = json!({ - "$type": "app.bsky.actor.profile", - "displayName": input.handle - }); - if let Err(e) = tranquil_pds::repo_ops::create_record_internal( - &state, - &did_for_commit, - &tranquil_pds::types::PROFILE_COLLECTION, - &tranquil_pds::types::PROFILE_RKEY, - &profile_record, - ) - .await - { - warn!("Failed to create default profile for {}: {}", did, e); - } + .await; } - let hostname = &tranquil_config::get().server.hostname; if !is_migration { if let Some(ref recipient) = verification_recipient { - let verification_token = tranquil_pds::auth::verification_token::generate_signup_token( + super::provision::enqueue_signup_verification( + &state, + user_id, &did_for_commit, verification_channel, recipient, - ); - let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display( - &verification_token, - ); - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification( - state.user_repo.as_ref(), - state.infra_repo.as_ref(), - user_id, - verification_channel, - recipient, - &formatted_token, - hostname, ) - .await - { - warn!( - "Failed to enqueue signup verification notification: {:?}", - e - ); - } + .await; } } else if let Some(ref recipient) = verification_recipient { - let token = tranquil_pds::auth::verification_token::generate_migration_token( + super::provision::enqueue_migration_verification( + &state, + user_id, &did_for_commit, verification_channel, recipient, - ); - let formatted_token = - tranquil_pds::auth::verification_token::format_token_for_display(&token); - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification( - state.user_repo.as_ref(), - state.infra_repo.as_ref(), - user_id, - verification_channel, - recipient, - &formatted_token, - hostname, ) - .await - { - warn!("Failed to enqueue migration verification: {:?}", e); - } + .await; } - let access_meta = - match tranquil_pds::auth::create_access_token_with_metadata(&did, &secret_key_bytes) { - Ok(m) => m, - Err(e) => { - error!("createAccount: Error creating access token: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; - let refresh_meta = - match tranquil_pds::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes) { - Ok(m) => m, - Err(e) => { - error!("createAccount: Error creating refresh token: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; - let session_data = tranquil_db_traits::SessionTokenCreate { - did: did_for_commit.clone(), - access_jti: access_meta.jti.clone(), - refresh_jti: refresh_meta.jti.clone(), - access_expires_at: access_meta.expires_at, - refresh_expires_at: refresh_meta.expires_at, - login_type: tranquil_db_traits::LoginType::Modern, - mfa_verified: false, - scope: Some("transition:generic transition:chat.bsky".to_string()), - controller_did: None, - app_password_name: None, + let session = match super::provision::create_and_store_session( + &state, + &did, + &did_for_commit, + &secret_key_bytes, + "transition:generic transition:chat.bsky", + None, + ) + .await + { + Ok(s) => s, + Err(e) => return e.into_response(), }; - if let Err(e) = state.session_repo.create_session(&session_data).await { - error!("createAccount: Error creating session: {:?}", e); - return ApiError::InternalError(None).into_response(); - } let did_doc = state.did_resolver.resolve_did_document(&did).await; @@ -740,8 +605,8 @@ pub async fn create_account( handle: handle.clone().into(), did: did_for_commit, did_doc, - access_jwt: access_meta.token, - refresh_jwt: refresh_meta.token, + access_jwt: session.access_jwt, + refresh_jwt: session.refresh_jwt, verification_required: !is_migration, verification_channel, }), diff --git a/crates/tranquil-api/src/identity/plc/request.rs b/crates/tranquil-api/src/identity/plc/request.rs index 600f54f..8883600 100644 --- a/crates/tranquil-api/src/identity/plc/request.rs +++ b/crates/tranquil-api/src/identity/plc/request.rs @@ -1,7 +1,4 @@ -use axum::{ - extract::State, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use chrono::{Duration, Utc}; use tracing::{info, warn}; use tranquil_pds::api::EmptyResponse; @@ -16,14 +13,12 @@ fn generate_plc_token() -> String { pub async fn request_plc_operation_signature( State(state): State, auth: Auth, -) -> Result { - if let Err(e) = tranquil_pds::auth::scope_check::check_identity_scope( +) -> Result, ApiError> { + tranquil_pds::auth::scope_check::check_identity_scope( &auth.auth_source, auth.scope.as_deref(), tranquil_pds::oauth::scopes::IdentityAttr::Wildcard, - ) { - return Ok(e); - } + )?; let user_id = state .user_repo .get_id_by_did(&auth.did) @@ -53,5 +48,5 @@ pub async fn request_plc_operation_signature( warn!("Failed to enqueue PLC operation notification: {:?}", e); } info!("PLC operation signature requested for user {}", auth.did); - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } diff --git a/crates/tranquil-api/src/identity/plc/sign.rs b/crates/tranquil-api/src/identity/plc/sign.rs index 4c1fd0f..81ff781 100644 --- a/crates/tranquil-api/src/identity/plc/sign.rs +++ b/crates/tranquil-api/src/identity/plc/sign.rs @@ -1,9 +1,4 @@ -use axum::{ - Json, - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use chrono::Utc; use k256::ecdsa::SigningKey; use serde::{Deserialize, Serialize}; @@ -43,14 +38,12 @@ pub async fn sign_plc_operation( State(state): State, auth: Auth, Json(input): Json, -) -> Result { - if let Err(e) = tranquil_pds::auth::scope_check::check_identity_scope( +) -> Result, ApiError> { + tranquil_pds::auth::scope_check::check_identity_scope( &auth.auth_source, auth.scope.as_deref(), tranquil_pds::oauth::scopes::IdentityAttr::Wildcard, - ) { - return Ok(e); - } + )?; let did = &auth.did; if did.starts_with("did:web:") { return Err(ApiError::InvalidRequest( @@ -145,11 +138,7 @@ pub async fn sign_plc_operation( let _ = state.infra_repo.delete_plc_token(user_id, token).await; info!("Signed PLC operation for user {}", did); - Ok(( - StatusCode::OK, - Json(SignPlcOperationOutput { - operation: signed_op, - }), - ) - .into_response()) + Ok(Json(SignPlcOperationOutput { + operation: signed_op, + })) } diff --git a/crates/tranquil-api/src/identity/plc/submit.rs b/crates/tranquil-api/src/identity/plc/submit.rs index 8dfa8a0..4a2bb21 100644 --- a/crates/tranquil-api/src/identity/plc/submit.rs +++ b/crates/tranquil-api/src/identity/plc/submit.rs @@ -1,8 +1,4 @@ -use axum::{ - Json, - extract::State, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use k256::ecdsa::SigningKey; use serde::Deserialize; use serde_json::Value; @@ -23,14 +19,12 @@ pub async fn submit_plc_operation( State(state): State, auth: Auth, Json(input): Json, -) -> Result { - if let Err(e) = tranquil_pds::auth::scope_check::check_identity_scope( +) -> Result, ApiError> { + tranquil_pds::auth::scope_check::check_identity_scope( &auth.auth_source, auth.scope.as_deref(), tranquil_pds::oauth::scopes::IdentityAttr::Wildcard, - ) { - return Ok(e); - } + )?; let did = &auth.did; if did.starts_with("did:web:") { return Err(ApiError::InvalidRequest( @@ -76,7 +70,7 @@ pub async fn submit_plc_operation( .plc_rotation_key .clone() .unwrap_or_else(|| user_did_key.clone()); - if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) { + if let Some(rotation_keys) = op.get("rotationKeys").and_then(Value::as_array) { let has_server_key = rotation_keys .iter() .any(|k| k.as_str() == Some(&server_rotation_key)); @@ -86,11 +80,11 @@ pub async fn submit_plc_operation( )); } } - if let Some(services) = op.get("services").and_then(|v| v.as_object()) - && let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object()) + if let Some(services) = op.get("services").and_then(Value::as_object) + && let Some(pds) = services.get("atproto_pds").and_then(Value::as_object) { - let service_type = pds.get("type").and_then(|v| v.as_str()); - let endpoint = pds.get("endpoint").and_then(|v| v.as_str()); + let service_type = pds.get("type").and_then(Value::as_str); + let endpoint = pds.get("endpoint").and_then(Value::as_str); if service_type != Some(tranquil_pds::plc::ServiceType::Pds.as_str()) { return Err(ApiError::InvalidRequest( "Incorrect type on atproto_pds service".into(), @@ -102,8 +96,8 @@ pub async fn submit_plc_operation( )); } } - if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object()) - && let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) + if let Some(verification_methods) = op.get("verificationMethods").and_then(Value::as_object) + && let Some(atproto_key) = verification_methods.get("atproto").and_then(Value::as_str) && atproto_key != user_did_key { return Err(ApiError::InvalidRequest( @@ -111,11 +105,11 @@ pub async fn submit_plc_operation( )); } if let Some(also_known_as) = (!user.handle.is_empty()) - .then(|| op.get("alsoKnownAs").and_then(|v| v.as_array())) + .then(|| op.get("alsoKnownAs").and_then(Value::as_array)) .flatten() { let expected_handle = format!("at://{}", user.handle); - let first_aka = also_known_as.first().and_then(|v| v.as_str()); + let first_aka = also_known_as.first().and_then(Value::as_str); if first_aka != Some(&expected_handle) { return Err(ApiError::InvalidRequest( "Incorrect handle in alsoKnownAs".into(), @@ -163,5 +157,5 @@ pub async fn submit_plc_operation( warn!(did = %did, "Failed to refresh DID cache after PLC update"); } info!(did = %did, "PLC operation submitted successfully"); - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } diff --git a/crates/tranquil-api/src/identity/provision.rs b/crates/tranquil-api/src/identity/provision.rs index a863fee..b050ad4 100644 --- a/crates/tranquil-api/src/identity/provision.rs +++ b/crates/tranquil-api/src/identity/provision.rs @@ -2,10 +2,11 @@ use jacquard_common::types::{integer::LimitedU32, string::Tid}; use jacquard_repo::{mst::Mst, storage::BlockStore}; use k256::ecdsa::SigningKey; use std::sync::Arc; +use tranquil_db_traits::CommsChannel; use tranquil_pds::api::error::ApiError; use tranquil_pds::repo_ops::create_signed_commit; use tranquil_pds::state::AppState; -use tranquil_pds::types::Did; +use tranquil_pds::types::{Did, Handle}; pub struct PlcDidResult { pub did: Did, @@ -74,6 +75,7 @@ pub async fn submit_plc_genesis( Ok(genesis_result.did) } +#[derive(Clone)] pub struct GenesisRepo { pub encrypted_key_bytes: Vec, pub commit_cid: cid::Cid, @@ -120,3 +122,240 @@ pub async fn init_genesis_repo( genesis_block_cids: vec![mst_root.to_bytes(), commit_cid.to_bytes()], }) } + +pub struct SigningKeyResult { + pub secret_key_bytes: Vec, + pub signing_key: SigningKey, + pub reserved_key_id: Option, +} + +pub async fn resolve_signing_key( + state: &AppState, + signing_key_did: Option<&str>, +) -> Result { + match signing_key_did { + Some(key_did) => { + let key = state + .infra_repo + .get_reserved_signing_key(key_did) + .await + .map_err(|e| { + tracing::error!("Error looking up reserved signing key: {:?}", e); + ApiError::InternalError(None) + })? + .ok_or(ApiError::InvalidSigningKey)?; + let signing_key = SigningKey::from_slice(&key.private_key_bytes).map_err(|e| { + tracing::error!("Error creating signing key: {:?}", e); + ApiError::InternalError(None) + })?; + Ok(SigningKeyResult { + secret_key_bytes: key.private_key_bytes, + signing_key, + reserved_key_id: Some(key.id), + }) + } + None => { + use k256::SecretKey; + use rand::rngs::OsRng; + let secret_key = SecretKey::random(&mut OsRng); + let secret_key_bytes = secret_key.to_bytes().to_vec(); + let signing_key = SigningKey::from_slice(&secret_key_bytes).map_err(|e| { + tracing::error!("Error creating signing key: {:?}", e); + ApiError::InternalError(None) + })?; + Ok(SigningKeyResult { + secret_key_bytes, + signing_key, + reserved_key_id: None, + }) + } + } +} + +pub async fn sequence_new_account( + state: &AppState, + did: &Did, + handle: &Handle, + repo: &GenesisRepo, + display_name: &str, +) { + if let Err(e) = tranquil_pds::repo_ops::sequence_identity_event(state, did, Some(handle)).await + { + tracing::warn!("Failed to sequence identity event for {}: {}", did, e); + } + if let Err(e) = tranquil_pds::repo_ops::sequence_account_event( + state, + did, + tranquil_db_traits::AccountStatus::Active, + ) + .await + { + tracing::warn!("Failed to sequence account event for {}: {}", did, e); + } + if let Err(e) = tranquil_pds::repo_ops::sequence_genesis_commit( + state, + did, + &repo.commit_cid, + &repo.mst_root_cid, + &repo.repo_rev, + ) + .await + { + tracing::warn!("Failed to sequence commit event for {}: {}", did, e); + } + if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event( + state, + did, + &repo.commit_cid.to_string(), + Some(&repo.repo_rev), + ) + .await + { + tracing::warn!("Failed to sequence sync event for {}: {}", did, e); + } + let profile_record = serde_json::json!({ + "$type": "app.bsky.actor.profile", + "displayName": display_name + }); + if let Err(e) = tranquil_pds::repo_ops::create_record_internal( + state, + did, + &tranquil_pds::types::PROFILE_COLLECTION, + &tranquil_pds::types::PROFILE_RKEY, + &profile_record, + ) + .await + { + tracing::warn!("Failed to create default profile for {}: {}", did, e); + } +} + +pub struct CommsUsernames { + pub discord: Option, + pub telegram: Option, + pub signal: Option, +} + +pub fn normalize_comms_usernames( + discord: Option<&str>, + telegram: Option<&str>, + signal: Option<&str>, +) -> CommsUsernames { + CommsUsernames { + discord: discord + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()), + telegram: telegram + .map(|s| s.trim().trim_start_matches('@')) + .filter(|s| !s.is_empty()) + .map(String::from), + signal: signal + .map(|s| s.trim().trim_start_matches('@')) + .filter(|s| !s.is_empty()) + .map(|s| s.to_lowercase()), + } +} + +pub struct SessionResult { + pub access_jwt: String, + pub refresh_jwt: String, +} + +pub async fn create_and_store_session( + state: &AppState, + did_str: &str, + did: &Did, + signing_key_bytes: &[u8], + scope: &str, + controller_did: Option<&Did>, +) -> Result { + let access_meta = + tranquil_pds::auth::create_access_token_with_metadata(did_str, signing_key_bytes).map_err( + |e| { + tracing::error!("Error creating access token: {:?}", e); + ApiError::InternalError(None) + }, + )?; + let refresh_meta = + tranquil_pds::auth::create_refresh_token_with_metadata(did_str, signing_key_bytes) + .map_err(|e| { + tracing::error!("Error creating refresh token: {:?}", e); + ApiError::InternalError(None) + })?; + let session_data = tranquil_db_traits::SessionTokenCreate { + did: did.clone(), + access_jti: access_meta.jti.clone(), + refresh_jti: refresh_meta.jti.clone(), + access_expires_at: access_meta.expires_at, + refresh_expires_at: refresh_meta.expires_at, + login_type: tranquil_db_traits::LoginType::Modern, + mfa_verified: false, + scope: Some(scope.to_string()), + controller_did: controller_did.cloned(), + app_password_name: None, + }; + state + .session_repo + .create_session(&session_data) + .await + .map_err(|e| { + tracing::error!("Error creating session: {:?}", e); + ApiError::InternalError(None) + })?; + Ok(SessionResult { + access_jwt: access_meta.token, + refresh_jwt: refresh_meta.token, + }) +} + +pub async fn enqueue_signup_verification( + state: &AppState, + user_id: uuid::Uuid, + did: &Did, + channel: CommsChannel, + recipient: &str, +) { + let token = + tranquil_pds::auth::verification_token::generate_signup_token(did, channel, recipient); + let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token); + let hostname = &tranquil_config::get().server.hostname; + if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification( + state.user_repo.as_ref(), + state.infra_repo.as_ref(), + user_id, + channel, + recipient, + &formatted, + hostname, + ) + .await + { + tracing::warn!("Failed to enqueue signup verification: {:?}", e); + } +} + +pub async fn enqueue_migration_verification( + state: &AppState, + user_id: uuid::Uuid, + did: &Did, + channel: CommsChannel, + recipient: &str, +) { + let token = + tranquil_pds::auth::verification_token::generate_migration_token(did, channel, recipient); + let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token); + let hostname = &tranquil_config::get().server.hostname; + if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification( + state.user_repo.as_ref(), + state.infra_repo.as_ref(), + user_id, + channel, + recipient, + &formatted, + hostname, + ) + .await + { + tracing::warn!("Failed to enqueue migration verification: {:?}", e); + } +}