1use 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#[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 #[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#[derive(Debug, Clone)]
51pub struct Invitation {
52 pub code: PairingCode,
53 pub role: Role,
54 pub expires_at: DateTime<Utc>,
55}
56
57#[derive(Debug, Clone)]
59pub struct Joined {
60 pub campaign: Campaign,
61 pub server_key: PublicKey,
63 pub cert: SignedCert,
64}
65
66impl AuthClient {
67 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 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 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 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
148pub 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 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
213pub 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 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 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 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 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}