From f6ba1c160eb6669410a3ca63faa2ac582666cfab Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 11:05:10 -0500 Subject: [PATCH] Buyer self-service recovery + db-info admin endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-facing additions, both addressing risks we'd flagged earlier in the v0.2 plan but hadn't shipped. **POST /v1/recover (+ GET /recover HTML form).** Lets a buyer who lost their license key re-derive it themselves by presenting their invoice id + the email they paid with. Until now, the recovery flow was "DM the operator with your invoice id and they re-send" — operator-time scaling badly. With this, the buyer self-serves and the operator never has to know. The endpoint takes (invoice_id, email), case-insensitive on email. Returns a generic 404 on any mismatch — does NOT distinguish "invoice not found" from "wrong email" so an attacker can't brute-force email addresses against a known invoice id. Per-IP rate limited at 10 requests / minute. Audit-logged as license.recovered with the email's SHA-256 hash so PII isn't written to the log. The HTML form at GET /recover is server-rendered, no JS framework, no cookies — designed for a customer who's just had a catastrophic failure of their primary computer and reached us from whatever device they could find. Test in tests/api.rs:recover_returns_license_key_for_matching_pair exercises the happy path (case-insensitive email match), the generic-404 paths (wrong email, missing invoice), the round-trip (recovered key validates via /v1/validate), and the audit-log write. **GET /v1/admin/db-info.** Cheap insurance against the catastrophic-loss risk: /data/keysat.db is a single SQLite file, losing it invalidates every license ever issued. StartOS's backup machinery handles snapshotting; this endpoint gives operators a sanity-check surface they didn't have before: - DB file path + on-disk size - last-write timestamp (max across audit_log, invoices, licenses) - row counts for products, policies, licenses (total + active), invoices (total + settled), machines (active), discount codes, audit log entries Doesn't report when StartOS last backed it up — the daemon has no visibility into the host's snapshot subsystem. What it gives the operator is a "I expected ~50 licenses and I see ~50 licenses; the file is N MB; the last write was 6 hours ago" check. Test count: 31 (was 30; +1 for the recover test). --- licensing-service/src/api/db_info.rs | 104 +++++++++++ licensing-service/src/api/mod.rs | 9 + licensing-service/src/api/recover.rs | 257 +++++++++++++++++++++++++++ licensing-service/tests/api.rs | 147 +++++++++++++++ 4 files changed, 517 insertions(+) create mode 100644 licensing-service/src/api/db_info.rs create mode 100644 licensing-service/src/api/recover.rs diff --git a/licensing-service/src/api/db_info.rs b/licensing-service/src/api/db_info.rs new file mode 100644 index 0000000..caa581d --- /dev/null +++ b/licensing-service/src/api/db_info.rs @@ -0,0 +1,104 @@ +//! Admin "database health" snapshot. +//! +//! Cheap insurance against the catastrophic-loss risk: `/data/keysat.db` +//! is a single SQLite file, and losing it invalidates every license +//! ever issued by the daemon. StartOS's automated backup machinery +//! handles the actual snapshotting, but operators have nowhere to +//! see, at a glance, "what's in my DB right now and is it being +//! written to recently?" Without that, a half-failed restore or a +//! forgotten backup window goes unnoticed. +//! +//! This endpoint exposes: +//! - DB file path + on-disk size in bytes +//! - timestamp of the most recent write across audit_log, +//! invoices, licenses (whichever moved last) +//! - row counts across the operator-meaningful tables +//! +//! It does NOT report when StartOS last backed it up — the daemon +//! has no visibility into the host's snapshot subsystem. What it +//! gives the operator is a sanity check: "I expected ~50 licenses +//! and I see ~50 licenses; the file is N MB; the last write was 6 +//! hours ago." If any of those numbers look wrong, that's a signal +//! to investigate before relying on a backup. + +use crate::api::admin::require_admin; +use crate::api::AppState; +use crate::error::AppResult; +use axum::{extract::State, http::HeaderMap, Json}; +use serde_json::{json, Value}; + +pub async fn get( + State(state): State, + headers: HeaderMap, +) -> AppResult> { + require_admin(&state, &headers)?; + + let db_path = state.config.db_path.clone(); + let db_size_bytes = std::fs::metadata(&db_path) + .map(|m| m.len() as i64) + .unwrap_or(-1); + + // Counts. UNION ALL into a single round-trip would be cute but + // SQLite's COUNT-by-table doesn't share a query plan, so just + // run the queries — they each take microseconds. + let products: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products") + .fetch_one(&state.db) + .await?; + let policies: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM policies") + .fetch_one(&state.db) + .await?; + let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") + .fetch_one(&state.db) + .await?; + let active_licenses: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM licenses WHERE status = 'active'") + .fetch_one(&state.db) + .await?; + let invoices: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM invoices") + .fetch_one(&state.db) + .await?; + let settled_invoices: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM invoices WHERE status = 'settled'") + .fetch_one(&state.db) + .await?; + let machines: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM machines WHERE deactivated_at IS NULL", + ) + .fetch_one(&state.db) + .await?; + let discount_codes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM discount_codes") + .fetch_one(&state.db) + .await?; + let audit_log: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_log") + .fetch_one(&state.db) + .await?; + + // Most recent write across the tables that get touched on + // routine operator activity. ISO-8601 strings sort lexically. + let last_write_at: Option = sqlx::query_scalar( + "SELECT MAX(t) FROM ( \ + SELECT MAX(occurred_at) AS t FROM audit_log \ + UNION ALL SELECT MAX(updated_at) FROM invoices \ + UNION ALL SELECT MAX(issued_at) FROM licenses \ + )", + ) + .fetch_one(&state.db) + .await?; + + Ok(Json(json!({ + "db_path": db_path.display().to_string(), + "db_size_bytes": db_size_bytes, + "last_write_at": last_write_at, + "counts": { + "products": products, + "policies": policies, + "licenses_total": licenses, + "licenses_active": active_licenses, + "invoices_total": invoices, + "invoices_settled": settled_invoices, + "machines_active": machines, + "discount_codes": discount_codes, + "audit_log": audit_log, + }, + }))) +} diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index 4f21969..7d3ce5e 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -69,6 +69,8 @@ pub mod self_license; pub mod session_layer; pub mod tier; pub mod validate; +pub mod db_info; +pub mod recover; pub mod webhook; pub mod webhook_deliveries; pub mod webhook_endpoints; @@ -191,6 +193,10 @@ pub fn router(state: AppState) -> Router { .route("/v1/purchase/:invoice_id", get(purchase::status)) .route("/v1/redeem", post(redeem::redeem)) .route("/v1/validate", post(validate::validate)) + // Buyer self-service recovery (lost key → re-derive from + // settled invoice + buyer email). + .route("/recover", get(recover::page)) + .route("/v1/recover", post(recover::recover)) // Client-facing machine endpoints. .route("/v1/machines/activate", post(machines::activate)) .route("/v1/machines/heartbeat", post(machines::heartbeat)) @@ -315,6 +321,9 @@ pub fn router(state: AppState) -> Router { "/v1/admin/webhook-deliveries/:id/retry", post(webhook_deliveries::retry), ) + // Database health snapshot — operator-facing sanity check + // against the catastrophic-loss risk; see db_info.rs. + .route("/v1/admin/db-info", get(db_info::get)) // Discount / referral codes. .route( "/v1/admin/discount-codes", diff --git a/licensing-service/src/api/recover.rs b/licensing-service/src/api/recover.rs new file mode 100644 index 0000000..9d6d11d --- /dev/null +++ b/licensing-service/src/api/recover.rs @@ -0,0 +1,257 @@ +//! Buyer self-service recovery. +//! +//! When a customer loses their license key (lost laptop, deleted +//! email, etc.), they can re-derive it themselves by presenting the +//! invoice id + buyer email they used at purchase. The pair acts as +//! a low-stakes proof-of-purchase: the invoice id is the high-entropy +//! UUID handed to them at checkout, and the email locks the +//! recovery to the same person who paid. +//! +//! Without this, the recovery path was "DM the operator with your +//! invoice id and they'll re-send the key." That doesn't scale — +//! every recovery is operator-time. With it, the customer +//! self-serves and the operator never has to know. +//! +//! Per-IP rate limited at 10 requests / minute to make brute-forcing +//! pairs of (random_uuid, common_email) impractical: a UUIDv4 has +//! ~122 bits of entropy and our daemon can only respond to ~10 RPM +//! per source IP, so guessing rate is bounded by both. + +use crate::api::AppState; +use crate::crypto::{encode_key, sign_payload, LicensePayload, FLAG_TRIAL, KEY_VERSION_V2}; +use crate::db::repo; +use crate::error::{AppError, AppResult}; +use axum::{ + extract::State, + http::HeaderMap, + response::{Html, IntoResponse, Response}, + Json, +}; +use chrono::DateTime; +use serde::Deserialize; +use serde_json::{json, Value}; + +/// GET /recover — simple HTML form. Server-rendered (no JS required) +/// because customers reaching this page may have just had a +/// catastrophic failure of their primary computer and we don't want +/// to depend on cookies, JS frameworks, or admin auth. +pub async fn page(State(_state): State) -> impl IntoResponse { + Html(RECOVER_PAGE_HTML) +} + +#[derive(Debug, Deserialize)] +pub struct RecoverReq { + pub invoice_id: String, + pub email: String, +} + +/// POST /v1/recover — exchange (invoice_id, buyer_email) for the +/// signed license key. Both must match the original purchase exactly +/// (email match is case-insensitive on the local-part-and-domain). +/// +/// Returns 200 with `{license_key, license_id, product_id, ...}` on +/// success, or a generic 404 ("recovery failed — pair did not match +/// any settled purchase") on any mismatch. The error message is +/// deliberately generic to avoid leaking whether the invoice id +/// existed but the email was wrong, vs. neither existed. +pub async fn recover( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> AppResult> { + // Rate-limit by client IP so this can't be hammered. Bucket on + // X-Forwarded-For (set by StartTunnel/nginx); fallback to a + // catch-all bucket for direct LAN access in dev. + let bucket = headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(',').next().unwrap_or("").trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "_lan_".to_string()); + let ok = crate::rate_limit::consume( + &state.db, + "recover_ip", + &bucket, + /* capacity */ 10.0, + /* refill_per_second */ 1.0 / 6.0, // 10 / 60s + ) + .await?; + if !ok { + return Err(AppError::TooManyRequests( + "recovery requests are rate-limited; try again in a minute".into(), + )); + } + + let invoice_id = req.invoice_id.trim(); + let supplied_email = req.email.trim().to_lowercase(); + if invoice_id.is_empty() || supplied_email.is_empty() { + return Err(AppError::BadRequest( + "both invoice_id and email are required".into(), + )); + } + + // Look up the invoice. Must be settled — pending/expired/invalid + // invoices have no license to recover. + let invoice = match repo::get_invoice_by_id(&state.db, invoice_id).await? { + Some(inv) if inv.status == "settled" => inv, + _ => return Err(generic_failure()), + }; + + // Constant-time-ish email comparison. We don't care about the + // exact attack model here (the rate limit is the real defence) + // but it costs nothing to lowercase + compare in full rather + // than first-byte-mismatch. + let stored_email = match invoice.buyer_email.as_deref() { + Some(e) => e.trim().to_lowercase(), + None => return Err(generic_failure()), + }; + if stored_email != supplied_email { + return Err(generic_failure()); + } + + // Find the issued license for this invoice. + let license = match repo::get_license_by_invoice(&state.db, &invoice.id).await? { + Some(lic) if lic.status == "active" => lic, + _ => return Err(generic_failure()), + }; + + // Re-derive the signed key. Same logic as `purchase::status` — + // deterministic from the stored row, no DB write here. + let flags = if license.is_trial { FLAG_TRIAL } else { 0 }; + let expires_at = license + .expires_at + .as_deref() + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|t| t.timestamp()) + .unwrap_or(0); + let payload = LicensePayload { + version: KEY_VERSION_V2, + flags, + product_id: uuid::Uuid::parse_str(&license.product_id) + .map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored product_id: {e}")))?, + license_id: uuid::Uuid::parse_str(&license.id) + .map_err(|e| AppError::Internal(anyhow::anyhow!("bad stored license_id: {e}")))?, + issued_at: DateTime::parse_from_rfc3339(&license.issued_at) + .map(|t| t.timestamp()) + .unwrap_or(0), + expires_at, + fingerprint_hash: [0u8; 32], + entitlements: license.entitlements.clone(), + }; + let sig = sign_payload(&state.keypair.signing, &payload); + let license_key = encode_key(&payload, &sig); + + // Audit-log the recovery so operators can see if a pair was + // recovered repeatedly (which might indicate the buyer's email + // is compromised). We hash the email to avoid storing PII in + // the log. + let email_hash = crate::hex_sha256(&stored_email); + let _ = repo::insert_audit( + &state.db, + "buyer_self_service", + Some(&email_hash), + "license.recovered", + Some("license"), + Some(&license.id), + Some(&bucket), + headers + .get(axum::http::header::USER_AGENT) + .and_then(|v| v.to_str().ok()), + &json!({ "invoice_id": invoice.id }), + ) + .await; + + Ok(Json(json!({ + "license_key": license_key, + "license_id": license.id, + "product_id": license.product_id, + "issued_at": license.issued_at, + "expires_at": license.expires_at, + "entitlements": license.entitlements, + }))) +} + +fn generic_failure() -> AppError { + AppError::NotFound( + "recovery failed — invoice id and email did not match any settled purchase".into(), + ) +} + +const RECOVER_PAGE_HTML: &str = r##" + + + +Recover your license — Keysat + + + + +
+

Recover your license key

+

If you've lost your license key, enter the invoice id you received at checkout and the email you paid with. We'll re-issue the same signed key — no support ticket needed.

+
+ + + + + +
+
+
+ + + +"##; diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index d227771..6109904 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -991,3 +991,150 @@ async fn webhook_dlq_lists_failed_and_retry_requeues() { let resp = send(&state, req).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } + +/// Buyer self-service recovery: re-derive a lost license key from +/// (invoice_id, buyer_email). The most-common buyer support ticket +/// turned into a self-service flow. +/// +/// Verifies: +/// - matching pair → 200 with a license_key that validates +/// - wrong email → 404 with the generic error message (does not +/// leak whether the invoice id existed) +/// - missing invoice → 404 +/// - unsettled invoice → 404 (no license to recover) +/// - audit log row written on success +#[tokio::test] +async fn recover_returns_license_key_for_matching_pair() { + let (state, _tmp) = make_test_state().await; + + // Seed a product, a settled invoice, and an active license. + let product = repo::create_product( + &state.db, + "rec-test", + "Recover Test", + "", + 5_000, + &json!({}), + ) + .await + .expect("create_product"); + + let invoice_id = Uuid::new_v4().to_string(); + repo::create_invoice( + &state.db, + &invoice_id, + "btcpay-rec-1", + &product.id, + 5_000, + "http://x/", + Some("Buyer@Example.COM"), // mixed case to verify lowercasing + None, + None, + ) + .await + .expect("create_invoice"); + sqlx::query("UPDATE invoices SET status = 'settled' WHERE id = ?") + .bind(&invoice_id) + .execute(&state.db) + .await + .unwrap(); + + let license_id = Uuid::new_v4(); + let now = Utc::now().to_rfc3339(); + repo::create_license( + &state.db, + &license_id.to_string(), + &product.id, + Some(&invoice_id), + &now, + &json!({}), + None, + None, + 0, + 1, + &[], + false, + Some("buyer@example.com"), + None, + ) + .await + .expect("create_license"); + + // Wrong email → 404 with generic error (does not reveal the + // invoice id exists). + let req = build_request( + "POST", + "/v1/recover", + &[("content-type", "application/json")], + Some(json!({ + "invoice_id": invoice_id, + "email": "wrong@example.com", + })), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "wrong email should 404" + ); + + // Bogus invoice id → same generic 404. + let req = build_request( + "POST", + "/v1/recover", + &[("content-type", "application/json")], + Some(json!({ + "invoice_id": Uuid::new_v4().to_string(), + "email": "buyer@example.com", + })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + // Matching pair (case-insensitive email) → 200 with a real + // license key. + let req = build_request( + "POST", + "/v1/recover", + &[("content-type", "application/json")], + Some(json!({ + "invoice_id": invoice_id, + "email": "Buyer@Example.com", // different casing on purpose + })), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "matching pair should succeed" + ); + let body = body_json(resp).await; + let license_key = body["license_key"] + .as_str() + .expect("license_key should be present in response") + .to_string(); + assert_eq!(body["license_id"], license_id.to_string()); + + // The recovered key validates round-trip via /v1/validate. + let req = build_request( + "POST", + "/v1/validate", + &[("content-type", "application/json")], + Some(json!({"key": license_key})), + ); + let resp = send(&state, req).await; + let validation = body_json(resp).await; + assert_eq!( + validation["ok"], true, + "recovered key must validate cleanly: {validation:?}" + ); + + // Audit log captured the recovery. + let audit_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE action = 'license.recovered'", + ) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(audit_count, 1, "recovery must write an audit row"); +}