Skip to main content

edda_client/
pairing.rs

1//! Pairing devices with a short code: the DM's device ("host") and a new
2//! device ("joiner") run the PAKE in `edda_auth::pairing`, relayed by
3//! the auth service.
4//!
5//! ```text
6//! host                         relay                         joiner
7//!  | POST /pairings  ─────────▶  slot 7
8//!  | shows 7-tavern-dragon                   (told the code out loud)
9//!  |                                ◀────────── joiner/0: SPAKE2 message
10//!  | host/0: SPAKE2 message ──────▶
11//!  |                                ◀────────── joiner/1: sealed {key, name}
12//!  | opens it (or cancels: wrong code), asks the DM, adds the device
13//!  | host/1: sealed {campaign, server key, certificate} ─▶
14//! ```
15
16use std::time::Duration;
17
18use chrono::{DateTime, Utc};
19use edda_auth::{
20    DeviceCert, DeviceKey, PublicKey, Role, SignedCert,
21    pairing::{Exchange, JoinAnswer, JoinRequest, PairingCode, PairingError, Side},
22    protocol::{Campaign, Device, PairingMessage, PairingSlot},
23};
24use reqwest::StatusCode;
25
26use crate::{ClientError, auth::AuthClient, check, send};
27
28/// What can go wrong pairing a device.
29#[derive(Debug, thiserror::Error)]
30pub enum PairError {
31    #[error(transparent)]
32    Client(#[from] ClientError),
33    #[error(transparent)]
34    Pairing(#[from] PairingError),
35    #[error("nobody joined before the code expired")]
36    Expired,
37    #[error("this code has already been used, has expired, or was cancelled")]
38    Unavailable,
39    /// The joiner's message didn't open: they used a different code (or
40    /// someone guessed). The pairing has been cancelled.
41    #[error("the code didn't match; the pairing was cancelled")]
42    WrongCode,
43    #[error("the DM declined")]
44    Declined,
45    #[error("the DM's answer doesn't check out: {0}")]
46    BadAnswer(&'static str),
47}
48
49/// A code the host can hand out.
50#[derive(Debug, Clone)]
51pub struct Invitation {
52    pub code: PairingCode,
53    pub role: Role,
54    pub expires_at: DateTime<Utc>,
55}
56
57/// What a joiner gets out of a successful pairing.
58#[derive(Debug, Clone)]
59pub struct Joined {
60    pub campaign: Campaign,
61    /// The auth service's key, vouched for by the DM's device.
62    pub server_key: PublicKey,
63    pub cert: SignedCert,
64}
65
66impl AuthClient {
67    /// Opens a pairing (DM devices only) and makes up its code. The words
68    /// are made here and never sent anywhere.
69    pub async fn invite(&self, role: Role) -> Result<Invitation, ClientError> {
70        let slot: PairingSlot = send(self.http.post(self.url("/pairings"))).await?;
71        Ok(Invitation {
72            code: PairingCode::generate(slot.slot),
73            role,
74            expires_at: slot.expires_at,
75        })
76    }
77
78    /// Cancels a pairing this DM device opened.
79    pub async fn cancel_pairing(&self, slot: u16) -> Result<(), ClientError> {
80        let url = self.url(&format!("/pairings/{slot}"));
81        check(self.http.delete(url).send().await?).await.map(drop)
82    }
83
84    async fn relay_send(
85        &self,
86        slot: u16,
87        side: Side,
88        n: u8,
89        data: Vec<u8>,
90    ) -> Result<(), PairError> {
91        let url = self.url(&format!("/pairings/{slot}/{}/{n}", side.as_str()));
92        let response = self
93            .http
94            .put(url)
95            .json(&PairingMessage { data })
96            .send()
97            .await;
98        match check(response.map_err(ClientError::from)?).await {
99            Ok(_) => Ok(()),
100            Err(ClientError::NotFound | ClientError::Exhausted(_)) => Err(PairError::Unavailable),
101            Err(e) => Err(e.into()),
102        }
103    }
104
105    /// Waits for a message from `side`, until `deadline`.
106    async fn relay_receive(
107        &self,
108        slot: u16,
109        side: Side,
110        n: u8,
111        deadline: DateTime<Utc>,
112    ) -> Result<Vec<u8>, PairError> {
113        let url = self.url(&format!("/pairings/{slot}/{}/{n}", side.as_str()));
114        loop {
115            let response = check(
116                self.http
117                    .get(&url)
118                    .send()
119                    .await
120                    .map_err(ClientError::from)?,
121            )
122            .await;
123            match response {
124                Ok(r) if r.status() == StatusCode::NO_CONTENT => {
125                    if Utc::now() > deadline {
126                        return Err(PairError::Expired);
127                    }
128                }
129                Ok(r) => {
130                    let message: PairingMessage =
131                        r.json().await.map_err(ClientError::InvalidResponse)?;
132                    return Ok(message.data);
133                }
134                Err(ClientError::NotFound) => return Err(PairError::Unavailable),
135                Err(e) => {
136                    // A blip shouldn't end the pairing; keep trying until
137                    // the deadline.
138                    if Utc::now() > deadline {
139                        return Err(e.into());
140                    }
141                    tokio::time::sleep(Duration::from_secs(1)).await;
142                }
143            }
144        }
145    }
146}
147
148/// The host's side: waits for someone to join with `invitation`'s code,
149/// asks `approve` about them, and if approved admits them to the campaign.
150/// `dm` must be logged in as the DM device `key`.
151pub async fn host(
152    dm: &AuthClient,
153    key: &DeviceKey,
154    campaign: &Campaign,
155    server_key: PublicKey,
156    invitation: &Invitation,
157    approve: impl FnOnce(&JoinRequest) -> bool,
158) -> Result<Device, PairError> {
159    let slot = invitation.code.slot;
160    let result = run_host(dm, key, campaign, server_key, invitation, approve).await;
161    if matches!(result, Err(PairError::WrongCode | PairError::Pairing(_))) {
162        // Burn the code: one guess is all anyone gets.
163        let _ = dm.cancel_pairing(slot).await;
164    }
165    result
166}
167
168async fn run_host(
169    dm: &AuthClient,
170    key: &DeviceKey,
171    campaign: &Campaign,
172    server_key: PublicKey,
173    invitation: &Invitation,
174    approve: impl FnOnce(&JoinRequest) -> bool,
175) -> Result<Device, PairError> {
176    let slot = invitation.code.slot;
177    let deadline = invitation.expires_at;
178    let (exchange, ours) = Exchange::start(Side::Host, &invitation.code);
179    let theirs = dm.relay_receive(slot, Side::Joiner, 0, deadline).await?;
180    dm.relay_send(slot, Side::Host, 0, ours).await?;
181    let mut channel = exchange.finish(&theirs)?;
182
183    let sealed = dm.relay_receive(slot, Side::Joiner, 1, deadline).await?;
184    let request: JoinRequest = channel.open(&sealed).map_err(|e| match e {
185        PairingError::WrongCode => PairError::WrongCode,
186        e => e.into(),
187    })?;
188
189    if !approve(&request) {
190        dm.relay_send(slot, Side::Host, 1, channel.seal(&JoinAnswer::Declined))
191            .await?;
192        return Err(PairError::Declined);
193    }
194    let cert = DeviceCert::issue(
195        key,
196        campaign.id,
197        request.key,
198        invitation.role,
199        &request.name,
200    )
201    .map_err(|e| ClientError::Rejected(e.to_string()))?;
202    let device = dm.add_device(&cert).await?;
203    let answer = JoinAnswer::Approved {
204        campaign: campaign.clone(),
205        server_key,
206        cert: Box::new(cert),
207    };
208    dm.relay_send(slot, Side::Host, 1, channel.seal(&answer))
209        .await?;
210    Ok(device)
211}
212
213/// The joiner's side: pairs `key` using a code from the DM. On success the
214/// device is in the campaign and can log in.
215pub async fn join(
216    auth: &AuthClient,
217    code: &PairingCode,
218    key: &DeviceKey,
219    name: &str,
220) -> Result<Joined, PairError> {
221    let slot = code.slot;
222    // The host has up to the pairing's lifetime to answer; the relay tells
223    // us sooner if the pairing is cancelled.
224    let deadline = Utc::now() + chrono::Duration::minutes(10);
225    let (exchange, ours) = Exchange::start(Side::Joiner, code);
226    auth.relay_send(slot, Side::Joiner, 0, ours).await?;
227    let theirs = auth.relay_receive(slot, Side::Host, 0, deadline).await?;
228    let mut channel = exchange.finish(&theirs)?;
229
230    let request = JoinRequest {
231        key: key.public(),
232        name: name.to_string(),
233    };
234    auth.relay_send(slot, Side::Joiner, 1, channel.seal(&request))
235        .await?;
236    let sealed = match auth.relay_receive(slot, Side::Host, 1, deadline).await {
237        // The host cancels when our message doesn't open for it.
238        Err(PairError::Unavailable) => return Err(PairError::WrongCode),
239        other => other?,
240    };
241    let (campaign, server_key, cert) = match channel.open(&sealed)? {
242        JoinAnswer::Approved {
243            campaign,
244            server_key,
245            cert,
246        } => (campaign, server_key, *cert),
247        JoinAnswer::Declined => return Err(PairError::Declined),
248    };
249
250    // Trust the DM, but check what can be checked.
251    let issued = cert
252        .verify()
253        .map_err(|_| PairError::BadAnswer("certificate signature is invalid"))?;
254    if issued.device != key.public() || issued.campaign != campaign.id || issued.name != name {
255        return Err(PairError::BadAnswer("certificate is for something else"));
256    }
257    // The DM's device knows the auth service's real key; make sure this is
258    // the service we're actually talking to.
259    if auth.server_key().await? != server_key {
260        return Err(PairError::BadAnswer(
261            "this auth server isn't the one your DM uses",
262        ));
263    }
264    Ok(Joined {
265        campaign,
266        server_key,
267        cert,
268    })
269}