summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorojacobson <ojacobson@noreply.codeberg.org>2025-09-10 23:11:33 +0200
committerojacobson <ojacobson@noreply.codeberg.org>2025-09-10 23:11:33 +0200
commit10ccf6afafa2b2c3beab991505fc780e7f8d8357 (patch)
tree1e10b660f2c68cb73d3c17703257ec0be51c36af
parent342d4720576369f83d88b0dbde6ca5d58aebd252 (diff)
parent96b62f1018641b3dc28cc189d314a1bff475b751 (diff)
Implement VAPID key support for Web Push.
This is intended to be a low-operator-involvement, self-managing approach: * The server generates its own VAPID key. The operator does not need to run complex `openssl` commands. * The server rotates its VAPID key regularly. The operator does not need to maintain a calendar or run administrative tools. * The included CLI changes allow for immediate key rotation if needed. ## Correctness This change is speculative, to a degree, and is based on some guesses and loose understandings of the various Web Push and VAPID specs and docs. We use RustCrypto's p256 crate to generate keys rather than a dedicated VAPID crate (one does exist) as I trust their code review and security standards further, but that means that I've had to guess what kind of P256 key is actually appropriate for this use case, and what encoding clients need. If those elements are incorrect they're not hard to change, but they need further verification before we commit to them on `main`. Changing the encoding can be done in the `src/vapid/event.rs` file. Changing the key type is more involved, but is still contained to `src/vapid/`. I already did that once, switching from `p256::SecretKey` to `p256::ecdsa::SingingKey`, and it's not too bad - takes maybe 20 minutes on a good day. The real risk is that we can't tell from the database what kind of key we have, so we need to settle this before making any permanent, durable changes to other peoples' data. ## Safety This change stores the private key, entirely in the clear, in the Pilcrow database. Anyone who gets the database or who gets access to `vapid_signing_key` can impersonate the server in Web Push messages. Operators are theoretically responsible for managing that risk. We are responsible for setting them up for success, and for making the right thing to do the easiest thing to do. This change tries to accomplish those goals in two ways: * It relies on previous changes to the process' umask to ensure that the database is not readable by other OS users. OS permissions are pretty thin protection these days, as they only apply to operations carried out through the OS, but it's better than nothing at preventing casual snooping on the private key. * Key rotation. The maximum period for which an exposed key can stay valid is 30 days, plus as long as it takes for clients to wake up, notice the key has changed, and invalidate their existing subscriptions. The impact of a key leak is also moderate at best. We don't intend to send clients confidential information, or instruct clients to take compromising action, via push message. We intend to use them to notify clients of events that users may be interested in, like messages. Abusing the push system will, at worst, distract users or prompt them to disable notifications, or potentially deliver notifications for messages that never existed, which users can identify by reading the chat for themselves. ## Operator tooling This change introduces the `pilcrow rotate-vapid-key` command, to immediately rotate the VAPID key. This is designed to be an online _or_ offline command: all it does is destroy the current key, so that the server will generate a new one. I've included some sketchy but complete documentation; it's probably inadequate, but then, so is that whole file. I would resist the use of operator CLI tools as contrary to our low-admin-involvement aesthetic, but in this situation it's hard to avoid. The alternatives on the one hand are documenting the schema and telling operators how to use `sqlite3` to rotate keys, or splitting the keys into a separate database that the operator can delete outright to trigger key rotation, or on the other hand stopping to implement privileges and operator interfaces inside of Pilcrow. I think a CLI is an acceptable compromise between inadequate operator support from the one set of alternatives, and massive complexity and a large delay on the other. We will likely, however, have to revise this later. Merges vapid-keys into push-notify.
-rw-r--r--.sqlx/query-3f1bf6aaceb140ae03262bed1fb39fb9cb5e89b67861fb384e6238eb9553e6e7.json12
-rw-r--r--.sqlx/query-cf9bc5c02ff22b98b8eec670070aeaf22811cf4e1527624c615a88eb29e6c9df.json12
-rw-r--r--.sqlx/query-edd16f1507f3b40270d652c7c204a4b9a518af418cc7e7fce9a6f0a106a6d66e.json32
-rw-r--r--.sqlx/query-f2218170662ff7450a9a9b18b38e92d740f4f715f39944774a9e8392e0267123.json12
-rw-r--r--.sqlx/query-f31071e0c069c2e33b3e44fc2524d2b93ff8af35c4978a467a1c7b1331750cac.json12
-rw-r--r--Cargo.lock121
-rw-r--r--Cargo.toml2
-rw-r--r--docs/api/events.md30
-rw-r--r--docs/ops.md14
-rw-r--r--migrations/20250829220738_vapid_keys.sql17
-rw-r--r--src/app.rs5
-rw-r--r--src/boot/app.rs23
-rw-r--r--src/boot/handlers/boot/test.rs68
-rw-r--r--src/cli.rs25
-rw-r--r--src/event/app.rs22
-rw-r--r--src/event/handlers/stream/test/mod.rs1
-rw-r--r--src/event/handlers/stream/test/vapid.rs111
-rw-r--r--src/event/mod.rs10
-rw-r--r--src/lib.rs1
-rw-r--r--src/routes.rs8
-rw-r--r--src/test/fixtures/event/mod.rs21
-rw-r--r--src/test/fixtures/event/stream.rs17
-rw-r--r--src/vapid/app.rs104
-rw-r--r--src/vapid/event.rs48
-rw-r--r--src/vapid/history.rs55
-rw-r--r--src/vapid/middleware.rs17
-rw-r--r--src/vapid/mod.rs9
-rw-r--r--src/vapid/repo.rs139
-rw-r--r--ui/lib/state/remote/state.svelte.js15
29 files changed, 958 insertions, 5 deletions
diff --git a/.sqlx/query-3f1bf6aaceb140ae03262bed1fb39fb9cb5e89b67861fb384e6238eb9553e6e7.json b/.sqlx/query-3f1bf6aaceb140ae03262bed1fb39fb9cb5e89b67861fb384e6238eb9553e6e7.json
new file mode 100644
index 0000000..ad505b5
--- /dev/null
+++ b/.sqlx/query-3f1bf6aaceb140ae03262bed1fb39fb9cb5e89b67861fb384e6238eb9553e6e7.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete from vapid_signing_key\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 0
+ },
+ "nullable": []
+ },
+ "hash": "3f1bf6aaceb140ae03262bed1fb39fb9cb5e89b67861fb384e6238eb9553e6e7"
+}
diff --git a/.sqlx/query-cf9bc5c02ff22b98b8eec670070aeaf22811cf4e1527624c615a88eb29e6c9df.json b/.sqlx/query-cf9bc5c02ff22b98b8eec670070aeaf22811cf4e1527624c615a88eb29e6c9df.json
new file mode 100644
index 0000000..ca62f63
--- /dev/null
+++ b/.sqlx/query-cf9bc5c02ff22b98b8eec670070aeaf22811cf4e1527624c615a88eb29e6c9df.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete from vapid_key\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 0
+ },
+ "nullable": []
+ },
+ "hash": "cf9bc5c02ff22b98b8eec670070aeaf22811cf4e1527624c615a88eb29e6c9df"
+}
diff --git a/.sqlx/query-edd16f1507f3b40270d652c7c204a4b9a518af418cc7e7fce9a6f0a106a6d66e.json b/.sqlx/query-edd16f1507f3b40270d652c7c204a4b9a518af418cc7e7fce9a6f0a106a6d66e.json
new file mode 100644
index 0000000..2481fa9
--- /dev/null
+++ b/.sqlx/query-edd16f1507f3b40270d652c7c204a4b9a518af418cc7e7fce9a6f0a106a6d66e.json
@@ -0,0 +1,32 @@
+{
+ "db_name": "SQLite",
+ "query": "\n select\n key.changed_at as \"changed_at: DateTime\",\n key.changed_sequence as \"changed_sequence: Sequence\",\n signing.key as \"key: Vec<u8>\"\n from vapid_key as key\n join vapid_signing_key as signing\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "changed_at: DateTime",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "changed_sequence: Sequence",
+ "ordinal": 1,
+ "type_info": "Integer"
+ },
+ {
+ "name": "key: Vec<u8>",
+ "ordinal": 2,
+ "type_info": "Blob"
+ }
+ ],
+ "parameters": {
+ "Right": 0
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "edd16f1507f3b40270d652c7c204a4b9a518af418cc7e7fce9a6f0a106a6d66e"
+}
diff --git a/.sqlx/query-f2218170662ff7450a9a9b18b38e92d740f4f715f39944774a9e8392e0267123.json b/.sqlx/query-f2218170662ff7450a9a9b18b38e92d740f4f715f39944774a9e8392e0267123.json
new file mode 100644
index 0000000..a5880c1
--- /dev/null
+++ b/.sqlx/query-f2218170662ff7450a9a9b18b38e92d740f4f715f39944774a9e8392e0267123.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert into vapid_key (changed_at, changed_sequence)\n values ($1, $2)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": []
+ },
+ "hash": "f2218170662ff7450a9a9b18b38e92d740f4f715f39944774a9e8392e0267123"
+}
diff --git a/.sqlx/query-f31071e0c069c2e33b3e44fc2524d2b93ff8af35c4978a467a1c7b1331750cac.json b/.sqlx/query-f31071e0c069c2e33b3e44fc2524d2b93ff8af35c4978a467a1c7b1331750cac.json
new file mode 100644
index 0000000..6827441
--- /dev/null
+++ b/.sqlx/query-f31071e0c069c2e33b3e44fc2524d2b93ff8af35c4978a467a1c7b1331750cac.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert into vapid_signing_key (key)\n values ($1)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "f31071e0c069c2e33b3e44fc2524d2b93ff8af35c4978a467a1c7b1331750cac"
+}
diff --git a/Cargo.lock b/Cargo.lock
index b24fc91..0f9ece9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -223,6 +223,12 @@ dependencies = [
]
[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -439,6 +445,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -567,6 +585,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
name = "either"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -576,6 +608,26 @@ dependencies = [
]
[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -643,6 +695,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -782,6 +844,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
]
[[package]]
@@ -814,6 +877,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1433,6 +1507,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1494,6 +1580,7 @@ dependencies = [
"argon2",
"axum",
"axum-extra",
+ "base64 0.22.1",
"chrono",
"clap",
"faker_rand",
@@ -1503,6 +1590,7 @@ dependencies = [
"itertools",
"mime",
"nix",
+ "p256",
"password-hash",
"pin-project",
"rand",
@@ -1599,6 +1687,15 @@ dependencies = [
]
[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1687,6 +1784,16 @@ dependencies = [
]
[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
+[[package]]
name = "rsa"
version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1826,6 +1933,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
name = "serde"
version = "1.0.217"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 5b861a8..c0648cd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,6 +25,7 @@ assets = [
argon2 = "0.5.3"
axum = { version = "0.8.1", features = ["form"] }
axum-extra = { version = "0.10.0", features = ["cookie", "query", "typed-header"] }
+base64 = "0.22.1"
chrono = { version = "0.4.39", features = ["serde"] }
clap = { version = "4.5.30", features = ["derive", "env"] }
futures = "0.3.31"
@@ -33,6 +34,7 @@ hex-literal = "0.4.1"
itertools = "0.14.0"
mime = "0.3.17"
nix = { version = "0.30.1", features = ["fs"] }
+p256 = { version = "0.13.2", features = ["ecdsa"] }
password-hash = { version = "0.5.0", features = ["std"] }
rand = "0.8.5"
rand_core = { version = "0.6.4", features = ["getrandom"] }
diff --git a/docs/api/events.md b/docs/api/events.md
index e692f82..9db4613 100644
--- a/docs/api/events.md
+++ b/docs/api/events.md
@@ -237,3 +237,33 @@ These events have the `event` field set to `"deleted"`. They include the followi
| :---- | :-------- | :---------------------------------- |
| `at` | timestamp | The moment the message was deleted. |
| `id` | string | The deleted message's ID. |
+
+## VAPID key events
+
+The following events describe changes to Pilcrow's [VAPID key](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#applicationserverkey).
+
+These events have the `type` field set to `"vapid"`.
+
+### VAPID key changed
+
+```json
+{
+ "type": "vapid",
+ "event": "changed",
+ "at": "2025-08-30T05:44:58.100206Z",
+ "key": "BKILjh0SzTiHOZ5P_sfduv-iqtOg_S18nR7ePcjnDivJaOY6nOG1L3OmvjXjNnthlRDdVnawl1_owfdPCvmDt5U="
+}
+```
+
+Sent whenever the server's VAPID key changes.
+
+These events have the `event` field set to `"changed"`. They include the following additional fields:
+
+| Field | Type | Description |
+| :---- | :-------- | :----------------------------------------------------------------------------------------------- |
+| `at` | timestamp | The moment the key was changed. |
+| `key` | string | A URL-safe Base64 encoding of the VAPID public key, usable to create new Web Push subscriptions. |
+
+The server may change its VAPID key at any time, and will do so periodically to manage the risk of the private key being leaked. When the key is changed, old keys are immediately destroyed, and the corresponding change events are removed from the event stream.
+
+Clients must use the most recent VAPID key when creating Web Push subscriptions. If the key changes, clients must invalidate or recreate existing subscriptions - the previous key is no longer valid and will no longer be used, and push subscriptions using that key will not be fulfilled.
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.
diff --git a/migrations/20250829220738_vapid_keys.sql b/migrations/20250829220738_vapid_keys.sql
new file mode 100644
index 0000000..64f4295
--- /dev/null
+++ b/migrations/20250829220738_vapid_keys.sql
@@ -0,0 +1,17 @@
+create table vapid_signing_key (
+ key blob
+ not null
+);
+
+create unique index vapid_signing_key_singleton
+ on vapid_signing_key (0);
+
+create table vapid_key (
+ changed_at text
+ not null,
+ changed_sequence bigint
+ not null
+);
+
+create unique index vapid_key_singleton
+ on vapid_key (0);
diff --git a/src/app.rs b/src/app.rs
index e61672f..0f3f6ad 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -11,6 +11,7 @@ use crate::{
message::app::Messages,
setup::app::Setup,
token::{self, app::Tokens},
+ vapid::app::Vapid,
};
#[derive(Clone)]
@@ -69,4 +70,8 @@ impl App {
pub const fn users(&self) -> Users<'_> {
Users::new(&self.db, &self.events)
}
+
+ pub const fn vapid(&self) -> Vapid<'_> {
+ Vapid::new(&self.db, &self.events)
+ }
}
diff --git a/src/boot/app.rs b/src/boot/app.rs
index 0ed5d1b..543429f 100644
--- a/src/boot/app.rs
+++ b/src/boot/app.rs
@@ -4,10 +4,12 @@ use sqlx::sqlite::SqlitePool;
use super::Snapshot;
use crate::{
conversation::{self, repo::Provider as _},
+ db::NotFound,
event::{Event, Sequence, repo::Provider as _},
message::{self, repo::Provider as _},
name,
user::{self, repo::Provider as _},
+ vapid::{self, repo::Provider as _},
};
pub struct Boot<'a> {
@@ -26,6 +28,7 @@ impl<'a> Boot<'a> {
let users = tx.users().all(resume_point).await?;
let conversations = tx.conversations().all(resume_point).await?;
let messages = tx.messages().all(resume_point).await?;
+ let vapid = tx.vapid().current().await.optional()?;
tx.commit().await?;
@@ -50,9 +53,16 @@ impl<'a> Boot<'a> {
.filter(Sequence::up_to(resume_point))
.map(Event::from);
+ let vapid_events = vapid
+ .iter()
+ .flat_map(vapid::History::events)
+ .filter(Sequence::up_to(resume_point))
+ .map(Event::from);
+
let events = user_events
.merge_by(conversation_events, Sequence::merge)
.merge_by(message_events, Sequence::merge)
+ .merge_by(vapid_events, Sequence::merge)
.collect();
Ok(Snapshot {
@@ -65,8 +75,9 @@ impl<'a> Boot<'a> {
#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub enum Error {
- Name(#[from] name::Error),
Database(#[from] sqlx::Error),
+ Name(#[from] name::Error),
+ Ecdsa(#[from] p256::ecdsa::Error),
}
impl From<user::repo::LoadError> for Error {
@@ -88,3 +99,13 @@ impl From<conversation::repo::LoadError> for Error {
}
}
}
+
+impl From<vapid::repo::Error> for Error {
+ fn from(error: vapid::repo::Error) -> Self {
+ use vapid::repo::Error;
+ match error {
+ Error::Database(error) => error.into(),
+ Error::Ecdsa(error) => error.into(),
+ }
+ }
+}
diff --git a/src/boot/handlers/boot/test.rs b/src/boot/handlers/boot/test.rs
index cb50442..3c09b0f 100644
--- a/src/boot/handlers/boot/test.rs
+++ b/src/boot/handlers/boot/test.rs
@@ -81,6 +81,74 @@ async fn includes_messages() {
}
#[tokio::test]
+async fn includes_vapid_key() {
+ let app = fixtures::scratch_app().await;
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("key rotation always succeeds");
+
+ let viewer = fixtures::identity::fictitious();
+ let response = super::handler(State(app), viewer)
+ .await
+ .expect("boot always succeeds");
+
+ response
+ .snapshot
+ .events
+ .into_iter()
+ .filter_map(fixtures::event::vapid)
+ .filter_map(fixtures::event::vapid::changed)
+ .exactly_one()
+ .expect("only one vapid key has been created");
+}
+
+#[tokio::test]
+async fn includes_only_latest_vapid_key() {
+ let app = fixtures::scratch_app().await;
+
+ app.vapid()
+ .refresh_key(&fixtures::ancient())
+ .await
+ .expect("key rotation always succeeds");
+
+ let viewer = fixtures::identity::fictitious();
+ let response = super::handler(State(app.clone()), viewer.clone())
+ .await
+ .expect("boot always succeeds");
+
+ let original_key = response
+ .snapshot
+ .events
+ .into_iter()
+ .filter_map(fixtures::event::vapid)
+ .filter_map(fixtures::event::vapid::changed)
+ .exactly_one()
+ .expect("only one vapid key has been created");
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("key rotation always succeeds");
+
+ let response = super::handler(State(app), viewer)
+ .await
+ .expect("boot always succeeds");
+
+ let rotated_key = response
+ .snapshot
+ .events
+ .into_iter()
+ .filter_map(fixtures::event::vapid)
+ .filter_map(fixtures::event::vapid::changed)
+ .exactly_one()
+ .expect("only one vapid key should be returned");
+
+ assert_ne!(original_key, rotated_key);
+}
+
+#[tokio::test]
async fn includes_expired_messages() {
let app = fixtures::scratch_app().await;
let sender = fixtures::user::create(&app, &fixtures::ancient()).await;
diff --git a/src/cli.rs b/src/cli.rs
index 378686b..263a4fd 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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/event/app.rs b/src/event/app.rs
index 7359bfb..fe90465 100644
--- a/src/event/app.rs
+++ b/src/event/app.rs
@@ -8,9 +8,12 @@ use sqlx::sqlite::SqlitePool;
use super::{Event, Sequence, Sequenced, broadcaster::Broadcaster};
use crate::{
conversation::{self, repo::Provider as _},
+ db::NotFound,
message::{self, repo::Provider as _},
name,
user::{self, repo::Provider as _},
+ vapid,
+ vapid::repo::Provider as _,
};
pub struct Events<'a> {
@@ -57,9 +60,17 @@ impl<'a> Events<'a> {
.filter(Sequence::after(resume_at))
.map(Event::from);
+ let vapid = tx.vapid().current().await.optional()?;
+ let vapid_events = vapid
+ .iter()
+ .flat_map(vapid::History::events)
+ .filter(Sequence::after(resume_at))
+ .map(Event::from);
+
let replay_events = user_events
.merge_by(conversation_events, Sequence::merge)
.merge_by(message_events, Sequence::merge)
+ .merge_by(vapid_events, Sequence::merge)
.collect::<Vec<_>>();
let resume_live_at = replay_events.last().map_or(resume_at, Sequenced::sequence);
@@ -86,6 +97,7 @@ impl<'a> Events<'a> {
pub enum Error {
Database(#[from] sqlx::Error),
Name(#[from] name::Error),
+ Ecdsa(#[from] p256::ecdsa::Error),
}
impl From<user::repo::LoadError> for Error {
@@ -107,3 +119,13 @@ impl From<conversation::repo::LoadError> for Error {
}
}
}
+
+impl From<vapid::repo::Error> for Error {
+ fn from(error: vapid::repo::Error) -> Self {
+ use vapid::repo::Error;
+ match error {
+ Error::Database(error) => error.into(),
+ Error::Ecdsa(error) => error.into(),
+ }
+ }
+}
diff --git a/src/event/handlers/stream/test/mod.rs b/src/event/handlers/stream/test/mod.rs
index 3bc634f..c3a6ce6 100644
--- a/src/event/handlers/stream/test/mod.rs
+++ b/src/event/handlers/stream/test/mod.rs
@@ -4,5 +4,6 @@ mod message;
mod resume;
mod setup;
mod token;
+mod vapid;
use super::{QueryParams, Response, handler};
diff --git a/src/event/handlers/stream/test/vapid.rs b/src/event/handlers/stream/test/vapid.rs
new file mode 100644
index 0000000..dbc3929
--- /dev/null
+++ b/src/event/handlers/stream/test/vapid.rs
@@ -0,0 +1,111 @@
+use axum::extract::State;
+use axum_extra::extract::Query;
+use futures::StreamExt as _;
+
+use crate::test::{fixtures, fixtures::future::Expect as _};
+
+#[tokio::test]
+async fn live_vapid_key_changes() {
+ // Set up the context
+ let app = fixtures::scratch_app().await;
+ let resume_point = fixtures::boot::resume_point(&app).await;
+
+ // Subscribe to events
+
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+ let super::Response(events) = super::handler(
+ State(app.clone()),
+ subscriber,
+ None,
+ Query(super::QueryParams { resume_point }),
+ )
+ .await
+ .expect("subscribe never fails");
+
+ // Rotate the VAPID key
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the vapid key always succeeds");
+
+ // Verify that there's a key rotation event
+
+ events
+ .filter_map(fixtures::event::stream::vapid)
+ .filter_map(fixtures::event::stream::vapid::changed)
+ .next()
+ .expect_some("a vapid key change event is sent")
+ .await;
+}
+
+#[tokio::test]
+async fn stored_vapid_key_changes() {
+ // Set up the context
+ let app = fixtures::scratch_app().await;
+ let resume_point = fixtures::boot::resume_point(&app).await;
+
+ // Rotate the VAPID key
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the vapid key always succeeds");
+
+ // Subscribe to events
+
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+ let super::Response(events) = super::handler(
+ State(app.clone()),
+ subscriber,
+ None,
+ Query(super::QueryParams { resume_point }),
+ )
+ .await
+ .expect("subscribe never fails");
+
+ // Verify that there's a key rotation event
+
+ events
+ .filter_map(fixtures::event::stream::vapid)
+ .filter_map(fixtures::event::stream::vapid::changed)
+ .next()
+ .expect_some("a vapid key change event is sent")
+ .await;
+}
+
+#[tokio::test]
+async fn no_past_vapid_key_changes() {
+ // Set up the context
+ let app = fixtures::scratch_app().await;
+
+ // Rotate the VAPID key
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the vapid key always succeeds");
+
+ // Subscribe to events
+
+ let resume_point = fixtures::boot::resume_point(&app).await;
+
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+ let super::Response(events) = super::handler(
+ State(app.clone()),
+ subscriber,
+ None,
+ Query(super::QueryParams { resume_point }),
+ )
+ .await
+ .expect("subscribe never fails");
+
+ // Verify that there's a key rotation event
+
+ events
+ .filter_map(fixtures::event::stream::vapid)
+ .filter_map(fixtures::event::stream::vapid::changed)
+ .next()
+ .expect_wait("a vapid key change event is not sent")
+ .await;
+}
diff --git a/src/event/mod.rs b/src/event/mod.rs
index f41dc9c..83b0ce7 100644
--- a/src/event/mod.rs
+++ b/src/event/mod.rs
@@ -2,7 +2,7 @@ use std::time::Duration;
use axum::response::sse::{self, KeepAlive};
-use crate::{conversation, message, user};
+use crate::{conversation, message, user, vapid};
pub mod app;
mod broadcaster;
@@ -22,6 +22,7 @@ pub enum Event {
User(user::Event),
Conversation(conversation::Event),
Message(message::Event),
+ Vapid(vapid::Event),
}
// Serialized representation is intended to look like the serialized representation of `Event`,
@@ -40,6 +41,7 @@ impl Sequenced for Event {
Self::User(event) => event.instant(),
Self::Conversation(event) => event.instant(),
Self::Message(event) => event.instant(),
+ Self::Vapid(event) => event.instant(),
}
}
}
@@ -62,6 +64,12 @@ impl From<message::Event> for Event {
}
}
+impl From<vapid::Event> for Event {
+ fn from(event: vapid::Event) -> Self {
+ Self::Vapid(event)
+ }
+}
+
impl Heartbeat {
// The following values are a first-rough-guess attempt to balance noticing connection problems
// quickly with managing the (modest) costs of delivering and processing heartbeats. Feel
diff --git a/src/lib.rs b/src/lib.rs
index f05cce3..6b2a83c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -28,3 +28,4 @@ mod token;
mod ui;
mod umask;
mod user;
+mod vapid;
diff --git a/src/routes.rs b/src/routes.rs
index b848afb..2979abe 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -4,7 +4,9 @@ use axum::{
routing::{delete, get, post},
};
-use crate::{app::App, boot, conversation, event, expire, invite, login, message, setup, ui};
+use crate::{
+ app::App, boot, conversation, event, expire, invite, login, message, setup, ui, vapid,
+};
pub fn routes(app: &App) -> Router<App> {
// UI routes that can be accessed before the administrator completes setup.
@@ -56,6 +58,10 @@ pub fn routes(app: &App) -> Router<App> {
app.clone(),
expire::middleware,
))
+ .route_layer(middleware::from_fn_with_state(
+ app.clone(),
+ vapid::middleware,
+ ))
.route_layer(setup::Required(app.clone()));
[
diff --git a/src/test/fixtures/event/mod.rs b/src/test/fixtures/event/mod.rs
index 08b17e7..f8651ba 100644
--- a/src/test/fixtures/event/mod.rs
+++ b/src/test/fixtures/event/mod.rs
@@ -23,6 +23,13 @@ pub fn user(event: Event) -> Option<crate::user::Event> {
}
}
+pub fn vapid(event: Event) -> Option<crate::vapid::Event> {
+ match event {
+ Event::Vapid(event) => Some(event),
+ _ => None,
+ }
+}
+
pub mod conversation {
use crate::conversation::{Event, event};
@@ -72,3 +79,17 @@ pub mod user {
}
}
}
+
+pub mod vapid {
+ use crate::vapid::{Event, event};
+
+ // This could be defined as `-> event::Changed`. However, I want the interface to be consistent
+ // with the event stream transformers for other types, and we'd have to refactor the return type
+ // to `-> Option<event::Changed>` the instant VAPID keys sprout a second event.
+ #[allow(clippy::unnecessary_wraps)]
+ pub fn changed(event: Event) -> Option<event::Changed> {
+ match event {
+ Event::Changed(changed) => Some(changed),
+ }
+ }
+}
diff --git a/src/test/fixtures/event/stream.rs b/src/test/fixtures/event/stream.rs
index 5b3621d..bb83d0d 100644
--- a/src/test/fixtures/event/stream.rs
+++ b/src/test/fixtures/event/stream.rs
@@ -14,6 +14,10 @@ pub fn user(event: Event) -> Ready<Option<crate::user::Event>> {
future::ready(event::user(event))
}
+pub fn vapid(event: Event) -> Ready<Option<crate::vapid::Event>> {
+ future::ready(event::vapid(event))
+}
+
pub mod conversation {
use std::future::{self, Ready};
@@ -60,3 +64,16 @@ pub mod user {
future::ready(user::created(event))
}
}
+
+pub mod vapid {
+ use std::future::{self, Ready};
+
+ use crate::{
+ test::fixtures::event::vapid,
+ vapid::{Event, event},
+ };
+
+ pub fn changed(event: Event) -> Ready<Option<event::Changed>> {
+ future::ready(vapid::changed(event))
+ }
+}
diff --git a/src/vapid/app.rs b/src/vapid/app.rs
new file mode 100644
index 0000000..ddb1f4d
--- /dev/null
+++ b/src/vapid/app.rs
@@ -0,0 +1,104 @@
+use chrono::{TimeDelta, Utc};
+use sqlx::SqlitePool;
+
+use super::{History, repo, repo::Provider as _};
+use crate::{
+ clock::DateTime,
+ db::NotFound as _,
+ event::{Broadcaster, Sequence, repo::Provider},
+};
+
+pub struct Vapid<'a> {
+ db: &'a SqlitePool,
+ events: &'a Broadcaster,
+}
+
+impl<'a> Vapid<'a> {
+ pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self {
+ 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()?;
+ if key.is_none() {
+ let changed_at = tx.sequence().next(ensure_at).await?;
+ let (key, secret) = History::begin(&changed_at);
+
+ tx.vapid().clear().await?;
+ tx.vapid().store_signing_key(&secret).await?;
+
+ let events = key.events().filter(Sequence::start_from(changed_at));
+ tx.vapid().record_events(events.clone()).await?;
+
+ tx.commit().await?;
+
+ self.events.broadcast_from(events);
+ } else if let Some(key) = key
+ // Somewhat arbitrarily, rotate keys every 30 days.
+ && key.older_than(ensure_at.to_owned() - TimeDelta::days(30))
+ {
+ // If you can think of a way to factor out this duplication, be my guest. I tried.
+ // The only approach I could think of mirrors `crate::user::create::Create`, encoding
+ // the process in a state machine made of types, and that's a very complex solution
+ // to a problem that doesn't seem to merit it. -o
+ let changed_at = tx.sequence().next(ensure_at).await?;
+ let (key, secret) = key.rotate(&changed_at);
+
+ tx.vapid().clear().await?;
+ tx.vapid().store_signing_key(&secret).await?;
+
+ // Refactoring constraint: this `events` iterator borrows `key`. Anything that moves
+ // `key` has to give it back, but it can't give both `key` back and an event iterator
+ // borrowing from `key` because Rust doesn't support types that borrow from other
+ // parts of themselves.
+ let events = key.events().filter(Sequence::start_from(changed_at));
+ tx.vapid().record_events(events.clone()).await?;
+
+ // Refactoring constraint: we _really_ want to commit the transaction before we send
+ // out events, so that anything acting on those events is guaranteed to see the state
+ // of the service at some point at or after the side effects of this. I'd also prefer
+ // to keep the commit in the same method that the transaction is begun in, for clarity.
+ tx.commit().await?;
+
+ self.events.broadcast_from(events);
+ }
+ // else, the key exists and is not stale. Don't bother allocating a sequence number, and
+ // in fact throw away the whole transaction.
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error(transparent)]
+ Database(#[from] sqlx::Error),
+ #[error(transparent)]
+ Ecdsa(#[from] p256::ecdsa::Error),
+}
+
+impl From<repo::Error> for Error {
+ fn from(error: repo::Error) -> Self {
+ use repo::Error;
+ match error {
+ Error::Database(error) => error.into(),
+ Error::Ecdsa(error) => error.into(),
+ }
+ }
+}
diff --git a/src/vapid/event.rs b/src/vapid/event.rs
new file mode 100644
index 0000000..af70ac2
--- /dev/null
+++ b/src/vapid/event.rs
@@ -0,0 +1,48 @@
+use base64::{Engine, engine::general_purpose::URL_SAFE};
+use p256::ecdsa::VerifyingKey;
+use serde::Serialize;
+
+use crate::event::{Instant, Sequenced};
+
+#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
+#[serde(tag = "event", rename_all = "snake_case")]
+pub enum Event {
+ Changed(Changed),
+}
+
+impl Sequenced for Event {
+ fn instant(&self) -> Instant {
+ match self {
+ Self::Changed(event) => event.instant(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
+pub struct Changed {
+ #[serde(flatten)]
+ pub instant: Instant,
+ #[serde(serialize_with = "as_vapid_key")]
+ pub key: VerifyingKey,
+}
+
+impl From<Changed> for Event {
+ fn from(event: Changed) -> Self {
+ Self::Changed(event)
+ }
+}
+
+impl Sequenced for Changed {
+ fn instant(&self) -> Instant {
+ self.instant
+ }
+}
+
+fn as_vapid_key<S>(key: &VerifyingKey, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: serde::Serializer,
+{
+ let key = key.to_sec1_bytes();
+ let key = URL_SAFE.encode(key);
+ key.serialize(serializer)
+}
diff --git a/src/vapid/history.rs b/src/vapid/history.rs
new file mode 100644
index 0000000..42f062b
--- /dev/null
+++ b/src/vapid/history.rs
@@ -0,0 +1,55 @@
+use p256::ecdsa::{SigningKey, VerifyingKey};
+use rand::thread_rng;
+
+use super::event::{Changed, Event};
+use crate::{clock::DateTime, event::Instant};
+
+#[derive(Debug)]
+pub struct History {
+ pub key: VerifyingKey,
+ pub changed: Instant,
+}
+
+// Lifecycle interface
+impl History {
+ pub fn begin(changed: &Instant) -> (Self, SigningKey) {
+ let key = SigningKey::random(&mut thread_rng());
+ (
+ Self {
+ key: VerifyingKey::from(&key),
+ changed: *changed,
+ },
+ key,
+ )
+ }
+
+ // `self` _is_ unused here, clippy is right about that. This choice is deliberate, however - it
+ // makes it harder to inadvertently reuse a rotated key via its history, and it makes the
+ // lifecycle interface more obviously consistent between this and other History types.
+ #[allow(clippy::unused_self)]
+ pub fn rotate(self, changed: &Instant) -> (Self, SigningKey) {
+ Self::begin(changed)
+ }
+}
+
+// State interface
+impl History {
+ pub fn older_than(&self, when: DateTime) -> bool {
+ self.changed.at < when
+ }
+}
+
+// Events interface
+impl History {
+ pub fn events(&self) -> impl Iterator<Item = Event> + Clone {
+ [self.changed()].into_iter()
+ }
+
+ fn changed(&self) -> Event {
+ Changed {
+ key: self.key,
+ instant: self.changed,
+ }
+ .into()
+ }
+}
diff --git a/src/vapid/middleware.rs b/src/vapid/middleware.rs
new file mode 100644
index 0000000..02951ba
--- /dev/null
+++ b/src/vapid/middleware.rs
@@ -0,0 +1,17 @@
+use axum::{
+ extract::{Request, State},
+ middleware::Next,
+ response::Response,
+};
+
+use crate::{app::App, clock::RequestedAt, error::Internal};
+
+pub async fn middleware(
+ State(app): State<App>,
+ RequestedAt(now): RequestedAt,
+ request: Request,
+ next: Next,
+) -> Result<Response, Internal> {
+ app.vapid().refresh_key(&now).await?;
+ Ok(next.run(request).await)
+}
diff --git a/src/vapid/mod.rs b/src/vapid/mod.rs
new file mode 100644
index 0000000..9798654
--- /dev/null
+++ b/src/vapid/mod.rs
@@ -0,0 +1,9 @@
+pub mod app;
+pub mod event;
+mod history;
+mod middleware;
+pub mod repo;
+
+pub use event::Event;
+pub use history::History;
+pub use middleware::middleware;
diff --git a/src/vapid/repo.rs b/src/vapid/repo.rs
new file mode 100644
index 0000000..4ac5286
--- /dev/null
+++ b/src/vapid/repo.rs
@@ -0,0 +1,139 @@
+use p256::{NistP256, ecdsa::SigningKey, elliptic_curve::FieldBytes};
+use sqlx::{Sqlite, SqliteConnection, Transaction};
+
+use super::{
+ History,
+ event::{Changed, Event},
+};
+use crate::{
+ clock::DateTime,
+ db::NotFound,
+ event::{Instant, Sequence},
+};
+
+pub trait Provider {
+ fn vapid(&mut self) -> Vapid<'_>;
+}
+
+impl Provider for Transaction<'_, Sqlite> {
+ fn vapid(&mut self) -> Vapid<'_> {
+ Vapid(self)
+ }
+}
+
+pub struct Vapid<'a>(&'a mut SqliteConnection);
+
+impl Vapid<'_> {
+ pub async fn record_events(
+ &mut self,
+ events: impl IntoIterator<Item = Event>,
+ ) -> Result<(), sqlx::Error> {
+ for event in events {
+ self.record_event(&event).await?;
+ }
+ Ok(())
+ }
+
+ pub async fn record_event(&mut self, event: &Event) -> Result<(), sqlx::Error> {
+ match event {
+ Event::Changed(changed) => self.record_changed(changed).await,
+ }
+ }
+
+ async fn record_changed(&mut self, changed: &Changed) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ insert into vapid_key (changed_at, changed_sequence)
+ values ($1, $2)
+ "#,
+ changed.instant.at,
+ changed.instant.sequence,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn clear(&mut self) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ delete from vapid_key
+ "#
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ sqlx::query!(
+ r#"
+ delete from vapid_signing_key
+ "#
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn store_signing_key(&mut self, key: &SigningKey) -> Result<(), Error> {
+ let key = key.to_bytes();
+ let key = key.as_slice();
+ sqlx::query!(
+ r#"
+ insert into vapid_signing_key (key)
+ values ($1)
+ "#,
+ key,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn current(&mut self) -> Result<History, Error> {
+ let key = sqlx::query!(
+ r#"
+ select
+ key.changed_at as "changed_at: DateTime",
+ key.changed_sequence as "changed_sequence: Sequence",
+ signing.key as "key: Vec<u8>"
+ from vapid_key as key
+ join vapid_signing_key as signing
+ "#
+ )
+ .map(|row| {
+ let key = FieldBytes::<NistP256>::from_slice(&row.key);
+ let key = SigningKey::from_bytes(key)?;
+ let key = key.verifying_key().to_owned();
+
+ let changed = Instant::new(row.changed_at, row.changed_sequence);
+
+ Ok::<_, Error>(History { key, changed })
+ })
+ .fetch_one(&mut *self.0)
+ .await??;
+
+ Ok(key)
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error(transparent)]
+pub enum Error {
+ Ecdsa(#[from] p256::ecdsa::Error),
+ Database(#[from] sqlx::Error),
+}
+
+impl<T> NotFound for Result<T, Error> {
+ type Ok = T;
+ type Error = Error;
+
+ fn optional(self) -> Result<Option<T>, Error> {
+ match self {
+ Ok(value) => Ok(Some(value)),
+ Err(Error::Database(sqlx::Error::RowNotFound)) => Ok(None),
+ Err(other) => Err(other),
+ }
+ }
+}
diff --git a/ui/lib/state/remote/state.svelte.js b/ui/lib/state/remote/state.svelte.js
index 3d65e4a..8845e02 100644
--- a/ui/lib/state/remote/state.svelte.js
+++ b/ui/lib/state/remote/state.svelte.js
@@ -7,6 +7,7 @@ export class State {
users = $state(new Users());
conversations = $state(new Conversations());
messages = $state(new Messages());
+ vapid_key = $state(null);
static boot({ currentUser, heartbeat, resumePoint, events }) {
const state = new State({
@@ -36,6 +37,8 @@ export class State {
return this.onUserEvent(event);
case 'message':
return this.onMessageEvent(event);
+ case 'vapid':
+ return this.onVapidEvent(event);
}
}
@@ -88,4 +91,16 @@ export class State {
const { id } = event;
this.messages.remove(id);
}
+
+ onVapidEvent(event) {
+ switch (event.event) {
+ case 'changed':
+ return this.onVapidChanged(event);
+ }
+ }
+
+ onVapidChanged(event) {
+ let { key } = event;
+ this.vapid_key = key;
+ }
}