Skip to main content

wire/server/
pairing.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! This machine's door for a browser: pairing, and the challenge and
7//! signature that open a session (`crates/wire/spec/Server.lean` §8-93).
8//!
9//! The entry decision has already let only a browser of this listener's
10//! own origin through (`crates/wire/spec/Reception/Entry.lean` §8-94);
11//! the rules of what each route then decides are the door's
12//! (`server::door`, `reception::pairing`).
13
14use std::sync::Arc;
15
16use axum::Json;
17use axum::extract::State;
18use axum::http::header::ORIGIN;
19use axum::http::{HeaderMap, StatusCode};
20use axum::response::{IntoResponse, Response};
21use kernel::{AxCode, AxError};
22use serde::{Deserialize, Serialize};
23
24use crate::answer::DeviceId;
25
26use super::config::{ShellState, refusal_text};
27
28/// `POST /pair`: one of the two codes, the browser's public device key,
29/// and the name the page gives itself.
30#[derive(Debug, Deserialize)]
31pub struct PairBody {
32    #[serde(flatten)]
33    pub proof: PairProof,
34    pub public_key: String,
35    pub label: String,
36}
37
38/// Which code a pairing presents: exactly one of the two.
39#[derive(Debug, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum PairProof {
42    /// The open code `/web` handed over through the user-only redirect file.
43    Open(String),
44    /// The pairing code the terminal shows.
45    Code(String),
46}
47
48/// What a pairing answers: the id the city gave the browser.
49#[derive(Debug, Serialize)]
50pub struct PairAnswer {
51    pub device: DeviceId,
52}
53
54/// What `POST /session/challenge` answers: the nonce the device signs.
55#[derive(Debug, Serialize)]
56pub struct ChallengeAnswer {
57    pub nonce: String,
58}
59
60/// `POST /session`: the device, the nonce it was given, and its signature
61/// over that nonce.
62#[derive(Debug, Deserialize)]
63pub struct SessionBody {
64    pub device: DeviceId,
65    pub nonce: String,
66    pub signature: String,
67}
68
69/// What a session answers: the token the page keeps in memory and shows
70/// on every later request.
71#[derive(Debug, Serialize)]
72pub struct SessionAnswer {
73    pub token: String,
74}
75
76pub(super) async fn accept_pairing(
77    State(state): State<Arc<ShellState>>,
78    Json(body): Json<PairBody>,
79) -> Response {
80    match state.door.pair(&body.proof, &body.public_key, &body.label) {
81        Ok(device) => (StatusCode::OK, Json(PairAnswer { device })).into_response(),
82        Err(err) => refusal(&err),
83    }
84}
85
86pub(super) async fn offer_challenge(State(state): State<Arc<ShellState>>) -> Response {
87    match state.door.challenge() {
88        Ok(nonce) => (StatusCode::OK, Json(ChallengeAnswer { nonce })).into_response(),
89        Err(err) => refusal(&err),
90    }
91}
92
93pub(super) async fn open_session(
94    State(state): State<Arc<ShellState>>,
95    headers: HeaderMap,
96    Json(body): Json<SessionBody>,
97) -> Response {
98    // The entry decision admitted only a browser of this listener's own
99    // origin, and a browser is a request that carried one; the signature
100    // is checked over the origin it was made on.
101    let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else {
102        return refusal(
103            &AxError::failure(
104                AxCode::GateDenied,
105                "open a browser session",
106                "the request carried no Origin",
107            )
108            .with_recovery("open a session from the city's own page"),
109        );
110    };
111    match state.door.open_session(&body, origin) {
112        Ok(token) => (StatusCode::OK, Json(SessionAnswer { token })).into_response(),
113        Err(err) => refusal(&err),
114    }
115}
116
117/// The status a refusal of the door is answered with.
118fn refusal(err: &AxError) -> Response {
119    let code = err.code();
120    let status = if matches!(code, AxCode::PairingRefused | AxCode::GateDenied) {
121        StatusCode::FORBIDDEN
122    } else if matches!(code, AxCode::InvalidArgs) {
123        StatusCode::UNPROCESSABLE_ENTITY
124    } else {
125        StatusCode::INTERNAL_SERVER_ERROR
126    };
127    (status, refusal_text(err)).into_response()
128}