PushSubscription/set rejects the unpadded base64url keys the W3C Push API produces

Issue Description

A browser client cannot pass PushSubscription.toJSON().keys straight through to
PushSubscription/set: the subscription is rejected with

invalidProperties: "Failed to decode keys."

unless the client re-pads the values first.

crates/jmap/src/push/set.rs:307-311 decodes the p256dh and auth key values with
base64::engine::general_purpose::URL_SAFE. In base64 0.22 that engine is the PADDED
config (DecodePaddingMode::RequireCanonical), so an unpadded value fails to decode.

The W3C Push API specifies the opposite. PushSubscription.toJSON() serialises the keys
as “the URL-safe base64 encoding WITHOUT padding [RFC4648]”. A real browser therefore
hands the application an 87-character p256dh and a 22-character auth — neither of which
Stalwart accepts.

Every browser client hits this on its first attempt.

Unchanged on main @764c2c1 (2026-07-12).

Expected Behavior

PushSubscription/set accepts the key values exactly as a browser produces them —
unpadded base64url, per the W3C Push API’s definition of PushSubscription.toJSON().

Being liberal in what is accepted costs nothing here: padded and unpadded base64url are
unambiguous, and no other client is harmed by accepting both.

Actual Behavior

Unpadded keys (the browser’s own output, 87 + 22 chars) are rejected:

notCreated: { "p": { "type": "invalidProperties",
                     "description": "Failed to decode keys.",
                     "properties": ["keys"] } }

The identical keys, re-padded to 88 + 24 chars, are accepted.

Reproduction Steps

  1. Run Stalwart (verified on v0.16.11-alpine; unchanged on main).

  2. Generate a P-256 keypair and a 16-byte auth secret, and encode them as UNPADDED
    base64url — i.e. exactly what PushSubscription.toJSON() gives a web application.

  3. PushSubscription/set create with those keys:

    {
    “using”: [“urn:ietf:params:jmap:core”],
    “methodCalls”: [[“PushSubscription/set”, { “create”: { “p”: {
    “deviceClientId”: “probe”,
    “url”: “https://example.com/push”,
    “types”: null,
    “keys”: { “p256dh”: “<87 chars, no ‘=’>”, “auth”: “<22 chars, no ‘=’>” }
    }}}, “a”]]
    }

    → notCreated: invalidProperties “Failed to decode keys.”

  4. Repeat with the same keys re-padded to 88 and 24 characters.

    → created.

Relevant Log Output

Observed JMAP responses (no server-side error is logged):

keys exactly as a browser produces them (87 + 22 chars, no ‘=’):
→ {“type”:“invalidProperties”,
“description”:“Failed to decode keys.”,
“properties”:[“keys”]}

the same keys, re-padded (88 + 24 chars):
→ {“id”:“b”,“keys”:null,“expires”:“2026-07-20T07:55:48Z”}

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

Client: a browser-based JMAP webmail (static SPA, service worker). No proxy.

Suggested fix — decode with an engine that tolerates both paddings, e.g.

base64::engine::GeneralPurpose::new(
    &base64::alphabet::URL_SAFE,
    base64::engine::GeneralPurposeConfig::new()
        .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent),
)

Related: I have filed a separate, more serious report — the encrypted push payload is
base64-wrapped while Content-Encoding: aes128gcm promises raw octets, so no browser can
decrypt it. Fixing the padding alone does not make Web Push work; both are needed, plus
RFC 9749 (VAPID) for Chromium and Safari.

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

This confirms the report’s technical detail is accurate in the current source. I don’t have anything further to add from docs since this is a bug report, not a docs question, and there’s no existing forum coverage to point to.

This confirms the report is accurate: this is a bug, and the report is technically well-founded, with the exact fix already identified. I’ll reply acknowledging it.

Confirmed: crates/jmap/src/push/set.rs decodes p256dh and auth with general_purpose::URL_SAFE, which requires canonical (padded) base64url and rejects the unpadded encoding that PushSubscription.toJSON() produces per the W3C Push API spec. This is a genuine bug report rather than a docs question, so I don’t have a documented workaround to point to.

For anyone hitting this now: re-pad the p256dh (to 88 chars) and auth (to 24 chars) values with = before sending PushSubscription/set as a workaround until this is fixed. The fix you suggested (using a GeneralPurpose engine with DecodePaddingMode::Indifferent, or decoding with URL_SAFE_NO_PAD) matches the code at that location. This has been logged for the team to review; a human maintainer will follow up on the report itself.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Fixed in v0.16.14 (not yet released).