TLS for Hue bridges, which cannot be verified the ordinary way.
A bridge presents a certificate whose common name is its bridge id, with no subjectAltName, signed by a Signify root that is neither sent on the wire nor publicly downloadable. There is no name to match and no certificate authority to bundle, so ordinary verification cannot succeed. Every other Hue client responds by turning verification off.
This module pins instead: the first connection records the certificate's SHA-256 fingerprint, and every connection afterwards requires the same one. That is the SSH host-key model.
What pinning does and does not protect
It does not protect the very first connection. Whatever answers at that moment becomes the trusted certificate, so an attacker already in position during first contact is pinned rather than caught. Pair over a network you trust. Every interception after first contact is detected, which is more than an unverified client can say.
A changed fingerprint means either a factory-reset bridge or an interception, and those are indistinguishable from here, so it fails closed.
Session resumption is disabled, deliberately
Every connection here performs a full handshake. :ssl would otherwise cache a
successful session and resume it for the next connection to the same
host:port — and a resumed session presents no certificate, so
verify_fun is never called and the pin is never consulted. That turned a pin
into a one-shot check: once any connection to a bridge succeeded, a second
client with a completely wrong fingerprint resumed that session and talked to
the bridge, with no alert and no error. Verified against real hardware, and
invisible to a fixture suite because each synthetic listener gets a fresh port
and so has no session to resume.
The cost is a full handshake — a few extra round trips and one RSA verification — on every connection instead of only the first. Against a bridge on the local network that is negligible, and the alternative is a pin that stops being enforced after the first connection, which is no pin at all.
Learning the fingerprint
ssl_options/1 with no :fingerprint returns unverified options. That is the
first-contact connection, and the only one that should ever run without a pin:
connect, read the certificate with :ssl.peercert/1, store fingerprint/1 of
it against the bridge id from common_name/1, and pin every connection after
that.
capture_certificate/3 is that connection, packaged. Hue.Discovery.identify/2
makes it once per bridge and pins everything afterwards — including its own
/api/config request — to what came back.
Summary
Types
A certificate as DER bytes, or as the record OTP's :ssl passes to a verify_fun.
Functions
First contact only. Opens a TLS connection to host:port with
verification off, reads the certificate the server presented, and closes.
Returns the leaf certificate's DER bytes.
The common name of a certificate, which on a bridge is its bridge id.
The SHA-256 fingerprint of a DER-encoded certificate, lowercase hex.
Folds the forms a fingerprint is plausibly written in into the one
fingerprint/1 produces: lowercase, unseparated hex.
Builds :ssl options.
The :ssl verify_fun callback, in OTP's four-argument form.
Types
Functions
@spec capture_certificate(String.t(), :inet.port_number(), keyword()) :: {:ok, binary()} | {:error, term()}
First contact only. Opens a TLS connection to host:port with
verification off, reads the certificate the server presented, and closes.
Returns the leaf certificate's DER bytes.
This is the one call in the library that talks to a bridge without a pin, and
it exists solely to produce the pin. Feed the result to fingerprint/1 and
common_name/1, store both, and use ssl_options/1 with a :fingerprint
from then on. It does not decide anything and it does not remember anything:
deciding whether the certificate may be trusted is the caller's job, and on
the first connection there is nothing to decide it against.
Nothing about the pinned path changes as a result of this function existing.
It borrows ssl_options/1's no-fingerprint branch rather than assembling
weaker options of its own, so there is exactly one definition of "unverified"
in this module and no verify_fun anywhere that returns :valid without
comparing a pin.
Borrowing is also what keeps this correct rather than merely consistent: those options disable session resumption, and a capture that resumed a session would be handed a cached certificate instead of the one the server is presenting now. A function whose entire job is to observe the live certificate must never resume.
It connects separately rather than reaching into the connection Req is about
to make. Capturing during Req's own handshake would mean installing a
verify_fun that accepts every certificate and records it as a side effect —
a function whose whole purpose is to trust anything, sitting one keyword away
from verify_pinned/4 in a module about verification — and would additionally
depend on :ssl running the callback in the calling process, which is a
Finch/NimblePool implementation detail rather than a guarantee. The cost is
one extra connection, once per bridge, ever.
Options
:timeout— milliseconds to wait for the handshake. Defaults to5000.
Returns {:error, reason} with :ssl's own reason term for a connection or
handshake that did not complete.
@spec common_name(certificate()) :: String.t() | nil
The common name of a certificate, which on a bridge is its bridge id.
Accepts DER bytes or a decoded OTPCertificate. Returns nil for a
certificate whose subject carries no common name.
The SHA-256 fingerprint of a DER-encoded certificate, lowercase hex.
Takes DER bytes rather than a decoded certificate on purpose. The pin has to describe the bytes that crossed the wire, and re-encoding a decoded record to recover them would make the pin depend on OTP's encoder reproducing its own decoder exactly.
Folds the forms a fingerprint is plausibly written in into the one
fingerprint/1 produces: lowercase, unseparated hex.
A pin is copied from somewhere, and the obvious source prints something else.
openssl x509 -fingerprint -sha256 emits 21:CD:F4:8F:… — uppercase, colon
separated. Comparing that to fingerprint/1's output byte for byte fails, and
verify_pinned/4 reports that failure as :certificate_changed, which this
module documents as "your bridge was replaced or you are being intercepted".
A library that fails closed and is authoritative about why must not spell a
transcription format the same as an attack, so the forms are folded together
here rather than compared as typed.
Accepts, all yielding the same pin: lowercase hex, uppercase hex, either with
: separators, and any of those with surrounding whitespace.
Anything that is not then exactly 64 hex characters raises. A SHA-256 digest has one length, and a pin shorter than the digest is a pin that matches more certificates than the one it was taken from.
Builds :ssl options.
Options
:fingerprint— pin to this certificate, in any formnormalize_fingerprint/1accepts. The normal path.:verify— pass:noneto disable verification entirely. This is what every other Hue client does; it is never the default here.
With neither, verification is off: that is first contact, before a fingerprint
exists to pin to. Anything else raises. In a library whose job is to verify,
a typo in an option name must not quietly mean "verify nothing", so unknown
keys, a key given twice — only the first of which would be read — and the
contradictory verify: :none alongside a :fingerprint are all rejected
rather than resolved.
Why the pinned options look the way they do
cacerts: [] supplies no trust anchors. Trust comes from the pin, and handing
the connection the system roots as well would only widen what :ssl accepts
before the pin is ever consulted.
depth: 0 allows no intermediates. A bridge sends exactly one certificate, so
this costs nothing today. It is not, however, what keeps the pin aimed at the
right certificate: OTP walks a chain from the top down, so a two-certificate
chain reports unknown_ca against the issuer, and the pin would be
compared against that issuer regardless of the depth setting. Depth only
rejects chains of three or more. The practical consequence is worth stating
plainly: if Signify firmware ever begins sending an intermediate, this code
reports :certificate_changed — which reads as "your bridge was replaced or
you are being intercepted" — for what is actually a benign update.
customize_hostname_check cannot disable hostname verification here, contrary
to how it is usually described. The match fun is only consulted against
identifiers the certificate presents, and a bridge certificate presents none:
it has no subjectAltName, and OTP 28.5 removed the CN-id fallback that once
stood in for one, so the check fails on an empty list — on any current OTP
for every kind of reference id, on older OTP when reached by IP address — and
:ssl converts valid_peer into {:bad_cert, :hostname_check_failed}
before this module sees it. It is retained only so that a certificate which
does present a name a caller connected to is still judged by its
fingerprint instead of by that name.
@spec verify_pinned(tuple(), binary(), term(), String.t()) :: {:valid, String.t()} | {:unknown, String.t()} | {:fail, term()}
The :ssl verify_fun callback, in OTP's four-argument form.
OTP hands this both the decoded certificate and the exact DER bytes it was
decoded from (ssl_handshake.erl, apply_fun/5, which dispatches on
is_function(Fun, 4)). The pin is compared against those bytes.
An unknown or self-signed issuer is accepted only when the fingerprint matches. A peer that verified normally is also held to the pin: a validated path proves the certificate was issued by an authority the path accepted, not that it came from this bridge.
Do not fold :valid_peer in with :valid, and do not delete it as dead
code. It does not fire while a bridge sends a single certificate, but the
reason is chain length, not the absence of a CA store: pkix_path_validation/3
handles an untrusted chain by promoting its head to the trust anchor and
recursing over whatever remains, so a one-certificate chain leaves nothing to
validate and produces no peer event at all. That holds with or without
cacerts.
Once the chain is longer and validation gets past its head, the leaf arrives as
:valid_peer, and this branch is the only thing still comparing it to the pin.
Reaching it also depends on surviving the hostname check, and what that takes
moved in OTP 28.5. Through OTP 28.3, a certificate with no subjectAltName
reached by IP lost its reference ids before matching, so :ssl reported
hostname_check_failed first, whereas a DNS reference id fell back to CN-ids
and let :valid_peer through. OTP 28.5 removed that CN-id fallback (the
RFC 9525 direction): with no subjectAltName the check now fails before any
match fun is consulted, for every kind of reference id, so only a leaf that
carries a subjectAltName can arrive as :valid_peer. A SAN-bearing chain is
not exotic — nothing here controls what a caller connects to — and a SAN-less
one now fails closed either way, just as hostname_check_failed instead of
through the pin.
Unrecognised events fail closed rather than raising.