diff options
| author | Owen Jacobson <owen@grimoire.ca> | 2025-08-30 02:59:51 -0400 |
|---|---|---|
| committer | Owen Jacobson <owen@grimoire.ca> | 2025-08-30 02:59:51 -0400 |
| commit | 96b62f1018641b3dc28cc189d314a1bff475b751 (patch) | |
| tree | 1e10b660f2c68cb73d3c17703257ec0be51c36af | |
| parent | 3bd63e20777126216777b392615dc8144f21bb9a (diff) | |
Allow administrators to rotate VAPID keys immediately if needed.
In spite of my design preference away from CLI tools, this is a CLI tool: `pilcrow --database-url <URL> rotate-vapid-key`. This is something we can implement here and now, which does not require us to confront the long-avoided issue of how to handle the idea that some users are allowed to make server-operational changes and some aren't, by delegating the problem back to the OS.
The implementation is a little half-baked to make it easier to rip out later. I would ordinarily prefer to push both `serve` (the default verb, not actually named in this change) and `rotate-vapid-key` into their own, separate CLI submodules, with their own argument structs, but that change is much more intrusive and would make this effectively permanent. This can be yanked out in a few minutes by deleting a few lines of `cli.rs` and inlining the `serve` function.
Nonetheless, nothing is as permanent as a temporary solution, so I've written at least some bare-minimum operations documentation on how to use this and what it does.
| -rw-r--r-- | docs/ops.md | 14 | ||||
| -rw-r--r-- | src/cli.rs | 25 | ||||
| -rw-r--r-- | src/vapid/app.rs | 17 |
3 files changed, 53 insertions, 3 deletions
diff --git a/docs/ops.md b/docs/ops.md index a622c04..678d2a4 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -21,3 +21,17 @@ By default, the `pilcrow` command will set the process' umask to a value that pr - any octal value corresponding to a valid umask, such as `0027`. Pilcrow does not check or change the permissions of the database after creation. Changing the umask of the server after the database has been created has no effect on the database's filesystem permissions. + +## VAPID keys + +Pilcrow uses [VAPID] to identify itself to public Web Push brokers, which then deliver notifications to Pilcrow's users of interesting events, such as messages. VAPID uses cryptographic signatures to authenticate the server. + +[VAPID]: https://datatracker.ietf.org/doc/html/rfc8292 + +The key is stored in the `pilcrow` database. Pilcrow will create its key automatically, and will rotate the key every 30 days. + +If the `pilcrow` database is accessed inappropriately or leaked, then the key can be used to send push notifications to Pilcrow users as if from the Pilcrow server. If this happens, the key _must_ be rotated to prevent misuse. + +The key can be rotated at any time running `pilcrow […options…] rotate-vapid-key`, as the same user Pilcrow normally runs as. This does not require that the server be shut down or restarted. The `[…options…]` must be set to the same values as used by the running server. + +When the key is rotated, no further push messages will be sent from the Pilcrow server using that key. Unfortunately, the Web Push protocol doesn't allow Pilcrow to proactively invalidate clients' push subscriptions, but Pilcrow will inform clients when the key is rotated so that they can invalidate the affected subscriptions themselves. @@ -10,7 +10,8 @@ use axum::{ middleware, response::{IntoResponse, Response}, }; -use clap::{CommandFactory, Parser}; +use chrono::Utc; +use clap::{CommandFactory, Parser, Subcommand}; use sqlx::sqlite::SqlitePool; use tokio::net; @@ -65,6 +66,15 @@ pub struct Args { /// upgrades #[arg(short = 'D', long, env, default_value = "sqlite://pilcrow.db.backup")] backup_database_url: String, + + #[command(subcommand)] + command: Option<Command>, +} + +#[derive(Subcommand)] +enum Command { + /// Immediately rotate the server's VAPID (Web Push) application key. + RotateVapidKey, } impl Args { @@ -89,6 +99,16 @@ impl Args { let pool = self.pool().await?; let app = App::from(pool); + + match self.command { + None => self.serve(app).await?, + Some(Command::RotateVapidKey) => app.vapid().rotate_key(&Utc::now()).await?, + } + + Result::<_, Error>::Ok(()) + } + + async fn serve(self, app: App) -> Result<(), Error> { let app = routes::routes(&app) .route_layer(middleware::from_fn(clock::middleware)) .route_layer(middleware::map_response(Self::server_info())) @@ -101,7 +121,7 @@ impl Args { println!("{started_msg}"); serve.await?; - Result::<_, Error>::Ok(()) + Ok(()) } async fn listener(&self) -> io::Result<net::TcpListener> { @@ -140,5 +160,6 @@ fn started_msg(listener: &net::TcpListener) -> io::Result<String> { enum Error { Io(#[from] io::Error), Database(#[from] db::Error), + Sqlx(#[from] sqlx::Error), Umask(#[from] umask::Error), } diff --git a/src/vapid/app.rs b/src/vapid/app.rs index 8886c9f..ddb1f4d 100644 --- a/src/vapid/app.rs +++ b/src/vapid/app.rs @@ -1,4 +1,4 @@ -use chrono::TimeDelta; +use chrono::{TimeDelta, Utc}; use sqlx::SqlitePool; use super::{History, repo, repo::Provider as _}; @@ -18,6 +18,21 @@ impl<'a> Vapid<'a> { Self { db, events } } + pub async fn rotate_key(&self, rotate_at: &DateTime) -> Result<(), sqlx::Error> { + let mut tx = self.db.begin().await?; + // This is called from a separate CLI utility (see `cli.rs`), and we _can't_ deliver events + // to active clients from another process, so don't do anything that would require us to + // send events, like generating a new key. + // + // Instead, the server's next `refresh_key` call will generate a key and notify clients + // of the change. All we have to do is remove the existing key, so that the server can know + // to do so. + tx.vapid().clear().await?; + tx.commit().await?; + + Ok(()) + } + pub async fn refresh_key(&self, ensure_at: &DateTime) -> Result<(), Error> { let mut tx = self.db.begin().await?; let key = tx.vapid().current().await.optional()?; |
