Conversation
`Crypto-Key` and `Encryption` are lists of `;`-separated parameters (RFC 8188 2.1, RFC 8291 4) whose order is not fixed, but `_handle_data_message` sliced the prefix off by position - `[3:]` for `dh=`, `[5:]` for `salt=`. A sender that signs with VAPID adds `p256ecdsa` next to `dh`, so the slice leaves `<dh>; p256ecdsa=<...>`. `urlsafe_b64decode` silently discards `;`, the space and the label characters and decodes the whole thing, producing 137 bytes where a P-256 point needs 65: `ValueError: Invalid EC key.` from `from_encoded_point`, or `binascii.Error: Incorrect padding` first, depending on the total length. The failure lands before `self.callback(...)` and before the selective ACK, so the message is neither delivered nor acknowledged and the server redelivers it on every reconnect - the client dies again within a second of every login. `_webpush_header_param` looks the parameter up by name, so its position no longer matters, and restores the base64url padding that the wire format strips. A header holding a bare value with no `name=` prefix is still accepted, and a header that has parameters but not the requested one now raises `ValueError` with a clear message instead of corrupting the key silently. Observed against a Russian intercom operator that started signing its pushes with VAPID: `crypto-key` arrives as `dh=<87 chars>; p256ecdsa=<87 chars>` and `encryption` as `salt=<24 chars>` - so `dh` is unpadded while `salt` is padded, which is why padding alone does not fix it. The same code decrypted that operator's pushes fine two months earlier, when `crypto-key` still carried only `dh`; `_decrypt_raw_data` is byte-identical across 0.4.0-0.4.5, so this is a change on the sender's side rather than a regression here. Tests: `uv run pytest tests/` green, including a regression test that encrypts with real http_ece, hands `_handle_data_message` the exact production header shape and asserts the callback receives the payload. Reverting to the positional slice fails it. `ruff check`, `ruff format --check` and `mypy` clean. Refs sdb9696#42.
|
Field evidence corroborating this PR's diagnosis, from a Home Assistant install running the core The failure is fully deterministic across restarts, which I think supports the "neither delivered nor acknowledged, so the server redelivers on every reconnect" mechanism described here rather than a transient malformed payload. On 2026-08-13 Home Assistant Core was restarted five times, and the listener died on 5 of 5:
Each crash is preceded 2-3 seconds earlier by Ring entity setup ( Traceback, identical every time: The This lands as Happy to capture the raw |
|
A data point in favour of this approach: I've been running the equivalent of this Selecting by name is the right call over widening the slice — the parameter order I wrote a standalone check that builds real ECE payloads with real ECDH keys and https://github.com/slettmayer/ha-fcm-header-fix/blob/main/scripts/test_fcm_header_fix.py Please take it for this PR's tests if it's useful. I've written up the full Also worth pairing this with #37: this PR fixes the current crash, #37 stops the |
There was a problem hiding this comment.
This looks like a step in the right direction. Haven't been able to test this locally myself but this would explain the unrecoverable failures people see after a while of re-establishing a new authenticated ring API connection. @sdb9696
I actually started seeing things work again after applying this custom integration:
home-assistant/core#179149 (comment)
|
Independent confirmation from production, plus one gap that I think is worth closing alongside this. ConfirmationHit this on a Home Assistant integration talking to a vendor push service that started signing with VAPID. The header: The positional slice leaves What makes it nasty is that it is unrecoverable rather than transient. The exception reaches
The gapBoth extraction calls sit outside the
Each then produces the same unacked-redelivery wedge as the original bug. With #37 also applied the log reads Moving the two extraction calls inside the same Happy to open a small PR stacked on this if that is useful — I have it implemented with tests, on top of this PR and #37. |
|
@sdb9696 (Steven B.) hasn't been active since June from what I can see. What usually happens when a project this widely used appears to get abandoned? Are there any other reviewers? |
Fixes #42, and supplies the field evidence #37 was waiting for.
Summary
Crypto-KeyandEncryptionare lists of;-separated parameters (RFC 8188 §2.1, RFC 8291 §4) whose order is not fixed, but_handle_data_messageslices the prefix off by position:A sender that signs with VAPID adds
p256ecdsanext todh, so the slice leaves<dh>; p256ecdsa=<…>.urlsafe_b64decodesilently discards;, the space and the label characters and decodes the whole thing, producing 137 bytes where a P-256 point needs 65 —ValueError: Invalid EC key.fromfrom_encoded_point, orbinascii.Error: Incorrect paddingfirst, depending on the total length.The failure lands before
self.callback(...)and before the selective ACK atfcmpushclient.py:605-608, so the message is neither delivered nor acknowledged. The server redelivers it on every reconnect and the client dies again within a second of every login.Field evidence
@MultivitaminJuice, this is the answer to the open question in #37:
They are in the wild. A Russian intercom operator (Elektronny Gorod / «Мой Дом») started signing its pushes with VAPID. A diagnostic probe on a live Home Assistant install captured the shapes — lengths and parameter names only, no key material:
Two details worth keeping:
dharrives unpadded (87 chars) whilesaltarrives padded (24 chars). So padding alone is neither necessary nor sufficient — with only Harden encrypted push payload decoding #37 applied,binascii.Error: Incorrect paddingsimply becomesValueError: Invalid EC key.dhdecodes only once it is separated from the VAPID parameter._decrypt_raw_datais byte-identical across 0.4.0–0.4.5, and the same code decrypted 16 of that operator'sCALL_INCOMINGpushes two months earlier, whencrypto-keystill carried onlydh. The change is on the sender's side.This also means #42's suggested
removeprefix("dh=")does not fix the symptom #42 reports: on this header it removes the label but leaves the; p256ecdsa=…tail attached.Changes
_webpush_header_param(header, name)looks a parameter up by name, so its position no longer matters, and restores the base64url padding the wire format strips.name=prefix is still accepted as that value.ValueErrorwith a clear message instead of corrupting the key silently. That composes with the per-message isolation in Harden encrypted push payload decoding #37 and theValueErrorhandling in Recover from transient read failures instead of aborting the client #38: the bad message gets skipped instead of taking the listener down.Testing
uv run pytest tests/— 24 passed, including a regression test that encrypts with realhttp_ece, hands_handle_data_messagethe exact production header shape and asserts the callback receives the payload. Reverting to the positional slice fails it withbinascii.Error: Incorrect padding.ruff check,ruff format --checkandmypyclean.Relationship to the other open PRs
Independent of both, and they compose:
_urlsafe_b64decode_paddedis a no-op on the already-padded values this PR produces. Happy to rebase onto it if you would rather land Harden encrypted push payload decoding #37 first, or to fold this into it.Happy to adjust naming, split the error-raising behaviour out, or add the parameter parsing to #37 instead if that is the shorter route to a release.