receipts
This commit is contained in:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user