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");
}
}