summaryrefslogtreecommitdiff
path: root/src/vapid
diff options
context:
space:
mode:
authorojacobson <ojacobson@noreply.codeberg.org>2025-11-07 23:17:15 +0100
committerojacobson <ojacobson@noreply.codeberg.org>2025-11-07 23:17:15 +0100
commit9e6f19f0f188eaa7f8b6be21c8405786cfb0dddd (patch)
treeb2999341645dec61e8143d7bb1b8a9d0056e0db1 /src/vapid
parent3c588861ef5814de329743147398dbae22c1aeeb (diff)
parent78d901328261d2306cf59c8e83fc217a63aa4a64 (diff)
Set up infrastructure for push message subscriptions.
A subscription allows an application server (here, the Pilcrow server) to send web push messages to a user agent. On the server, Pilcrow records subscriptions verbatim, in the clear. Each subscription has an associated key, which will be used to encrypt messages for the corresponding client, but we store them in the clear, for the same broad reason that we store the VAPID key in the clear. They allow anyone who obtains them to impersonate the server and send push messages to clients, but they're rotated regularly - clients must rotate them whenever the server's VAPID key changes. On the client, we monitor VAPID key change events to drive automatic subscription management, once the user sets up an initial subscription manually (which we must do as it can involve a user-interaction-only prompt for permission to send notifications). This isn't the final UI, but rather a bare-minimum version to let us move on with testing push notifications. Merges push-subscribe into push-notify.
Diffstat (limited to 'src/vapid')
-rw-r--r--src/vapid/app.rs5
-rw-r--r--src/vapid/event.rs13
-rw-r--r--src/vapid/middleware.rs6
-rw-r--r--src/vapid/mod.rs1
-rw-r--r--src/vapid/ser.rs63
5 files changed, 73 insertions, 15 deletions
diff --git a/src/vapid/app.rs b/src/vapid/app.rs
index 61523d5..7d872ed 100644
--- a/src/vapid/app.rs
+++ b/src/vapid/app.rs
@@ -6,6 +6,7 @@ use crate::{
clock::DateTime,
db::NotFound as _,
event::{Broadcaster, Sequence, repo::Provider},
+ push::repo::Provider as _,
};
pub struct Vapid {
@@ -60,6 +61,10 @@ impl Vapid {
let changed_at = tx.sequence().next(ensure_at).await?;
let (key, secret) = key.rotate(&changed_at);
+ // This will delete _all_ stored subscriptions. This is fine; they're all for the
+ // current VAPID key, and we won't be able to use them anyways once the key is rotated.
+ // We have no way to inform the push broker services of that, unfortunately.
+ tx.push().clear().await?;
tx.vapid().clear().await?;
tx.vapid().store_signing_key(&secret).await?;
diff --git a/src/vapid/event.rs b/src/vapid/event.rs
index af70ac2..cf3be77 100644
--- a/src/vapid/event.rs
+++ b/src/vapid/event.rs
@@ -1,6 +1,4 @@
-use base64::{Engine, engine::general_purpose::URL_SAFE};
use p256::ecdsa::VerifyingKey;
-use serde::Serialize;
use crate::event::{Instant, Sequenced};
@@ -22,7 +20,7 @@ impl Sequenced for Event {
pub struct Changed {
#[serde(flatten)]
pub instant: Instant,
- #[serde(serialize_with = "as_vapid_key")]
+ #[serde(with = "crate::vapid::ser::key")]
pub key: VerifyingKey,
}
@@ -37,12 +35,3 @@ impl Sequenced for Changed {
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/middleware.rs b/src/vapid/middleware.rs
index 02951ba..3129aa7 100644
--- a/src/vapid/middleware.rs
+++ b/src/vapid/middleware.rs
@@ -4,14 +4,14 @@ use axum::{
response::Response,
};
-use crate::{app::App, clock::RequestedAt, error::Internal};
+use crate::{clock::RequestedAt, error::Internal, vapid::app::Vapid};
pub async fn middleware(
- State(app): State<App>,
+ State(vapid): State<Vapid>,
RequestedAt(now): RequestedAt,
request: Request,
next: Next,
) -> Result<Response, Internal> {
- app.vapid().refresh_key(&now).await?;
+ vapid.refresh_key(&now).await?;
Ok(next.run(request).await)
}
diff --git a/src/vapid/mod.rs b/src/vapid/mod.rs
index 9798654..364f602 100644
--- a/src/vapid/mod.rs
+++ b/src/vapid/mod.rs
@@ -3,6 +3,7 @@ pub mod event;
mod history;
mod middleware;
pub mod repo;
+pub mod ser;
pub use event::Event;
pub use history::History;
diff --git a/src/vapid/ser.rs b/src/vapid/ser.rs
new file mode 100644
index 0000000..02c77e1
--- /dev/null
+++ b/src/vapid/ser.rs
@@ -0,0 +1,63 @@
+pub mod key {
+ use std::fmt;
+
+ use base64::{Engine as _, engine::general_purpose::URL_SAFE};
+ use p256::ecdsa::VerifyingKey;
+ use serde::{Deserializer, Serialize as _, de};
+
+ // This serialization - to a URL-safe base-64-encoded string and back - is based on my best
+ // understanding of RFC 8292 and the corresponding browser APIs. Particularly, it's based on
+ // section 3.2:
+ //
+ // > The "k" parameter includes an ECDSA public key [FIPS186] in uncompressed form [X9.62] that
+ // > is encoded using base64url encoding [RFC7515].
+ //
+ // <https://datatracker.ietf.org/doc/html/rfc8292#section-3.2>
+ //
+ // I believe this is also supported by MDN's explanation:
+ //
+ // > `applicationServerKey`
+ // >
+ // > A Base64-encoded string or ArrayBuffer containing an ECDSA P-256 public key that the push
+ // > server will use to authenticate your application server. If specified, all messages from
+ // > your application server must use the VAPID authentication scheme, and include a JWT signed
+ // > with the corresponding private key. This key IS NOT the same ECDH key that you use to
+ // > encrypt the data. For more information, see "Using VAPID with WebPush".
+ //
+ // <https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#applicationserverkey>
+
+ pub fn serialize<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)
+ }
+
+ pub fn deserialize<'de, D>(deserializer: D) -> Result<VerifyingKey, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ deserializer.deserialize_str(Visitor)
+ }
+
+ struct Visitor;
+ impl de::Visitor<'_> for Visitor {
+ type Value = VerifyingKey;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ formatter.write_str("a string containing a VAPID key")
+ }
+
+ fn visit_str<E>(self, key: &str) -> Result<Self::Value, E>
+ where
+ E: de::Error,
+ {
+ let key = URL_SAFE.decode(key).map_err(E::custom)?;
+ let key = VerifyingKey::from_sec1_bytes(&key).map_err(E::custom)?;
+
+ Ok(key)
+ }
+ }
+}