Skip to content

feat(ssl): separate chain validation from hostname verification - #173

Merged
cayossarian merged 2 commits into
mainfrom
feat/leaf-verdict-primitives
Aug 28, 2026
Merged

feat(ssl): separate chain validation from hostname verification#173
cayossarian merged 2 commits into
mainfrom
feat/leaf-verdict-primitives

Conversation

@cayossarian

Copy link
Copy Markdown
Member

Why

A consumer pinned to a panel's CA gets one boolean for two different questions — is this the panel and does its certificate name this address — so a panel that moved to a new DHCP lease is indistinguishable from something impersonating one, and both surface as a verification failure.

The consumer that needs the distinction is the Home Assistant integration's reconfigure flow, where the two have opposite remedies: one is repaired by asking the panel to regenerate its certificate, and the other must be refused.

What

build_panel_ssl_context takes check_hostname. The chain, the signature and the expiry are still verified against the pinned anchor; only the binding between the certificate and the name used to dial it is left unasserted. An attacker without a key that anchor signed cannot complete the handshake either way.

leaf_names_host supplies the other half. Hand-written against getpeercert() because ssl.match_hostname was removed in Python 3.12, and stricter than that function was:

  • no wildcards — a panel names literal addresses
  • no commonName fallback
  • DNS and IP entries never substitute for one another
  • addresses compare parsed, names compare casefolded, one trailing dot insignificant

Everything it cannot read answers False.

Why here rather than in the consumer

_ssl.py's module docstring already makes the argument about the fingerprint: a security primitive with two implementations is a defect waiting for a firmware upgrade to find it. That applies more to a hand-written hostname matcher, which is security-relevant and has no standard-library implementation left to defer to.

Testing

1172 pass. 18 new tests cover the relaxed context end-to-end against a real handshake (a leaf naming localhost, reached at 127.0.0.1), that relaxing the name does not relax trust, and the matcher's rules individually. _ssl.py is at 100% coverage.

Compatibility

Additive. check_hostname defaults to True, so every existing caller keeps hostname verification without asking for it.

A consumer pinned to a panel's CA gets one boolean for two different
questions -- "is this the panel" and "does its certificate name this
address" -- so a panel that moved to a new DHCP lease is indistinguishable
from something impersonating one, and both are reported as a verification
failure.

`build_panel_ssl_context` gains `check_hostname`, which leaves the chain,
signature and expiry checks intact and only stops asserting the binding
between the certificate and the name used to dial it. An attacker without
a key the pinned CA signed still cannot complete the handshake.

`leaf_names_host` supplies the other half, hand-written against
`getpeercert()` because `ssl.match_hostname` was removed in 3.12. It is
stricter than that function was: no wildcards, no `commonName` fallback,
DNS and IP entries that never substitute for one another, addresses
compared parsed and names casefolded.

Both live here rather than on the consumer side for the reason the module
docstring already gives about the fingerprint: a security primitive with
two implementations is a defect waiting for a firmware upgrade to find it.
CodeFactor flagged the matcher as a complex method, and it was: SAN
extraction, host normalisation, address parsing and two matching rules in
one body, with the branch for which rule applies interleaved through the
loop.

Split into `_san_entries`, `_names_address` and `_names_dns`, leaving
`leaf_names_host` as the dispatcher it should have been -- normalise, then
ask exactly one of the two questions. Behaviour is unchanged and the rules
now read one per function, which for security code is the point.
@cayossarian
cayossarian merged commit bf9fc8e into main Aug 28, 2026
6 checks passed
@cayossarian
cayossarian deleted the feat/leaf-verdict-primitives branch August 28, 2026 22:12
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