This commit is contained in:
2025-10-18 21:08:27 +03:00
parent 10c457eb34
commit 7ef437ee45
+337
View File
@@ -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<String>,
pub mode: Option<String>,
pub players: Vec<String>,
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<String>,
pub mode: Option<String>,
pub players: Option<Vec<String>>,
}
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::<u16>().ok())
.unwrap_or(8080);
format!("http://localhost:{}", port)
}
fn get_private_key(db: &rusqlite::Connection) -> Option<RsaPrivateKey> {
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<String>,
) -> Option<SignedReceipt> {
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<Arc<AppState>>,
Json(req): Json<GenerateReceiptRequest>,
) -> Result<Response, AppError> {
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<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Response, AppError> {
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<Arc<AppState>>,
session: Session,
Json(req): Json<ClaimRequest>,
) -> Result<Response, AppError> {
// 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())
}