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. For a certificate with no subjectAltName
reached by IP address, public_key drops IP reference ids before the match fun
is consulted, so the check fails on an empty list and :ssl converts
valid_peer into {:bad_cert, :hostname_check_failed} before this module sees
it. It is retained only so that a pinned certificate which does chain to a
supplied authority is still judged by its fingerprint instead of by a name it
was never issued for.
@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 connecting by name rather than by address: for a
certificate with no subjectAltName an IP reference id is dropped before
hostname matching, so :ssl reports hostname_check_failed first, whereas a
DNS reference id falls back to CN-ids and lets :valid_peer through. Neither
condition is exotic — Hue.Discovery returns mDNS .local names, and nothing
here controls what a caller connects to.
Unrecognised events fail closed rather than raising.