Skip to main content

auth_service/
pairing.rs

1//! Relaying pairing messages. See `edda_auth::pairing` for the protocol.
2//!
3//! Messages are kept in Postgres and fetched by long polling, so the two
4//! sides may be talking to different instances. The relay enforces what it
5//! can without knowing the code: only the DM device that opened a pairing
6//! can speak for the host or cancel it, and only one device can ever speak
7//! as the joiner, so an attacker gets a single guess per code.
8
9use std::{sync::Arc, time::Duration};
10
11use axum::{
12    Json, Router,
13    extract::{Path, State},
14    http::StatusCode,
15    response::{IntoResponse, Response},
16    routing::{delete, post, put},
17};
18use chrono::{DateTime, Utc};
19use edda_auth::{
20    PublicKey,
21    pairing::Side,
22    protocol::{Claims, PairingMessage, PairingSlot},
23    server::Authenticated,
24};
25use edda_server::{Limits, openapi::ErrorBody};
26
27use crate::{error::ApiError, routes::AppState};
28
29const PAIRING_LIFETIME: chrono::Duration = chrono::Duration::minutes(10);
30/// Once the host has answered, the joiner only needs to collect it.
31const AFTER_ANSWER: chrono::Duration = chrono::Duration::minutes(1);
32const MAX_SLOT: u16 = 999;
33/// Messages per side: the SPAKE2 message, then one encrypted message.
34const MESSAGES_PER_SIDE: u8 = 2;
35const MAX_MESSAGE_BYTES: usize = 16 * 1024;
36const LONG_POLL: Duration = Duration::from_secs(20);
37const POLL_INTERVAL: Duration = Duration::from_millis(150);
38
39type AppStateRef = Arc<AppState>;
40
41/// The pairing routes, the message relay limited like signing in.
42pub fn router(limits: &Limits) -> Router<AppStateRef> {
43    // Joining needs no token, so the messages get fewer tries: someone
44    // spraying slots can't spoil everyone's pairings.
45    let messages = Router::new()
46        .route(
47            "/pairings/{slot}/host/{n}",
48            put(send_as_host).get(|state, path| receive(state, path, Side::Host)),
49        )
50        .route(
51            "/pairings/{slot}/joiner/{n}",
52            put(send_as_joiner).get(|state, path| receive(state, path, Side::Joiner)),
53        );
54    Router::new()
55        .route("/pairings", post(open))
56        .route("/pairings/{slot}", delete(cancel))
57        .merge(limits.guard_sign_ins(messages))
58}
59
60#[utoipa::path(
61    post,
62    path = "/pairings",
63    tag = "pairing",
64    responses(
65        (status = 201, description = "A pairing, for 10 minutes.", body = PairingSlot),
66        (status = 403, description = "Not a DM device.", body = ErrorBody),
67        (status = 409, description = "No free pairings; try again shortly.", body = ErrorBody),
68    )
69)]
70async fn open(
71    State(state): State<AppStateRef>,
72    Authenticated(claims): Authenticated,
73) -> Result<(StatusCode, Json<PairingSlot>), ApiError> {
74    require_dm(&claims)?;
75    let pool = state.store.pool();
76    sqlx::query("DELETE FROM pairings WHERE expires_at < now()")
77        .execute(pool)
78        .await?;
79    let expires_at = Utc::now() + PAIRING_LIFETIME;
80    // Random rather than sequential, so a slot number says nothing about
81    // other pairings. Retry on the (rare) collision.
82    for _ in 0..50 {
83        let slot = rand::random_range(1..=MAX_SLOT);
84        let inserted = sqlx::query(
85            "INSERT INTO pairings (slot, campaign_id, host_key, expires_at) \
86             VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
87        )
88        .bind(i32::from(slot))
89        .bind(claims.campaign)
90        .bind(claims.key.as_bytes().to_vec())
91        .bind(expires_at)
92        .execute(pool)
93        .await?;
94        if inserted.rows_affected() == 1 {
95            return Ok((StatusCode::CREATED, Json(PairingSlot { slot, expires_at })));
96        }
97    }
98    Err(ApiError::Conflict(
99        "no free pairing slots; try again shortly".into(),
100    ))
101}
102
103#[utoipa::path(
104    delete,
105    path = "/pairings/{slot}",
106    tag = "pairing",
107    params(("slot" = u16, Path, description = "The pairing's number: the first part of the code.")),
108    responses(
109        (status = 204, description = "Cancelled."),
110        (status = 404, description = "No such pairing of this device's.", body = ErrorBody),
111    )
112)]
113async fn cancel(
114    State(state): State<AppStateRef>,
115    Authenticated(claims): Authenticated,
116    Path(slot): Path<u16>,
117) -> Result<StatusCode, ApiError> {
118    host_of(&state, slot, &claims).await?;
119    sqlx::query("DELETE FROM pairings WHERE slot = $1")
120        .bind(i32::from(slot))
121        .execute(state.store.pool())
122        .await?;
123    Ok(StatusCode::NO_CONTENT)
124}
125
126#[utoipa::path(
127    put,
128    path = "/pairings/{slot}/host/{n}",
129    tag = "pairing",
130    params(("slot" = u16, Path, description = "The pairing's number: the first part of the code."), ("n" = u8, Path, description = "Which message of that side: 0 or 1.")),
131    request_body = PairingMessage,
132    responses(
133        (status = 201, description = "Relayed."),
134        (status = 404, description = "No such pairing of this device's.", body = ErrorBody),
135        (status = 409, description = "That message was sent already.", body = ErrorBody),
136    )
137)]
138async fn send_as_host(
139    State(state): State<AppStateRef>,
140    Authenticated(claims): Authenticated,
141    Path((slot, n)): Path<(u16, u8)>,
142    Json(message): Json<PairingMessage>,
143) -> Result<StatusCode, ApiError> {
144    host_of(&state, slot, &claims).await?;
145    store(&state, slot, Side::Host, n, &message).await?;
146    if n == MESSAGES_PER_SIDE - 1 {
147        sqlx::query("UPDATE pairings SET expires_at = LEAST(expires_at, $1) WHERE slot = $2")
148            .bind(Utc::now() + AFTER_ANSWER)
149            .bind(i32::from(slot))
150            .execute(state.store.pool())
151            .await?;
152    }
153    Ok(StatusCode::CREATED)
154}
155
156#[utoipa::path(
157    put,
158    path = "/pairings/{slot}/joiner/{n}",
159    tag = "pairing",
160    security(()),
161    params(("slot" = u16, Path, description = "The pairing's number: the first part of the code."), ("n" = u8, Path, description = "Which message of that side: 0 or 1.")),
162    request_body = PairingMessage,
163    responses(
164        (status = 201, description = "Relayed."),
165        (status = 404, description = "No such pairing, or it has expired.", body = ErrorBody),
166        (status = 409, description = "The code has been used already, or that message was sent already.", body = ErrorBody),
167    )
168)]
169/// Anyone who has the code may join, but only once: the first joiner's
170/// first message claims the pairing.
171async fn send_as_joiner(
172    State(state): State<AppStateRef>,
173    Path((slot, n)): Path<(u16, u8)>,
174    Json(message): Json<PairingMessage>,
175) -> Result<StatusCode, ApiError> {
176    active(&state, slot).await?;
177    store(&state, slot, Side::Joiner, n, &message).await?;
178    Ok(StatusCode::CREATED)
179}
180
181/// Waits (up to 20 seconds) for a side's `n`th message: `200` with it, `204`
182/// if it hasn't come yet (ask again), `404` once the pairing is gone.
183async fn receive(
184    State(state): State<AppStateRef>,
185    Path((slot, n)): Path<(u16, u8)>,
186    side: Side,
187) -> Result<Response, ApiError> {
188    let started = tokio::time::Instant::now();
189    loop {
190        active(&state, slot).await?;
191        let row: Option<(Vec<u8>,)> = sqlx::query_as(
192            "SELECT data FROM pairing_messages WHERE slot = $1 AND side = $2 AND n = $3",
193        )
194        .bind(i32::from(slot))
195        .bind(side.as_str())
196        .bind(i16::from(n))
197        .fetch_optional(state.store.pool())
198        .await?;
199        if let Some((data,)) = row {
200            return Ok(Json(PairingMessage { data }).into_response());
201        }
202        if started.elapsed() >= LONG_POLL {
203            return Ok(StatusCode::NO_CONTENT.into_response());
204        }
205        tokio::time::sleep(POLL_INTERVAL).await;
206    }
207}
208
209async fn store(
210    state: &AppState,
211    slot: u16,
212    side: Side,
213    n: u8,
214    message: &PairingMessage,
215) -> Result<(), ApiError> {
216    if n >= MESSAGES_PER_SIDE {
217        return Err(ApiError::BadRequest("no such message in a pairing".into()));
218    }
219    if message.data.len() > MAX_MESSAGE_BYTES {
220        return Err(ApiError::BadRequest("pairing message too large".into()));
221    }
222    // A joiner's later message must come from whoever sent its first, but
223    // the relay can't tell joiners apart; it doesn't need to: a stranger
224    // without the code can't produce anything the host will accept, and
225    // the host then cancels the pairing.
226    let inserted = sqlx::query(
227        "INSERT INTO pairing_messages (slot, side, n, data) VALUES ($1, $2, $3, $4) \
228         ON CONFLICT DO NOTHING",
229    )
230    .bind(i32::from(slot))
231    .bind(side.as_str())
232    .bind(i16::from(n))
233    .bind(&message.data)
234    .execute(state.store.pool())
235    .await?;
236    if inserted.rows_affected() == 0 {
237        return Err(ApiError::Conflict(match (side, n) {
238            (Side::Joiner, 0) => "this code has already been used".into(),
239            _ => "that message was already sent".into(),
240        }));
241    }
242    Ok(())
243}
244
245/// The pairing's expiry, if it exists and hasn't expired.
246async fn active(state: &AppState, slot: u16) -> Result<(PublicKey, DateTime<Utc>), ApiError> {
247    let row: Option<(Vec<u8>, DateTime<Utc>)> = sqlx::query_as(
248        "SELECT host_key, expires_at FROM pairings WHERE slot = $1 AND expires_at > now()",
249    )
250    .bind(i32::from(slot))
251    .fetch_optional(state.store.pool())
252    .await?;
253    let (host, expires_at) = row.ok_or(ApiError::NotFound)?;
254    let host = host
255        .try_into()
256        .ok()
257        .and_then(|bytes| PublicKey::from_bytes(bytes).ok())
258        .ok_or_else(|| ApiError::Internal("stored host key is invalid".into()))?;
259    Ok((host, expires_at))
260}
261
262/// Checks that the caller is the DM device that opened the pairing.
263async fn host_of(state: &AppState, slot: u16, claims: &Claims) -> Result<(), ApiError> {
264    let (host, _) = active(state, slot).await?;
265    if host != claims.key {
266        // Not revealing whether the slot is in use by someone else.
267        return Err(ApiError::NotFound);
268    }
269    Ok(())
270}
271
272fn require_dm(claims: &Claims) -> Result<(), ApiError> {
273    if claims.is_dm() {
274        Ok(())
275    } else {
276        Err(ApiError::Forbidden(
277            "only a DM device can pair new devices".into(),
278        ))
279    }
280}
281
282/// Receiving, for the `OpenAPI` description: the routes call [`receive`]
283/// with their side, so these are only described, never called. Messages
284/// are sealed between the two devices, so reading needs no token.
285#[allow(dead_code)]
286pub(crate) mod described {
287    use edda_auth::protocol::PairingMessage;
288    use edda_server::openapi::ErrorBody;
289
290    #[utoipa::path(
291        get,
292        path = "/pairings/{slot}/host/{n}",
293        tag = "pairing",
294        security(()),
295        params(("slot" = u16, Path, description = "The pairing's number: the first part of the code."), ("n" = u8, Path, description = "Which message of that side: 0 or 1.")),
296        responses(
297            (status = 200, description = "The host's message.", body = PairingMessage),
298            (status = 204, description = "Not sent yet, after waiting up to 20 seconds: ask again."),
299            (status = 404, description = "No such pairing, or it has expired.", body = ErrorBody),
300        )
301    )]
302    pub fn receive_from_host() {}
303
304    #[utoipa::path(
305        get,
306        path = "/pairings/{slot}/joiner/{n}",
307        tag = "pairing",
308        security(()),
309        params(("slot" = u16, Path, description = "The pairing's number: the first part of the code."), ("n" = u8, Path, description = "Which message of that side: 0 or 1.")),
310        responses(
311            (status = 200, description = "The joiner's message.", body = PairingMessage),
312            (status = 204, description = "Not sent yet, after waiting up to 20 seconds: ask again."),
313            (status = 404, description = "No such pairing, or it has expired.", body = ErrorBody),
314        )
315    )]
316    pub fn receive_from_joiner() {}
317}