1use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
4use chrono::{DateTime, Duration, Utc};
5use edda_auth::{
6 DeviceKey, Nonce, PublicKey, Role, SignedCert,
7 protocol::{AuthExport, Campaign, CampaignSettings, Challenge, Device, Principal},
8};
9use edda_types::Edition;
10use sha2::{Digest, Sha256};
11use sqlx::{PgExecutor, PgPool, types::Json};
12use uuid::Uuid;
13
14use crate::error::ApiError;
15
16type ChallengeRow = (Uuid, Vec<u8>, Vec<u8>, DateTime<Utc>);
17
18type DeviceRow = (Json<SignedCert>, DateTime<Utc>, Option<DateTime<Utc>>);
19
20pub struct IssuedChallenge {
22 pub campaign: Uuid,
23 pub key: PublicKey,
24 pub nonce: Nonce,
25 pub expires_at: DateTime<Utc>,
26}
27
28#[derive(Clone)]
30pub struct Store {
31 pool: PgPool,
32}
33
34impl Store {
35 pub fn new(pool: PgPool) -> Self {
37 Self { pool }
38 }
39
40 pub fn pool(&self) -> &PgPool {
42 &self.pool
43 }
44
45 pub async fn token_key(&self) -> Result<DeviceKey, ApiError> {
47 sqlx::query("INSERT INTO token_key (seed) VALUES ($1) ON CONFLICT DO NOTHING")
48 .bind(DeviceKey::generate().seed().to_vec())
49 .execute(&self.pool)
50 .await?;
51 let (seed,): (Vec<u8>,) = sqlx::query_as("SELECT seed FROM token_key")
52 .fetch_one(&self.pool)
53 .await?;
54 let seed = seed
55 .try_into()
56 .map_err(|_| ApiError::Internal("stored token key is not 32 bytes".into()))?;
57 Ok(DeviceKey::from_seed(seed))
58 }
59
60 pub async fn create_campaign(
62 &self,
63 name: &str,
64 edition: Edition,
65 ) -> Result<Campaign, ApiError> {
66 let campaign = Campaign {
67 id: Uuid::new_v4(),
68 name: name.to_string(),
69 edition,
70 };
71 sqlx::query(
72 "INSERT INTO campaigns (id, name, edition, created_at) VALUES ($1, $2, $3, now())",
73 )
74 .bind(campaign.id)
75 .bind(&campaign.name)
76 .bind(edition.to_string())
77 .execute(&self.pool)
78 .await?;
79 Ok(campaign)
80 }
81
82 pub async fn campaign(&self, id: Uuid) -> Result<Campaign, ApiError> {
84 let row: Option<(String, String)> =
85 sqlx::query_as("SELECT name, edition FROM campaigns WHERE id = $1")
86 .bind(id)
87 .fetch_optional(&self.pool)
88 .await?;
89 let (name, edition) = row.ok_or(ApiError::NotFound)?;
90 let edition: Edition = edition
91 .parse()
92 .map_err(|e: edda_types::edition::UnknownEdition| ApiError::Internal(e.to_string()))?;
93 Ok(Campaign { id, name, edition })
94 }
95
96 pub async fn delete_campaign(&self, id: Uuid) -> Result<Option<(Campaign, u64)>, ApiError> {
100 let campaign = match self.campaign(id).await {
101 Ok(campaign) => campaign,
102 Err(ApiError::NotFound) => return Ok(None),
103 Err(e) => return Err(e),
104 };
105 let mut tx = self.pool.begin().await?;
106 let (devices,): (i64,) =
107 sqlx::query_as("SELECT count(*) FROM devices WHERE campaign_id = $1")
108 .bind(id)
109 .fetch_one(&mut *tx)
110 .await?;
111 sqlx::query("DELETE FROM challenges WHERE campaign_id = $1")
112 .bind(id)
113 .execute(&mut *tx)
114 .await?;
115 sqlx::query("DELETE FROM campaigns WHERE id = $1")
117 .bind(id)
118 .execute(&mut *tx)
119 .await?;
120 tx.commit().await?;
121 Ok(Some((campaign, u64::try_from(devices).unwrap_or(0))))
122 }
123
124 pub async fn new_setup_code(
127 &self,
128 campaign: Uuid,
129 valid_for: Duration,
130 ) -> Result<String, ApiError> {
131 self.campaign(campaign).await?;
132 let code = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 24]>());
133 sqlx::query(
134 "INSERT INTO setup_codes (code_hash, campaign_id, expires_at) VALUES ($1, $2, $3)",
135 )
136 .bind(hash(&code))
137 .bind(campaign)
138 .bind(Utc::now() + valid_for)
139 .execute(&self.pool)
140 .await?;
141 Ok(code)
142 }
143
144 pub async fn peek_setup_code(&self, code: &str) -> Result<Option<Uuid>, ApiError> {
146 let row: Option<(Uuid,)> = sqlx::query_as(
147 "SELECT campaign_id FROM setup_codes WHERE code_hash = $1 AND expires_at > now()",
148 )
149 .bind(hash(code))
150 .fetch_optional(&self.pool)
151 .await?;
152 Ok(row.map(|(campaign,)| campaign))
153 }
154
155 pub async fn take_setup_code(
157 &self,
158 executor: impl PgExecutor<'_>,
159 code: &str,
160 ) -> Result<Option<Uuid>, ApiError> {
161 let row: Option<(Uuid,)> = sqlx::query_as(
162 "DELETE FROM setup_codes WHERE code_hash = $1 AND expires_at > now() RETURNING campaign_id",
163 )
164 .bind(hash(code))
165 .fetch_optional(executor)
166 .await?;
167 Ok(row.map(|(campaign,)| campaign))
168 }
169
170 pub async fn add_device(
172 &self,
173 executor: impl PgExecutor<'_>,
174 cert: &SignedCert,
175 ) -> Result<Device, ApiError> {
176 let c = &cert.cert;
177 let added_at = Utc::now();
178 let inserted = sqlx::query(
179 "INSERT INTO devices (campaign_id, public_key, role, name, cert, added_at) \
180 VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING",
181 )
182 .bind(c.campaign)
183 .bind(c.device.as_bytes().to_vec())
184 .bind(role_str(c.role))
185 .bind(&c.name)
186 .bind(Json(cert))
187 .bind(added_at)
188 .execute(executor)
189 .await?;
190 if inserted.rows_affected() == 0 {
191 return Err(ApiError::Conflict(
192 "this key is already registered in the campaign".into(),
193 ));
194 }
195 Ok(Device {
196 cert: cert.clone(),
197 added_at,
198 revoked_at: None,
199 })
200 }
201
202 pub async fn export_campaign(&self, id: Uuid) -> Result<Option<AuthExport>, ApiError> {
205 let campaign = match self.campaign(id).await {
206 Ok(campaign) => campaign,
207 Err(ApiError::NotFound) => return Ok(None),
208 Err(e) => return Err(e),
209 };
210 let (created_at,): (DateTime<Utc>,) =
211 sqlx::query_as("SELECT created_at FROM campaigns WHERE id = $1")
212 .bind(id)
213 .fetch_one(&self.pool)
214 .await?;
215 Ok(Some(AuthExport {
216 campaign,
217 created_at,
218 devices: self.devices(id).await?,
219 settings: self.settings(id).await?,
220 }))
221 }
222
223 pub async fn import_campaign(&self, export: &AuthExport) -> Result<u64, ApiError> {
228 let campaign = &export.campaign;
229 for device in &export.devices {
230 let cert = device
231 .cert
232 .verify()
233 .map_err(|e| ApiError::BadRequest(format!("a device's certificate: {e}")))?;
234 if cert.campaign != campaign.id {
235 return Err(ApiError::BadRequest(
236 "a device's certificate is for another campaign".into(),
237 ));
238 }
239 }
240 let mut tx = self.pool.begin().await?;
241 let inserted = sqlx::query(
242 "INSERT INTO campaigns (id, name, edition, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
243 )
244 .bind(campaign.id)
245 .bind(&campaign.name)
246 .bind(campaign.edition.to_string())
247 .bind(export.created_at)
248 .execute(&mut *tx)
249 .await?;
250 if inserted.rows_affected() == 0 {
251 return Err(ApiError::Conflict(format!(
252 "campaign {} is here already",
253 campaign.id
254 )));
255 }
256 for device in &export.devices {
257 let c = &device.cert.cert;
258 sqlx::query(
259 "INSERT INTO devices (campaign_id, public_key, role, name, cert, added_at, \
260 revoked_at) VALUES ($1, $2, $3, $4, $5, $6, $7)",
261 )
262 .bind(c.campaign)
263 .bind(c.device.as_bytes().to_vec())
264 .bind(role_str(c.role))
265 .bind(&c.name)
266 .bind(Json(&device.cert))
267 .bind(device.added_at)
268 .bind(device.revoked_at)
269 .execute(&mut *tx)
270 .await?;
271 }
272 if !export.settings.is_default() {
273 put_settings(&mut *tx, campaign.id, &export.settings).await?;
274 }
275 tx.commit().await?;
276 Ok(u64::try_from(export.devices.len()).unwrap_or(u64::MAX))
277 }
278
279 pub async fn settings(&self, campaign: Uuid) -> Result<CampaignSettings, ApiError> {
282 let row: Option<(Json<CampaignSettings>,)> =
283 sqlx::query_as("SELECT settings FROM campaign_settings WHERE campaign_id = $1")
284 .bind(campaign)
285 .fetch_optional(&self.pool)
286 .await?;
287 Ok(row.map(|(Json(settings),)| settings).unwrap_or_default())
288 }
289
290 pub async fn set_settings(
293 &self,
294 campaign: Uuid,
295 settings: &CampaignSettings,
296 ) -> Result<(), ApiError> {
297 self.campaign(campaign).await?;
298 put_settings(&self.pool, campaign, settings).await
299 }
300
301 pub async fn devices(&self, campaign: Uuid) -> Result<Vec<Device>, ApiError> {
303 let rows: Vec<DeviceRow> = sqlx::query_as(
304 "SELECT cert, added_at, revoked_at FROM devices WHERE campaign_id = $1 ORDER BY added_at",
305 )
306 .bind(campaign)
307 .fetch_all(&self.pool)
308 .await?;
309 Ok(rows
310 .into_iter()
311 .map(|(Json(cert), added_at, revoked_at)| Device {
312 cert,
313 added_at,
314 revoked_at,
315 })
316 .collect())
317 }
318
319 pub async fn revoke(&self, campaign: Uuid, key: PublicKey) -> Result<Device, ApiError> {
322 let mut tx = self.pool.begin().await?;
323 let active: Vec<(Vec<u8>, String)> = sqlx::query_as(
326 "SELECT public_key, role FROM devices \
327 WHERE campaign_id = $1 AND revoked_at IS NULL FOR UPDATE",
328 )
329 .bind(campaign)
330 .fetch_all(&mut *tx)
331 .await?;
332 let target = active
333 .iter()
334 .find(|(k, _)| k.as_slice() == key.as_bytes())
335 .ok_or(ApiError::NotFound)?;
336 let dms = active.iter().filter(|(_, role)| role == "dm").count();
337 if target.1 == "dm" && dms == 1 {
338 return Err(ApiError::Unprocessable(
339 "can't revoke the campaign's last DM device".into(),
340 ));
341 }
342 let row: DeviceRow = sqlx::query_as(
343 "UPDATE devices SET revoked_at = now() WHERE campaign_id = $1 AND public_key = $2 \
344 RETURNING cert, added_at, revoked_at",
345 )
346 .bind(campaign)
347 .bind(key.as_bytes().to_vec())
348 .fetch_one(&mut *tx)
349 .await?;
350 tx.commit().await?;
351 let (Json(cert), added_at, revoked_at) = row;
352 Ok(Device {
353 cert,
354 added_at,
355 revoked_at,
356 })
357 }
358
359 pub async fn add_service(&self, name: &str, key: PublicKey) -> Result<(), ApiError> {
361 sqlx::query(
362 "INSERT INTO services (public_key, name, added_at) VALUES ($1, $2, now()) \
363 ON CONFLICT (public_key) DO UPDATE SET name = EXCLUDED.name",
364 )
365 .bind(key.as_bytes().to_vec())
366 .bind(name)
367 .execute(&self.pool)
368 .await?;
369 Ok(())
370 }
371
372 pub async fn principal(
374 &self,
375 campaign: Uuid,
376 key: PublicKey,
377 ) -> Result<Option<(Principal, String)>, ApiError> {
378 let device: Option<(String, String)> = sqlx::query_as(
379 "SELECT role, name FROM devices \
380 WHERE campaign_id = $1 AND public_key = $2 AND revoked_at IS NULL",
381 )
382 .bind(campaign)
383 .bind(key.as_bytes().to_vec())
384 .fetch_optional(&self.pool)
385 .await?;
386 if let Some((role, name)) = device {
387 let role = match role.as_str() {
388 "dm" => Role::Dm,
389 _ => Role::Player,
390 };
391 return Ok(Some((role.into(), name)));
392 }
393 let service: Option<(String,)> =
394 sqlx::query_as("SELECT name FROM services WHERE public_key = $1")
395 .bind(key.as_bytes().to_vec())
396 .fetch_optional(&self.pool)
397 .await?;
398 if service.is_some() && self.campaign(campaign).await.is_ok() {
399 return Ok(service.map(|(name,)| (Principal::Service, name)));
400 }
401 Ok(None)
402 }
403
404 pub async fn create_challenge(
406 &self,
407 campaign: Uuid,
408 key: PublicKey,
409 server: PublicKey,
410 valid_for: Duration,
411 ) -> Result<Challenge, ApiError> {
412 sqlx::query("DELETE FROM challenges WHERE expires_at < now()")
413 .execute(&self.pool)
414 .await?;
415 let challenge = Challenge {
416 id: Uuid::new_v4(),
417 nonce: Nonce::random(),
418 campaign,
419 server,
420 expires_at: Utc::now() + valid_for,
421 };
422 sqlx::query(
423 "INSERT INTO challenges (id, campaign_id, public_key, nonce, expires_at) \
424 VALUES ($1, $2, $3, $4, $5)",
425 )
426 .bind(challenge.id)
427 .bind(campaign)
428 .bind(key.as_bytes().to_vec())
429 .bind(challenge.nonce.0.to_vec())
430 .bind(challenge.expires_at)
431 .execute(&self.pool)
432 .await?;
433 Ok(challenge)
434 }
435
436 pub async fn take_challenge(&self, id: Uuid) -> Result<Option<IssuedChallenge>, ApiError> {
439 let row: Option<ChallengeRow> = sqlx::query_as(
440 "DELETE FROM challenges WHERE id = $1 \
441 RETURNING campaign_id, public_key, nonce, expires_at",
442 )
443 .bind(id)
444 .fetch_optional(&self.pool)
445 .await?;
446 let Some((campaign, key, nonce, expires_at)) = row else {
447 return Ok(None);
448 };
449 let corrupt = |what: &str| ApiError::Internal(format!("stored challenge has a bad {what}"));
450 Ok(Some(IssuedChallenge {
451 campaign,
452 key: PublicKey::from_bytes(key.try_into().map_err(|_| corrupt("key"))?)
453 .map_err(|_| corrupt("key"))?,
454 nonce: Nonce(nonce.try_into().map_err(|_| corrupt("nonce"))?),
455 expires_at,
456 }))
457 }
458}
459
460fn hash(code: &str) -> Vec<u8> {
461 Sha256::digest(code.trim().as_bytes()).to_vec()
462}
463
464fn role_str(role: Role) -> &'static str {
465 match role {
466 Role::Dm => "dm",
467 Role::Player => "player",
468 }
469}
470
471async fn put_settings(
472 executor: impl PgExecutor<'_>,
473 campaign: Uuid,
474 settings: &CampaignSettings,
475) -> Result<(), ApiError> {
476 sqlx::query(
477 "INSERT INTO campaign_settings (campaign_id, settings) VALUES ($1, $2) \
478 ON CONFLICT (campaign_id) DO UPDATE SET settings = EXCLUDED.settings",
479 )
480 .bind(campaign)
481 .bind(Json(settings))
482 .execute(executor)
483 .await?;
484 Ok(())
485}