From 3bd63e20777126216777b392615dc8144f21bb9a Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sat, 30 Aug 2025 00:50:24 -0400 Subject: Generate, store, and deliver a VAPID key. VAPID is used to authenticate applications to push brokers, as part of the [Web Push] specification. It's notionally optional, but we believe [Apple requires it][apple], and in any case making it impossible to use subscription URLs without the corresponding private key available, and thus harder to impersonate the server, seems like a good security practice regardless. [Web Push]: https://developer.mozilla.org/en-US/docs/Web/API/Push_API [apple]: https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers There are several implementations of VAPID for Rust: * [web_push](https://docs.rs/web-push/latest/web_push/) includes an implementation of VAPID but requires callers to provision their own keys. We will likely use this crate for Web Push fulfilment, but we cannot use it for key generation. * [vapid](https://docs.rs/vapid/latest/vapid/) includes an implementation of VAPID key generation. It delegates to `openssl` to handle cryptographic operations. * [p256](https://docs.rs/p256/latest/p256/) implements NIST P-256 in Rust. It's maintained by the RustCrypto team, though as of this writing it is largely written by a single contributor. It isn't specifically designed for use with VAPID. I opted to use p256 for this, as I believe the RustCrypto team are the most likely to produce a correct and secure implementation, and because openssl has consistently left a bad taste in my mouth for years. Because it's a general implementation of the algorithm, I expect that it will require more work for us to adapt it for use with VAPID specifically; I'm willing to chance it and we can swap it out for the vapid crate if it sucks. This has left me with one area of uncertainty: I'm not actually sure I'm using the right parts of p256. The choice of `ecdsa::SigningKey` over `p256::SecretKey` is based on [the MDN docs] using phrases like "This value is part of a signing key pair generated by your application server, and usable with elliptic curve digital signature (ECDSA), over the P-256 curve." and on [RFC 8292]'s "The 'k' parameter includes an ECDSA public key in uncompressed form that is encoded using base64url encoding. However, we won't be able to test my implementation until we implement some other key parts of Web Push, which are out of scope of this commit. [the MDN docs]: https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/options [RFC 8292]: https://datatracker.ietf.org/doc/html/rfc8292#section-3.2 Following the design used for storing logins and users, VAPID keys are split into a non-synchronized part (consisting of the private key), whose exposure would allow others to impersonate the Pilcrow server, and a synchronized part (consisting of event coordinates and, notionally, the public key), which is non-sensitive and can be safely shared with any user. However, the public key is derived from the stored private key, rather than being stored directly, to minimize redundancy in the stored data. Following the design used for expiring stale entities, the app checks for and creates, or rotates, its VAPID key using middleware that runs before most API requests. If, at that point, the key is either absent, or more than 30 days old, it is replaced. This imposes a small tax on API request latency, which is used to fund prompt and automatic key rotation without the need for an operator-facing key management interface. VAPID keys are delivered to clients via the event stream, as laid out in `docs/api/events.md`. There are a few reasons for this, but the big one is that changing the VAPID key would immediately invalidate push subscriptions: we throw away the private key, so we wouldn't be able to publish to them any longer. Clients must replace their push subscriptions in order to resume delivery, and doing so promptly when notified that the key has changed will minimize the gap. This design is intended to allow for manual key rotation. The key can be rotated "immedately" by emptying the `vapid_key` and `vapid_signing_key` tables (which destroys the rotated kye); the server will generate a new one before it is needed, and will notify clients that the key has been invalidated. This change includes client support for tracking the current VAPID key. The client doesn't _use_ this information anywhere, yet, but it has it. --- ui/lib/state/remote/state.svelte.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) (limited to 'ui/lib/state') 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; + } } -- cgit v1.2.3 From 78d901328261d2306cf59c8e83fc217a63aa4a64 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sun, 26 Oct 2025 16:46:04 -0400 Subject: Add a button to the client to set up a push subscription. Once a user has set up a push subscription, the client will re-establish it as needed whenever possible, falling back to manual intervention only when it is unable to create a push subscription. This change imposes some architectural changes to the client, though they're not huge: the `session` type now includes a body of state (`push`) whose methods also call into the Pilcrow API. Previously, calls to the API were not made within the `session` types, and were instead only made by page and layout code, but orchestrating that for the push subscription lifecycle proved too complex to deal with. This is an experimental alternative, but it might be something we explore further in the future. --- ui/lib/apiServer.js | 4 + ui/lib/components/PushSubscription.svelte | 27 ++++++ ui/lib/session.svelte.js | 19 ++-- ui/lib/state/local/push.svelte.js | 141 ++++++++++++++++++++++++++++++ ui/lib/state/remote/state.svelte.js | 15 ---- ui/routes/(app)/me/+page.svelte | 12 +++ 6 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 ui/lib/components/PushSubscription.svelte create mode 100644 ui/lib/state/local/push.svelte.js (limited to 'ui/lib/state') diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js index ac707a5..f55f271 100644 --- a/ui/lib/apiServer.js +++ b/ui/lib/apiServer.js @@ -55,6 +55,10 @@ export async function acceptInvite(inviteId, name, password) { .catch(responseError); } +export async function createPushSubscription(subscription, vapid) { + return apiServer.post('/push/subscribe', { subscription, vapid }).catch(responseError); +} + export function subscribeToEvents(resumePoint) { const eventsUrl = apiServer.getUri({ url: '/events', diff --git a/ui/lib/components/PushSubscription.svelte b/ui/lib/components/PushSubscription.svelte new file mode 100644 index 0000000..a85cbb3 --- /dev/null +++ b/ui/lib/components/PushSubscription.svelte @@ -0,0 +1,27 @@ + + +{#if vapid !== null} + {#if subscription === null} +
+ +
+ {/if} +{:else} + Waiting for VAPID key… +{/if} diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js index c415d0c..cd41aa4 100644 --- a/ui/lib/session.svelte.js +++ b/ui/lib/session.svelte.js @@ -5,6 +5,7 @@ import { goto } from '$app/navigation'; import * as api from './apiServer.js'; import * as r from './state/remote/state.svelte.js'; import * as l from './state/local/conversations.svelte.js'; +import * as p from './state/local/push.svelte.js'; import { Watchdog } from './watchdog.js'; import { DateTime } from 'luxon'; @@ -51,6 +52,7 @@ class Message { class Session { remote = $state(); local = $state(); + push = $state(); currentUser = $derived(this.remote.currentUser); users = $derived(this.remote.users.all); messages = $derived( @@ -62,7 +64,7 @@ class Session { ), ); - static boot({ login, resume_point, heartbeat, events }) { + static async boot({ login, resume_point, heartbeat, events }) { const remote = r.State.boot({ currentUser: login, resumePoint: resume_point, @@ -70,22 +72,25 @@ class Session { events, }); const local = l.Conversations.fromLocalStorage(); - return new Session(remote, local); + const push = await p.Push.boot(events); + return new Session(remote, local, push); } - reboot({ login, resume_point, heartbeat, events }) { + async reboot({ login, resume_point, heartbeat, events }) { this.remote = r.State.boot({ currentUser: login, resumePoint: resume_point, heartbeat, events, }); + this.push = await p.Push.boot(events); } - constructor(remote, local) { + constructor(remote, local, push) { this.watchdog = new Watchdog(this.watchdogExpired.bind(this)); this.remote = remote; this.local = local; + this.push = push; } begin() { @@ -107,6 +112,7 @@ class Session { onMessage(message) { const event = JSON.parse(message.data); this.remote.onEvent(event); + this.push.onEvent(event); this.local.retainConversations(this.remote.conversations.all); this.watchdog.reset(this.heartbeatMillis()); } @@ -127,7 +133,7 @@ class Session { // which the session may have been abandoned. if (!this.active()) return; - this.reboot(response); + await this.reboot(response); this.begin(); } } @@ -139,6 +145,7 @@ async function bootOrNavigate(navigateTo) { } catch (err) { switch (true) { case err instanceof api.LoggedOut: + await this.push.unsubscribe(); await navigateTo('/login'); break; case err instanceof api.SetupRequired: @@ -152,5 +159,5 @@ async function bootOrNavigate(navigateTo) { export async function boot() { const response = await bootOrNavigate(async (url) => redirect(307, url)); - return Session.boot(response); + return await Session.boot(response); } diff --git a/ui/lib/state/local/push.svelte.js b/ui/lib/state/local/push.svelte.js new file mode 100644 index 0000000..82846b1 --- /dev/null +++ b/ui/lib/state/local/push.svelte.js @@ -0,0 +1,141 @@ +import * as api from '$lib/apiServer.js'; + +// In a few places in this module, I've used suffix to identify which type we're working with: +// * ...Base64 is a string containing a URL-safe base64 value; +// * ...Buffer is an ArrayBuffer holding untyped bytes; and +// * ...Bytes is a Uint8Array holding typed bytes. +// Working with byte streams in JS is _fun_. + +export class Push { + static async boot(events) { + const serviceWorker = await navigator.serviceWorker.ready; + const pushManager = serviceWorker.pushManager; + const subscription = await serviceWorker.pushManager.getSubscription(); + + const push = new Push(pushManager, subscription); + + for (const event of events) { + push.onEvent(event); + } + + return push; + } + + vapidKey = $state(null); + subscription = $state(null); + + constructor(pushManager, subscription) { + this.pushManager = pushManager; + this.subscription = subscription; + } + + async hasPermission() { + const vapidKeyBuffer = this.vapidKey; + const pushOptions = { + userVisibleOnly: true, + applicationServerKey: vapidKeyBuffer, + }; + + const state = await this.pushManager.permissionState(pushOptions); + return state === 'granted'; + } + + async subscribe() { + const vapidKeyBuffer = this.vapidKey; + const pushOptions = { + userVisibleOnly: true, + applicationServerKey: vapidKeyBuffer, + }; + this.subscription = await this.pushManager.subscribe(pushOptions); + + const subscriptionJSON = this.subscription.toJSON(); + const vapidKeyBytes = new Uint8Array(vapidKeyBuffer); + const vapidKeyBase64 = vapidKeyBytes.toBase64({ alphabet: 'base64url' }); + await api.createPushSubscription(subscriptionJSON, vapidKeyBase64); + } + + async resubscribe() { + if (this.subscription !== null) { + // If we have a subscription but it's not for the current VAPID key, then the VAPID key has + // been rotated and we need to replace the subscription. The server cannot deliver messages to + // subscriptions associated with the old VAPID key after rotation. + + // Per spec, the `options.applicationServerKey` field is either null or an ArrayBuffer, + // regardless of the representation passed into `subscribe` to create the subscription: + // + // + if (!equalArrayBuffers(this.subscription.options.applicationServerKey, this.vapidKey)) { + // We have a subscription, and the server key has rotated, so resubscribe. Destroy the old + // subscription first - some UAs (Firefox) want subscriptions within an origin to share a + // consistent VAPID key, and we're explicitly changing the VAPID key here. + await this.unsubscribe(); + await this.subscribe(); + } + } else if (await this.hasPermission()) { + // If we have permission to create push subscriptions, but no push subscription, then set up + // a new subscription. This primarily happens after logging into Pilcrow if the user + // previously had a push subscription and has logged out. + await this.subscribe(); + } + } + + async unsubscribe() { + if (this.subscription !== null) { + await this.subscription.unsubscribe(); + this.subscription = null; + } + } + + onEvent(event) { + switch (event.type) { + case 'vapid': + return this.onVapidEvent(event); + } + } + + onVapidEvent(event) { + switch (event.event) { + case 'changed': + return this.onVapidChanged(event); + } + } + + onVapidChanged(event) { + const { key: vapidKeyBase64 } = event; + + // For ease of use later on, parse the key into an ArrayBuffer. This is a little fiddly because + // of the APIs involved, but it makes comparing the key to the current subscription's key + // (which is also provided as an ArrayBuffer) much easier. + const vapidKeyBytes = Uint8Array.fromBase64(vapidKeyBase64, { + alphabet: 'base64url', + }); + // In practice, `vapidKeyBytes.buffer` is going to be the same bytes as this slice, but in + // principle it could be a subset of the underlying buffer, and there's very little downside to + // being meticulous here. + this.vapidKey = vapidKeyBytes.buffer.slice( + vapidKeyBytes.byteOffset, + vapidKeyBytes.byteOffset + vapidKeyBytes.byteLength, + ); + + // Note that `resubscribe()` is async; this will start the [re]subscription process but will + // return to the caller before it completes. I'm not willing to make the entire event handling + // chain all the way back up to the EventSource asynchronous, since EventSource isn't designed + // to support that (we could make it work), and we can't wait for async functions in a non-async + // context, so this is the best we can do. + this.resubscribe(); + } +} + +function equalArrayBuffers(aBuffer, bBuffer) { + // You might be thinking, surely there's a way to compare two array buffers for equality. I + // certainly expected that there would be. [Nope]. + // + // [Nope]: https://github.com/wbinnssmith/arraybuffer-equal + // + // The algorithm here is simple enough not to need an external dependency. However, this + // comparison is not designed to deal with detached ArrayBuffer instances. + const aBytes = new Uint8Array(aBuffer); + const bBytes = new Uint8Array(bBuffer); + + return aBytes.length === bBytes.length && aBytes.every((value, index) => value === bBytes[index]); +} diff --git a/ui/lib/state/remote/state.svelte.js b/ui/lib/state/remote/state.svelte.js index 8845e02..3d65e4a 100644 --- a/ui/lib/state/remote/state.svelte.js +++ b/ui/lib/state/remote/state.svelte.js @@ -7,7 +7,6 @@ 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({ @@ -37,8 +36,6 @@ export class State { return this.onUserEvent(event); case 'message': return this.onMessageEvent(event); - case 'vapid': - return this.onVapidEvent(event); } } @@ -91,16 +88,4 @@ 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; - } } diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte index 0c960c8..ddb1245 100644 --- a/ui/routes/(app)/me/+page.svelte +++ b/ui/routes/(app)/me/+page.svelte @@ -2,10 +2,16 @@ import LogOut from '$lib/components/LogOut.svelte'; import Invites from '$lib/components/Invites.svelte'; import ChangePassword from '$lib/components/ChangePassword.svelte'; + import PushSubscription from '$lib/components/PushSubscription.svelte'; import { goto } from '$app/navigation'; import * as api from '$lib/apiServer.js'; + const { data } = $props(); + const { session } = data; + const subscription = $derived(session.push.subscription); + const vapid = $derived(session.push.vapidKey); + let invites = $state([]); async function logOut() { @@ -25,10 +31,16 @@ invites.push(response.data); } } + + async function subscribe() { + await session.push.subscribe(); + }
+ +

-- cgit v1.2.3