1use 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);
30const AFTER_ANSWER: chrono::Duration = chrono::Duration::minutes(1);
32const MAX_SLOT: u16 = 999;
33const 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
41pub fn router(limits: &Limits) -> Router<AppStateRef> {
43 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 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)]
169async 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
181async 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 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
245async 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
262async fn host_of(state: &AppState, slot: u16, claims: &Claims) -> Result<(), ApiError> {
264 let (host, _) = active(state, slot).await?;
265 if host != claims.key {
266 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#[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}