Skip to content

Add bound Samsung server certificate profile - #33

Merged
QuiteYellow merged 1 commit into
QuiteYellow:mainfrom
Moballo-LLC:codex/py-07-certificate-profiles
Aug 15, 2026
Merged

Add bound Samsung server certificate profile#33
QuiteYellow merged 1 commit into
QuiteYellow:mainfrom
Moballo-LLC:codex/py-07-certificate-profiles

Conversation

@Jason-Morcos

@Jason-Morcos Jason-Morcos commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Context

This is the third authentication slice agreed in #28. The no-behavior-change CertificateAuth refactor (#31) and the isolated PskAuth provider (#32) are merged and released as v0.1.4 and v0.1.5.

For the newer laundry certificate cohort, an already-authorized connection required all of these details together:

  1. an existing authorized client certificate and key;
  2. a P-256 ClientHello limited to ECDHE-ECDSA-AES128-GCM-SHA256;
  3. the ordered signature offer RSA+SHA256:ECDSA+SHA256:RSA+SHA1:ECDSA+SHA1, with session tickets disabled;
  4. the applicable Samsung CA chain; and
  5. verification that the server leaf has the expected Samsung role and exact hardware-certificate UUID.

The HW-S61B details reported in this PR show that the same wire profile also appears on Samsung VD-family devices, while their certificate UUID can be distinct from the runtime OCF UUID reported by /oic/d.

What changes

  • Adds immutable, opt-in SamsungServerProfile.bound_device(...).
  • Treats expected_certificate_identity as the UUID embedded in the hardware leaf, without assuming it equals the runtime OCF device UUID.
  • Adds explicit SamsungServerRole.HOME_APPLIANCE and SamsungServerRole.VD_DEVICE roles for OU=OCF HA Device and OU=OCF VD Device respectively.
  • Keeps the home-appliance role as the default for existing calls; there is no automatic fallback between roles.
  • Integrates the profile through CertificateAuth.from_files and from_memory for client-certificate sessions.
  • Adds ServerCertificateAuth for a server-authenticated channel that sends no client certificate.
  • Keeps the default CertificateAuth context setup and verification callback unchanged.
  • Restricts the profiled ClientHello to P-256, the existing ECDSA cipher, the observed ordered signature set, and no session ticket.
  • Preserves normal chain verification while requiring exactly one selected subject role and one canonical, non-zero certificate UUID in the common name.
  • Reads the already OpenSSL-verified peer leaf through the OpenSSL subject API, so the Samsung factory-leaf encoding reported on the HW-S61B and TU7x does not depend on cryptography reparsing it. Subject parse failures are logged and still fail closed.
  • Allows one to four optional, context-scoped additional CA certificates and rejects malformed PEM, trailing content, duplicate certificates, leaf trust anchors, and missing CA constraints.
  • Includes a pyOpenSSL 23.1 compatibility path whose emitted ClientHello is wire-tested against the current exact curve-list path.

Identity and ownership boundary

The certificate UUID is a hardware identity, not necessarily the OCF application identity. A caller must obtain it through a trusted path, pin it before connecting, and separately bind the authenticated session to the expected runtime OCF UUID. The profile never learns an identity from the first LAN endpoint it reaches.

ServerCertificateAuth covers only the authenticated DTLS carrier used by server-authenticated flows such as manufacturer-certificate OTM. Credential discovery, derivation, ownership decisions, OTM resource writes, provisioning, rotation, persistence, and cloud/account access remain outside this PR.

How this relates to #16 and #20

Validation

  • 186 tests with current dependencies.
  • 186 tests on Python 3.11 with the declared pyOpenSSL 23.1 dependency floor.
  • All 1,070 LocalThings tests against this exact package.
  • A complete synthetic DTLS handshake where the server requests a client certificate and the server-only provider sends none while verifying the VD-role chain and pinned UUID.
  • A signed synthetic VD leaf with trailing NULL data in both signature AlgorithmIdentifier values: OpenSSL chain verification and the full DTLS handshake succeed, while the wrong subject role and UUID still fail.
  • Cross-role and cross-identity rejection, generated valid/invalid chains, matching/mismatched identities, missing CA extensions, malformed subjects, default/profile isolation, and cold-context reuse.
  • Wire-level assertions for cipher suites, P-256 supported groups, signature algorithms, and absent session tickets on both OpenSSL paths.
  • Share-safety, Ruff, distribution contents, exact-head wheel/sdist builds, and isolated wheel/sdist imports.
  • Commit 83a5973 is signed and uses Jason’s GitHub noreply address.

Related PRs

This PR is one commit directly on v0.1.6 and has no dependency on unmerged work.

@vmonkey

vmonkey commented Aug 13, 2026

Copy link
Copy Markdown

I have a Samsung soundbar here (HW-S61B, oic.d.networkaudio, Tizen 6.5, firmware HW-S61BWWB-1010.0) and went through how this profile would handle it. Most of it lines up with what I see on the wire:

  • the server selects ECDHE-ECDSA-AES128-GCM-SHA256, P-256;
  • its CertificateRequest advertises exactly the sig-alg order used here (RSA+SHA256:ECDSA+SHA256:RSA+SHA1:ECDSA+SHA1);
  • no session tickets get used;
  • it sends two certificates (leaf issued by Samsung Electronics OCF VD Device SubCA v1, root omitted), so the bundled root CA is enough for chain verification.

The leaf subject checks are where it breaks for this model:

  1. The OU has to be exactly OCF HA Device. This leaf says OU=OCF VD Device and there's no way to override it, so verification always fails.
  2. The CN UUID has to equal the expected identity. On this soundbar the certificate's UUID is a factory value that doesn't match the runtime OCF device UUID (the one /oic/d reports, and the one the PSK handshake uses as its identity hint). Passing the runtime UUID fails the check. Passing the factory UUID would clear this check, but it would still fail on the OU above — and even if it passed, you'd be pinning an identity the OCF layer never uses.

This device selects manufacturer-cert OTM (oxmsel=2), which is a server-authenticated channel with no client identity leaf — exactly the case where pinning the server counts.

@Jason-Morcos
Jason-Morcos force-pushed the codex/py-07-certificate-profiles branch from 4138905 to a95002f Compare August 13, 2026 21:53
@Jason-Morcos

Copy link
Copy Markdown
Contributor Author

Thanks — this was a good catch. You are right on both points.

I had treated the UUID in the Samsung hardware leaf as though it were necessarily the runtime OCF UUID. They are separate identities on at least some Samsung PKI devices, so the latest revision now calls that input expected_certificate_identity and does not imply that it must equal /oic/d.

I also split the subject roles explicitly. The existing default remains SamsungServerRole.HOME_APPLIANCE / OU=OCF HA Device; the soundbar path uses SamsungServerRole.VD_DEVICE / OU=OCF VD Device. There is no fallback between them, and the selected profile still requires a valid chain, the exact role, and the exact pinned certificate UUID.

For the server-authenticated manufacturer-cert channel you described, there is now a provider that does not load or send a client certificate:

profile = SamsungServerProfile.bound_device(
    factory_certificate_uuid,
    role=SamsungServerRole.VD_DEVICE,
)
auth = ServerCertificateAuth(server_profile=profile)

I added a complete synthetic DTLS handshake test where the server requests a client certificate, the client sends none, and the client still verifies the VD-role chain and pinned certificate UUID. The emitted ClientHello retains the P-256, cipher, sig-alg order, and no-ticket contract you observed.

This should cover the authenticated transport part of the HW-S61B case. It deliberately does not perform manufacturer-cert OTM, and the caller still needs a trusted way to obtain the factory certificate UUID and bind that authenticated session to the expected runtime OCF UUID. If you are able to try the updated branch against the soundbar, that would be very helpful.

@vmonkey

vmonkey commented Aug 14, 2026

Copy link
Copy Markdown

I ran the updated branch against my soundbar. The connection side all works: the restricted ClientHello gets accepted, the client sends an empty Certificate when asked, the ECDHE-ECDSA-AES128-GCM-SHA256 session comes up, the chain verifies against the bundled root CA, and GET /oic/d over that session returns 2.05.

The failure happens before the subject checks. certificate.to_cryptography() itself raises on this leaf:

ValueError: error parsing asn1 value: ParseError { kind: ExtraData, location: ["Certificate::tbs_cert", "TbsCertificate::signature_alg"] }

The factory cert has trailing bytes inside TbsCertificate::signature_alg, so it isn't clean DER. OpenSSL doesn't mind and the chain verifies fine at every depth, but cryptography 50 refuses to parse the cert at all, and the blanket except turns that into a silent False. Your synthetic certs are always well-formed, which is why the synthetic test doesn't catch it. (pyOpenSSL 26.4.0, cryptography 50.0.0, cffi 2.1.1 here.)

What worked for me was falling back to OpenSSL's name parser when to_cryptography() fails:

+_NAME_OIDS = {
+    "CN": NameOID.COMMON_NAME,
+    "OU": NameOID.ORGANIZATIONAL_UNIT_NAME,
+    "O": NameOID.ORGANIZATION_NAME,
+    "C": NameOID.COUNTRY_NAME,
+}
+
+def _subject_components(certificate):
+    try:
+        return [
+            (attribute.oid, attribute.value)
+            for relative_name in certificate.to_cryptography().subject.rdns
+            for attribute in relative_name
+        ]
+    except ValueError:
+        # Lenient path for factory leaves that are not clean DER
+        # (extra bytes inside TbsCertificate::signature_alg).
+        with warnings.catch_warnings():
+            warnings.simplefilter("ignore", DeprecationWarning)
+            pairs = certificate.get_subject().get_components()
+        return [
+            (_NAME_OIDS[key.decode()], value.decode())
+            for key, value in pairs
+            if key.decode() in _NAME_OIDS
+        ]

(_verify_peer then calls _subject_components(certificate) instead of the inline comprehension.)

Only the parsing gets relaxed here, all the checks stay the same. With this in place, VD_DEVICE + factory cert UUID verifies, and both controls (runtime OCF UUID, HOME_APPLIANCE role) still refuse like they should. So the role split and expected_certificate_identity are fine for this device — the strict DER parse was the only thing in the way.

@vmonkey

vmonkey commented Aug 14, 2026

Copy link
Copy Markdown

I also tried the same setup on a second device I have here — a Samsung TV (oic.d.tv, TU7x series, T-KTSU2 firmware). Different model family from the soundbar, same VD cohort, and identical results end to end:

  • chain: leaf OCF Device: 2017_TV (<uuid>), OU=OCF VD DeviceSamsung Electronics OCF VD Device SubCA v1Samsung Electronics OCF Root CA. The bundled root CA is enough again.
  • the restricted ClientHello is accepted, and an empty client Certificate completes the handshake;
  • the factory cert UUID doesn't match the runtime OCF UUID — same factory/runtime split as the soundbar;
  • same non-DER leaf: to_cryptography() fails with the identical ParseError { kind: ExtraData } at the identical spot (TbsCertificate::signature_alg).

With the parse fallback in place, VD_DEVICE + factory cert UUID verifies, GET /oic/d returns 2.05, and both controls (runtime OCF UUID, HOME_APPLIANCE role) still refuse.

So whatever issues these leaves does it the same way across VD model families and years: the strict-parse problem isn't specific to one device, and the VD role + certificate-identity pinning works as designed on both.

@QuiteYellow

Copy link
Copy Markdown
Owner

vmonkey's second and third reports are the blocker here, and I think the fix is smaller than the patch they offered.

The failure is a parser disagreement. OpenSSL has already parsed and verified this leaf by the time _verify_peer runs; the chain checks out at every depth. Then the callback hands the same bytes to cryptography through certificate.to_cryptography(), which refuses them:

ValueError: error parsing asn1 value: ParseError { kind: ExtraData,
  location: ["Certificate::tbs_cert", "TbsCertificate::signature_alg"] }

Two X.509 parsers, two answers. Samsung's factory leaves carry trailing bytes inside TbsCertificate::signature_alg, OpenSSL tolerates that, cryptography 50 does not. vmonkey saw it on an HW-S61B soundbar and again on a TU7x TV, so it holds across VD model families and years.

The blanket except Exception: return False at the end of the callback then turns a parse error into a verification refusal. A user hitting this is told their chain is invalid. They will go looking for a certificate problem that isn't there, and the obvious next move for someone debugging that is to stop verifying, which is the opposite of what this PR is for.

vmonkey's fallback works, but it keeps both parsers and adds a third path between them. I'd rather cut the second parser out of the peer-leaf path entirely:

components = [
    (key.decode(), value.decode())
    for key, value in certificate.get_subject().get_components()
]

That is the cert OpenSSL just verified, read through the API that verified it. No NameOID map, no fallback branch, no version-dependent behaviour between cryptography releases. It's also what the OCF reference implementation does: iotivity-lite pulls the UUID out of the leaf CN via oc_certs_parse_CN_for_UUID, working off the mbedTLS-parsed certificate rather than re-parsing with something else. One parser, the TLS stack's.

to_cryptography() on the caller-supplied additional CA PEMs is fine to keep. Those are ours, they're well-formed, and the extension checks need it.

Three asks before this goes in:

  1. Drop to_cryptography() for the peer leaf, as above.
  2. Add a fixture leaf with trailing bytes in TbsCertificate::signature_alg and assert it verifies. Every cert in test_certificate_profiles.py is generated well-formed, which is why 180 green tests didn't catch a failure that two real devices hit on the first try.
  3. Split the blanket except Exception. A parse failure and a subject mismatch should not be indistinguishable to whoever reads the log. Log the parse failure, keep failing closed.

One non-blocking observation while you're in there. Pinning the exact OU/O/C tuple is stricter than iotivity-lite, which scans the CN for a UUID and doesn't constrain the rest of the subject. Given we've now seen two subject shapes from Samsung across two device classes, I'd expect a third eventually. Worth a comment in the code explaining why the tuple is pinned, so whoever hits the next cohort knows it was deliberate.

Everything else in here looks right to me. The role split and the rename to expected_certificate_identity are the correct response to vmonkey's first report, and separating the hardware cert identity from the runtime OCF UUID is a distinction I hadn't drawn before they raised it.

This is the only thing blocking the merge. If the parser change lands quickly the stack order is unaffected; separate comment on #34 about what happens if it doesn't.

@Jason-Morcos
Jason-Morcos force-pushed the codex/py-07-certificate-profiles branch from a95002f to 83a5973 Compare August 14, 2026 21:57
@Jason-Morcos

Copy link
Copy Markdown
Contributor Author

Thanks! agreed that the peer leaf should only go through the parser that already verified it.

I rebased this onto v0.1.6 and changed _verify_peer to read the subject through OpenSSL’s get_subject().get_components() API. to_cryptography() remains only for the caller-supplied CA certificates, where we need the extension checks.

I also removed the blanket exception. Subject parsing or decoding failures now produce a fixed warning and fail closed; ordinary role or UUID mismatches follow the normal verification-failure path without that parse warning. There’s also a comment explaining why the complete C/O/OU role remains deliberately pinned instead of following iotivity-lite’s CN-only check.

The regression fixture is fully synthetic. It adds the trailing NULL to both signature AlgorithmIdentifier values, recomputes the leaf signature, verifies the chain through OpenSSL, and completes the full no-client-certificate DTLS handshake. The same leaf is still rejected with the wrong role or certificate UUID. It also captures the parser-version difference: cryptography 50 rejects the encoding while the declared dependency floor accepts it.
This now follows the same OpenSSL subject-component path that has been working in the Home Assistant integration I’m migrating from.

@QuiteYellow
QuiteYellow merged commit e9aee1c into QuiteYellow:main Aug 15, 2026
8 checks passed
@QuiteYellow

Copy link
Copy Markdown
Owner

Merged in v0.1.7, thanks. Reading the peer leaf through OpenSSL's get_components() and keeping to_cryptography() only for the caller-supplied CA certs is the right split. The trailing-NULL fixture is a good proof of why: it's a leaf OpenSSL accepts that cryptography 50 rejects, which is exactly the reparse we're avoiding.

One minor thing for a follow-up, non-blocking: _verify_peer decodes the subject components as ASCII, so a legitimate leaf carrying a UTF8String subject with any non-ASCII byte would fail closed. Failing closed is the safe direction, but if a real Samsung leaf ever has a non-ASCII CN it would be rejected with just the generic parse warning. Worth decoding as UTF-8, or handling the components explicitly, in a follow-up.

On the soundbar: I don't have any VD hardware here, so I couldn't exercise the VD-role path against a real device; only the synthetic handshake test covers it. If you're able to point the ServerCertificateAuth path at your soundbar, that would close the last gap.

@vmonkey

vmonkey commented Aug 15, 2026

Copy link
Copy Markdown

Thanks!

Re-ran merged v0.1.7 (dec84c9) ServerCertificateAuth against my HW-S61B — VD_DEVICE hardware (leaf OU OCF VD Device; /oic/p reports vid: VD-NetworkAudio-002S).

Three expectations, 3/3 met:

  1. VD_DEVICE + pinned factory cert UUID → handshake OK. Restricted ClientHello accepted, empty client cert accepted, ECDHE-ECDSA-AES128-GCM-SHA256 session up, chain verified against the bundled OCF root CA, GET /oic/d → 2.05 (166 B).
  2. Negative control — pin the runtime OCF /oic/d UUID instead → handshake refused (SessionError).
  3. Negative control — HOME_APPLIANCE role → handshake refused (SessionError).

Data flow over the no-client-cert channel, all read-only GETs:

resource code
/oic/d 2.05
/oic/p 2.05 (mnmo: HW-S61B, mnfv: HW-S61BWWB-1010.0)
/sec/provisioninginfo 2.05
/oic/res 2.05 (64 links)
/sec/networkaudio/deviceinfo 4.01 (ACL gate, as designed)

So the merged get_components()-only parse is confirmed on a real VD leaf; the trailing-NULL fixture matches this device's failure mode under the old to_cryptography() path (cryptography refused to parse the leaf's TbsCertificate::signature_alg).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants