fix(config_flow): normalize a pasted PEM before parsing it

A PEM pasted from a text editor can carry bytes cryptography's parser
refuses outright: a UTF-8 BOM some Windows editors silently prepend,
CRLF line endings, and a stray blank line a paste can introduce
between the header/body/footer. None of those are meaningful in PEM,
but any of them surfaces as an opaque InvalidHeader with no hint of
what's wrong -- which is why the same certificate pasted from
Command Prompt's `type` (no BOM, no stray blank lines) loads fine
while the same file opened in an editor and copied doesn't (issue
#291).

Normalize at the point the pasted blob is first captured, not just
before minting the leaf cert: the same string is stored in the config
entry and reused to re-mint the leaf on a future reconfigure, so a
raw copy would keep failing every time it's read back, not just on
the first attempt.
This commit is contained in:
Marc Billow
2026-08-06 00:41:43 +00:00
parent 26e4c9c167
commit 455ed5b27c
2 changed files with 63 additions and 2 deletions
+28 -2
View File
@@ -163,6 +163,26 @@ def _fetch_samsung_uuid() -> str:
raise RuntimeError(f"UUID not found in {_SAMSUNG_CLOUD_HOST} certificate subject")
def _normalize_pem(text: str) -> str:
"""Clean up a pasted PEM blob before handing it to `cryptography`'s
parser (issue #291).
A PEM copied out of a text editor can carry a few bytes `cryptography`
refuses outright: a UTF-8 BOM some Windows editors silently prepend, CR
line endings (or CRLF), and blank lines a paste can introduce between
the header/body/footer. Any of those surfaces as an opaque
`InvalidHeader` with no hint of what's actually wrong -- which is why
the same certificate pasted from `type` (no BOM, no stray blank lines)
loads fine while the editor's copy doesn't. None of the stripped bytes
are meaningful in PEM: the format is BOM-free, line-ending-agnostic, and
has no blank-line syntax of its own.
"""
text = text.lstrip("\ufeff")
text = text.replace("\r\n", "\n").replace("\r", "\n")
lines = [line for line in text.split("\n") if line.strip()]
return "\n".join(lines)
def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str, str]:
"""Mint a fresh RSA-2048 leaf cert signed by the CA.
@@ -722,8 +742,14 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if leaf_cert and leaf_key:
existing_leaf = (leaf_cert, leaf_key)
else:
self._ca_cert_pem = user_input[CONF_CA_CERT_PEM].strip()
self._ca_key_pem = user_input[CONF_CA_KEY_PEM].strip()
# Normalized before the probe even runs (issue #291), not
# just before minting: the same pasted blob is also what
# gets stored in the config entry and reused to re-mint the
# leaf on a future reconfigure, so a raw copy with a BOM or
# CRLF line endings would keep failing every time it's read
# back, not just this once.
self._ca_cert_pem = _normalize_pem(user_input[CONF_CA_CERT_PEM])
self._ca_key_pem = _normalize_pem(user_input[CONF_CA_KEY_PEM])
try:
info = await self.hass.async_add_executor_job(
+35
View File
@@ -75,6 +75,41 @@ async def test_successful_setup(hass: HomeAssistant, mock_probe) -> None:
assert result["data"][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
async def test_setup_normalizes_messy_pasted_pem(hass: HomeAssistant, mock_probe) -> None:
"""A PEM with a leading UTF-8 BOM, CRLF line endings, and a stray blank
line -- the kind a Windows text editor's copy produces, as opposed to a
`type` dump (issue #291) -- must still be accepted and stored in its
normalized form, not rejected with an opaque InvalidHeader."""
messy_cert = "\ufeff" + MOCK_CA_CERT_PEM.replace("\n", "\r\n") + "\r\n\r\n"
messy_key = "\ufeff" + MOCK_CA_KEY_PEM.replace("\n", "\r\n")
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: messy_cert,
CONF_CA_KEY_PEM: messy_key,
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
assert result["data"][CONF_CA_KEY_PEM] == MOCK_CA_KEY_PEM
def test_normalize_pem_strips_bom_crlf_and_blank_lines() -> None:
"""Unit-level check of the helper itself, isolated from the flow."""
from custom_components.localthings.config_flow import _normalize_pem
messy = "\ufeff-----BEGIN CERTIFICATE-----\r\nTEST-CA\r\n\r\n-----END CERTIFICATE-----\r\n"
assert _normalize_pem(messy) == (
"-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
)
# A clean PEM (the `type`-dump case) passes through unchanged.
clean = "-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
assert _normalize_pem(clean) == clean
def test_order_candidates_prefers_known_ports() -> None:
"""Live ports are ordered with the historically known DTLS ports first,
then the rest ascending."""