Skip to content

Newer Samsung firmware rejects the AC14K_M anchor with unknown_ca: the OwnerPSK generation #16

Open

Description

@paul-toben

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.

Device model

Samsung Bespoke AI Combo Washer/Dryer — WD53DBA900HZ (device cert issued 2025-02-23)

Summary

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.

I'm not sure if there's any path forward here.

Activity

  1. QuiteYellow commented on Jul 30, 2026

    @QuiteYellow
    Owner

    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.

    One clean attempt on a freshly power-cycled unit:

    openssl s_client -connect <device-ip>:5684 -dtls1_2 \
      -cipher 'ECDHE-ECDSA-AES128-GCM-SHA256@SECLEVEL=0' \
      -cert client.pem -key client.key -CAfile fullchain.pem \
      -showcerts -msg 2>&1 | head -60
    

    -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.

    coap_sec_probe.py: minimal plain-CoAP security-resource probe, read-only, redacted
    #!/usr/bin/env python3
    """Minimal, dependency-free plain-CoAP (no DTLS) probe of the OCF
    security resources on udp/5683. Read-only GETs only.
    
    Output is redacted by default (target IP and any device UUIDs are
    masked) so it's safe to paste directly into a GitHub issue/comment.
    Pass --reveal to see real values while debugging locally.
    """
    import re
    import socket
    import struct
    import sys
    import time
    
    try:
        import cbor2
        HAVE_CBOR = True
    except ImportError:
        HAVE_CBOR = False
    
    URI_PATH, ACCEPT = 11, 17
    UUID_RE = re.compile(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}')
    
    REVEAL = '--reveal' in sys.argv
    
    
    def redact(text, host):
        if REVEAL:
            return text
        text = text.replace(host, '<target-ip>')
        return UUID_RE.sub('<uuid-redacted>', text)
    
    
    def _vlen(v):
        if v < 13:
            return v, b''
        if v < 269:
            return 13, bytes([v - 13])
        return 14, struct.pack('>H', v - 269)
    
    
    def encode_options(opts):
        out = b''
        prev = 0
        for n, val in sorted(opts, key=lambda x: x[0]):
            d, dx = _vlen(n - prev)
            l, lx = _vlen(len(val))
            out += bytes([(d << 4) | l]) + dx + lx + val
            prev = n
        return out
    
    
    def parse_coap(data):
        mt = (data[0] >> 4) & 0x03
        tkl = data[0] & 0x0F
        code = data[1]
        mid = int.from_bytes(data[2:4], 'big')
        tok = data[4:4 + tkl]
        i = 4 + tkl
        payload = b''
        opts = []
        prev = 0
        while i < len(data):
            b = data[i]
            if b == 0xFF:
                payload = data[i + 1:]
                break
            d_nib, l_nib = b >> 4, b & 0x0F
            i += 1
            if d_nib == 13:
                delta = 13 + data[i]; i += 1
            elif d_nib == 14:
                delta = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
            else:
                delta = d_nib
            if l_nib == 13:
                length = 13 + data[i]; i += 1
            elif l_nib == 14:
                length = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
            else:
                length = l_nib
            num = prev + delta
            opts.append((num, data[i:i + length]))
            i += length
            prev = num
        return mt, code, mid, tok, opts, payload
    
    
    def printable_strings(data, minlen=4):
        out, cur = [], b''
        for b in data:
            if 0x20 <= b < 0x7f:
                cur += bytes([b])
            else:
                if len(cur) >= minlen:
                    out.append(cur.decode())
                cur = b''
        if len(cur) >= minlen:
            out.append(cur.decode())
        return out
    
    
    def build_get(segs, mid, tok):
        opts = [(URI_PATH, s.encode()) for s in segs]
        opts.append((ACCEPT, b'\x3c'))  # application/cbor
        return bytes([0x40 | len(tok), 1, mid >> 8, mid & 0xFF]) + tok + encode_options(opts)
    
    
    def go(sock, host, port, label, segs, wait=4):
        go.mid = getattr(go, 'mid', 0x4000) + 1
        mid = go.mid
        tok = bytes([mid & 0xFF])
        msg = build_get(segs, mid, tok)
        print(redact(f"-> {label}  (plain CoAP GET /{'/'.join(segs)} to {host}:{port})", host))
        sock.sendto(msg, (host, port))
    
        deadline = time.time() + wait
        while time.time() < deadline:
            try:
                sock.settimeout(max(0.1, deadline - time.time()))
                data, _ = sock.recvfrom(65535)
            except socket.timeout:
                break
            except OSError as e:
                # ICMP port-unreachable etc. surface here on some platforms.
                print(f"   socket error: {e}\n")
                return None, None
            if len(data) < 4:
                continue  # too short to be a CoAP message; ignore
            try:
                mt, code, rmid, rtok, opts, payload = parse_coap(data)
            except (IndexError, ValueError) as e:
                print(f"   unparseable {len(data)}B reply, ignored ({e})")
                continue
            kind = ['CON', 'NON', 'ACK', 'RST'][mt]
            # Match on BOTH message-id and token, and only trust a reply we
            # actually correlate. Samsung's stack often empty-ACKs then sends
            # the real answer as a separate packet, and slow/oo responses to
            # an earlier request will otherwise be misread as this one's.
            matched = (rmid == mid) or (rtok == tok and len(tok) > 0)
            tag = "MATCH" if matched else "stray/other-req"
            print(f"   <- mid={rmid:#06x} tok={rtok.hex()} type={kind} code={code >> 5}.{code & 0x1F:02d} {len(payload)}B [{tag}]")
            if mt == 0:  # CON: ack it so the device doesn't retransmit
                sock.sendto(bytes([0x60, 0x00, rmid >> 8, rmid & 0xFF]), (host, port))
            if matched:
                if kind == 'ACK' and code == 0 and not payload:
                    continue  # empty ACK, real answer is a separate packet — keep waiting
                if payload:
                    if HAVE_CBOR:
                        try:
                            print("      " + redact(repr(cbor2.loads(payload))[:600], host))
                        except Exception:
                            print("      strings:", [redact(s, host) for s in printable_strings(payload)])
                    else:
                        print("      strings:", [redact(s, host) for s in printable_strings(payload)])
                print()
                return code, payload
        print("   TIMEOUT (no correlated reply — re-run; a lone empty ACK here is not a 'no')\n")
        return None, None
    
    
    def main():
        args = [a for a in sys.argv[1:] if not a.startswith('--')]
        if not args:
            sys.exit(f"usage: {sys.argv[0]} <ip> [port=5683] [--reveal]")
        host = args[0]
        port = int(args[1]) if len(args) > 1 else 5683
    
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        print(redact(f"=== Plain CoAP security probe of {host}:{port} (no DTLS, no cert) ===\n", host))
        probes = [
            ("/oic/res (resource + sec-flag enumeration)", ['oic', 'res']),
            ("/oic/sec/doxm (owner + UUID; often world-readable)", ['oic', 'sec', 'doxm']),
            ("/oic/sec/pstat (provisioning/onboarding state)", ['oic', 'sec', 'pstat']),
        ]
        for label, segs in probes:
            # Isolate each probe so one bad reply doesn't abort the rest.
            try:
                go(sock, host, port, label, segs)
            except Exception as e:
                print(f"   probe failed, continuing: {e}\n")
        sock.close()
    
    
    if __name__ == '__main__':
        main()
  2. paul-toben commented on Jul 30, 2026

    @paul-toben
    Author

    Here is everything the Smartthings app shows under software for this device:
    Image

    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

    /oic/sec/doxm : {'oxms': [2, 65282], 'oxmsel': 65282, 'sct': 1, 'owned': True,
                     'deviceuuid': <redacted>, 'devowneruuid': <redacted>, 'rowneruuid': <redacted>}
    /oic/sec/pstat: {'isop': True, 'cm': 0, 'tm': 0, 'om': 4, 'sm': 4, ...}
    

    owned:true, isop:true. oxmsel is 65282 (0xFF02, vendor-specific); oxms also lists 2 (mfgcert). /oic/res returns oic.d.washer plus 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):

    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.

  3. paul-toben commented on Jul 30, 2026

    @paul-toben
    Author

    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.

  4. jjyn0215 commented on Aug 1, 2026

    @jjyn0215

    Does your washer has any opened TCP port?

  5. noisymime commented on Aug 2, 2026

    @noisymime

    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:

    [+] DTLS handshake to 192.168.0.213:5684...
        SSL error: [('SSL routines', '', 'tlsv1 alert unknown ca')]
    

    Firmware on it is Tizen 7.0, 20250818.225446

    Does your washer has any opened TCP port?

    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 .

  6. noisymime commented on Aug 2, 2026

    @noisymime

    And in case it helps, here's the output of coap_sec_probe:

    === Plain CoAP security probe of <target-ip>:5683 (no DTLS, no cert) ===
    
    -> /oic/res (resource + sec-flag enumeration)  (plain CoAP GET /oic/res to <target-ip>:5683)
       <- mid=0x4001 tok=01 type=ACK code=2.05 421B [MATCH]
          strings: ['bdix$<uuid-redacted>elinks', 'dhrefm/oic/sec/doxmbrt', 'joic.r.doxmbif', 'ooic.if.baselineap', 'csec', 'dport', 'rx.org.iotivity.tls', 'dhrefn/oic/sec/pstatbrt', 'koic.r.pstatbif', 'ooic.if.baselineap', 'csec', 'dport', 'rx.org.iotivity.tls', 'dhreff/oic/dbrt', 'hoic.wk.dbif', 'ooic.if.baselinehoic.if.rap', 'csec', 'rx.org.iotivity.tcp', 'dhreff/oic/pbrt', 'hoic.wk.pbif', 'ooic.if.baselinehoic.if.rap', 'csec', 'rx.org.iotivity.tcp']
    
    -> /oic/sec/doxm (owner + UUID; often world-readable)  (plain CoAP GET /oic/sec/doxm to <target-ip>:5683)
       <- mid=0x4002 tok=02 type=ACK code=2.05 213B [MATCH]
          strings: ['doxms', 'foxmsel', 'csct', 'eowned', 'jdeviceuuidx$<uuid-redacted>ldevowneruuidx$<uuid-redacted>jrowneruuidx$<uuid-redacted>brt', 'joic.r.doxmbif', 'ooic.if.baseline']
    
    -> /oic/sec/pstat (provisioning/onboarding state)  (plain CoAP GET /oic/sec/pstat to <target-ip>:5683)
       <- mid=0x4003 tok=03 type=ACK code=2.05 157B [MATCH]
          strings: ['disop', 'jdeviceuuidx$<uuid-redacted>jrowneruuidx$<uuid-redacted>brt', 'koic.r.pstatbif', 'ooic.if.baseline']
    
  7. QuiteYellow commented on Aug 2, 2026

    @QuiteYellow
    Owner

    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.

  8. Jason-Morcos commented on Aug 2, 2026

    @Jason-Morcos
    Contributor

    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

  9. QuiteYellow commented on Aug 3, 2026

    @QuiteYellow
    Owner

    @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.

  10. paul-toben commented on Aug 5, 2026

    @paul-toben
    Author

    @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.

  11. Jason-Morcos commented on Aug 6, 2026

    @Jason-Morcos
    Contributor

    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.

  12. DUC750 commented on Aug 14, 2026

    @DUC750

    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:

    /oic/res            2.05   (rt: oic.wk.d + oic.d.refrigerator; Easy Setup resources present:
                                oic.r.easysetup, WiFiConfResURI, DevConfResURI, ...)
    /oic/sec/doxm       4.01
    /oic/sec/pstat      4.01
    /oic/d              4.01
    /.well-known/core   4.01
    /device/0           4.01
    

    @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/core 4.01, /device/0 4.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.

  13. 27 remaining items

  14. Jason-Morcos commented on Sep 29, 2026

    @Jason-Morcos
    Contributor

    @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 additionalauthrequired missing 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.

  15. davids777 commented on Sep 29, 2026

    @davids777

    @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.

  16. herrniffler commented on Sep 29, 2026

    @herrniffler

    @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.

  17. QuiteYellow commented on Sep 29, 2026

    @QuiteYellow
    Owner

    @davids777 here it is. One TLS session, no client certificate, a CSM, then seven GETs one at a time, printing response codes only.

    python3 --version                  # needs 3.11 or newer
    python3 -m venv .venv
    .venv/bin/pip install smartthings-local
    .venv/bin/python3 ocf_tcp_anon_probe.py <oven-ip> --port 44433
    

    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 no
    client certificate, sends an RFC 8323 CSM and one GET per href, and prints
    the response code for each. Seven hrefs by default: the two that decide
    whether an anonymous session can read appliance state, plus five that are
    free 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 causes
    and nothing in the output separates them: an authorization refusal that
    answers nothing, a framing or signaling stall, another client already
    holding the appliance, and a request option the handler drops instead of
    refusing. 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 request
    entirely 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 on
    Samsung's older stack, so a CSM the appliance mishandles can stall the
    exchange in a way that looks nothing like a 4.01; use --no-csm to send the
    GETs without any signaling frame. Stop any local integration that polls the
    same appliance before reading anything into a silent run.
    
    The GETs go out one at a time, each drained before the next is sent, so a
    silent href is the last request the appliance saw rather than one of seven
    in flight. Extra outstanding requests are not free of firmware scheduling
    and session-state effects, and a run that has to be diagnosed is worth more
    than the seconds pipelining saves. --pipeline sends them all up front if a
    previous run needs reproducing.
    
    Everything goes down ONE TLS session, and the whole run shares ONE
    handshake budget (--max-handshakes). The TLS 1.2 fallback, the retry after
    a transient timeout, and the second session opened when the first answers
    nothing all draw on it, so no sequence of failures can cost more handshakes
    than the budget allows. The appliance's TLS service has a small session
    table and degrades if it is hammered with handshakes.
    
    Response payloads are never printed, only their byte counts, and the
    appliance certificate's fingerprint is withheld by default: truncated or
    not, it is a stable identifier for one machine, and nothing in the question
    this answers needs it. --fingerprint prints it in full for a private
    comparison between two endpoints on the same appliance. --save-dir writes
    payloads to owner-only local files for offline inspection.
    
    Requires Python 3.11+ and smartthings-local 0.1.12 or newer, which carries
    the 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__ import annotations
    
    import argparse
    import hashlib
    import os
    import re
    import socket
    import ssl
    import sys
    import time
    from dataclasses import dataclass
    from pathlib import Path
    
    if sys.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:
        from smartthings_local.protocol.coap import (
            BLOCK2,
            block_fields,
            fmt_code,
            option_values,
        )
        from smartthings_local.protocol.coap_tcp import (
            CSM_CODE,
            CoapTcpCodecError,
            CoapTcpStreamDecoder,
            build_coap_tcp_csm,
            build_coap_tcp_get,
            build_coap_tcp_message,
        )
    except ImportError:
        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 = 0xE2
    PONG_CODE = 0xE3
    SIGNALING = {
        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}"
    )
    
    
    def redact(text: str) -> str:
        """Blank UUIDs out of a string so output is safe to paste into an issue."""
        return UUID_PATTERN.sub("<uuid>", text)
    
    
    def split_href(href: str) -> tuple[str, tuple[str, ...]]:
        """Split an href into its path and its query items."""
        path, separator, query = href.partition("?")
        if not separator:
            return path, ()
        return path, tuple(item for item in query.split("&") if item)
    
    
    class HandshakeBudget:
        """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 = total
            self.used = 0
    
        @property
        def remaining(self) -> int:
            return self.total - self.used
    
        def spend(self) -> bool:
            """Take one handshake from the budget, or report it exhausted."""
            if self.remaining <= 0:
                return False
            self.used += 1
            return True
    
    
    @dataclass
    class DrainResult:
        """What a read loop ran into, beyond the responses it recorded."""
    
        signal: int | None = None
        closed: bool = False
    
    
    def peer_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)
        if der is None:
            return "none offered"
        if not fingerprint:
            return f"offered, {len(der)} B (fingerprint withheld; --fingerprint prints it)"
        digest = hashlib.sha256(der).hexdigest()
        return f"offered, {len(der)} B, sha256 {digest}"
    
    
    def prepare_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)
        if existed:
            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)
        return path
    
    
    def write_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)
        with os.fdopen(descriptor, "wb") as handle:
            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)
    
    
    def close_quietly(tls: ssl.SSLSocket) -> None:
        """Close the session without a failed close masking the printed results."""
        try:
            tls.close()
        except OSError as error:
            print(f"  close failed: {redact(str(error))}")
    
    
    def build_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 = False
        if args.cafile:
            context.verify_mode = ssl.CERT_REQUIRED
            context.load_verify_locations(cafile=args.cafile)
        else:
            context.verify_mode = ssl.CERT_NONE
    
        cipher_policy = "default"
        try:
            context.set_ciphers("DEFAULT@SECLEVEL=0")
            cipher_policy = "DEFAULT@SECLEVEL=0"
        except ssl.SSLError:
            # LibreSSL, and some distribution builds, reject the @SECLEVEL syntax.
            pass
    
        if args.tls12_only:
            context.maximum_version = ssl.TLSVersion.TLSv1_2
        return context, cipher_policy
    
    
    def connect(
        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.
        """
        while True:
            if not budget.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)
            except OSError as error:
                sys.exit(f"TCP connect failed: {error}")
    
            try:
                tls = context.wrap_socket(raw)
            except ssl.SSLCertVerificationError as error:
                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))}"
                )
            except TimeoutError as error:
                raw.close()
                if budget.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")
                continue
            except ssl.SSLError as error:
                raw.close()
                if args.tls12_only:
                    sys.exit(f"TLS handshake failed: {redact(str(error))}")
                if budget.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 = True
                continue
            except OSError as error:
                raw.close()
                sys.exit(f"TLS handshake failed: {redact(str(error))}")
    
            return tls, cipher_policy
    
    
    def drain(
        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()
    
        def still_waiting() -> bool:
            if wait_for is not None:
                return wait_for in pending
            return bool(pending)
    
        while still_waiting() and time.monotonic() < deadline:
            tls.settimeout(max(0.5, deadline - time.monotonic()))
            try:
                chunk = tls.recv(8192)
            except TimeoutError:
                break
            except OSError as error:
                # 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 = True
                break
            if not chunk:
                print("  the appliance closed the connection")
                outcome.closed = True
                break
    
            try:
                received = decoder.feed(chunk)
            except CoapTcpCodecError as error:
                print(f"  unparseable CoAP-over-TCP frame: {error}")
                outcome.closed = True
                break
    
            for message in received:
                name = SIGNALING.get(message.code)
                if name is not None:
                    print(f"  <- {name} ({fmt_code(message.code)})")
                    if message.code == PING_CODE:
                        try:
                            tls.sendall(
                                build_coap_tcp_message(
                                    code=PONG_CODE, token=message.token
                                )
                            )
                        except OSError as error:
                            print(f"  Pong failed: {redact(str(error))}")
                            outcome.closed = True
                            return outcome
                        print("  -> Pong")
                    if stop_on_signal is not None and message.code == stop_on_signal:
                        outcome.signal = message.code
                        return outcome
                    continue
    
                href = pending.pop(message.token, None)
                payload = message.payload or b""
                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 = False
                detail = f"{len(payload)} B"
                blocks = option_values(message.options, BLOCK2)
                if blocks:
                    number, more, _szx = block_fields(blocks[0])
                    partial = bool(more)
                    detail += f", block {number}"
                    detail += ", more to follow" if more else ", final block"
    
                if href is None:
                    print(
                        f"  <- {code} ({detail}) for an "
                        f"unrecognized token {message.token.hex()}"
                    )
                    continue
    
                if sent_at is not None and message.token in sent_at:
                    elapsed = (time.monotonic() - sent_at[message.token]) * 1000
                    detail += 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({number for number, _ in message.options})
                print(f"  <- {href}: {code} ({detail}) options={numbers}")
                results[href] = (
                    f"{code}, first block only, transfer not followed"
                    if partial
                    else code
                )
                if save_dir is not None and payload:
                    stem = re.sub(r"[^A-Za-z0-9]+", "_", href).strip("_")
                    suffix = ".partial.cbor" if partial else ".cbor"
                    target = save_dir / f"{stem}{suffix}"
                    write_private(target, payload)
                    print(f"     payload written to {target} (owner-only)")
        return outcome
    
    
    def run_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) * 1000
        version = tls.version()
        cipher = tls.cipher()
        print(
            f"TLS completed: yes, {version}, "
            f"{cipher[0] if cipher else 'unknown'}, {handshake_ms:.0f} ms"
        )
        print(f"cipher policy: {cipher_policy}")
        print(f"Accept sent: {accept if accept is not None else 'none'}")
        print("client certificate sent: none")
        print(f"appliance certificate: {peer_certificate(tls, fingerprint=args.fingerprint)}")
        if args.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] = {}
    
        if args.no_csm:
            print("CSM: not sent (--no-csm)")
        else:
            tls.sendall(build_coap_tcp_csm())
            if args.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,
                )
                if outcome.signal is None:
                    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] = {}
        for index, href in enumerate(hrefs, start=1):
            path, query = split_href(href)
            token = b"STLP" + index.to_bytes(4, "big")
            pending[token] = href
            sent_at[token] = time.monotonic()
            try:
                tls.sendall(
                    build_coap_tcp_get(path, token=token, query=query, accept=accept)
                )
            except OSError as error:
                print(f"  -> GET {href} failed to send: {redact(str(error))}")
                break
            print(f"  -> GET {href}")
            if args.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,
            )
            if outcome.closed:
                print("  the session is gone; the remaining hrefs were not sent")
                break
    
        if args.pipeline and pending:
            drain(
                tls,
                decoder,
                pending,
                results,
                time.monotonic() + args.timeout,
                save_dir=save_dir,
                sent_at=sent_at,
            )
        close_quietly(tls)
        return results, version
    
    
    def main() -> 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()
    
        if args.accept.lower() == "none":
            accept = None
        else:
            try:
                accept = int(args.accept)
            except ValueError:
                parser.error("--accept takes a content-format number or 'none'")
    
        if args.max_handshakes < 1:
            parser.error("--max-handshakes must be at least 1")
    
        hrefs = tuple(args.paths) if args.paths else DEFAULT_PATHS
        save_dir = args.save_dir
        if save_dir is not None:
            save_dir = prepare_save_dir(save_dir)
    
        budget = HandshakeBudget(args.max_handshakes)
        target = f"{args.host}:{args.port}" if args.show_host else f"<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)
        if not results:
            # 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("")
            if budget.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" if args.cafile else "not attempted")
        )
        print(f"  handshakes spent: {budget.used} of {budget.total}")
        for href in hrefs:
            code = results.get(href)
            if code is None:
                print(f"  {href}: TLS completed, no correlated CoAP response")
            else:
                print(f"  {href}: {code}")
        return 0
    
    
    if __name__ == "__main__":
        raise SystemExit(main())
  18. davids777 commented on Sep 29, 2026

    @davids777

    @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.

  19. Jason-Morcos commented on Sep 29, 2026

    @Jason-Morcos
    Contributor

    @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.

  20. QuiteYellow commented on Sep 30, 2026

    @QuiteYellow
    Owner

    @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.

  21. KRZ303 commented on Oct 3, 2026

    @KRZ303

    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.

    Full findings and integration links.

  22. Jason-Morcos commented on Oct 3, 2026

    @Jason-Morcos
    Contributor

    @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.

  23. KRZ303 commented on Oct 3, 2026

    @KRZ303

    @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.

  24. LoganSample680 commented on Oct 4, 2026

    @LoganSample680

    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!

  25. joazito commented on Oct 11, 2026

    @joazito

    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?

  26. blupine commented on Oct 14, 2026

    @blupine

    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

    • 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.

    The recovery method, target/principal checks, and HA results are documented in the detailed LocalThings #435 report.

  27. QuiteYellow commented on Oct 17, 2026

    @QuiteYellow
    Owner

    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:

    from smartthings_local.protocol.ocf_discovery import (
        discover_ocf_secure_ports, read_plaintext_ocf_resource)
    
    print(discover_ocf_secure_ports("<fridge-ip>"))               # advertised secure port(s), asks 5683 by default
    print(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.

    No idea what eSDK on 17654 is.

  28. QuiteYellow commented on Oct 17, 2026

    @QuiteYellow
    Owner

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    help wantedExtra attention is needed

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions