diff --git a/cruciverb-server/src/receipts.rs b/cruciverb-server/src/receipts.rs new file mode 100644 index 0000000..f047c52 --- /dev/null +++ b/cruciverb-server/src/receipts.rs @@ -0,0 +1,337 @@ +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use axum::response::{IntoResponse, Response}; +use rsa::{RsaPrivateKey, Pkcs1v15Sign}; +use rsa::pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey}; +use sha2::{Sha256, Digest}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as B64; +use serde::{Serialize, Deserialize}; + +use crate::db; +use crate::routes::AppError; +use crate::session::Session; +use crate::state::AppState; + +#[derive(Serialize, Deserialize, Clone)] +pub struct SolveReceipt { + pub receipt_id: String, + pub puzzle_hash: String, + pub solve_time_secs: i64, + pub hints_used: i64, + pub solve_date: String, + pub instance_url: String, + pub player_name: String, + pub room_code: Option, + pub mode: Option, + pub players: Vec, + pub created_at: String, + pub expires_at: String, +} + +#[derive(Serialize, Deserialize)] +pub struct SignedReceipt { + pub receipt: SolveReceipt, + pub signature: String, + pub key_id: String, +} + +#[derive(Deserialize)] +pub struct GenerateReceiptRequest { + pub puzzle_id: String, + pub solve_time: i64, + pub hints_used: i64, + pub player_name: String, + pub room_code: Option, + pub mode: Option, + pub players: Option>, +} + +fn get_instance_url() -> String { + if let Ok(url) = std::env::var("INSTANCE_URL") { + return url; + } + let port = std::env::var("PORT").ok() + .and_then(|p| p.parse::().ok()) + .unwrap_or(8080); + format!("http://localhost:{}", port) +} + +fn get_private_key(db: &rusqlite::Connection) -> Option { + let priv_pem: String = db.query_row( + "SELECT private_pem FROM ap_keys WHERE id = 1", + [], + |row| row.get(0), + ).ok()?; + RsaPrivateKey::from_pkcs1_pem(&priv_pem).ok() +} + +pub fn sign_receipt_data(private_key: &RsaPrivateKey, receipt_json: &[u8]) -> String { + let hash = Sha256::digest(receipt_json); + // DER-encoded DigestInfo for SHA-256 + let mut digest_info = vec![ + 0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, + 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20, + ]; + digest_info.extend_from_slice(&hash); + let padding = Pkcs1v15Sign::new_unprefixed(); + let sig = private_key.sign(padding, &digest_info).expect("receipt signing failed"); + B64.encode(&sig) +} + +pub fn verify_receipt_signature(public_key_pem: &str, receipt_json: &[u8], signature_b64: &str) -> Result<(), String> { + use rsa::RsaPublicKey; + let public_key = RsaPublicKey::from_pkcs1_pem(public_key_pem) + .map_err(|e| format!("bad public key: {}", e))?; + let sig_bytes = B64.decode(signature_b64) + .map_err(|_| "bad signature base64".to_string())?; + let hash = Sha256::digest(receipt_json); + let mut digest_info = vec![ + 0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, + 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20, + ]; + digest_info.extend_from_slice(&hash); + let padding = Pkcs1v15Sign::new_unprefixed(); + public_key.verify(padding, &digest_info, &sig_bytes) + .map_err(|e| format!("signature verification failed: {}", e)) +} + +pub fn generate_receipt( + db: &rusqlite::Connection, + puzzle_hash: &str, + solve_time: i64, + hints: i64, + player_name: &str, + room_code: Option<&str>, + mode: Option<&str>, + other_players: Vec, +) -> Option { + let private_key = get_private_key(db)?; + let instance_url = get_instance_url(); + let now = chrono::Utc::now(); + let expires = now + chrono::Duration::days(30); + + let receipt = SolveReceipt { + receipt_id: uuid::Uuid::new_v4().to_string(), + puzzle_hash: puzzle_hash.to_string(), + solve_time_secs: solve_time, + hints_used: hints, + solve_date: now.format("%Y-%m-%d").to_string(), + instance_url: instance_url.clone(), + player_name: player_name.to_string(), + room_code: room_code.map(|s| s.to_string()), + mode: mode.map(|s| s.to_string()), + players: other_players, + created_at: now.to_rfc3339(), + expires_at: expires.to_rfc3339(), + }; + + let receipt_json = serde_json::to_string(&receipt).ok()?; + let signature = sign_receipt_data(&private_key, receipt_json.as_bytes()); + let key_id = format!("{}/ap/actor#main-key", instance_url); + + let players_json = serde_json::to_string(&receipt.players).unwrap_or_else(|_| "[]".into()); + + crate::db::store_receipt( + db, &receipt.receipt_id, &signature, &receipt.expires_at, + puzzle_hash, solve_time, hints, &receipt.solve_date, + &instance_url, player_name, room_code, mode, + Some(&players_json), + ); + + Some(SignedReceipt { receipt, signature, key_id }) +} + +// -- route handlers -- + +pub async fn generate_receipt_handler( + State(state): State>, + Json(req): Json, +) -> Result { + let dbi = state.db.lock().await; + + // get puzzle JSON to compute hash + let (puzzle_json, _) = db::get_puzzle_json(&dbi.db, &req.puzzle_id) + .ok_or_else(|| AppError::with_status(StatusCode::NOT_FOUND, "puzzle not found"))?; + let puzzle_hash = hex::encode(Sha256::digest(puzzle_json.as_bytes())); + + let players = req.players.unwrap_or_default(); + let signed = generate_receipt( + &dbi.db, + &puzzle_hash, + req.solve_time, + req.hints_used, + &req.player_name, + req.room_code.as_deref(), + req.mode.as_deref(), + players, + ).ok_or_else(|| AppError::with_status( + StatusCode::INTERNAL_SERVER_ERROR, + "could not generate receipt - signing keys not available", + ))?; + + let instance_url = get_instance_url(); + let receipt_url = format!("{}/api/receipts/{}", instance_url, signed.receipt.receipt_id); + + Ok(Json(serde_json::json!({ + "receipt": signed.receipt, + "signature": signed.signature, + "key_id": signed.key_id, + "receipt_url": receipt_url, + "receipt_id": signed.receipt.receipt_id, + })).into_response()) +} + +pub async fn get_receipt_handler( + State(state): State>, + Path(id): Path, +) -> Result { + let dbi = state.db.lock().await; + let json = db::get_receipt(&dbi.db, &id) + .ok_or_else(|| AppError::with_status(StatusCode::NOT_FOUND, "receipt not found or expired"))?; + + // add the key_id to the response + let instance_url = get_instance_url(); + let key_id = format!("{}/ap/actor#main-key", instance_url); + let mut val: serde_json::Value = serde_json::from_str(&json) + .map_err(|_| AppError::with_status(StatusCode::INTERNAL_SERVER_ERROR, "corrupt receipt data"))?; + val["key_id"] = serde_json::json!(key_id); + + Ok(Json(val).into_response()) +} + +#[derive(Deserialize)] +pub struct ClaimRequest { + pub receipt: SolveReceipt, + pub signature: String, + pub key_id: String, +} + +pub async fn claim_receipt_handler( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + // check not already claimed + { + let dbi = state.db.lock().await; + if db::is_receipt_claimed(&dbi.db, &req.receipt.receipt_id) { + return Err(AppError::with_status(StatusCode::CONFLICT, "receipt already claimed")); + } + } + + // validate receipt data bounds + if req.receipt.solve_time_secs < 0 || req.receipt.solve_time_secs > 172_800 { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, "invalid solve time")); + } + if req.receipt.hints_used < 0 || req.receipt.hints_used > 1000 { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, "invalid hints count")); + } + + // check receipt hasn't expired + if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&req.receipt.expires_at) { + if chrono::Utc::now() > expires { + return Err(AppError::with_status(StatusCode::GONE, "receipt expired")); + } + } + + // fetch remote public key - unless it is our own instance, in which case + // verify against the locally stored key (receipts issued here point at + // the configured INSTANCE_URL, which can be localhost or an internal name) + let actor_url = req.key_id.split('#').next() + .ok_or_else(|| AppError::with_status(StatusCode::BAD_REQUEST, "bad key_id format"))? + .to_string(); + + let actor_host = actor_url + .split("//") + .nth(1) + .and_then(|s| s.split('/').next()) + .unwrap_or(""); + let instance_host = get_instance_url(); + let instance_host = instance_host + .split("//") + .nth(1) + .and_then(|s| s.split('/').next()) + .unwrap_or(""); + let is_self = std::env::var("AP_DOMAIN") + .ok() + .filter(|d| d == actor_host) + .is_some() + || actor_host == instance_host; + + let public_key_pem; + if is_self { + let dbi = state.db.lock().await; + match dbi.db.query_row( + "SELECT public_pem FROM ap_keys WHERE id = 1", + [], + |r| r.get::<_, String>(0), + ) { + Ok(pem) => public_key_pem = pem, + Err(_) => { + return Err(AppError::with_status( + StatusCode::INTERNAL_SERVER_ERROR, + "local signing key not available", + )) + } + } + } else { + if !actor_url.starts_with("https://") { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, "key_id must use HTTPS")); + } + if crate::security::is_internal_url(&actor_url) { + return Err(AppError::with_status(StatusCode::FORBIDDEN, "internal URL not allowed")); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| AppError::with_status(StatusCode::INTERNAL_SERVER_ERROR, "http client error"))?; + + let actor_json: serde_json::Value = client.get(&actor_url) + .header("Accept", "application/activity+json, application/json") + .send() + .await + .map_err(|e| AppError::with_status(StatusCode::BAD_GATEWAY, &format!("failed to fetch actor: {}", e)))? + .json() + .await + .map_err(|_| AppError::with_status(StatusCode::BAD_GATEWAY, "bad actor response"))?; + + public_key_pem = actor_json + .get("publicKey") + .and_then(|pk| pk.get("publicKeyPem")) + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::with_status(StatusCode::BAD_GATEWAY, "no publicKey in actor document"))? + .to_string(); + } + + // verify signature + let receipt_json = serde_json::to_string(&req.receipt) + .map_err(|_| AppError::with_status(StatusCode::BAD_REQUEST, "bad receipt data"))?; + + verify_receipt_signature(&public_key_pem, receipt_json.as_bytes(), &req.signature) + .map_err(|e| AppError::with_status(StatusCode::FORBIDDEN, &e))?; + + // claim it and record the solve + let dbi = state.db.lock().await; + db::claim_receipt(&dbi.db, &req.receipt.receipt_id, &session.token, &req.receipt.instance_url); + db::record_solve( + &dbi.db, + &session.token, + &format!("receipt:{}", req.receipt.receipt_id), + req.receipt.solve_time_secs, + req.receipt.hints_used, + false, + None, + ); + + Ok(Json(serde_json::json!({ + "claimed": true, + "solve_time": req.receipt.solve_time_secs, + "hints": req.receipt.hints_used, + })).into_response()) +}