skip to content
all writeups
5 min read

Verifying what you cannot trust

Web Push in Atlas, where VAPID and RFC 8291 payload encryption keep the push service out of the notification it delivers, and a webhook receiver in the emailer that deleted its unsigned predecessor outright.

otjcollegesecuritycryptographyweb

Both halves of this week’s log were about the same thing: a party I have to route through but do not control. The push service carries my notification to the phone. The webhook endpoint sits open on the internet waiting for the sender to POST to it. Neither can be removed from the design, so both have to be made to prove themselves. Of the degree’s modules, Managing Information Security was the one with genuinely new insight in it for me – mostly, I suspect, because it was the one where I had no prior intuitions to lean on. This week it stopped being case studies: I was the one naming the threat and deciding what a proportionate response looked like.

Push the service cannot read

Atlas gained Web Push across two days in mid-August, and both days were mostly about intermediaries. Web Push is, in RFC 8291’s own words, “an intermediated protocol by necessity”: my server POSTs to a push service and that service delivers to the phone. Transport encryption does not help here, because the service is the TLS endpoint – without something extra it is the first reader of every notification it delivers.

Two mechanisms, with two different jobs.

RFC 8291 message encryption keeps the service out of the content. ECDH on P-256 between a fresh ephemeral server keypair and the subscription’s p256dh key, an auth secret the user agent issued alongside the subscription, then HKDF-SHA-256 down to an AES-128-GCM key and nonce:

atlas-web/src/lib/server/push/crypto.ts
const prkKey = await hmacSha256(authSecret, ecdhSecret);
const keyInfo = concatBytes(ENCODER.encode('WebPush: info'), uaPublic, asPublic);
const ikm = await hkdfExpand(prkKey, keyInfo, 32);

const prk = await hmacSha256(salt, ikm);
const cek = await hkdfExpand(prk, ENCODER.encode('Content-Encoding: aes128gcm'), 16);
const nonce = await hkdfExpand(prk, ENCODER.encode('Content-Encoding: nonce'), 12);

The spec’s scope is precise: confidentiality and integrity for the message from application server to user agent. It hides the payload from the service and stops the service tampering with it or forging one. It does nothing about metadata, which is the honest half of the story – the service still sees the endpoint (one per subscription), the timing and frequency of every send, the TTL and Urgency headers, and the VAPID key that identifies me across all of them. RFC 8030, the delivery protocol underneath, keeps a privacy-considerations section for exactly that. Encryption hides the letter, not the postmark.

RFC 8292 VAPID works the other direction: it identifies me to the service. An ES256-signed JWT in the Authorization header, carrying the push service’s origin as aud, an expiry the spec caps at 24 hours (Atlas uses 12) and a contact address as sub:

atlas-web/src/lib/server/push/crypto.ts
      JSON.stringify({
        aud: new URL(endpoint).origin,
        exp: Math.floor(Date.now() / 1000) + VAPID_TTL_SECONDS,
        sub: subject,
      }),

The word “voluntary” is in the spec’s title and it is not payload protection. It gives the service a stable identity to attribute requests to, a contact when something is wrong, and a way to bind a subscription to one application server. Every send sets TTL: 3600 and marks a permission prompt blocking the agent mid-turn as high urgency, which RFC 8030 §5.3 permits a service to use as a hint about what to deliver first, or at all, under resource pressure.

One constraint shaped all of it: the Worker runs under nodejs_als, not nodejs_compat, so the web-push package and node:crypto are both unreachable. The crypto is WebCrypto primitives wired by hand – which turns the title of this piece back on me. With no library standing behind the implementation, I was the party that could not be trusted, so the test pins the encryption against RFC 8291 §5’s own worked example byte-for-byte, asserting every named intermediate from Appendix A (PRK_key, IKM, PRK, CEK, NONCE) on its own. A wrong final ciphertext otherwise just reads as “wrong bytes”, with nothing pointing at which stage introduced it.

Deleting the unsigned path

The emailer takes Resend’s delivery webhooks – email.*, domain.*, contact.*, suppression.* – and turns them into the analytics table. A webhook receiver is a strange kind of endpoint: a URL on the public internet whose entire job is to believe what is POSTed to it.

Going to build the per-email delivery trace, I found two receivers. The good one, /api/v1/webhooks/resend/{accountId}, verified the Svix signature but only handled domain.* events and never wrote to the analytics table at all. And an older one at /api/webhooks/resend accepted unauthenticated POSTs from anyone on the internet and inserted them straight into the table, behind a // TODO: Verify Svix webhook signature when signing secret is configured that had never been done. It also took its account id from the payload, and Resend payloads contain none, so every row it had ever written was attributed to 'unknown'.

The fix made the verified receiver the only writer, and deleted the unsigned one outright:

src/routes/api/v1/webhooks/resend/[accountId]/+server.ts
const headers = {
	'svix-id': request.headers.get('svix-id') ?? '',
	'svix-timestamp': request.headers.get('svix-timestamp') ?? '',
	'svix-signature': request.headers.get('svix-signature') ?? ''
};

let payload: ResendWebhookPayload;
try {
	const wh = new Webhook(signingSecret);
	payload = wh.verify(rawBody, headers) as ResendWebhookPayload;
} catch {
	return json({ error: 'Invalid signature' }, { status: 401 });
}

The signing secret is the account’s own, decrypted at request time. The account comes from the URL that secret was registered against, never from the payload – the payload is attacker-controlled until the signature check passes. The library signs id.timestamp.body with HMAC-SHA-256, compares in constant time and rejects timestamps more than five minutes from now, so a captured request cannot be replayed later; I read that in the library’s source rather than assuming it.

Deleting the old endpoint rather than flagging it off was the deliberate part. A disabled insecure path is still a path: it survives in the code, it can come back on with a config change nobody reviews, and it sits there teaching the next reader that the shape is acceptable. The repo’s notes on the trust boundary end with “Do not reintroduce an unauthenticated ingest path.”

Verified empirically, not by inspection: a signed request against a local instance returned 200 and a correctly attributed row; the same request unsigned returned 401 and wrote nothing.

The security module had me writing threat models for other people’s scenarios. Both of these were smaller and more real: name the party you cannot remove, decide what they get to see, and then make the boundary checkable – against a published test vector in one case, and a forged request in the other.

Projects

what this writeup is about