You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Newer Samsung firmware rejects the AC14K_M anchor with unknown_ca: the OwnerPSK generation #16
Status, 2026-09-22. The title this issue opened under said two things, and one of them was wrong. Every appliance measured since, including the older ones this library was built on, binds 5683 and 5684 as well as its ephemeral secure port, so the ports never separated the generations; 0.1.18 reads the secure port from /oic/res on 5683 for all of them (#98). What does separate them is the second half: this generation answers an AC14K_M client certificate with unknown_ca (a bare SHA-256 leaf too; the self-signed default from #95 has not been offered to one yet), and the credential it accepts is a per-appliance OwnerPSK. The connection findings are in docs/ocf-pki-laundry.md; the credential path is being built in mbillow/localthings#435; certificate pass/fail reports by model go to #99; the F09 laundry build has its own thread at #110. This issue stays open as the record for the generation until the library carries that credential.
This newer-generation unit isn't reachable by the library's current approach, for two reasons: it uses different ports and a different trust anchor.
1. It exposes OCF on the standard CoAP ports, not the 49152–49160 range
If I run the documented compatibility check of nmap on ports 49152–49160, this device is completely silent across that whole range. Its OCF stack is on the standard ports instead:
udp/5683 (plain CoAP) — responds, but every resource is locked: GET /.well-known/core → 4.01, GET /device/0 → 4.03
udp/5684 (coaps / CoAP-over-DTLS) — live DTLS server, returns a proper ServerHello
2. The DTLS endpoint rejects the AC14K_M cert — different PKI
Handshake to 5684 with an AC14K_M-signed leaf (minted by setup_cert.py) fails with an unknown ca alert. It's on Samsung's OCF Root PKI, not the RemoteAccessCA/AC14K_M family:
Device leaf: CN=OCF Device: Appliance SE (04700f20-…), OU=OCF HA Device, O=Samsung Electronics
└ issuer: Samsung Electronics OCF HA Device SubCA v1
└ issuer: Samsung Electronics OCF Root CA
So the AC14K_M that works on older appliances appears to be closed on this generation — even reaching the right port (5684) still gets rejected at client-cert validation.
Thanks, for sharing this. Before writing this generation off, I want to push on the one inference that isn't nailed down, because it's burned me on the older appliances.
A device presenting an OCF-Root server cert doesn't tell you what client cert it accepts. Those are separate trust stores. The appliances where setup_cert.py works today also present a leaf chained to Samsung's OCF Root CA, same as yours, and they still accept an AC14K_M client cert for mutual auth. So the thing to pin down is the unknown_ca, not the server chain.
And unknown_ca needs care, because on my own hardware it's fired for reasons unrelated to the CA:
Cipher list: this firmware's mbedtls chokes on long multi-cipher ClientHellos and can reject before the cert is evaluated. openssl s_client offers a broad list by default. Retry with a single suite (ECDHE-ECDSA-AES128-GCM-SHA256 is what the older units take).
SHA-1 / security level: the whole AC14K_M chain is SHA-1/RSA (AC14K_M, RemoteAccessCA, CECA, all sha1WithRSAEncryption). Two ways that bites. Your OpenSSL client at default seclevel may refuse to send a SHA-1 cert at all, which looks like a server rejection; add @SECLEVEL=0 to rule that out. And newer firmware may reject SHA-1 in its verify path, or only accept ECDSA client certs. The retest below shows which: its CertificateRequest lists the sig-algs it'll take.
Wedge: after a handful of failed handshakes these stacks wedge and return unknown ca / handshake_failure to everything until they recover (minutes) or power-cycle. If you've been retrying, a fresh result needs a cold device.
-msg is the part that matters: it shows whether your OpenSSL actually put its Certificate on the wire before the alert. Alert after your Certificate flight is a real server-side rejection, and your read holds. No cert sent (seclevel), or a death mid-ClientHello (cipher), is an artifact, and there's still a door.
The alert code then tells you why a real rejection happened. unknown_ca (48) is specifically "issuer not in my trust store". A hash or algorithm refusal is usually bad_certificate (42) or handshake_failure (40). To isolate the leaf-hash case, re-mint just the leaf -sha256 and retry: if the alert changes, leaf hash was the gate; if it's identical, it wasn't.
What this device accepts for client certs is the open question, and the CertificateRequest in that -msg output is where to read it.
That onboarding surface is the other cheap test. You hit /.well-known/core (4.01) and /device/0 (4.03) on 5683 but not the security resources. OCF usually leaves /oic/sec/doxm world-readable so an onboarding tool can find the device pre-ownership; if it answers, it returns the device UUID and ownership state (owned, devowneruuid, owner-transfer methods). owned:false or a live RFOTM state means there's a provisioning surface; owned:true pinned to a hub means it's sealed. Small dependency-free probe below, read-only, IP and UUIDs redacted by default so it's safe to paste back:
python3 coap_sec_probe.py <device-ip>
Firmware version from the SmartThings app (device page, three dots, Information) would help too. Please leave the serial off that screen.
Here is everything the Smartthings app shows under software for this device:
I ran the checks on a freshly power-cycled unit, and captured the same handshake against a working fridge (setup_cert.py authenticates to it, 2.05) for comparison.
Here are the results from coap_sec_probe.py on the Washer, plain CoAP 5683
certificate_types: rsa_sign, ecdsa_sign
supported_signature_algorithms: ecdsa+sha256, rsa+sha256
certificate_authorities: CN=Samsung Electronics OCF Root CA
CN=Samsung Electronics OCF Root CA TEST
Fridge (49154) — accepts the same AC14K_M cert:
certificate_types: rsa_sign, ecdsa_sign
supported_signature_algorithms: ecdsa+sha256, rsa+sha256, ecdsa+sha1, rsa+sha1
certificate_authorities: CN=Samsung Electronics OCF Root CA
CN=Samsung Electronics OCF Root CA TEST
The advertised certificate_authorities is identical on both, and the fridge accepts AC14K_M while advertising that same list, so I don't think the CA list is what's gating the washer. The one CertReq difference is sig-algs: the fridge lists SHA-1, the washer is SHA-256 only.
The washer's alert is unknown_ca (48) rather than bad_certificate, which reads more like "issuer not trusted" than a hash-policy reject, so it may be the device's actual trust store (/oic/sec/cred, which I didn't touch) lacking the AC14K_M chain, with the SHA-256-only sig-algs as a second data point.
On your checks: our Certificate goes on the wire before the alert; cipher negotiates ECDHE-ECDSA-AES128-GCM-SHA256 on both. I tried re-signing the SHA-256 leaf and it returned the same unknown_ca.
Thanks for looking into this. It'd be great to get working, but I'm not holding my breath. I just hope this isn't the way new Smartthings devices are headed.
One more thing to add... if I repeatedly scan nmap on the ports 49152-49160, usually I get all of the ports reporting closed. But every once in a while I'll get a smattering of ports showing open|filtered. Occasionally I'll even see all of those ports open at the same time.
FWIW I'm seeing this same behavior on a Family Hub fridge from 2024, model RF65A9770B1/SA
udp/5683 and udp/5684 are open and the ports in the 49152-49160 range seem to randomly open and close. Sometimes they're all open, sometimes it's none, other times it's just 1 or 2 of them. The cert verification against port 5684 gives:
Following up on the sig-algs angle: I think this closes out AC14K_M specifically.
paul-toben's re-sign test is the tell: a fresh SHA-256 leaf got the identical unknown_ca alert. If the leaf's hash were the problem, that should have changed. It didn't, which points at the rest of the chain: AC14K_M, RemoteAccessCA, and CECA are all SHA-1, and none of us can re-sign those without Samsung's intermediate keys. A supported_signature_algorithms list of SHA-256 only fits a firmware that now enforces SHA-256 across the whole chain, not just the leaf. That means the handshake fails before identity is ever checked.
One cheap thing still worth trying: a bare SHA-256 leaf with no intermediates at all. Different test than the re-sign, since it tells us whether the device already trusts AC14K_M as a cached intermediate. Not expecting it to change the conclusion, but it's cheap.
My guess is diagnostics didn't go away. That'd be a strange thing for Samsung to drop entirely, since techs need some way in. More likely AC14K_M specifically got retired because it's a leaked credential, and got replaced by something that can't leak the same way: probably per-request, account-authenticated issuance rather than a bundled static cert. If so, there's probably still a path in, just not this one.
For anyone who wants to dig further: libocpmapi.so in the SmartThings app exports a function called OCCloudCertificateIssueRequest, a strong hint about where the replacement logic lives. I didn't chase it past confirming the name.
If anyone gets a device freshly paired through the official app and can capture what that provisioning traffic looks like (sanitized, no tokens or device UUIDs), that'd answer this fastest. Otherwise I'd treat AC14K_M as closed, but not the broader question.
I have this exact appliance and was able to fully get this working locally (yes, it was a pain to do so) - I've got an approach now that's been stable for about a week, with no dropping or resets. I'm working on opening a series of PRs to this repo to get it supported here too
#25 (comment) is the first of the PRs explicitly for this machine
I have ~7 samsung appliances and this was definitely one of the toughest ones to get working in a stable way
I was able to get it working locally without breaking the SmartThings cloud though - it's definitely possible
@Jason-Morcos thank you for the PRs (#21–#25), this is great to see. Looking forward to getting the stack merged into main so more people here can test it against their own units.
@paul-toben this is your exact appliance, so once it lands it'll be worth a try. I'll keep the issue open until then.
@QuiteYellow Thanks. I'll keep an eye out for the PRs as this develops. It's exciting to see so much work around this project.
@Jason-Morcos the one thing I'm concerned about is if the appliance can still be factory-reset in after the OCF ownership change, and get it back into a state where it could be re-onboarded to Smartthings.
I'm also curious if you think this method can stand up to future firmware updates/changes from Samsung.
Hey @paul-toben - I can confirm the appliance can still be factory-reset back to SmartThings (I had to do it several times in testing haha)!
Also, the way I have it implemented it actually doesn't break SmartThings connectivity at all. In fact, breaking smart things connectivity seems to cause weird device resets.
The only downside with the laundry machines so far is that they seem to not support Observe so they have to be manually polled.
As to Samsung - I think Samsung could make all of these different approaches significantly more difficult, if they really wanted to. That being said, it seems unlikely they go through the effort of trying to do so given in many cases it would require fairly substantial security revamps to block these workarounds. But it is within the realm of possibility imo that they find a way to block this.
Second Family Hub data point for this thread (EU unit), because it differs from @noisymime's in two ways that look relevant to the endpoint-discovery work in #36.
1. The secure endpoint is not 5684 here — it is advertised, and it is not in the 4915x band either.
GET /oic/res on udp/5683 answers 2.05 and the sec-flagged links carry dport: 46060. Not 5684, not 4915x. Verified stable across 3 consecutive reads and again two days later, so it is not a per-boot ephemeral value on this unit:
run 1: 1024 B, dport = [46060]
run 2: 1024 B, dport = [46060]
run 3: 1024 B, dport = [46060]
A DTLS ClientHello to that port gets a live server:
depth=1 CN=Samsung Electronics OCF HA Device SubCA v1, OU=OCF HA Device SubCA, O=Samsung Electronics, C=KR
depth=0 CN=OCF Device: Appliance SE (<redacted>), OU=OCF HA Device
PKEY: id-ecPublicKey, 256 bit; sigalg: ecdsa-with-SHA256
So for this unit a probe list of 5684 + 49152-49160 would come up empty on all ten ports while the device is sitting there answering on 46060. Reading dport out of /oic/res is the only thing that finds it — which is exactly what #36 is doing, so mainly posting this as a concrete case that the fixed candidate list would miss.
2. The security resources are locked on this one, unlike @noisymime's Family Hub.
Same read-only plain-CoAP GETs, different answers:
@noisymime's unit returned 2.05 on doxm/pstat with owned / oxmsel / UUIDs visible. Mine returns 4.01 on both, so no ownership state or OTM method list to report from here. That lockdown pattern looks closer to @paul-toben's WD53 (/.well-known/core4.01, /device/04.03) than to the other fridge — which suggests "Family Hub" is not one profile, and the readable-doxm case may not be something to rely on across the class.
3. Minor, on the flapping 4915x ports.
49152-49160 are consistently closed here — actively refused with ICMP port-unreachable, 3/3 runs. Worth flagging one measurement trap I hit: my first sweep showed 49152/udp open|filtered, which was purely the target's ICMP error rate limit suppressing one of the nine replies. With --scan-delay 1s and one port per run it is reproducibly closed. Given @paul-toben and @noisymime both report ports that "randomly open and close", some of that may be the same artifact rather than genuinely transient listeners — cheap to rule out with a scan delay before reading anything into it.
What I did not do: I never minted or presented a client certificate, so I have no alert class to contribute — I cannot confirm unknown_ca on this unit myself, and the OCF-PKI classification above is inferred from the server chain alone. All reads were plain unauthenticated GETs; no writes and nothing touched beyond the resources listed.
Happy to run further read-only probes on this unit if that would help characterise the fridge side of this class. Model/firmware details I can add from the app if useful.
@davids777 — the completed profile resolves the earlier port ambiguity: 44708 is advertised and responds, and the CA names are now observed on DTLS as well as TCP. Keeping additionalauthrequiredmissing is the right interpretation. Neither that absence nor the 0x8 feature mask establishes that our WD53 authorization/ownership procedure applies to this oven.
@QuiteYellow — the no-client-certificate TCP comparison would be useful. There is one concrete lesson from our reverse engineering that may help with the proposed script: CoAP-over-TCP framing support did not imply RFC 8323 signaling support on our older Samsung stack. During the retired WD53 cloud-hub experiment, mutual TLS completed, but the appliance's IoTivity 1.2 client did not correctly handle CSM/PING/PONG/Release/Abort. The hub's automatic CSM/PING led to silence; suppressing those signaling frames on that dedicated legacy listener allowed the experiment to progress. It still failed later at the appliance's cloud sign-in gate, so this was not a successful cloud replacement or a pairing solution.
That observation was on the appliance's outbound cloud client, not this oven's inbound 44433 listener. I would not assume the same limitation here or change the library default from it. It does mean a script should report “TLS completed, no correlated CoAP response” separately from 4.01, rather than letting a CSM/framing stall count as an authorization result. If it waits for a peer CSM before sending GETs, that assumption should be explicit.
For the proposed comparison, please use the exact batch request /device/0?if=oic.if.b. A 4.01 establishes that this anonymous session cannot read that batch; it does not rule out TCP as a future authenticated carrier. A 2.05 becomes state-read evidence only after checking locally that the complete decoded batch contains actual appliance-state representations, rather than just links/metadata or per-resource errors. A redacted yes/no summary is enough; no raw state payload is needed. Likewise, “no client certificate” and “server certificate verified” are separate facts worth labeling.
The macOS/Linux difference is still unresolved: both host and OpenSSL version changed. The stateless probe proves a HelloVerifyRequest response, not completion of OpenSSL's later handshake. I would preserve that as a diagnostic difference rather than attribute it to a specific OpenSSL release yet.
@QuiteYellow yes please, post it and I'll run it once against 44433 from a Linux host on the same LAN. I'll use /device/0?if=oic.if.b per @Jason-Morcos and report the TLS result, whether a client certificate was sent (none), and each GET's code, calling out "TLS completed, no CoAP response" separately if that's what happens. If /device/0 comes back 2.05, I'll check the decoded batch locally for actual state and post only a yes/no summary.
@QuiteYellow — the same gating shows up on the DTLS side for the oven I posted earlier (NV7B7997ADK/U1): an anonymous, no-client-cert session (ServerCertificateAuth, SamsungServerProfile.bound_device(<cert-uuid>)) over the advertised secure DTLS port gets:
GET /oic/d -> 2.05 (rt: oic.wk.d, oic.d.oven; n: "[oven] Samsung (LCD)")
GET /oic/res -> 2.05 (resource directory, /oic/sec/doxm listed first)
GET /oic/sec/doxm -> 2.05 (owned: true, oxmsel: 65282 / 0xFF02 vendor-specific)
GET /oic/sec/pstat -> 2.05 (isop: true, cm/tm: 0, om/sm: 4)
GET /device/0 -> 4.01 (protected)
So on this oven, over DTLS rather than your dryer's TCP 44433, the same split holds: discovery + doxm/pstat readable with no credential, /device/0 gated behind 4.01. Matches your dryer's 2.05/2.05/4.01 shape exactly, just on the other transport. Not proof the two carriers share a gate implementation, but it's one more data point that the split is consistent across transports on devices that otherwise disagree about which client-cert carrier they accept.
Those four lines are what I ran on macOS and on Linux against my dryer, and both gave identical output. Stock macOS python3 is 3.9.6, so if that is yours, build the venv with a newer one (python3.12 -m venv .venv). Framing and decoding go through this library's own CoAP-over-TCP codec.
I promised two GETs. The rest ride the same session for free, and a round trip here costs days. /device/0 without the query separates a gated href from a gated batch interface; the size of /oic/res is the per-read mode tag for this generation; /oic/sec/doxm, /oic/sec/pstat and /oic/p are what @herrniffler read over DTLS and @elirnyk read in plaintext, so your codes line up with theirs. --path replaces the set.
@herrniffler, your NV7B7997ADK/U1 result sharpens this. You got 2.05 on /oic/d, /oic/res, /oic/sec/doxm and /oic/sec/pstat, 4.01 on /device/0, anonymously over DTLS. My dryer gives the same shape over TCP, re-measured with my own bridge stopped:
/oic/d 2.05 153 B
/device/0?if=oic.if.b 4.01 0 B
/device/0 4.01 0 B
/oic/res 2.05 1703 B
/oic/sec/doxm 2.05 217 B
/oic/sec/pstat 2.05 157 B
/oic/p 2.05 342 B
So the split holds on two transports. Whether a unit on the refusing side of this issue draws the line there is what 44433 can still answer.
@Jason-Morcos: it sends its own CSM and waits for nothing, with --no-csm and --wait-csm covering the other two cases. My dryer sends no CSM of its own in six seconds and answers the same either way. A Ping gets a Pong, and nothing else goes out. The summary prints TLS completed, client certificate sent: none and server certificate verified as three separate lines, and --cafile turns the third into a real check.
Two things govern how the output reads. An unanswered token prints TLS completed, no correlated CoAP response, which covers an authorisation refusal that answers nothing, a framing stall, another client holding the appliance, and a request option the handler drops where it could refuse, so stop the LocalThings integration against that oven for the run. And Accept can decide between a code and silence: this sends 60, while on my dryer Accept: 10000 gives 4.06 on /oic/d and no answer at all on the batch href, four times out of four.
Three times today a first attempt failed transiently on my own hardware and worked immediately after, so the run carries a budget of three handshakes, --max-handshakes, covering the TLS 1.2 fallback, the retry and a second session alike, and stops once it is spent. Addresses and UUIDs are redacted, payloads counted but never printed, and the appliance certificate's fingerprint is withheld unless --fingerprint asks for it. --save-dir writes payloads to owner-only files if you want to decode the batch.
ocf_tcp_anon_probe.py
#!/usr/bin/env python3"""Read-only probe: can an anonymous TLS session over TCP read OCF resources?Opens one TLS connection to an appliance's CoAP-over-TCP listener with noclient certificate, sends an RFC 8323 CSM and one GET per href, and printsthe response code for each. Seven hrefs by default: the two that decidewhether an anonymous session can read appliance state, plus five that arefree context on a session that is already open. --path replaces the set.Nothing is written to the appliance: the only messages sent are the CSM,GETs, and a Pong if the appliance sends a Ping.Three facts are reported separately, because they fail independently: * whether the TLS handshake completed * whether a client certificate was sent (never: none is loaded) * whether the appliance's certificate was verified (only with --cafile)A GET with no matching response is reported as "no correlated response"rather than as an authorization result. Silence has at least four causesand nothing in the output separates them: an authorization refusal thatanswers nothing, a framing or signaling stall, another client alreadyholding the appliance, and a request option the handler drops instead ofrefusing. That last one is measured, not hypothetical: a dryer answers/device/0?if=oic.if.b with 4.01 under Accept 60 and drops the same requestentirely under Accept 10000, while answering 4.06 to Accept 10000 on/oic/d. The drop is per request and leaves the session usable.CoAP-over-TCP framing support does not imply RFC 8323 signaling support onSamsung's older stack, so a CSM the appliance mishandles can stall theexchange in a way that looks nothing like a 4.01; use --no-csm to send theGETs without any signaling frame. Stop any local integration that polls thesame appliance before reading anything into a silent run.The GETs go out one at a time, each drained before the next is sent, so asilent href is the last request the appliance saw rather than one of sevenin flight. Extra outstanding requests are not free of firmware schedulingand session-state effects, and a run that has to be diagnosed is worth morethan the seconds pipelining saves. --pipeline sends them all up front if aprevious run needs reproducing.Everything goes down ONE TLS session, and the whole run shares ONEhandshake budget (--max-handshakes). The TLS 1.2 fallback, the retry aftera transient timeout, and the second session opened when the first answersnothing all draw on it, so no sequence of failures can cost more handshakesthan the budget allows. The appliance's TLS service has a small sessiontable and degrades if it is hammered with handshakes.Response payloads are never printed, only their byte counts, and theappliance certificate's fingerprint is withheld by default: truncated ornot, it is a stable identifier for one machine, and nothing in the questionthis answers needs it. --fingerprint prints it in full for a privatecomparison between two endpoints on the same appliance. --save-dir writespayloads to owner-only local files for offline inspection.Requires Python 3.11+ and smartthings-local 0.1.12 or newer, which carriesthe CoAP-over-TCP codec this uses: python3 -m venv .venv .venv/bin/pip install smartthings-local .venv/bin/python3 ocf_tcp_anon_probe.py <appliance-ip> --port 44433"""from __future__ importannotationsimportargparseimporthashlibimportosimportreimportsocketimportsslimportsysimporttimefromdataclassesimportdataclassfrompathlibimportPathifsys.version_info< (3, 11):
sys.exit(
f"Python 3.11 or newer is required (running {sys.version.split()[0]}). ""On macOS the system python3 is older than the library's floor; make a ""venv with a newer interpreter."
)
try:
fromsmartthings_local.protocol.coapimport (
BLOCK2,
block_fields,
fmt_code,
option_values,
)
fromsmartthings_local.protocol.coap_tcpimport (
CSM_CODE,
CoapTcpCodecError,
CoapTcpStreamDecoder,
build_coap_tcp_csm,
build_coap_tcp_get,
build_coap_tcp_message,
)
exceptImportError:
sys.exit(
"smartthings_local is not installed in this interpreter. ""Run: python3 -m venv .venv && .venv/bin/pip install smartthings-local"
)
# The first two answer the question this was written for: does an anonymous# session read appliance state, or is the state batch gated. The rest are# cheap context on the same session, since opening another costs a# handshake this hardware would rather not have.# /device/0 separates a gated href from a gated batch interface# /oic/res the directory, and its size is the per-read mode tag# for this generation (a reduced directory has been# seen alongside failing handshakes)# /oic/sec/doxm ownership state over this carrier, to compare with# the same resource read in plaintext on 5683# /oic/sec/pstat the provisioning state machine beside it# /oic/p platform, so the set matches what reporters on this# tracker have been reading over the other transports## The set is the union of what two reporters read anonymously in September# 2026, one over DTLS and one in plaintext on 5683, so a run here lines up# against theirs href for href.DEFAULT_PATHS= (
"/oic/d",
"/device/0?if=oic.if.b",
"/device/0",
"/oic/res",
"/oic/sec/doxm",
"/oic/sec/pstat",
"/oic/p",
)
# The library's coap.build_get_request sends Accept: 60 (application/cbor)# by default, so the probe sends it too and the codes describe the request# shape the library would make. Measured on a dryer over this carrier, with# the competing bridge stopped: no Accept and Accept 60 both return 2.05 on# /oic/d and 4.01 on /device/0?if=oic.if.b, while Accept 10000 (OCF CBOR)# returns 4.06 on /oic/d and is silently dropped on the batch href.DEFAULT_ACCEPT=60# Every handshake this run may spend, across all of them: the first attempt,# the TLS 1.2 fallback, the retry after a transient timeout, and the second# session opened when the first answers nothing. Three leaves room for one# recovery and one more session, which is what the observed transient# failures needed, and stops a run that keeps failing differently from# working through one budget per failure shape.DEFAULT_HANDSHAKE_BUDGET=3# The library names the CSM code; the rest of RFC 8323's signaling codes# have no carrier there yet, so they are spelled out here.PING_CODE=0xE2PONG_CODE=0xE3SIGNALING= {
CSM_CODE: "CSM",
PING_CODE: "Ping",
PONG_CODE: "Pong",
0xE4: "Release",
0xE5: "Abort",
}
UUID_PATTERN=re.compile(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
)
defredact(text: str) ->str:
"""Blank UUIDs out of a string so output is safe to paste into an issue."""returnUUID_PATTERN.sub("<uuid>", text)
defsplit_href(href: str) ->tuple[str, tuple[str, ...]]:
"""Split an href into its path and its query items."""path, separator, query=href.partition("?")
ifnotseparator:
returnpath, ()
returnpath, tuple(itemforiteminquery.split("&") ifitem)
classHandshakeBudget:
"""The one cap on how many TLS handshakes a run may cost. Shared by the handshake fallbacks inside connect() and by the second session main() opens when the first answers nothing, so that a run which fails a different way each time cannot spend a fresh allowance per failure shape. """def__init__(self, total: int) ->None:
self.total=totalself.used=0@propertydefremaining(self) ->int:
returnself.total-self.useddefspend(self) ->bool:
"""Take one handshake from the budget, or report it exhausted."""ifself.remaining<=0:
returnFalseself.used+=1returnTrue@dataclassclassDrainResult:
"""What a read loop ran into, beyond the responses it recorded."""signal: int|None=Noneclosed: bool=Falsedefpeer_certificate(tls: ssl.SSLSocket, *, fingerprint: bool) ->str:
"""Describe the appliance certificate without identifying the machine. A SHA-256 over the DER tells whether two endpoints on one appliance present the same certificate, which is worth comparing against a separate DTLS port. It is also a stable identifier for that one appliance, and truncating it does not change that, so it is printed only when asked for. """der=tls.getpeercert(binary_form=True)
ifderisNone:
return"none offered"ifnotfingerprint:
returnf"offered, {len(der)} B (fingerprint withheld; --fingerprint prints it)"digest=hashlib.sha256(der).hexdigest()
returnf"offered, {len(der)} B, sha256 {digest}"defprepare_save_dir(path: Path) ->Path:
"""Create the payload directory owner-only, and say so if it existed."""existed=path.exists()
path.mkdir(parents=True, exist_ok=True, mode=0o700)
ifexisted:
print("save directory already existed; its permissions are unchanged")
else:
# mkdir's mode is masked by the umask, so set it outright.os.chmod(path, 0o700)
returnpathdefwrite_private(target: Path, payload: bytes) ->None:
"""Write a response payload to a file only its owner can read."""descriptor=os.open(target, os.O_WRONLY|os.O_CREAT|os.O_TRUNC, 0o600)
withos.fdopen(descriptor, "wb") ashandle:
handle.write(payload)
# O_CREAT's mode applies only to a file this call created; an existing# one keeps whatever it had.os.chmod(target, 0o600)
defclose_quietly(tls: ssl.SSLSocket) ->None:
"""Close the session without a failed close masking the printed results."""try:
tls.close()
exceptOSErroraserror:
print(f" close failed: {redact(str(error))}")
defbuild_context(args: argparse.Namespace) ->tuple[ssl.SSLContext, str]:
"""Build a client context that offers no certificate of its own."""context=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname=Falseifargs.cafile:
context.verify_mode=ssl.CERT_REQUIREDcontext.load_verify_locations(cafile=args.cafile)
else:
context.verify_mode=ssl.CERT_NONEcipher_policy="default"try:
context.set_ciphers("DEFAULT@SECLEVEL=0")
cipher_policy="DEFAULT@SECLEVEL=0"exceptssl.SSLError:
# LibreSSL, and some distribution builds, reject the @SECLEVEL syntax.passifargs.tls12_only:
context.maximum_version=ssl.TLSVersion.TLSv1_2returncontext, cipher_policydefconnect(
args: argparse.Namespace,
budget: HandshakeBudget,
) ->tuple[ssl.SSLSocket, str]:
"""Open one anonymous TLS session, spending handshakes from the budget. Two failure shapes earn another attempt. A refused TLS 1.3 flight is retried capped at TLS 1.2. A handshake that times out is retried as-is: the first connection made after another client's session goes away has been seen to fail once and succeed immediately afterwards, so a single timeout is a weak basis for reporting a port dead. Neither carries its own allowance: both draw on the budget the whole run shares, so a timeout followed by a TLS error cannot cost three attempts unless the budget says it can. """whileTrue:
ifnotbudget.spend():
sys.exit(
f"the handshake budget of {budget.total} is spent; stopping ""rather than opening another session against this appliance ""(--max-handshakes raises it)"
)
context, cipher_policy=build_context(args)
try:
raw=socket.create_connection((args.host, args.port), timeout=args.timeout)
exceptOSErroraserror:
sys.exit(f"TCP connect failed: {error}")
try:
tls=context.wrap_socket(raw)
exceptssl.SSLCertVerificationErroraserror:
raw.close()
sys.exit(
"TLS completed far enough to see the appliance certificate, and it "f"did not verify against {args.cafile}: {redact(str(error))}"
)
exceptTimeoutErroraserror:
raw.close()
ifbudget.remaining<=0:
sys.exit(
"TLS handshake timed out and the handshake budget is spent: "f"{redact(str(error))}"
)
print(f"TLS handshake timed out: {redact(str(error))}")
print(f"retrying unchanged, {budget.remaining} handshake(s) left")
continueexceptssl.SSLErroraserror:
raw.close()
ifargs.tls12_only:
sys.exit(f"TLS handshake failed: {redact(str(error))}")
ifbudget.remaining<=0:
sys.exit(
"TLS handshake failed offering TLS 1.3 and the handshake "f"budget is spent: {redact(str(error))}"
)
print(f"TLS handshake failed offering TLS 1.3: {redact(str(error))}")
print(
"retrying with the offer capped at TLS 1.2, "f"{budget.remaining} handshake(s) left"
)
args.tls12_only=TruecontinueexceptOSErroraserror:
raw.close()
sys.exit(f"TLS handshake failed: {redact(str(error))}")
returntls, cipher_policydefdrain(
tls: ssl.SSLSocket,
decoder: CoapTcpStreamDecoder,
pending: dict[bytes, str],
results: dict[str, str],
deadline: float,
*,
save_dir: Path|None,
sent_at: dict[bytes, float] |None=None,
stop_on_signal: int|None=None,
wait_for: bytes|None=None,
) ->DrainResult:
"""Read frames until the awaited token is answered or the deadline passes. With wait_for, the loop ends as soon as that one token is answered, but anything else that arrives in the meantime is still recorded: an answer to an earlier href that the appliance was slow about is a result, not noise. """outcome=DrainResult()
defstill_waiting() ->bool:
ifwait_forisnotNone:
returnwait_forinpendingreturnbool(pending)
whilestill_waiting() andtime.monotonic() <deadline:
tls.settimeout(max(0.5, deadline-time.monotonic()))
try:
chunk=tls.recv(8192)
exceptTimeoutError:
breakexceptOSErroraserror:
# ssl.SSLError is an OSError, and so is the ConnectionResetError# an abrupt RST raises. Both end the read.print(f" read failed: {redact(str(error))}")
outcome.closed=Truebreakifnotchunk:
print(" the appliance closed the connection")
outcome.closed=Truebreaktry:
received=decoder.feed(chunk)
exceptCoapTcpCodecErroraserror:
print(f" unparseable CoAP-over-TCP frame: {error}")
outcome.closed=Truebreakformessageinreceived:
name=SIGNALING.get(message.code)
ifnameisnotNone:
print(f" <- {name} ({fmt_code(message.code)})")
ifmessage.code==PING_CODE:
try:
tls.sendall(
build_coap_tcp_message(
code=PONG_CODE, token=message.token
)
)
exceptOSErroraserror:
print(f" Pong failed: {redact(str(error))}")
outcome.closed=Truereturnoutcomeprint(" -> Pong")
ifstop_on_signalisnotNoneandmessage.code==stop_on_signal:
outcome.signal=message.codereturnoutcomecontinuehref=pending.pop(message.token, None)
payload=message.payloadorb""code=fmt_code(message.code)
# Over a stream there is no datagram limit, so this carrier has# been seen returning whole representations. Block2 is still# legal, and a partial body must not be passed off as the whole# resource, so an unfinished transfer is named rather than# followed.partial=Falsedetail=f"{len(payload)} B"blocks=option_values(message.options, BLOCK2)
ifblocks:
number, more, _szx=block_fields(blocks[0])
partial=bool(more)
detail+=f", block {number}"detail+=", more to follow"ifmoreelse", final block"ifhrefisNone:
print(
f" <- {code} ({detail}) for an "f"unrecognized token {message.token.hex()}"
)
continueifsent_atisnotNoneandmessage.tokeninsent_at:
elapsed= (time.monotonic() -sent_at[message.token]) *1000detail+=f", {elapsed:.0f} ms"# Option numbers only, never their values. A response carrying a# vendor option number is a lead worth seeing; on one appliance# 65524 accompanies every successful read and no refusal.numbers=sorted({numberfornumber, _inmessage.options})
print(f" <- {href}: {code} ({detail}) options={numbers}")
results[href] = (
f"{code}, first block only, transfer not followed"ifpartialelsecode
)
ifsave_dirisnotNoneandpayload:
stem=re.sub(r"[^A-Za-z0-9]+", "_", href).strip("_")
suffix=".partial.cbor"ifpartialelse".cbor"target=save_dir/f"{stem}{suffix}"write_private(target, payload)
print(f" payload written to {target} (owner-only)")
returnoutcomedefrun_session(
args: argparse.Namespace,
budget: HandshakeBudget,
hrefs: tuple[str, ...],
accept: int|None,
save_dir: Path|None,
) ->tuple[dict[str, str], str|None]:
"""Open one session, send every GET, and return what came back."""started=time.monotonic()
tls, cipher_policy=connect(args, budget)
handshake_ms= (time.monotonic() -started) *1000version=tls.version()
cipher=tls.cipher()
print(
f"TLS completed: yes, {version}, "f"{cipher[0] ifcipherelse'unknown'}, {handshake_ms:.0f} ms"
)
print(f"cipher policy: {cipher_policy}")
print(f"Accept sent: {acceptifacceptisnotNoneelse'none'}")
print("client certificate sent: none")
print(f"appliance certificate: {peer_certificate(tls, fingerprint=args.fingerprint)}")
ifargs.cafile:
print(f"server certificate verified: yes, against {args.cafile}")
else:
print("server certificate verified: no, verification was not attempted")
decoder=CoapTcpStreamDecoder()
results: dict[str, str] = {}
ifargs.no_csm:
print("CSM: not sent (--no-csm)")
else:
tls.sendall(build_coap_tcp_csm())
ifargs.wait_csm:
print("CSM: sent, waiting for the appliance's CSM before the GETs")
outcome=drain(
tls,
decoder,
{b"\x00": "never matched"},
results,
time.monotonic() +args.timeout,
save_dir=None,
stop_on_signal=CSM_CODE,
)
ifoutcome.signalisNone:
print("CSM: none received from the appliance before the timeout")
else:
print("CSM: sent, not waiting for the appliance's")
pending: dict[bytes, str] = {}
sent_at: dict[bytes, float] = {}
forindex, hrefinenumerate(hrefs, start=1):
path, query=split_href(href)
token=b"STLP"+index.to_bytes(4, "big")
pending[token] =hrefsent_at[token] =time.monotonic()
try:
tls.sendall(
build_coap_tcp_get(path, token=token, query=query, accept=accept)
)
exceptOSErroraserror:
print(f" -> GET {href} failed to send: {redact(str(error))}")
breakprint(f" -> GET {href}")
ifargs.pipeline:
continue# One request outstanding at a time, so a silent href is the last# thing the appliance was asked rather than one of a batch.outcome=drain(
tls,
decoder,
pending,
results,
time.monotonic() +args.timeout,
save_dir=save_dir,
sent_at=sent_at,
wait_for=token,
)
ifoutcome.closed:
print(" the session is gone; the remaining hrefs were not sent")
breakifargs.pipelineandpending:
drain(
tls,
decoder,
pending,
results,
time.monotonic() +args.timeout,
save_dir=save_dir,
sent_at=sent_at,
)
close_quietly(tls)
returnresults, versiondefmain() ->int:
parser=argparse.ArgumentParser(
description=(
"Read-only CoAP-over-TLS-over-TCP probe for OCF appliances. ""Sends no credential and writes nothing."
)
)
parser.add_argument("host", help="appliance address")
parser.add_argument(
"--port",
type=int,
default=44433,
help="TCP port of the OCF-over-TLS listener (default: 44433)",
)
parser.add_argument(
"--path",
action="append",
dest="paths",
metavar="HREF",
help=(
"href to GET, repeatable; replaces the default set "f"{', '.join(DEFAULT_PATHS)}"
),
)
parser.add_argument(
"--no-csm",
action="store_true",
help="send the GETs without an RFC 8323 CSM",
)
parser.add_argument(
"--wait-csm",
action="store_true",
help="wait for the appliance's CSM before sending any GET",
)
parser.add_argument(
"--pipeline",
action="store_true",
help=(
"send every GET before reading any response, instead of one at ""a time; reproduces the request shape of earlier runs"
),
)
parser.add_argument(
"--accept",
default=str(DEFAULT_ACCEPT),
metavar="FORMAT",
help=(
"content format to request, or 'none' to send no Accept option "f"(default: {DEFAULT_ACCEPT}, application/cbor)"
),
)
parser.add_argument(
"--cafile",
help="verify the appliance certificate against this CA bundle",
)
parser.add_argument(
"--tls12-only",
action="store_true",
help="cap the offer at TLS 1.2 instead of retrying after a failure",
)
parser.add_argument(
"--max-handshakes",
type=int,
default=DEFAULT_HANDSHAKE_BUDGET,
metavar="N",
help=(
"every TLS handshake the run may spend, fallbacks, retries and "f"the second session included (default: {DEFAULT_HANDSHAKE_BUDGET})"
),
)
parser.add_argument(
"--timeout",
type=float,
default=10.0,
help="seconds to wait for each response (default: 10)",
)
parser.add_argument(
"--save-dir",
type=Path,
help=(
"write response payloads here for offline inspection; the ""directory and its files are created owner-only"
),
)
parser.add_argument(
"--fingerprint",
action="store_true",
help=(
"print the appliance certificate's full SHA-256; it identifies ""the machine, so it is withheld by default"
),
)
parser.add_argument(
"--show-host",
action="store_true",
help="print the appliance address; it is redacted by default",
)
args=parser.parse_args()
ifargs.accept.lower() =="none":
accept=Noneelse:
try:
accept=int(args.accept)
exceptValueError:
parser.error("--accept takes a content-format number or 'none'")
ifargs.max_handshakes<1:
parser.error("--max-handshakes must be at least 1")
hrefs=tuple(args.paths) ifargs.pathselseDEFAULT_PATHSsave_dir=args.save_dirifsave_dirisnotNone:
save_dir=prepare_save_dir(save_dir)
budget=HandshakeBudget(args.max_handshakes)
target=f"{args.host}:{args.port}"ifargs.show_hostelsef"<host>:{args.port}"print(f"target: {target}")
print(f"handshake budget: {budget.total} for the whole run")
results, version=run_session(args, budget, hrefs, accept, save_dir)
ifnotresults:
# Three transient first-attempt failures were seen on one appliance# in a day: a refused TLS 1.3 flight, a handshake timeout, and a# session that completed and was then closed before answering# anything. Each succeeded immediately afterwards. A run that# answers nothing at all is therefore worth one more session, out of# the same budget the handshake fallbacks spend from, rather than an# allowance of its own. This hardware's TLS session table degrades# under repeated handshakes, so nothing here loops.print("")
ifbudget.remaining>0:
print("nothing answered on that session; opening one more, then stopping")
results, version=run_session(args, budget, hrefs, accept, save_dir)
else:
print(
"nothing answered on that session, and the handshake budget is ""spent; stopping without opening another"
)
print("")
print("summary")
print(f" TLS completed: yes, {version}")
print(" client certificate sent: none")
print(
" server certificate verified: "+ ("yes"ifargs.cafileelse"not attempted")
)
print(f" handshakes spent: {budget.used} of {budget.total}")
forhrefinhrefs:
code=results.get(href)
ifcodeisNone:
print(f" {href}: TLS completed, no correlated CoAP response")
else:
print(f" {href}: {code}")
return0if__name__=="__main__":
raiseSystemExit(main())
@QuiteYellow ran your probe once against the NQ7000CD's TCP 44433 from macOS (Python 3.14, smartthings-local 0.1.20), with nothing else connected to the oven. Default path set, Accept 60, CSM sent without waiting.
TLS completed: yes, TLSv1.2, ECDHE-ECDSA-AES128-GCM-SHA256
client certificate sent: none
server certificate verified: not attempted
/oic/d 2.05 157 B
/device/0?if=oic.if.b 4.01 0 B
/device/0 4.01 0 B
/oic/res 2.05 11597 B
/oic/sec/doxm 2.05 218 B
/oic/sec/pstat 2.05 157 B
/oic/p 2.05 311 B
Same split as your dryer and @herrniffler's oven: public resources readable anonymously, the state batch and the bare href both 4.01, all answered within 150 ms. No CSM or Ping came back from the oven. So an anonymous TCP session is not a route past #16 on this unit, and it stays in "certificate rejected, credential not yet available." The one difference from your dryer is /oic/res at 11597 B against your 1703 B, in case that size tag is useful for this generation.
@davids777 — this resolves the specific TCP question on your NQ7000CD: with no client certificate, both the batch interface and bare /device/0 returned an explicit 4.01, while the public resources answered. There is no framing/CSM ambiguity in those completed responses, and no need to rerun this just to try more CSM variants. “Certificate rejected, credential not yet available” remains the appropriate status. An authenticated TCP carrier could still be useful later; this result does not supply its credential or establish write access.
The 11597 B directory is useful as a baseline for this unit, transport and request shape. I would not compare it with the dryer's 1703 B to classify a firmware generation or full/reduced mode: different models expose different resources, and query/representation choices also affect size. A mode comparison needs like-for-like reads on the same unit, ideally with resource-set differences checked locally. No further capture is needed for the present conclusion.
@herrniffler — your bound-server DTLS result adds a useful independently identified oven case. It establishes the public/protected split on that tested session without implying that TCP and DTLS share an implementation or that anonymous metadata access grants ownership.
@QuiteYellow — the explicit separation of TLS completion, absent client certificate and server verification answers the earlier interpretation concerns. The Accept observation is particularly useful: a silent request can be a representation-option issue while the session remains healthy. I checked the v0.1.20 plaintext helper too; it already uses Accept: 60, so that should not be silently conflated with an Accept: 10000 test.
Two concrete details in the posted script are worth tightening before others reuse it:
The retry cap is not currently enforced across failure types. With simulated sockets only, connect() made three connection attempts for timeout → SSL error → success: the SSL-error branch does not check attempt before its TLS-1.2 retry. The outer empty-result session retry is another budget. Use one explicit total-attempt budget shared by handshake fallback and the optional second session, and stop when exhausted. This does not undermine your successful first-session result, @davids777.
The default certificate fingerprint is still a stable device identifier. Truncating SHA-256 to 16 hex characters does not make it anonymous. I would omit it unless explicitly requested for a private endpoint comparison, and create optional saved-payload directories/files with owner-only permissions. Shared output can keep the TLS facts, response codes, lengths and option numbers without that fingerprint.
For future silent cases, sending one GET and draining its response before sending the next would also make diagnosis clearer while retaining the same TLS session. The current script sends all seven GETs before draining; that worked here, but extra requests are not literally free of firmware scheduling/session-state effects.
@Jason-Morcos both tightenings are in the script above, edited in place so the version people copy is the fixed one.
The attempt budget is now one counter for the run. The TLS 1.2 fallback, the timeout retry and the second session all spend from it, so timeout → SSL error no longer buys a third attempt: under a budget of 2 it stops after the second and says so. --max-handshakes sets it, and the summary prints what was spent.
The fingerprint is out of the default output altogether. --fingerprint prints the full digest for a private comparison between two endpoints on one appliance, and --save-dir now creates its directory 0700 and each payload file 0600.
The GETs go one at a time as well, each drained before the next is sent. --pipeline sends them all up front, the shape the earlier runs used. A late answer to an earlier href still gets recorded.
@davids777's codes stand and nothing here needs rerunning.
I got PSK authentication working on my Samsung LCD oven: board prefix LCD_R18_SCO_QMD_EU_22K, profile DA-KS-OVEN-0105X. I haven't checked its retail model number yet.
I recovered its existing 16-byte OwnerPSK from SmartThings 1.8.51.30 on rooted Android 17. The key was in the account-specific .datenc store, not Core's certificate-only store. I've published the scripts and procedure here.
My raw owner UUID contains NUL, so I also hit the limitation in #85. Mbed TLS 3.6.7 preserved all 16 bytes, negotiated TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256, and returned my oven's expected ID from authenticated GET /oic/d.
PR #115 adds the optional backend without removing OpenSSL's guard. It requires a separate native build.
With both patches installed, my oven loads in HA. It currently exposes only a connection-mode sensor, so authentication works but full oven support is still missing. I haven't tested other models.
@QuiteYellow — I checked the edited TCP probe and reran the earlier mixed-failure case with simulated sockets: timeout → SSL error now stops at two attempts under the shared budget. Default output omits the fingerprint, and the source has the owner-only save permissions and sequential GET default you described. That addresses my earlier points; none of the successful appliance measurements needs repeating.
@KRZ303 — the combination of a recovered credential and an authenticated /oic/d match is a useful advance beyond the earlier unknown-identity probes. The full-length Mbed TLS result also fits the wire-level measurement in #85: the zero byte is valid appliance identity data, while OpenSSL's legacy callback cannot represent it intact.
One distinction from our earlier research may help other readers: when explicitly provisioning a new owner/peer identity, we could choose a UUID without zero bytes to fit the OpenSSL transport. That does not fix an existing recovered OwnerPSK identity. It must remain byte-for-byte unchanged; trimming zeros or substituting the printable UUID would present a different identity. Your optional backend addresses that transport limitation without asking the owner to change appliance ownership again.
For people following along, both #115 and the companion LocalThings #575 are still open, and the latest released library is v0.1.21. The proposed wheel includes native source, not a ready-to-load backend. A compatible native build remains a separate requirement; installing the integration patch alone does not enable these identities. An absent/incompatible backend should remain a local capability error, not evidence that the recovered key is wrong.
I've read the proposed adapter and recovery code, but have not built the native backend or tested it on our appliances. Your demonstrated boundary is authentication and the expected device identity on this one oven; the connection-mode sensor does not establish oven capability coverage. I'll keep the acquisition details and next read-only inventory step together in #435 so those remain distinct from the transport work here.
@Jason-Morcos, exactly: I kept the recovered PSK and all 16 identity bytes unchanged.
The Mbed TLS backend fixes how that existing identity is sent; it does not replace or reprovision it.
I also followed your enumeration suggestion from LocalThings #435 and found the separate reason for the missing entities.
My oven's default /device/0 response contains resource links, while /device/0?if=oic.if.b returns the actual batch representations.
LocalThings was discarding the default map because it expected a list.
I opened LocalThings #580 for that integration-side fallback, independently of the PSK patches.
After deployment, my oven has 24 entities and nine active Observe subscriptions, instead of only the connection-mode sensor.
The existing oven registry handled the returned representations without a profile change.
The native packaging requirement hasn't changed: #115 still needs a compatible backend build, and the integration patch alone does not provide it.
I have now verified discovery and readable state on this oven.
No further pairing, ownership changes, or OCF security writes were needed for this fix.
Another WD53DBA900HZ (Bespoke AI Laundry Combo) owner here, running Home Assistant with LocalThings.
My Samsung fridge is now fully local through LocalThings, but the combo refuses the automatic certificate and setup falls through to the PSK menu (psk_owner / psk_peer / fallback_ca). The owner identity is pre-filled, so it looks like the same OwnerPSK profile described in docs/ocf-pki-laundry.md.
Happy to test once there's a public, non-reset way to open the ownership window, and I can provide sanitized diagnostics if that helps. Thanks for all the work on this!
Family Hub RS68N8941SL/EF: no local DTLS endpoint observed
Samsung RS68N8941SL/EF, Family Hub platform 24K_REF_LCD_FHUB9.0 (fw 20240808.203251), controller REF.6.0.18, latest per the SmartThings app. Probed from the same /22 LAN:
TCP: full scan, only 17654 is open. It's plain HTTP (no TLS) with Server: eSDK: 404 for GET /, 400 for HEAD /, empty body. This is the same listener as noisymime's Family Hub. 44433, 8001, 8002, 8080, 8443 and 8888 are closed.
UDP: 49152, 49153, 49155 and 49157-49160 are closed. 49154, 49156, 5353, 5683 and 5684 are open|filtered (inconclusive). A sweep of 32768-65535 was inconclusive because the fridge rate-limits ICMP unreachables.
DTLS: anonymous DTLS 1.2 ClientHellos to 5684, 5683, 49154 and 49156 got 0 bytes back (no HelloVerifyRequest, ServerHello or alert).
Traffic capture: a 6-minute router capture while using the SmartThings app shows the fridge only talking to Samsung cloud hosts (443/5223). On the LAN it sent Spotify Connect mDNS and multicast CoAP discovery (GET /oic/res?rt=x.org.iotivity.notification to 224.0.1.187:5683, source port 50093). No LAN device replied, and no inbound DTLS/CoAP traffic to the fridge was seen.
So the fridge runs an OCF/IoTivity stack as a discovery client, but I saw no server-side listener. Is this consistent with the Family Hub reports here? Does anyone know what eSDK on 17654 is?
Result: owner-principal PSK authentication and LocalThings import succeeded for Samsung AF18DX936WFN, platform R18_BIXBY_FAC_TIME_23K.
Verified results
The recovered principal has an embedded NUL in its raw 16-byte identity.
The installed smartthings-local 0.1.24 used its own DtlsPskClient path.
LocalThings 0.33.0 completed owner-PSK import and exposed MAIN and RAC climate devices, with subsequent sensor updates.
A separate length-aware mbedTLS session verified /oic/d and the protected /device/0?if=oic.if.b on the target.
Discovery selected the actual AC among two OCF responders sharing one IP, consistent with LocalThings #562.
This adds hardware evidence for #117 and #121. The deployed files have not been compared byte-for-byte with the published distributions.
Recovery source and limits
The existing credential was recovered from current native SmartThings memory on a rooted Galaxy S9. The two selected persisted SVR stores had no type-1 credential. We did not observe the original key-generation transaction or test physical control commands.
Your DTLS probes went to the wrong ports, and the test that reads the real one is missing: a plaintext unicast GET /oic/res to 5683.
Family Hub secure ports are ephemeral and move: 46060, then 39181, then 58551, then 46909 on @DUC750's FHUB10 (mbillow/localthings#213), and 48220 on a 24K_REF_LCD_FHUB9.0 identical to yours, where LocalThings reported "DTLS server confirmed on …:[48220] but the handshake never completed" (mbillow/localthings#552). 48220 sits inside the 32768-65535 range your sweep called inconclusive, so your own data never ruled out the port the sibling unit answered on. nmap cannot settle it either: on UDP it needs an ICMP unreachable before it will say closed, and an OCF socket ignores a non-CoAP payload, so open|filtered on 5683 is what a live server looks like to a scanner. Silence is also not how these fridges turn down a cert-less ClientHello on their advertised port. In @DUC750's measurements that handshake either completed outright or came back handshake_failure with an empty CA list.
smartthings-local 0.1.24 reads the advertised port over plaintext unicast. No multicast, no scan, nothing but the address:
fromsmartthings_local.protocol.ocf_discoveryimport (
discover_ocf_secure_ports, read_plaintext_ocf_resource)
print(discover_ocf_secure_ports("<fridge-ip>")) # advertised secure port(s), asks 5683 by defaultprint(read_plaintext_ocf_resource("<fridge-ip>", "/oic/res")) # the directory behind it
Both take a port argument (discovery_port= and port=), so try 5683, then 49153, then 50093, the source port your own capture shows the fridge sending from, which in the IoTivity tree is the same socket that serves plaintext unicast. There is a multicast helper as well, ocf_multicast.discover_ocf_responder_ports, but it pins TTL 1 and so only reaches the fridge from a host on its own segment.
Please include payload_bytes with each result, because that directory alternates between a 421 B and a 9356 B form in phases lasting tens of minutes to hours, and the two states differ both in advertised port and in whether a cert-less handshake completes (#213, mbillow/localthings#435), so one read on its own settles little.
Thanks for sharing this, @blupine. Your air conditioner is a different platform from @KRZ303's oven, and the identity with the embedded zero byte came through on 0.1.24, so that code path now has hardware behind it beyond their unit.
Where your key came from would be useful in #435, which is where acquisition is being tracked: theirs was in the account store on the phone, yours in the running app's memory, so the same credential can be reached from either place.
Activity
QuiteYellow commentedon Jul 30, 2026
Thanks, for sharing this. Before writing this generation off, I want to push on the one inference that isn't nailed down, because it's burned me on the older appliances.
A device presenting an OCF-Root server cert doesn't tell you what client cert it accepts. Those are separate trust stores. The appliances where
setup_cert.pyworks today also present a leaf chained to Samsung's OCF Root CA, same as yours, and they still accept an AC14K_M client cert for mutual auth. So the thing to pin down is theunknown_ca, not the server chain.And
unknown_caneeds care, because on my own hardware it's fired for reasons unrelated to the CA:openssl s_clientoffers a broad list by default. Retry with a single suite (ECDHE-ECDSA-AES128-GCM-SHA256is what the older units take).AC14K_M,RemoteAccessCA,CECA, allsha1WithRSAEncryption). Two ways that bites. Your OpenSSL client at default seclevel may refuse to send a SHA-1 cert at all, which looks like a server rejection; add@SECLEVEL=0to rule that out. And newer firmware may reject SHA-1 in its verify path, or only accept ECDSA client certs. The retest below shows which: its CertificateRequest lists the sig-algs it'll take.unknown ca/handshake_failureto everything until they recover (minutes) or power-cycle. If you've been retrying, a fresh result needs a cold device.One clean attempt on a freshly power-cycled unit:
-msgis the part that matters: it shows whether your OpenSSL actually put its Certificate on the wire before the alert. Alert after your Certificate flight is a real server-side rejection, and your read holds. No cert sent (seclevel), or a death mid-ClientHello (cipher), is an artifact, and there's still a door.The alert code then tells you why a real rejection happened.
unknown_ca (48)is specifically "issuer not in my trust store". A hash or algorithm refusal is usuallybad_certificate (42)orhandshake_failure (40). To isolate the leaf-hash case, re-mint just the leaf-sha256and retry: if the alert changes, leaf hash was the gate; if it's identical, it wasn't.What this device accepts for client certs is the open question, and the CertificateRequest in that
-msgoutput is where to read it.That onboarding surface is the other cheap test. You hit
/.well-known/core(4.01) and/device/0(4.03) on5683but not the security resources. OCF usually leaves/oic/sec/doxmworld-readable so an onboarding tool can find the device pre-ownership; if it answers, it returns the device UUID and ownership state (owned,devowneruuid, owner-transfer methods).owned:falseor a live RFOTM state means there's a provisioning surface;owned:truepinned to a hub means it's sealed. Small dependency-free probe below, read-only, IP and UUIDs redacted by default so it's safe to paste back:Firmware version from the SmartThings app (device page, three dots, Information) would help too. Please leave the serial off that screen.
coap_sec_probe.py: minimal plain-CoAP security-resource probe, read-only, redactedpaul-toben commentedon Jul 30, 2026
Here is everything the Smartthings app shows under software for this device:

I ran the checks on a freshly power-cycled unit, and captured the same handshake against a working fridge (
setup_cert.pyauthenticates to it,2.05) for comparison.Here are the results from
coap_sec_probe.pyon the Washer, plain CoAP 5683owned:true,isop:true.oxmselis65282(0xFF02, vendor-specific);oxmsalso lists2(mfgcert)./oic/resreturnsoic.d.washerplus the Easy Setup resources (oic.r.easysetup,WiFiConfResURI,CoapCloudConfResURI,DevConfResURI,x.com.samsung.accesspointlist).And here are the certificate requests on this combo Washer/Dryer vs my working fridge
Washer (5684) — rejects our cert,
unknown_ca (48):Fridge (49154) — accepts the same AC14K_M cert:
The advertised
certificate_authoritiesis identical on both, and the fridge accepts AC14K_M while advertising that same list, so I don't think the CA list is what's gating the washer. The one CertReq difference is sig-algs: the fridge lists SHA-1, the washer is SHA-256 only.The washer's alert is
unknown_ca (48)rather thanbad_certificate, which reads more like "issuer not trusted" than a hash-policy reject, so it may be the device's actual trust store (/oic/sec/cred, which I didn't touch) lacking the AC14K_M chain, with the SHA-256-only sig-algs as a second data point.On your checks: our Certificate goes on the wire before the alert; cipher negotiates
ECDHE-ECDSA-AES128-GCM-SHA256on both. I tried re-signing the SHA-256 leaf and it returned the same unknown_ca.Thanks for looking into this. It'd be great to get working, but I'm not holding my breath. I just hope this isn't the way new Smartthings devices are headed.
paul-toben commentedon Jul 30, 2026
One more thing to add... if I repeatedly scan nmap on the ports 49152-49160, usually I get all of the ports reporting closed. But every once in a while I'll get a smattering of ports showing open|filtered. Occasionally I'll even see all of those ports open at the same time.
jjyn0215 commentedon Aug 1, 2026
Does your washer has any opened TCP port?
feat(protocol): add DTLS ClientHello liveness probe + wire it into th…
feat(protocol): add DTLS ClientHello liveness probe + wire it into th…
noisymime commentedon Aug 2, 2026
FWIW I'm seeing this same behavior on a Family Hub fridge from 2024, model RF65A9770B1/SA
udp/5683andudp/5684are open and the ports in the 49152-49160 range seem to randomly open and close. Sometimes they're all open, sometimes it's none, other times it's just 1 or 2 of them. The cert verification against port 5684 gives:Firmware on it is Tizen 7.0, 20250818.225446
It appears to have a single tcp port open, 17654. Has a http server running on it, but everything I tried (Including root) returns a 404 .
noisymime commentedon Aug 2, 2026
And in case it helps, here's the output of coap_sec_probe:
QuiteYellow commentedon Aug 2, 2026
Following up on the sig-algs angle: I think this closes out AC14K_M specifically.
paul-toben's re-sign test is the tell: a fresh SHA-256 leaf got the identicalunknown_caalert. If the leaf's hash were the problem, that should have changed. It didn't, which points at the rest of the chain:AC14K_M,RemoteAccessCA, andCECAare all SHA-1, and none of us can re-sign those without Samsung's intermediate keys. Asupported_signature_algorithmslist of SHA-256 only fits a firmware that now enforces SHA-256 across the whole chain, not just the leaf. That means the handshake fails before identity is ever checked.One cheap thing still worth trying: a bare SHA-256 leaf with no intermediates at all. Different test than the re-sign, since it tells us whether the device already trusts AC14K_M as a cached intermediate. Not expecting it to change the conclusion, but it's cheap.
My guess is diagnostics didn't go away. That'd be a strange thing for Samsung to drop entirely, since techs need some way in. More likely AC14K_M specifically got retired because it's a leaked credential, and got replaced by something that can't leak the same way: probably per-request, account-authenticated issuance rather than a bundled static cert. If so, there's probably still a path in, just not this one.
For anyone who wants to dig further:
libocpmapi.soin the SmartThings app exports a function calledOCCloudCertificateIssueRequest, a strong hint about where the replacement logic lives. I didn't chase it past confirming the name.If anyone gets a device freshly paired through the official app and can capture what that provisioning traffic looks like (sanitized, no tokens or device UUIDs), that'd answer this fastest. Otherwise I'd treat AC14K_M as closed, but not the broader question.
Jason-Morcos commentedon Aug 2, 2026
I have this exact appliance and was able to fully get this working locally (yes, it was a pain to do so) - I've got an approach now that's been stable for about a week, with no dropping or resets. I'm working on opening a series of PRs to this repo to get it supported here too
#25 (comment) is the first of the PRs explicitly for this machine
I have ~7 samsung appliances and this was definitely one of the toughest ones to get working in a stable way
I was able to get it working locally without breaking the SmartThings cloud though - it's definitely possible
QuiteYellow commentedon Aug 3, 2026
@Jason-Morcos thank you for the PRs (#21–#25), this is great to see. Looking forward to getting the stack merged into main so more people here can test it against their own units.
@paul-toben this is your exact appliance, so once it lands it'll be worth a try. I'll keep the issue open until then.
paul-toben commentedon Aug 5, 2026
@QuiteYellow Thanks. I'll keep an eye out for the PRs as this develops. It's exciting to see so much work around this project.
@Jason-Morcos the one thing I'm concerned about is if the appliance can still be factory-reset in after the OCF ownership change, and get it back into a state where it could be re-onboarded to Smartthings.
I'm also curious if you think this method can stand up to future firmware updates/changes from Samsung.
Jason-Morcos commentedon Aug 6, 2026
Hey @paul-toben - I can confirm the appliance can still be factory-reset back to SmartThings (I had to do it several times in testing haha)!
Also, the way I have it implemented it actually doesn't break SmartThings connectivity at all. In fact, breaking smart things connectivity seems to cause weird device resets.
The only downside with the laundry machines so far is that they seem to not support Observe so they have to be manually polled.
As to Samsung - I think Samsung could make all of these different approaches significantly more difficult, if they really wanted to. That being said, it seems unlikely they go through the effort of trying to do so given in many cases it would require fairly substantial security revamps to block these workarounds. But it is within the realm of possibility imo that they find a way to block this.
DUC750 commentedon Aug 14, 2026
Second Family Hub data point for this thread (EU unit), because it differs from @noisymime's in two ways that look relevant to the endpoint-discovery work in #36.
1. The secure endpoint is not 5684 here — it is advertised, and it is not in the 4915x band either.
GET /oic/resonudp/5683answers2.05and the sec-flagged links carrydport: 46060. Not 5684, not 4915x. Verified stable across 3 consecutive reads and again two days later, so it is not a per-boot ephemeral value on this unit:A DTLS ClientHello to that port gets a live server:
So for this unit a probe list of
5684 + 49152-49160would come up empty on all ten ports while the device is sitting there answering on 46060. Readingdportout of/oic/resis the only thing that finds it — which is exactly what #36 is doing, so mainly posting this as a concrete case that the fixed candidate list would miss.2. The security resources are locked on this one, unlike @noisymime's Family Hub.
Same read-only plain-CoAP GETs, different answers:
@noisymime's unit returned
2.05on doxm/pstat withowned/oxmsel/ UUIDs visible. Mine returns4.01on both, so no ownership state or OTM method list to report from here. That lockdown pattern looks closer to @paul-toben's WD53 (/.well-known/core4.01,/device/04.03) than to the other fridge — which suggests "Family Hub" is not one profile, and the readable-doxm case may not be something to rely on across the class.3. Minor, on the flapping 4915x ports.
49152-49160are consistentlyclosedhere — actively refused with ICMP port-unreachable, 3/3 runs. Worth flagging one measurement trap I hit: my first sweep showed49152/udp open|filtered, which was purely the target's ICMP error rate limit suppressing one of the nine replies. With--scan-delay 1sand one port per run it is reproduciblyclosed. Given @paul-toben and @noisymime both report ports that "randomly open and close", some of that may be the same artifact rather than genuinely transient listeners — cheap to rule out with a scan delay before reading anything into it.What I did not do: I never minted or presented a client certificate, so I have no alert class to contribute — I cannot confirm
unknown_caon this unit myself, and the OCF-PKI classification above is inferred from the server chain alone. All reads were plain unauthenticated GETs; no writes and nothing touched beyond the resources listed.Happy to run further read-only probes on this unit if that would help characterise the fridge side of this class. Model/firmware details I can add from the app if useful.
docs(readme): fill gaps left by the auth/handshake stack merges
27 remaining items
Jason-Morcos commentedon Sep 29, 2026
@davids777 — the completed profile resolves the earlier port ambiguity: 44708 is advertised and responds, and the CA names are now observed on DTLS as well as TCP. Keeping
additionalauthrequiredmissing is the right interpretation. Neither that absence nor the0x8feature mask establishes that our WD53 authorization/ownership procedure applies to this oven.@QuiteYellow — the no-client-certificate TCP comparison would be useful. There is one concrete lesson from our reverse engineering that may help with the proposed script: CoAP-over-TCP framing support did not imply RFC 8323 signaling support on our older Samsung stack. During the retired WD53 cloud-hub experiment, mutual TLS completed, but the appliance's IoTivity 1.2 client did not correctly handle CSM/PING/PONG/Release/Abort. The hub's automatic CSM/PING led to silence; suppressing those signaling frames on that dedicated legacy listener allowed the experiment to progress. It still failed later at the appliance's cloud sign-in gate, so this was not a successful cloud replacement or a pairing solution.
That observation was on the appliance's outbound cloud client, not this oven's inbound 44433 listener. I would not assume the same limitation here or change the library default from it. It does mean a script should report “TLS completed, no correlated CoAP response” separately from
4.01, rather than letting a CSM/framing stall count as an authorization result. If it waits for a peer CSM before sending GETs, that assumption should be explicit.For the proposed comparison, please use the exact batch request
/device/0?if=oic.if.b. A4.01establishes that this anonymous session cannot read that batch; it does not rule out TCP as a future authenticated carrier. A2.05becomes state-read evidence only after checking locally that the complete decoded batch contains actual appliance-state representations, rather than just links/metadata or per-resource errors. A redacted yes/no summary is enough; no raw state payload is needed. Likewise, “no client certificate” and “server certificate verified” are separate facts worth labeling.The macOS/Linux difference is still unresolved: both host and OpenSSL version changed. The stateless probe proves a HelloVerifyRequest response, not completion of OpenSSL's later handshake. I would preserve that as a diagnostic difference rather than attribute it to a specific OpenSSL release yet.
davids777 commentedon Sep 29, 2026
@QuiteYellow yes please, post it and I'll run it once against 44433 from a Linux host on the same LAN. I'll use /device/0?if=oic.if.b per @Jason-Morcos and report the TLS result, whether a client certificate was sent (none), and each GET's code, calling out "TLS completed, no CoAP response" separately if that's what happens. If /device/0 comes back 2.05, I'll check the decoded batch locally for actual state and post only a yes/no summary.
herrniffler commentedon Sep 29, 2026
@QuiteYellow — the same gating shows up on the DTLS side for the oven I posted earlier (NV7B7997ADK/U1): an anonymous, no-client-cert session (
ServerCertificateAuth,SamsungServerProfile.bound_device(<cert-uuid>)) over the advertised secure DTLS port gets:So on this oven, over DTLS rather than your dryer's TCP 44433, the same split holds: discovery + doxm/pstat readable with no credential,
/device/0gated behind4.01. Matches your dryer's2.05/2.05/4.01shape exactly, just on the other transport. Not proof the two carriers share a gate implementation, but it's one more data point that the split is consistent across transports on devices that otherwise disagree about which client-cert carrier they accept.QuiteYellow commentedon Sep 29, 2026
@davids777 here it is. One TLS session, no client certificate, a CSM, then seven GETs one at a time, printing response codes only.
Those four lines are what I ran on macOS and on Linux against my dryer, and both gave identical output. Stock macOS
python3is 3.9.6, so if that is yours, build the venv with a newer one (python3.12 -m venv .venv). Framing and decoding go through this library's own CoAP-over-TCP codec.I promised two GETs. The rest ride the same session for free, and a round trip here costs days.
/device/0without the query separates a gated href from a gated batch interface; the size of/oic/resis the per-read mode tag for this generation;/oic/sec/doxm,/oic/sec/pstatand/oic/pare what @herrniffler read over DTLS and @elirnyk read in plaintext, so your codes line up with theirs.--pathreplaces the set.@herrniffler, your NV7B7997ADK/U1 result sharpens this. You got 2.05 on
/oic/d,/oic/res,/oic/sec/doxmand/oic/sec/pstat, 4.01 on/device/0, anonymously over DTLS. My dryer gives the same shape over TCP, re-measured with my own bridge stopped:So the split holds on two transports. Whether a unit on the refusing side of this issue draws the line there is what 44433 can still answer.
@Jason-Morcos: it sends its own CSM and waits for nothing, with
--no-csmand--wait-csmcovering the other two cases. My dryer sends no CSM of its own in six seconds and answers the same either way. A Ping gets a Pong, and nothing else goes out. The summary printsTLS completed,client certificate sent: noneandserver certificate verifiedas three separate lines, and--cafileturns the third into a real check.Two things govern how the output reads. An unanswered token prints
TLS completed, no correlated CoAP response, which covers an authorisation refusal that answers nothing, a framing stall, another client holding the appliance, and a request option the handler drops where it could refuse, so stop the LocalThings integration against that oven for the run. AndAcceptcan decide between a code and silence: this sends 60, while on my dryerAccept: 10000gives 4.06 on/oic/dand no answer at all on the batch href, four times out of four.Three times today a first attempt failed transiently on my own hardware and worked immediately after, so the run carries a budget of three handshakes,
--max-handshakes, covering the TLS 1.2 fallback, the retry and a second session alike, and stops once it is spent. Addresses and UUIDs are redacted, payloads counted but never printed, and the appliance certificate's fingerprint is withheld unless--fingerprintasks for it.--save-dirwrites payloads to owner-only files if you want to decode the batch.ocf_tcp_anon_probe.pydavids777 commentedon Sep 29, 2026
@QuiteYellow ran your probe once against the NQ7000CD's TCP 44433 from macOS (Python 3.14, smartthings-local 0.1.20), with nothing else connected to the oven. Default path set, Accept 60, CSM sent without waiting.
TLS completed: yes, TLSv1.2, ECDHE-ECDSA-AES128-GCM-SHA256
client certificate sent: none
server certificate verified: not attempted
/oic/d 2.05 157 B
/device/0?if=oic.if.b 4.01 0 B
/device/0 4.01 0 B
/oic/res 2.05 11597 B
/oic/sec/doxm 2.05 218 B
/oic/sec/pstat 2.05 157 B
/oic/p 2.05 311 B
Same split as your dryer and @herrniffler's oven: public resources readable anonymously, the state batch and the bare href both 4.01, all answered within 150 ms. No CSM or Ping came back from the oven. So an anonymous TCP session is not a route past #16 on this unit, and it stays in "certificate rejected, credential not yet available." The one difference from your dryer is /oic/res at 11597 B against your 1703 B, in case that size tag is useful for this generation.
Jason-Morcos commentedon Sep 29, 2026
@davids777 — this resolves the specific TCP question on your NQ7000CD: with no client certificate, both the batch interface and bare
/device/0returned an explicit4.01, while the public resources answered. There is no framing/CSM ambiguity in those completed responses, and no need to rerun this just to try more CSM variants. “Certificate rejected, credential not yet available” remains the appropriate status. An authenticated TCP carrier could still be useful later; this result does not supply its credential or establish write access.The 11597 B directory is useful as a baseline for this unit, transport and request shape. I would not compare it with the dryer's 1703 B to classify a firmware generation or full/reduced mode: different models expose different resources, and query/representation choices also affect size. A mode comparison needs like-for-like reads on the same unit, ideally with resource-set differences checked locally. No further capture is needed for the present conclusion.
@herrniffler — your bound-server DTLS result adds a useful independently identified oven case. It establishes the public/protected split on that tested session without implying that TCP and DTLS share an implementation or that anonymous metadata access grants ownership.
@QuiteYellow — the explicit separation of TLS completion, absent client certificate and server verification answers the earlier interpretation concerns. The Accept observation is particularly useful: a silent request can be a representation-option issue while the session remains healthy. I checked the v0.1.20 plaintext helper too; it already uses
Accept: 60, so that should not be silently conflated with anAccept: 10000test.Two concrete details in the posted script are worth tightening before others reuse it:
connect()made three connection attempts fortimeout → SSL error → success: the SSL-error branch does not checkattemptbefore its TLS-1.2 retry. The outer empty-result session retry is another budget. Use one explicit total-attempt budget shared by handshake fallback and the optional second session, and stop when exhausted. This does not undermine your successful first-session result, @davids777.For future silent cases, sending one GET and draining its response before sending the next would also make diagnosis clearer while retaining the same TLS session. The current script sends all seven GETs before draining; that worked here, but extra requests are not literally free of firmware scheduling/session-state effects.
QuiteYellow commentedon Sep 30, 2026
@Jason-Morcos both tightenings are in the script above, edited in place so the version people copy is the fixed one.
The attempt budget is now one counter for the run. The TLS 1.2 fallback, the timeout retry and the second session all spend from it, so
timeout → SSL errorno longer buys a third attempt: under a budget of 2 it stops after the second and says so.--max-handshakessets it, and the summary prints what was spent.The fingerprint is out of the default output altogether.
--fingerprintprints the full digest for a private comparison between two endpoints on one appliance, and--save-dirnow creates its directory0700and each payload file0600.The GETs go one at a time as well, each drained before the next is sent.
--pipelinesends them all up front, the shape the earlier runs used. A late answer to an earlier href still gets recorded.@davids777's codes stand and nothing here needs rerunning.
KRZ303 commentedon Oct 3, 2026
I got PSK authentication working on my Samsung LCD oven: board prefix
LCD_R18_SCO_QMD_EU_22K, profileDA-KS-OVEN-0105X. I haven't checked its retail model number yet.I recovered its existing 16-byte OwnerPSK from SmartThings
1.8.51.30on rooted Android 17. The key was in the account-specific.datencstore, not Core's certificate-only store. I've published the scripts and procedure here.My raw owner UUID contains NUL, so I also hit the limitation in #85. Mbed TLS 3.6.7 preserved all 16 bytes, negotiated
TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256, and returned my oven's expected ID from authenticatedGET /oic/d.PR #115 adds the optional backend without removing OpenSSL's guard. It requires a separate native build.
With both patches installed, my oven loads in HA. It currently exposes only a connection-mode sensor, so authentication works but full oven support is still missing. I haven't tested other models.
Full findings and integration links.
Jason-Morcos commentedon Oct 3, 2026
@QuiteYellow — I checked the edited TCP probe and reran the earlier mixed-failure case with simulated sockets:
timeout → SSL errornow stops at two attempts under the shared budget. Default output omits the fingerprint, and the source has the owner-only save permissions and sequential GET default you described. That addresses my earlier points; none of the successful appliance measurements needs repeating.@KRZ303 — the combination of a recovered credential and an authenticated
/oic/dmatch is a useful advance beyond the earlier unknown-identity probes. The full-length Mbed TLS result also fits the wire-level measurement in #85: the zero byte is valid appliance identity data, while OpenSSL's legacy callback cannot represent it intact.One distinction from our earlier research may help other readers: when explicitly provisioning a new owner/peer identity, we could choose a UUID without zero bytes to fit the OpenSSL transport. That does not fix an existing recovered OwnerPSK identity. It must remain byte-for-byte unchanged; trimming zeros or substituting the printable UUID would present a different identity. Your optional backend addresses that transport limitation without asking the owner to change appliance ownership again.
For people following along, both #115 and the companion LocalThings #575 are still open, and the latest released library is v0.1.21. The proposed wheel includes native source, not a ready-to-load backend. A compatible native build remains a separate requirement; installing the integration patch alone does not enable these identities. An absent/incompatible backend should remain a local capability error, not evidence that the recovered key is wrong.
I've read the proposed adapter and recovery code, but have not built the native backend or tested it on our appliances. Your demonstrated boundary is authentication and the expected device identity on this one oven; the connection-mode sensor does not establish oven capability coverage. I'll keep the acquisition details and next read-only inventory step together in #435 so those remain distinct from the transport work here.
KRZ303 commentedon Oct 3, 2026
@Jason-Morcos, exactly: I kept the recovered PSK and all 16 identity bytes unchanged.
The Mbed TLS backend fixes how that existing identity is sent; it does not replace or reprovision it.
I also followed your enumeration suggestion from LocalThings #435 and found the separate reason for the missing entities.
My oven's default
/device/0response contains resource links, while/device/0?if=oic.if.breturns the actual batch representations.LocalThings was discarding the default map because it expected a list.
I opened LocalThings #580 for that integration-side fallback, independently of the PSK patches.
After deployment, my oven has 24 entities and nine active Observe subscriptions, instead of only the connection-mode sensor.
The existing oven registry handled the returned representations without a profile change.
The native packaging requirement hasn't changed: #115 still needs a compatible backend build, and the integration patch alone does not provide it.
I have now verified discovery and readable state on this oven.
No further pairing, ownership changes, or OCF security writes were needed for this fix.
LoganSample680 commentedon Oct 4, 2026
Another WD53DBA900HZ (Bespoke AI Laundry Combo) owner here, running Home Assistant with LocalThings.
My Samsung fridge is now fully local through LocalThings, but the combo refuses the automatic certificate and setup falls through to the PSK menu (psk_owner / psk_peer / fallback_ca). The owner identity is pre-filled, so it looks like the same OwnerPSK profile described in docs/ocf-pki-laundry.md.
Happy to test once there's a public, non-reset way to open the ownership window, and I can provide sanitized diagnostics if that helps. Thanks for all the work on this!
joazito commentedon Oct 11, 2026
Family Hub RS68N8941SL/EF: no local DTLS endpoint observed
Samsung RS68N8941SL/EF, Family Hub platform 24K_REF_LCD_FHUB9.0 (fw 20240808.203251), controller REF.6.0.18, latest per the SmartThings app. Probed from the same /22 LAN:
Server: eSDK: 404 forGET /, 400 forHEAD /, empty body. This is the same listener as noisymime's Family Hub. 44433, 8001, 8002, 8080, 8443 and 8888 are closed.GET /oic/res?rt=x.org.iotivity.notificationto 224.0.1.187:5683, source port 50093). No LAN device replied, and no inbound DTLS/CoAP traffic to the fridge was seen.So the fridge runs an OCF/IoTivity stack as a discovery client, but I saw no server-side listener. Is this consistent with the Family Hub reports here? Does anyone know what eSDK on 17654 is?
blupine commentedon Oct 14, 2026
Samsung AF18DX936WFN: additional PSK hardware validation
Result: owner-principal PSK authentication and LocalThings import succeeded for Samsung AF18DX936WFN, platform
R18_BIXBY_FAC_TIME_23K.Verified results
DtlsPskClientpath./oic/dand the protected/device/0?if=oic.if.bon the target.This adds hardware evidence for #117 and #121. The deployed files have not been compared byte-for-byte with the published distributions.
Recovery source and limits
The existing credential was recovered from current native SmartThings memory on a rooted Galaxy S9. The two selected persisted SVR stores had no type-1 credential. We did not observe the original key-generation transaction or test physical control commands.
The recovery method, target/principal checks, and HA results are documented in the detailed LocalThings #435 report.
QuiteYellow commentedon Oct 17, 2026
Your DTLS probes went to the wrong ports, and the test that reads the real one is missing: a plaintext unicast
GET /oic/resto 5683.Family Hub secure ports are ephemeral and move: 46060, then 39181, then 58551, then 46909 on @DUC750's FHUB10 (mbillow/localthings#213), and 48220 on a
24K_REF_LCD_FHUB9.0identical to yours, where LocalThings reported "DTLS server confirmed on …:[48220] but the handshake never completed" (mbillow/localthings#552). 48220 sits inside the 32768-65535 range your sweep called inconclusive, so your own data never ruled out the port the sibling unit answered on. nmap cannot settle it either: on UDP it needs an ICMP unreachable before it will sayclosed, and an OCF socket ignores a non-CoAP payload, soopen|filteredon 5683 is what a live server looks like to a scanner. Silence is also not how these fridges turn down a cert-less ClientHello on their advertised port. In @DUC750's measurements that handshake either completed outright or came backhandshake_failurewith an empty CA list.smartthings-local 0.1.24 reads the advertised port over plaintext unicast. No multicast, no scan, nothing but the address:
Both take a port argument (
discovery_port=andport=), so try 5683, then 49153, then 50093, the source port your own capture shows the fridge sending from, which in the IoTivity tree is the same socket that serves plaintext unicast. There is a multicast helper as well,ocf_multicast.discover_ocf_responder_ports, but it pins TTL 1 and so only reaches the fridge from a host on its own segment.Please include
payload_byteswith each result, because that directory alternates between a 421 B and a 9356 B form in phases lasting tens of minutes to hours, and the two states differ both in advertised port and in whether a cert-less handshake completes (#213, mbillow/localthings#435), so one read on its own settles little.No idea what eSDK on 17654 is.
QuiteYellow commentedon Oct 17, 2026
Thanks for sharing this, @blupine. Your air conditioner is a different platform from @KRZ303's oven, and the identity with the embedded zero byte came through on 0.1.24, so that code path now has hardware behind it beyond their unit.
Where your key came from would be useful in #435, which is where acquisition is being tracked: theirs was in the account store on the phone, yours in the running app's memory, so the same credential can be reached from either place.