Gunnar

nordisk/frida

crates/frida-order/src/lib.rs

//! # frida-order — the order-based Relying Party API
//!
//! `frida-verifier` proves the raw WebAuthn ceremony in a browser. This crate is the piece a
//! *back-end* relying party (Vetra's `heimdal-frida`, or any consumer) actually integrates against:
//! an **order API** shaped to fit `heimdal_core::IdentityProvider`.
//!
//! ```text
//!   consumer back-end            frida-order (this crate)          holder (wallet / card / passkey)
//!   ─────────────────            ────────────────────────          ────────────────────────────────
//!   POST /order/sign   ───────▶  create order + Challenge
//!        { order_ref, challenge, handoff_url, qr }  ◀──────
//!   (browser opens handoff_url, same origin) ─────────────────────▶ run ceremony, sign Challenge
//!                                POST /order/{ref}/assert  ◀──────  submit eid-core Assertion
//!                                verify_assertion() [eid-core]
//!   GET /order/{ref}   ───────▶  { status: complete, completion }
//! ```
//!
//! ## Store
//!
//! In-memory only (`HashMap` behind a `Mutex`) — **never a relational database**. The shape matches
//! `eid_core::EventStore` (append-only orders keyed by `order_ref`); a durable deployment swaps the
//! map for a `redb`/`rocksdb`-backed `EventStore` without touching the HTTP surface.
//!
//! ## Crypto seam
//!
//! The completion is produced by `eid_core::verify_assertion` **on this server** (the verifier's
//! job — cf. `heimdal-freja` verifying Freja's JWS). `heimdal-frida::collect` therefore trusts the
//! result it polls, exactly as `PROTOCOL.md` specifies ("collect → the wallet's *verified*
//! presentation"). In production the transport is mutual-TLS gRPC/HTTP; here it is plain JSON/HTTP.
#![forbid(unsafe_code)]

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use axum::{
    Json, Router,
    extract::{Path, State},
    http::StatusCode,
    response::{Html, IntoResponse},
    routing::{get, post},
};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use eid_core::{Assertion, Challenge, SigAlg, verify_assertion};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

// ---------------------------------------------------------------------------------------------
// Wire DTOs — thin JSON wrappers over eid-core plain-data types (which carry the crypto fields).
// heimdal-frida mirrors these field names on the client side.
// ---------------------------------------------------------------------------------------------

/// Kind of order — auth ("prove who you are") or sign ("approve and sign this data").
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OrderKind {
    /// Plain authentication (no `data_to_sign`).
    Auth,
    /// Signature over user-visible data.
    Sign,
}

/// Body of `POST /order/auth`.
#[derive(Debug, Default, Deserialize)]
pub struct AuthReq {
    /// Optional identity hint (personal number / email); `None` = anonymous QR/handoff.
    #[serde(default)]
    pub subject: Option<String>,
    /// Optional text shown to the holder.
    #[serde(default)]
    pub user_visible_data: Option<String>,
    /// Which holder channel the consumer expects (`mobile` / `card`); advisory.
    #[serde(default)]
    pub channel: Option<String>,
}

/// Body of `POST /order/sign`.
#[derive(Debug, Deserialize)]
pub struct SignReq {
    /// Optional identity hint.
    #[serde(default)]
    pub subject: Option<String>,
    /// Short title shown above the signature prompt.
    pub title: String,
    /// Human-readable text the holder sees **and** signs.
    pub user_visible_data: String,
    /// Extra bytes covered by the signature but not displayed.
    #[serde(default)]
    pub user_non_visible_data: Option<Vec<u8>>,
    /// Advisory holder channel.
    #[serde(default)]
    pub channel: Option<String>,
}

/// Response to a successful `start` — everything the front-end needs to run the ceremony.
#[derive(Debug, Serialize)]
pub struct StartResp {
    /// Opaque per-order reference the consumer polls with.
    pub order_ref: String,
    /// The exact [`Challenge`] the holder must sign (delivered to the browser/wallet by the
    /// handoff page). `context` binds the origin + `order_ref` (anti-mixup).
    pub challenge: Challenge,
    /// **Same-origin** path the browser navigates to run the WebAuthn/wallet ceremony.
    pub handoff_url: String,
    /// Absolute URL for a cross-device QR (encodes the same handoff endpoint).
    pub qr: String,
}

/// The identity attributes the verifier attests for a completed order.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubjectClaims {
    /// National personal identity number, if known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub personal_number: Option<String>,
    /// Full name.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// Given name(s).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub given_name: Option<String>,
    /// Surname.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub surname: Option<String>,
}

/// The verified result of a completed order.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionResp {
    /// Disclosed identity of the holder.
    pub subject: SubjectClaims,
    /// Base64url id of the signing key that was matched and verified.
    pub signer_key_id: String,
    /// The verified signature bytes.
    pub signature: Vec<u8>,
}

/// Response to `GET /order/{ref}`.
#[derive(Debug, Serialize)]
pub struct StatusResp {
    /// `pending` | `complete` | `failed`.
    pub status: &'static str,
    /// UI hint while pending.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hint: Option<String>,
    /// Failure reason code when `failed`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    /// Present only when `complete`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub completion: Option<CompletionResp>,
}

/// Body of `POST /order/{ref}/assert` — the holder's signed [`Assertion`].
#[derive(Debug, Deserialize)]
pub struct AssertReq {
    /// The holder's assertion over `signing_input(challenge)`.
    pub assertion: Assertion,
}

/// Body of `POST /register` — enrol a holder key so the verifier can trust its assertions.
///
/// In production this is issuance (OpenID4VCI) / card-certificate trust; here it bootstraps a
/// trusted key + the claims to attest for it.
#[derive(Debug, Deserialize)]
pub struct RegisterReq {
    /// Key id the assertion will carry (`Assertion::key_id`).
    pub key_id: Vec<u8>,
    /// Public key bytes (raw 32-byte Ed25519, or SEC1 P-256).
    pub public_key: Vec<u8>,
    /// Signature algorithm.
    pub alg: SigAlg,
    /// Identity attributes to attest when this key signs.
    #[serde(default)]
    pub subject: SubjectClaims,
}

// ---------------------------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------------------------

enum OrderState {
    Pending,
    Complete(CompletionResp),
    Failed(String),
}

struct Order {
    challenge: Challenge,
    state: OrderState,
}

struct Holder {
    public_key: Vec<u8>,
    subject: SubjectClaims,
}

/// Shared server state. Everything is in-memory; see the module docs on the `EventStore` shape.
pub struct AppState {
    /// External origin used to build absolute cross-device QR URLs (e.g. `https://app.example.se`).
    pub base_url: String,
    /// Same-origin mount path the consuming app exposes frida under (e.g. `/frida`).
    pub path_prefix: String,
    orders: Mutex<HashMap<String, Order>>,
    holders: Mutex<HashMap<Vec<u8>, Holder>>,
}

impl AppState {
    /// New empty state for `base_url` (QR origin) mounted at `path_prefix` (same-origin path).
    pub fn new(base_url: impl Into<String>, path_prefix: impl Into<String>) -> Arc<Self> {
        Arc::new(Self {
            base_url: base_url.into(),
            path_prefix: path_prefix.into(),
            orders: Mutex::new(HashMap::new()),
            holders: Mutex::new(HashMap::new()),
        })
    }
}

/// Build the order-API router over `state`. Mount it at [`AppState::path_prefix`] behind the
/// consuming app's ingress so the WebAuthn RP-ID/origin matches the app (see the crate + DESIGN
/// docs on same-origin).
pub fn router(state: Arc<AppState>) -> Router {
    Router::new()
        .route("/order/auth", post(start_auth))
        .route("/order/sign", post(start_sign))
        .route("/order/{order_ref}", get(get_order))
        .route("/order/{order_ref}/cancel", post(cancel_order))
        .route("/order/{order_ref}/assert", post(submit_assertion))
        .route("/register", post(register))
        .route("/handoff/{order_ref}", get(handoff))
        .with_state(state)
}

// ---------------------------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------------------------

fn new_order(state: &AppState, kind: OrderKind, data_to_sign: Option<Vec<u8>>) -> StartResp {
    let order_ref = format!(
        "{}-{}",
        match kind {
            OrderKind::Auth => "auth",
            OrderKind::Sign => "sign",
        },
        Uuid::new_v4().simple()
    );
    // 32 random bytes of nonce from two v4 UUIDs (CSPRNG-backed via getrandom).
    let mut nonce = Uuid::new_v4().as_bytes().to_vec();
    nonce.extend_from_slice(Uuid::new_v4().as_bytes());
    // Bind the signature to this RP + this order (anti-mixup): part of the signed input.
    let context = format!("frida-order;origin={};order={order_ref}", state.base_url).into_bytes();
    let challenge = Challenge { nonce, context, data_to_sign };

    let handoff_url = format!("{}/handoff/{order_ref}", state.path_prefix);
    let qr = format!("{}{handoff_url}", state.base_url.trim_end_matches('/'));

    state.orders.lock().unwrap().insert(
        order_ref.clone(),
        Order { challenge: challenge.clone(), state: OrderState::Pending },
    );
    StartResp { order_ref, challenge, handoff_url, qr }
}

async fn start_auth(State(s): State<Arc<AppState>>, body: Option<Json<AuthReq>>) -> impl IntoResponse {
    let _req = body.map(|Json(b)| b).unwrap_or_default();
    Json(new_order(&s, OrderKind::Auth, None))
}

async fn start_sign(State(s): State<Arc<AppState>>, Json(req): Json<SignReq>) -> impl IntoResponse {
    let mut data = req.user_visible_data.into_bytes();
    if let Some(extra) = req.user_non_visible_data {
        data.extend_from_slice(&extra);
    }
    Json(new_order(&s, OrderKind::Sign, Some(data)))
}

async fn get_order(State(s): State<Arc<AppState>>, Path(order_ref): Path<String>) -> impl IntoResponse {
    let orders = s.orders.lock().unwrap();
    let Some(order) = orders.get(&order_ref) else {
        return (StatusCode::NOT_FOUND, "unknown order").into_response();
    };
    let resp = match &order.state {
        OrderState::Pending => StatusResp {
            status: "pending",
            hint: Some("waiting_for_holder".into()),
            reason: None,
            completion: None,
        },
        OrderState::Complete(c) => StatusResp {
            status: "complete",
            hint: None,
            reason: None,
            completion: Some(c.clone()),
        },
        OrderState::Failed(reason) => StatusResp {
            status: "failed",
            hint: None,
            reason: Some(reason.clone()),
            completion: None,
        },
    };
    Json(resp).into_response()
}

async fn cancel_order(State(s): State<Arc<AppState>>, Path(order_ref): Path<String>) -> impl IntoResponse {
    let mut orders = s.orders.lock().unwrap();
    match orders.get_mut(&order_ref) {
        Some(order) => {
            // Only cancel while still in flight; a terminal order stays terminal (idempotent-ish).
            if matches!(order.state, OrderState::Pending) {
                order.state = OrderState::Failed("rp_cancelled".into());
            }
            StatusCode::OK.into_response()
        }
        None => (StatusCode::NOT_FOUND, "unknown order").into_response(),
    }
}

async fn register(State(s): State<Arc<AppState>>, Json(req): Json<RegisterReq>) -> impl IntoResponse {
    s.holders.lock().unwrap().insert(
        req.key_id,
        Holder { public_key: req.public_key, subject: req.subject },
    );
    StatusCode::OK
}

async fn submit_assertion(
    State(s): State<Arc<AppState>>,
    Path(order_ref): Path<String>,
    Json(req): Json<AssertReq>,
) -> impl IntoResponse {
    // Resolve the challenge for this order (must be pending).
    let challenge = {
        let orders = s.orders.lock().unwrap();
        match orders.get(&order_ref) {
            Some(o) if matches!(o.state, OrderState::Pending) => o.challenge.clone(),
            Some(_) => return (StatusCode::CONFLICT, "order not pending").into_response(),
            None => return (StatusCode::NOT_FOUND, "unknown order").into_response(),
        }
    };

    // Resolve the trusted key + attested claims for this assertion's key_id.
    let holder = {
        let holders = s.holders.lock().unwrap();
        match holders.get(&req.assertion.key_id) {
            Some(h) => (h.public_key.clone(), h.subject.clone()),
            None => return (StatusCode::UNAUTHORIZED, "unknown holder key").into_response(),
        }
    };
    let (public_key, subject) = holder;

    // The verifier's core job: cryptographically verify the assertion over the challenge.
    if let Err(e) = verify_assertion(&challenge, &req.assertion, &public_key) {
        let mut orders = s.orders.lock().unwrap();
        if let Some(o) = orders.get_mut(&order_ref) {
            o.state = OrderState::Failed("verification_failed".into());
        }
        return (StatusCode::UNAUTHORIZED, format!("verification failed: {e}")).into_response();
    }

    let completion = CompletionResp {
        subject,
        signer_key_id: B64.encode(&req.assertion.key_id),
        signature: req.assertion.signature.clone(),
    };
    let mut orders = s.orders.lock().unwrap();
    if let Some(o) = orders.get_mut(&order_ref) {
        o.state = OrderState::Complete(completion);
    }
    StatusCode::OK.into_response()
}

/// The same-origin handoff page. In production this runs the real WebAuthn/wallet ceremony against
/// the RP and POSTs the resulting assertion to `/order/{ref}/assert`. The full browser wiring lives
/// in `frida-verifier` (the WebAuthn demo); here the page is a stub explaining the seam.
async fn handoff(Path(order_ref): Path<String>) -> Html<String> {
    Html(format!(
        "<!doctype html><meta charset=utf-8><title>frida handoff</title>\
         <h1>frida — order {order_ref}</h1>\
         <p>Run the WebAuthn/wallet ceremony here (same origin as the app), then POST the signed \
         assertion to <code>/order/{order_ref}/assert</code>. See frida-verifier for the browser \
         ceremony and DESIGN.md for the same-origin requirement.</p>"
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use ed25519_dalek::{Signer, SigningKey};
    use eid_core::{signing_input, HolderKind};
    use http_body_util::BodyExt;
    use tower::ServiceExt; // oneshot

    async fn call(app: &Router, method: &str, path: &str, body: serde_json::Value) -> (StatusCode, serde_json::Value) {
        let req = Request::builder()
            .method(method)
            .uri(path)
            .header("content-type", "application/json")
            .body(Body::from(serde_json::to_vec(&body).unwrap()))
            .unwrap();
        let resp = app.clone().oneshot(req).await.unwrap();
        let status = resp.status();
        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
        let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
        (status, json)
    }

    #[tokio::test]
    async fn sign_order_end_to_end_with_eid_core() {
        let state = AppState::new("https://app.example.se", "/frida");
        let app = router(state);

        // Enrol an Ed25519 holder (key_id = public key bytes, as in eid-core tests).
        let sk = SigningKey::from_bytes(&[42u8; 32]);
        let pk = sk.verifying_key().to_bytes().to_vec();
        let (st, _) = call(
            &app,
            "POST",
            "/register",
            serde_json::json!({
                "key_id": pk, "public_key": pk, "alg": "Ed25519",
                "subject": { "personal_number": "199001019999", "name": "Alice Andersson" }
            }),
        )
        .await;
        assert_eq!(st, StatusCode::OK);

        // Start a sign order.
        let (st, start) = call(
            &app,
            "POST",
            "/order/sign",
            serde_json::json!({ "title": "Approve", "user_visible_data": "Sign invoice #42" }),
        )
        .await;
        assert_eq!(st, StatusCode::OK);
        let order_ref = start["order_ref"].as_str().unwrap().to_string();
        assert!(order_ref.starts_with("sign-"));
        assert_eq!(start["handoff_url"], format!("/frida/handoff/{order_ref}"));
        let challenge: Challenge = serde_json::from_value(start["challenge"].clone()).unwrap();
        assert!(challenge.is_sign());
        assert!(String::from_utf8_lossy(&challenge.context).contains(&order_ref));

        // Still pending before the holder acts.
        let (_, status) = call(&app, "GET", &format!("/order/{order_ref}"), serde_json::Value::Null).await;
        assert_eq!(status["status"], "pending");

        // Holder signs the canonical input and submits the assertion.
        let sig = sk.sign(&signing_input(&challenge)).to_bytes().to_vec();
        let assertion = Assertion {
            kind: HolderKind::WebAuthn,
            alg: SigAlg::Ed25519,
            key_id: pk.clone(),
            signature: sig,
            attestation: vec![],
        };
        let (st, _) = call(
            &app,
            "POST",
            &format!("/order/{order_ref}/assert"),
            serde_json::json!({ "assertion": assertion }),
        )
        .await;
        assert_eq!(st, StatusCode::OK);

        // Now complete, with the verified, attested subject.
        let (_, status) = call(&app, "GET", &format!("/order/{order_ref}"), serde_json::Value::Null).await;
        assert_eq!(status["status"], "complete");
        assert_eq!(status["completion"]["subject"]["personal_number"], "199001019999");
        assert!(status["completion"]["signer_key_id"].is_string());
    }

    #[tokio::test]
    async fn tampered_assertion_is_rejected() {
        let state = AppState::new("https://app.example.se", "/frida");
        let app = router(state);
        let sk = SigningKey::from_bytes(&[7u8; 32]);
        let pk = sk.verifying_key().to_bytes().to_vec();
        call(&app, "POST", "/register",
            serde_json::json!({ "key_id": pk, "public_key": pk, "alg": "Ed25519" })).await;
        let (_, start) = call(&app, "POST", "/order/sign",
            serde_json::json!({ "title": "t", "user_visible_data": "pay" })).await;
        let order_ref = start["order_ref"].as_str().unwrap().to_string();

        // Sign a *different* challenge → signature will not verify against the stored one.
        let bogus = Challenge { nonce: vec![0u8; 32], context: b"wrong".to_vec(), data_to_sign: Some(b"pay".to_vec()) };
        let sig = sk.sign(&signing_input(&bogus)).to_bytes().to_vec();
        let assertion = Assertion { kind: HolderKind::WebAuthn, alg: SigAlg::Ed25519, key_id: pk, signature: sig, attestation: vec![] };
        let (st, _) = call(&app, "POST", &format!("/order/{order_ref}/assert"),
            serde_json::json!({ "assertion": assertion })).await;
        assert_eq!(st, StatusCode::UNAUTHORIZED);
        let (_, status) = call(&app, "GET", &format!("/order/{order_ref}"), serde_json::Value::Null).await;
        assert_eq!(status["status"], "failed");
        assert_eq!(status["reason"], "verification_failed");
    }

    #[tokio::test]
    async fn cancel_marks_failed() {
        let state = AppState::new("https://app.example.se", "/frida");
        let app = router(state);
        let (_, start) = call(&app, "POST", "/order/auth", serde_json::json!({})).await;
        let order_ref = start["order_ref"].as_str().unwrap().to_string();
        let (st, _) = call(&app, "POST", &format!("/order/{order_ref}/cancel"), serde_json::Value::Null).await;
        assert_eq!(st, StatusCode::OK);
        let (_, status) = call(&app, "GET", &format!("/order/{order_ref}"), serde_json::Value::Null).await;
        assert_eq!(status["status"], "failed");
        assert_eq!(status["reason"], "rp_cancelled");
    }
}