Skip to main content

auth_service/
store.rs

1//! Persistence in Postgres.
2
3use 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
20/// An answered challenge: who it was issued to.
21pub struct IssuedChallenge {
22    pub campaign: Uuid,
23    pub key: PublicKey,
24    pub nonce: Nonce,
25    pub expires_at: DateTime<Utc>,
26}
27
28/// Campaigns, devices, setup codes and challenges, in Postgres.
29#[derive(Clone)]
30pub struct Store {
31    pool: PgPool,
32}
33
34impl Store {
35    /// A store in `pool`'s database.
36    pub fn new(pool: PgPool) -> Self {
37        Self { pool }
38    }
39
40    /// The database.
41    pub fn pool(&self) -> &PgPool {
42        &self.pool
43    }
44
45    /// The token signing key, created by whichever instance starts first.
46    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    /// Creates a campaign.
61    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    /// A campaign by id.
83    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    /// Deletes a campaign with its devices, setup codes, pairings and
97    /// login challenges, returning what it was; `None` if there's no such
98    /// campaign. Its devices can't log in from then on.
99    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        // Devices, setup codes and pairings go with it (ON DELETE CASCADE).
116        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    /// A fresh one-time code that lets a device make itself DM of
125    /// `campaign`.
126    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    /// The campaign a setup code is for, without using it up.
145    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    /// Uses up a setup code, returning its campaign if it was valid.
156    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    /// Adds a device with its certificate, within the campaign's limit.
171    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    /// The campaign and its devices, to keep or move elsewhere; `None` if
203    /// there's no such campaign.
204    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    /// Takes in a campaign exported elsewhere, as it was, ids, keys and
224    /// dates included; returns how many devices came with it. All or
225    /// nothing, and not over a campaign that's here already. Every
226    /// certificate must be for the campaign and properly signed.
227    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    /// What was set for the campaign: by default, the services' own
280    /// limits, and changes allowed.
281    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    /// Sets what the campaign may keep and do; its devices' tokens say so
291    /// from their next login.
292    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    /// The campaign's devices, revoked ones too.
302    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    /// Revokes a device. Refuses to revoke the campaign's last DM device,
320    /// which would leave nobody able to manage it.
321    pub async fn revoke(&self, campaign: Uuid, key: PublicKey) -> Result<Device, ApiError> {
322        let mut tx = self.pool.begin().await?;
323        // Lock the campaign's devices so two DMs can't revoke each other at
324        // the same moment and leave none.
325        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    /// Lets a service log in to any campaign with `key`.
360    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    /// Who `key` is in `campaign`: an active device or a service.
373    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    /// A challenge for a device of the campaign to sign.
405    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    /// Removes a challenge, returning it if it existed. Whoever gets it
437    /// first wins, so each challenge is answered at most once.
438    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}