Skip to content

fix(push): VAPID key format silently broke every web-push send - #2166

Merged
jaylfc merged 1 commit into
devfrom
fix/web-push-vapid-key-format
Jul 27, 2026
Merged

fix(push): VAPID key format silently broke every web-push send#2166
jaylfc merged 1 commit into
devfrom
fix/web-push-vapid-key-format

Conversation

@jaylfc

@jaylfc jaylfc commented Jul 27, 2026

Copy link
Copy Markdown
Owner

What

Web push has been 100% broken for every user, silently. Found while live-debugging why a push never reached an Apple Watch (it was neither the network, the device, nor the cloud relay).

Root cause

load_or_create_vapid_keypair returns the VAPID private key as a PEM string, but pywebpush.webpush(vapid_private_key=...) feeds it to py_vapid.Vapid.from_string, which base64-decodes and DER-parses its input. A raw PEM fails that as Could not deserialize key data before any network call, on every send. The per-subscription except Exception handler caught it, logged a generic warning, and returned "failed" -- so no push ever went out and there was no clear signal. Classic swallowed 100%-failure.

Fix (verified live against Apple)

  • _vapid_signing_key() converts the PEM to the base64url-DER form pywebpush expects, once at fan-out. Confirmed: web.push.apple.com returns HTTP 201 and the notification arrives on the device (verified on a real Apple Watch).
  • VAPID sub moved off the reserved .local domain to mailto:info@taos.my; Apple is stricter than other push services about the sub claim.
  • A key that cannot be converted now logs at ERROR (not the swallowed per-send warning), because it breaks every send, not one -- a global misconfig should be loud.
  • Regression test asserting the converted key is accepted by Vapid.from_string (raw PEM would fail it).

Impact

Fixes web-push notifications for all taOS users (desktop PWA and mobile/watch), not just the case that surfaced it. Needs deploying to the live instance to take effect there.

Summary by CodeRabbit

  • Bug Fixes
    • Improved web-push compatibility with Apple devices.
    • Fixed VAPID key handling to prevent notification send failures.
    • Added graceful handling when push-signing credentials are invalid, avoiding application errors.

Web push has been 100% broken for all users. load_or_create_vapid_keypair
returns the private key as a PEM string, but pywebpush feeds it to py_vapid's
Vapid.from_string, which base64-decodes and DER-parses its input. A raw PEM
fails that as 'Could not deserialize key data' on EVERY send, and the
per-subscription handler caught it as a generic warning and returned 'failed',
so nothing ever pushed and there was no obvious signal.

Convert the PEM to base64url-DER once at fan-out (_vapid_signing_key), verified
live against Apple's push service (web.push.apple.com returns 201 and the
notification arrives on the device). Also:
- move the VAPID sub off the reserved .local domain to mailto:info@taos.my;
  Apple is stricter than other services about the sub claim.
- log an ERROR (not the swallowed per-send warning) when the key cannot be
  converted, so a global misconfig is loud instead of disabling all push
  invisibly. A 100% failure that logs nothing is how this hid.
- regression test: the converted key must be accepted by Vapid.from_string.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Web-push notifications now convert stored VAPID PEM keys to pywebpush-compatible signing keys before fan-out, use a routable VAPID subject address, and return zeroed results when conversion fails. A regression test verifies that the converted key is accepted by pywebpush.

Changes

VAPID web-push updates

Layer / File(s) Summary
VAPID signing-key conversion
tinyagentos/notifications_push.py, tests/test_notifications_push.py
Adds PEM-to-PKCS#8-DER base64url conversion, updates the VAPID subject address, and tests acceptance by py_vapid.Vapid01.
Web-push send integration
tinyagentos/notifications_push.py
Converts the key once before fan-out, returns zeroed counts on conversion failure, and passes the converted key to _send_one.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: hognek

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: correcting the VAPID key format that caused web-push sends to fail.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/web-push-vapid-key-format

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gitar-bot

gitar-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

Important

You are using the Gitar free plan. Upgrade to unlock code review, CI analysis, auto-apply, custom automations, and more.

Gitar

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix web-push by converting VAPID PEM to pywebpush key format

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Convert stored VAPID private-key PEM into base64url DER before fan-out sends.
• Use a routable VAPID sub claim to avoid Apple push-service rejection.
• Add regression test ensuring the converted key is accepted by py_vapid.
Diagram

graph TD
A["Notification row"] --> B["send_web_push()"] --> C["_vapid_signing_key()"] --> D["pywebpush.webpush()"] --> E{{"Push service"}}
B --> F[("Push subs DB")]

subgraph Legend
  direction LR
  _fn["Function"] ~~~ _db[("Database")] ~~~ _ext{{"External"}}
end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Store base64url-DER key at creation time
  • ➕ Eliminates conversion overhead at send time
  • ➕ Makes the persisted format match the send-time requirement explicitly
  • ➖ Requires a storage-format migration/back-compat handling for existing PEM files
  • ➖ Harder to interoperate with tooling that expects PEM on disk
2. Use a library/API path that accepts PEM directly
  • ➕ Avoids custom conversion code
  • ➕ Potentially aligns with common key material formats (PEM)
  • ➖ Depends on pywebpush/py_vapid behavior/version; may require upgrading/downgrading or patching
  • ➖ Risk of reintroducing silent failures if behavior differs across environments
3. Fail fast at VAPID load/boot time instead of at send fan-out
  • ➕ Misconfiguration becomes visible earlier, before any notifications attempt to send
  • ➕ Keeps send path simpler once initialized
  • ➖ Requires plumbing initialization/health-check state into runtime startup
  • ➖ May be harder in multi-process deployments where keys are loaded lazily

Recommendation: Keep the PR’s approach: converting PEM→base64url-DER once per fan-out is backward-compatible with existing persisted keys, fixes the immediate 100%-failure mode, and avoids a storage migration. If push is initialized during startup in the future, consider moving the conversion/validation there to surface global misconfig even earlier.

Files changed (2) +52 / -2

Bug fix (1) +34 / -2
notifications_push.pyConvert VAPID PEM to pywebpush signing-key format and harden failure signal +34/-2

Convert VAPID PEM to pywebpush signing-key format and harden failure signal

• Introduces '_vapid_signing_key()' to transform the persisted VAPID private key from PEM into base64url-encoded PKCS8 DER without padding, matching what pywebpush/py_vapid expects. The send fan-out now computes this once per send, logs an ERROR and disables web push if conversion fails globally, and updates the VAPID 'sub' claim to a routable 'mailto:' address for Apple compatibility.

tinyagentos/notifications_push.py

Tests (1) +18 / -0
test_notifications_push.pyAdd regression test for VAPID PEM→DER conversion compatibility +18/-0

Add regression test for VAPID PEM→DER conversion compatibility

• Adds a unit regression test that generates a VAPID keypair, converts the stored private PEM via '_vapid_signing_key()', and asserts 'py_vapid.Vapid01.from_string' accepts the result. This protects against reintroducing the silent “PEM passed where DER/base64url is required” failure mode.

tests/test_notifications_push.py

data_str = json.dumps(_build_payload(row))
results = await asyncio.gather(
*[_send_one(sub, data_str, private_pem, store) for sub in subs],
*[_send_one(sub, data_str, signing_key, store) for sub in subs],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: _send_one's third parameter is still named private_pem but now receives the converted signing_key. This mismatch is misleading and error-prone — a future maintainer could pass the raw PEM here, reintroducing the same 100%-failure bug.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 2 Warnings Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/notifications_push.py 304 _send_one's third parameter is still named private_pem but now receives the converted signing_key; misleading and error-prone
tinyagentos/routes/desktop_browser/push.py 40, 121 Same raw-PEM-to-pywebpush bug still present; PR claims to fix "every web-push send" but this file is unchanged and still passes raw PEM, plus _VAPID_SUB still uses reserved .local domain
Files Reviewed (2 changed + 1 related)
  • tinyagentos/notifications_push.py - 1 warning
  • tests/test_notifications_push.py
  • tinyagentos/routes/desktop_browser/push.py - 1 warning (unchanged file with same bug)

Fix these issues in Kilo Cloud


Reviewed by step-3.7-flash · Input: 70.7K · Output: 8.1K · Cached: 470.9K

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_notifications_push.py (1)

526-543: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the send-path wiring as well.

This test proves the converter output is parseable, but it would still pass if send_web_push later passed the raw PEM to _send_one. Add a focused test that stubs _send_one and asserts the converted key is passed once.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_notifications_push.py` around lines 526 - 543, Add a focused test
for send_web_push that stubs _send_one, invokes the send path with a generated
VAPID key, and asserts _send_one is called once with the converted signing key
from _vapid_signing_key rather than the raw PEM. Keep the existing parseability
regression test unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_notifications_push.py`:
- Around line 526-543: Add a focused test for send_web_push that stubs
_send_one, invokes the send path with a generated VAPID key, and asserts
_send_one is called once with the converted signing key from _vapid_signing_key
rather than the raw PEM. Keep the existing parseability regression test
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 53f10033-0dbb-43bf-b9ae-90205f9138cf

📥 Commits

Reviewing files that changed from the base of the PR and between 45d17f5 and 056adaa.

📒 Files selected for processing (2)
  • tests/test_notifications_push.py
  • tinyagentos/notifications_push.py

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 35 rules

Grey Divider


Action required

1. Desktop push still uses PEM 🐞 Bug ≡ Correctness
Description
This PR converts the stored VAPID private key from PEM to the base64url-DER string that
pywebpush/py_vapid expects, but only in tinyagentos.notifications_push.send_web_push(). The parallel
BrowserApp web-push sender (tinyagentos.routes.desktop_browser.push.send) still forwards the raw PEM
string as vapid_private_key, which this PR documents as failing py_vapid parsing before any network
call.
Code

tinyagentos/notifications_push.py[R208-227]

+def _vapid_signing_key(private_pem: str) -> str:
+    """Convert the stored VAPID PEM into the base64url-DER form pywebpush wants.
+
+    ``load_or_create_vapid_keypair`` returns the private key as a PEM string, but
+    ``pywebpush.webpush(vapid_private_key=...)`` feeds it to ``py_vapid`` via
+    ``Vapid.from_string``, which base64-decodes and DER-parses its input. A raw
+    PEM fails that as "Could not deserialize key data" on EVERY send, and the
+    per-subscription handler swallowed it as a generic warning -- so web push was
+    100% broken for every user with no obvious signal. Convert once at fan-out.
+    """
+    from cryptography.hazmat.primitives import serialization
+
+    key = serialization.load_pem_private_key(private_pem.encode(), password=None)
+    der = key.private_bytes(
+        serialization.Encoding.DER,
+        serialization.PrivateFormat.PKCS8,
+        serialization.NoEncryption(),
+    )
+    return base64.urlsafe_b64encode(der).decode().rstrip("=")
+
Relevance

⭐⭐⭐ High

Correctness bug aligned with PR intent; team often accepts desktop_browser hardening fixes.

PR-#518
PR-#301

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The PR adds a PEM→base64url(DER) converter and a regression test specifically because raw PEM breaks
py_vapid parsing; however, the BrowserApp web-push sender still passes the unconverted PEM string
returned by the shared VAPID loader directly to pywebpush.webpush().

tinyagentos/notifications_push.py[208-227]
tests/test_notifications_push.py[528-543]
tinyagentos/routes/desktop_browser/vapid.py[23-65]
tinyagentos/routes/desktop_browser/push.py[80-123]
tinyagentos/routes/desktop_browser/push.py[206-219]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`tinyagentos/routes/desktop_browser/push.py` still passes the VAPID **PEM** string (from `load_or_create_vapid_keypair`) directly as `vapid_private_key` into `pywebpush.webpush()`. This PR shows that `py_vapid.Vapid.from_string` (used under the hood by `pywebpush`) expects a **base64url-encoded DER** string and that raw PEM breaks parsing.

### Issue Context
- OS notification push (`tinyagentos/notifications_push.py`) now converts PEM → base64url(DER) via `_vapid_signing_key()` and uses the converted string for fan-out.
- BrowserApp push (`tinyagentos/routes/desktop_browser/push.py`) uses the same VAPID keypair loader (`tinyagentos/routes/desktop_browser/vapid.py`) and still passes the PEM string through unchanged.

### Fix Focus Areas
- tinyagentos/routes/desktop_browser/push.py[80-123]
- tinyagentos/routes/desktop_browser/push.py[206-219]
- tinyagentos/notifications_push.py[208-227]

### Implementation notes
- Reuse the existing conversion logic (either import `_vapid_signing_key` or move it to a small shared utility module so both push senders use the same conversion).
- Convert once per `send()` fan-out (similar to `send_web_push`) and pass the converted value as `vapid_private_key`.
- Consider adding a regression test in the desktop_browser push test suite similar to `test_vapid_signing_key_is_accepted_by_pywebpush_vapid` to ensure the desktop path also provides a `Vapid01.from_string`-accepted key.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +208 to +227
def _vapid_signing_key(private_pem: str) -> str:
"""Convert the stored VAPID PEM into the base64url-DER form pywebpush wants.

``load_or_create_vapid_keypair`` returns the private key as a PEM string, but
``pywebpush.webpush(vapid_private_key=...)`` feeds it to ``py_vapid`` via
``Vapid.from_string``, which base64-decodes and DER-parses its input. A raw
PEM fails that as "Could not deserialize key data" on EVERY send, and the
per-subscription handler swallowed it as a generic warning -- so web push was
100% broken for every user with no obvious signal. Convert once at fan-out.
"""
from cryptography.hazmat.primitives import serialization

key = serialization.load_pem_private_key(private_pem.encode(), password=None)
der = key.private_bytes(
serialization.Encoding.DER,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
return base64.urlsafe_b64encode(der).decode().rstrip("=")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Desktop push still uses pem 🐞 Bug ≡ Correctness

This PR converts the stored VAPID private key from PEM to the base64url-DER string that
pywebpush/py_vapid expects, but only in tinyagentos.notifications_push.send_web_push(). The parallel
BrowserApp web-push sender (tinyagentos.routes.desktop_browser.push.send) still forwards the raw PEM
string as vapid_private_key, which this PR documents as failing py_vapid parsing before any network
call.
Agent Prompt
### Issue description
`tinyagentos/routes/desktop_browser/push.py` still passes the VAPID **PEM** string (from `load_or_create_vapid_keypair`) directly as `vapid_private_key` into `pywebpush.webpush()`. This PR shows that `py_vapid.Vapid.from_string` (used under the hood by `pywebpush`) expects a **base64url-encoded DER** string and that raw PEM breaks parsing.

### Issue Context
- OS notification push (`tinyagentos/notifications_push.py`) now converts PEM → base64url(DER) via `_vapid_signing_key()` and uses the converted string for fan-out.
- BrowserApp push (`tinyagentos/routes/desktop_browser/push.py`) uses the same VAPID keypair loader (`tinyagentos/routes/desktop_browser/vapid.py`) and still passes the PEM string through unchanged.

### Fix Focus Areas
- tinyagentos/routes/desktop_browser/push.py[80-123]
- tinyagentos/routes/desktop_browser/push.py[206-219]
- tinyagentos/notifications_push.py[208-227]

### Implementation notes
- Reuse the existing conversion logic (either import `_vapid_signing_key` or move it to a small shared utility module so both push senders use the same conversion).
- Convert once per `send()` fan-out (similar to `send_web_push`) and pass the converted value as `vapid_private_key`.
- Consider adding a regression test in the desktop_browser push test suite similar to `test_vapid_signing_key_is_accepted_by_pywebpush_vapid` to ensure the desktop path also provides a `Vapid01.from_string`-accepted key.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@jaylfc
jaylfc merged commit d3ccf22 into dev Jul 27, 2026
19 checks passed
hognek pushed a commit to hognek/tinyagentos that referenced this pull request Jul 29, 2026
…fc#2166)

Web push has been 100% broken for all users. load_or_create_vapid_keypair
returns the private key as a PEM string, but pywebpush feeds it to py_vapid's
Vapid.from_string, which base64-decodes and DER-parses its input. A raw PEM
fails that as 'Could not deserialize key data' on EVERY send, and the
per-subscription handler caught it as a generic warning and returned 'failed',
so nothing ever pushed and there was no obvious signal.

Convert the PEM to base64url-DER once at fan-out (_vapid_signing_key), verified
live against Apple's push service (web.push.apple.com returns 201 and the
notification arrives on the device). Also:
- move the VAPID sub off the reserved .local domain to mailto:info@taos.my;
  Apple is stricter than other services about the sub claim.
- log an ERROR (not the swallowed per-send warning) when the key cannot be
  converted, so a global misconfig is loud instead of disabling all push
  invisibly. A 100% failure that logs nothing is how this hid.
- regression test: the converted key must be accepted by Vapid.from_string.
hognek pushed a commit to hognek/tinyagentos that referenced this pull request Jul 30, 2026
…fc#2166)

Web push has been 100% broken for all users. load_or_create_vapid_keypair
returns the private key as a PEM string, but pywebpush feeds it to py_vapid's
Vapid.from_string, which base64-decodes and DER-parses its input. A raw PEM
fails that as 'Could not deserialize key data' on EVERY send, and the
per-subscription handler caught it as a generic warning and returned 'failed',
so nothing ever pushed and there was no obvious signal.

Convert the PEM to base64url-DER once at fan-out (_vapid_signing_key), verified
live against Apple's push service (web.push.apple.com returns 201 and the
notification arrives on the device). Also:
- move the VAPID sub off the reserved .local domain to mailto:info@taos.my;
  Apple is stricter than other services about the sub claim.
- log an ERROR (not the swallowed per-send warning) when the key cannot be
  converted, so a global misconfig is loud instead of disabling all push
  invisibly. A 100% failure that logs nothing is how this hid.
- regression test: the converted key must be accepted by Vapid.from_string.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant