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:
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user