Adopt appliances by port redirection instead of SoftAP provisioning - #107
Adopt appliances by port redirection instead of SoftAP provisioning#1073735943886 wants to merge 8 commits into
Conversation
|
A branch that lets a bridged appliance take a firmware update is ready: Two things were in the way. The cloud asks whether the appliance is there before it offers The fix is for rethink to stop answering those connections. The ClientHello already says which Verified on one appliance, a It relies on what this PR adds, so I have not opened it as a PR of its own yet. |
|
This sounds very interesting, but also a bit suspicious. Redirecting already-provisioned devices would depend on two conditions (unless I misunderstood the scheme you proposed):
1 - I will need to verify if this is actually the case, I may not recall things correctly. 2 - I would be mildly surprised if LG's backend had a flaw of this magnitude. But if they had - It would be responsible to report it to LG instead of building infrastructure depending on the flaw never being fixed. |
|
I've rewritten this with AI help to express my point more clearly — the findings and the 1. Certificate pinning. You're right that the device pins — but it pins what it is told, and it asks again.
The pin isn't fixed at manufacture; it's re-acquired from Two things to note. The hostnames it asks for are That's also why rethink mints a certificate per requested SNI name: units of the same model My guess at why it's built this way: an appliance with a ten-year service life can't carry a 2. Client certificate validation. I think there's a premise worth correcting first: "non-destructive bridge" doesn't mean rethink skips Those are two separate operations in the ThinQ API:
So rethink is not presenting a forged or borrowed certificate. It presents one LG issued to it,
I checked which of those two calls the backend actually treats as binding, and it's step 3, not So the existing home registration really is untouched — the call that would modify it doesn't Which is why your second condition doesn't apply. Nothing here depends on the backend failing The impersonation scenario doesn't follow either. Knowing the UUID gets you nothing: step 1 is That’s my reading of it, anyway — but I could certainly be wrong. I'd welcome your thoughts. |
|
I'll need some more time to better understand this. Meanwhile, I've cherry-picked the two initial commits (management panel fixes). |
|
I've previously assumed that the device pins the certificates at provisioning time until it's re-provisioned. But I've re-checked this now and it turns out that it re-pins them on each boot, making this scenario possible. The other way around I haven't tested yet. Tell me if I understand this correctly. The device obtains a valid certificate from LG's backend. When redirected to rethink, it simply uses that certificate (no harm done), while rethink obtains another valid certificate from LG, without invalidating the old one, and without ejecting the device from the user's home. Is that right? |
|
That's my understanding too, except for one part I can't confirm. The only thing this PR changes there is the ejection. Whether the old certificate stays valid, I don't know. What makes me suspect it does is how little it took: the bridge was already there, and all that was needed was to stop deleting the device and to pass its real deploy info through instead of the placeholders. So my guess is that LG's side already accommodates a connection like this — but it is a guess, and I can't see the backend to check it. If that's not something you'd want to rely on, it doesn't have to hold up the rest. The redirection work stands on its own without it — SNI certificates, |
Enabling a bridge deleted the appliance from the owner's home and
registered it again under the name "Rethink xxxxxxxx". When the delete
left the registration behind, addDevice() retried with initDevice=true,
which makes the cloud tear down the registration and build a new one.
The visible damage: the appliance vanishes from the ThinQ app and from
any linked ecosystem, comes back renamed, and can no longer reach LG on
its own, because its registration has been rebound to the credentials
rethink just created.
None of that is needed. The material a bridge runs on - certificate,
private key, MQTT topics - comes from pair(), which has already run by
the time the device is added to the home. So:
- look the appliance up in the home first, and skip the delete when it
is already there,
- treat ERROR_ALREADY_DEVICES_REGISTERED_IN_HOME as success rather
than retrying with initDevice=true,
- reuse the alias the account already has for it.
An appliance the account does not have is registered exactly as before.
(cherry picked from commit c303e37)
The fake API server in the test is declared as definitely assigned: it is
assigned in listen() rather than in a constructor, which strict property
initialisation rejects.
rethink presents one certificate, named after config.hostname. That is enough for an appliance that went through SoftAP setup, because setup tells it to talk to config.hostname. It is not enough for an appliance that arrives by redirection instead: that one still asks for whatever LG hostname its firmware carries, and units of the same model do not agree on which. Observed on Korean units: some ask for kic-common.lgthinq.com, others for kic-mclip.lgthinq.com, with common.iot.kic.lgthinq.com on the MQTT port. Pinning config.hostname to one of them drops the others. The API port does not check the certificate, so this only shows up on the MQTT port, where it does - as a handshake that fails for some units and works for others. Mint a leaf per requested name, signed by our CA, and hand it out through SNICallback. The appliance pinned that CA when it fetched /route/certificate, so a name it asked for is all that is missing. Connections that ask for config.hostname, or ask for nothing, keep getting exactly what they got before. Names are checked against a hostname pattern before reaching openssl, and the number of distinct certificates is capped, so a peer on the network cannot drive unbounded process spawning. (cherry picked from commit b36678e) (cherry picked from commit c555735)
…uses /route tells the appliance which server to talk to from now on, and it always answers with config.hostname. For an appliance set up over SoftAP that is right - it was pointed here deliberately and needs a name it can resolve afterwards. It is wrong for an appliance that was never set up against rethink and only arrives because its traffic is redirected at the router. Handing that one config.hostname means it now needs a DNS record it did not need before, and - because the appliance stores what /route tells it - it keeps asking for that name long after the redirection is gone. Removing the redirection no longer hands the appliance back to the manufacturer, which was the one property that made redirection attractive: it is undone by deleting a router rule, with nothing left on the appliance. With advertise_requested_host set, /route echoes back the name the appliance asked for. It carries on using an address it already resolves, the redirection stays the only thing between it and the cloud, and no DNS entry is involved at any point. Off by default. Addresses and anything that is not a hostname fall back to config.hostname: the appliance would store an address and be pinned to one machine, and a Host header should not reach a URL unchecked. (cherry picked from commit 3482092) (cherry picked from commit ca71039)
Four places shelled out to openssl and each grew its own way of doing it. They disagreed on the parts that matter - whether a failure is noticed, whether the serial is unique, whether a name reaches the command line unchecked - so a fix in one of them was a fix in one of them only. util/pki.ts is now the one implementation: a wrapper that throws with openssl's stderr instead of handing back whatever landed on stdout, a hostname check, key/CSR generation, signing, and CA creation. Everything openssl reads or writes goes through a file in a temporary directory. That is not incidental: `req` cannot take a key on stdin, and writing two outputs to stdout fails when stdout is a pipe, which is what node hands a child process. The workaround for that was `sh -c 'cat | openssl ... /dev/stdin'`, which also required a shell in the image. Two things follow for the server certificate. The CA is no longer served as one - the default TLS context is now a leaf for config.hostname, so it carries a subjectAltName, which the CA, created with a subject and nothing else, does not. A connection that sends no SNI at all, an appliance reaching us by address among them, was getting that bare CA before. And because the CA's subject no longer has to match anything, the check that regenerated it when it did not match is gone. That check treated every failure alike: an unreadable file, or a changed hostname, silently replaced the CA that appliances have already pinned, leaving all of them unable to connect until each was provisioned again. Only "there is no CA yet" creates one now; anything else is reported. Serials are random rather than the fixed 0100 every appliance certificate carried, and addresses are refused by the hostname check - an address needs an IP: altname, and a DNS: one is not checked against it, so the certificate that was minted for one could not be used. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 72d36e5)
The route that signs an appliance's CSR discarded openssl's stderr, ignored its exit status, and answered resultCode 0000 with whatever had reached stdout. When signing failed that was an empty certificatePem delivered as a success, which the appliance can only report later as some unrelated problem. A request carrying no CSR at all took the same path, because openssl was handed an empty stdin. Sign through util/pki, which throws on a failure, and check that a CSR arrived before starting. Both cases now answer with a non-zero resultCode and no certificate. What is deliberately not checked is the otp, and the subject is not tied to :deviceId. This is the appliance's own local cloud and the CA it pins here is one we made for it, so signing what it asks for is the point; that is now written down rather than left to be inferred. The hostname pattern advertisedHost() used is the one in util/pki now, so the two copies that had to agree are one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 7b7e311)
Both places built a key and a CSR by piping PEM between openssl processes, and both worked around `req` not reading a key from stdin with the same `cat | openssl ... /dev/stdin` line - one through sh, the other through bash, which the alpine runtime image does not have, so that path could not have run in a container. They call util/pki instead. subprocess() in bridge/util.ts was the helper that made the pipes work; nothing uses it now, and it resolved with stdout whatever the exit status was, so it is gone rather than left for the next caller. The AWS-IoT CA fetch that both did by hand is fetchIotCaCertificate(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 52fe581)
A bridged appliance was introduced to the real cloud with a fixed set of
placeholder values - an HNA device's modem type, firmware version and
protocolVer '1' - regardless of what the appliance in the room actually is.
protocolVer is the one that matters. Told '1', the cloud framed its
reservation ("service") polls in a legacy wire encoding that a protocolVer 7
firmware ignores, so the appliance never answered them and the official app's
reservation screen reported it as unreachable. Basic state polls happen to use
a version-independent framing, which is why control worked throughout and only
that one screen was broken.
The appliance already tells us what it is: its deploy message carries the same
appInfo, and a platformInfo beside it. Keep both verbatim on the cloud-side
Device when it completes provisioning, persist them into the bridge device
state at registration, and prefer them when introducing the appliance
upstream.
Three levels, in order: what the appliance is reporting right now, what it
reported when it was registered, and the old placeholders. A state written
before this change has neither of the first two, so it keeps behaving exactly
as it did until the appliance re-deploys.
The three levels live in deployInfo() rather than inline in the connect
handler, so they can be tested without opening an MQTT connection.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Independent live validation of the server-leaf certificate portion of this PR with a newer ThinQ2 Wi-Fi module. Test device (household identifiers removed):
Behavior with current master was repeatable:
The generated CA certificate was also being presented directly as the HTTPS/MQTTS server certificate ( The local server leaf used:
Strict hostname/chain verification then passed for both ports 443 and 8883. On the next setup attempt the appliance continued past So this is an independent confirmation that newer |
This started from @kkqq9320's fork — https://github.com/kkqq9320/rethink — which is where the certificate,
/routeand bridge-registration work came from. Five of the commits here are his, included with his agreement.What this adds
Two capabilities that together let an appliance be adopted and kept, rather than taken over:
An appliance can be added without SoftAP provisioning. Today an appliance has to be put through SoftAP setup to reach rethink. That is not always possible or wanted — it means resetting a unit that is working, and on a multi-split installation it means doing that to every indoor unit. With this branch an appliance can instead be pointed at rethink purely by redirecting its traffic at the router (port DNAT on 443 and 8883). It is never taken out of setup mode, nothing on it is changed, and removing the redirection hands it straight back to the manufacturer.
Bridge mode no longer destroys the LG registration. Enabling a bridge used to delete the appliance from the owner's home and register it again, which renames it, announces a removal to every app on the account, and leaves the appliance unable to reach LG on its own. It now keeps the registration it has.
Running together, this gives local control in Home Assistant while the official app, Google Home and the ThinQ integration keep working.
What changed
/routeecho back the name the appliance asked for, so a redirected appliance keeps using an address it already resolves and stores nothing that outlives the redirection (advertise_requested_host, off by default).appInfo/platformInfoinstead of fixed placeholders.util/pki.ts: failures are reported instead of passed on as empty output, serials are unique, names are validated before reaching the command line, and no shell is needed.resultCode 0000and an empty certificate.Adding an appliance
The flow has nothing in common with SoftAP provisioning — the appliance is never put into setup mode and never leaves the state it is already in:
advertise_requested_hostenabled so/routeleaves the appliance on a name it already resolves.What this buys, and what it costs
Against SoftAP provisioning:
What it asks for in return:
/routestep to redirect them with, so the flow above is ThinQ2 only. The certificate work applies to their listeners too, but nothing here has been tried against a ThinQ1 unit.Why this works
The appliance is never told about rethink. It rebuilds its trust in the cloud every time it attaches, and the step it rebuilds it from is not authenticated:
/route/certificateand pins what comes back. That is now rethink's CA./routetells it which servers to use from here on.Steps 1 and 2 are what make the rest possible: the appliance will hand out its own trust anchor on request, over a channel that authenticates nobody. Everything after that is the appliance doing exactly what it does against the manufacturer's servers.
Why the redirection is done by port, not by DNS
Pointing the LG hostnames at rethink in DNS looks like the obvious way to do this, and it does not hold up. An appliance does not talk to one name: it opens connections to a spread of
*.lgthinq.comhosts on 443 and 8883 that differ by region and by model. Enumerating that set means keeping up with names the appliance has not asked for yet, and getting it wrong fails silently — the appliance simply carries on to the manufacturer.The names also do not stop at LG. Several are CNAMEs onward into AWS, so what an appliance actually resolves is an Amazon endpoint that is shared with everything else hosted there and does not stay put. Overriding by address is therefore out, and following the chain means intercepting names that have nothing to do with LG. There is no version of this that is a tidy list.
And there is no way back: the appliance caches what it resolved, so removing a DNS entry does not release it.
That is the failure reported in #58 #104: redirecting
common.lgthinq.comwas enough for an air conditioner, a washer and a dehumidifier, while an air purifier and a cooktop on the same network went on registering with the manufacturer. They were not asking for that name, and there is no list of the names they do ask for. Redirected by port, both of them attach — as does every other appliance on that network.That is also why one certificate is not enough. Because the appliance still asks for whichever LG name it was going to use, rethink has to be able to answer to all of them.
Why the redirection path needed work
An appliance that arrives by redirection behaves differently from one that was set up against rethink, in two ways that both had to be handled:
It asks for whatever LG hostname its firmware carries, and units of the same model do not agree on which. Observed across four
CST_570004_WWunits on one installation: some ask forkic-common.lgthinq.com, others forkic-mclip.lgthinq.com, withcommon.iot.kic.lgthinq.comon the MQTT port. rethink presented one certificate named afterconfig.hostname, so pinning it to one of those names dropped the others. The API port does not check the certificate, so this only showed up on the MQTT port — as a handshake that failed for some units and worked for others. rethink now mints a leaf per requested name, signed by the CA the appliance already pinned when it fetched/route/certificate, and hands it out throughSNICallback. Connections asking forconfig.hostname, or asking for nothing, are unaffected./routetold it to useconfig.hostnamefrom then on. That is right for an appliance set up over SoftAP, but wrong for a redirected one: it now needs a DNS record it did not need before, and because the appliance stores what/routetells it, it keeps asking for that name long after the redirection is gone. Removing the redirection then no longer hands the appliance back — which was the one property that made redirection attractive.advertise_requested_host(off by default) makes/routeecho the name the appliance asked for instead.Commits
Bug fixes, independent of the rest:
/monitorasked for/monitordeviceand the connection never came up.Redirected-appliance support:
advertise_requested_host, off by default.Certificate handling:
util/pki.tsis now the one implementation. Everything openssl reads or writes goes through a temporary file, which removes thesh -c 'cat | openssl ... /dev/stdin'workaround and with it the need for a shell in the image (the alpine runtime has no bash, so the monitor's path could not have run in a container). The default TLS context becomes a leaf forconfig.hostnamerather than the CA itself, so it carries a subjectAltName; the CA is left untouched on disk.resultCode 0000with whatever reached stdout. A failed signing arrived at the appliance as an empty certificate delivered as a success.bridge/util.tsis no longer used by anything and goes.protocolVer: '1'. Told that, the cloud framed its reservation ("service") polls in a legacy encoding that aprotocolVer 7firmware ignores, so the official app's reservation screen reported the appliance as unreachable. Basic state polls use a version-independent framing, which is why control worked throughout and only that one screen was broken.Compatibility
The CA on disk is not touched. Verified on an existing installation: the CA fingerprint is identical before and after startup, and no regeneration is logged. The trust anchor appliances have pinned is unchanged, so nothing needs re-provisioning.
Two changes are worth calling out:
config.hostnameis now a CA-signed leaf rather than the CA itself. Verified with strict verification: a client pinning the CA completes the handshake in all three cases —config.hostname, an LG hostname over SNI, and no SNI at all.config.hostname. That check treated every failure alike, so an unreadable file or a changed hostname silently replaced a CA that appliances had already pinned. Since the served certificate is now a leaf, the CA's subject no longer has to match anything. Only "there is no CA yet" creates one.The key generation moved from
openssl ec -pubouttoopenssl pkey -pubout. The public key PEM is byte-identical for both EC and RSA, so the hash that goes into the pairing ciphertext is unchanged.advertise_requested_hostis optional and defaults to false; existing config files need no changes.Testing
341 tests pass, including new coverage for the certificate issuer (unit and a real TLS handshake against a validating client), the openssl wrapper, hostname validation, the CSR signing route's failure paths, bridge registration, and the deploy-info fallback.
Attribution
Five of these commits are by @kkqq9320, from https://github.com/kkqq9320/rethink, submitted here with his agreement. Authorship is preserved in the commits.
Closes #104.