Files
Marc Billow 81dbc6fa03 Key devices on the OCF device ID instead of the serial number
Two Samsung air purifiers of the same model report the identical, well
formed serialNum `BS7SP9AW400114A` (issue #381). Since the entry's
unique_id, the device registry identifiers and every entity unique_id
were all minted from that string, the second unit was refused as already
configured, and would have collided entity-for-entity even if it hadn't
been.

This is the third firmware family to ship an unusable serialNum, after
`Nothing(SVC)` (#83) and the flash-unset sentinel (#189), and the first
one no heuristic can catch: the value is well formed, it's just shared.
`is_placeholder_serial` was a dead end.

So identity moves onto /oic/d's `di`, falling back to /oic/p's `pi`, then
the serial, then the host. `di` is what the protocol already uses to
address the endpoint -- if it were wrong or shared, OCF discovery and the
DTLS association wouldn't work at all -- and it's device-scoped, where
`pi` is platform-scoped and would be shared by a board hosting several
logical devices. Both units in #381 report a distinct `di`. A board that
answers neither resource lands exactly where it did before, so no
existing hardware regresses.

The re-key can't happen in async_migrate_entry: the UUID is only readable
from the device, and an entry can load entirely from its snapshot while
the appliance is off (#295). So v3 -> v4 only records the legacy key, and
the coordinator adopts the UUID on the first live poll, rewriting the
entity registry, the device registry (including subdevice identifiers)
and the entry's unique_id together. Rewriting rather than recreating is
what lets a user keep entity_ids, names, areas, statistics and every
automation that references them.

Three rules keep that adoption from misfiring:

- A poll that reads no UUID never demotes a UUID-keyed entry back onto
  its serial, so one failed reconnect doesn't re-key every entity.
- A changed UUID is followed only when the serial still corroborates it
  (a factory reset may regenerate `di`) or when the entry was keyed on
  its IP, which was never an identity to defend.
- When the identity is rejected as a different appliance, the serial
  isn't adopted either -- otherwise the intruder would gain exactly the
  corroboration needed to win the next poll.

Also stop redacting `di`/`pi` from diagnostics. They're randomly assigned
per-unit UUIDs, not account data, and blanking them is what made the
first #381 diagnostics download unable to answer the only question it was
requested to answer. The owner-set device name stays redacted.

Fixes #381
2026-08-17 05:24:22 +00:00

133 lines
4.7 KiB
Python

"""Tests for registry.redact — the safety net for diagnostics downloads."""
import json
from pathlib import Path
from custom_components.localthings.registry.batch import parse_device0_batch
from custom_components.localthings.registry.redact import REDACTED, redact_resources
FIXTURES = Path(__file__).resolve().parent / "fixtures"
def _load(name: str) -> dict:
data = json.loads((FIXTURES / name).read_text())
return parse_device0_batch(data["device0"])
def test_redacts_known_sensitive_fields_in_dishwasher_dump():
resources = _load("dishwasher_device.json")
redacted = redact_resources(resources)
info = redacted["/information/vs/0"]
assert info["x.com.samsung.da.serialNum"] == REDACTED
assert info["x.com.samsung.da.otnDUID"] == REDACTED
wireless = redacted["/wirelessinfo/vs/0"]
assert wireless["macaddressWiFi"] == REDACTED
assert wireless["macaddressBLE"] == REDACTED
provisioning = redacted["/voice/provisioning/vs/0"]
headers = provisioning["voice.provisioning.headers"]
assert headers["login_id"] == REDACTED
deviceinfo = provisioning["voice.provisioning.deviceinfo"]
assert deviceinfo["voice.provisioning.deviceinfo.accesstoken"] == REDACTED
assert deviceinfo["voice.provisioning.deviceinfo.deviceid"] == REDACTED
assert deviceinfo["voice.provisioning.deviceinfo.userid"] == REDACTED
def test_ordinary_state_fields_survive_untouched():
resources = _load("dishwasher_device.json")
redacted = redact_resources(resources)
op_state = redacted["/operational/state/vs/0"]
assert op_state["x.com.samsung.da.state"] == "Run"
assert op_state["x.com.samsung.da.progress"] == "Finish"
power = redacted["/power/vs/0"]
assert power["x.com.samsung.da.power"] == "On"
dishwasher = redacted["/dishwasher/vs/0"]
assert dishwasher["x.com.samsung.da.sanitize"] == "On"
assert dishwasher["x.com.samsung.da.rinseLevel"] == "4"
alarms = redacted["/alarms/vs/0"]["x.com.samsung.da.items"]
assert alarms[0]["x.com.samsung.da.code"] == "SNSF_Reached"
def test_redacts_known_sensitive_fields_in_refrigerator_dump():
resources = _load("refrigerator_device.json")
redacted = redact_resources(resources)
info = redacted["/information/vs/0"]
assert info["x.com.samsung.da.serialNum"] == REDACTED
wireless = redacted["/wirelessinfo/vs/0"]
assert wireless["macaddressWiFi"] == REDACTED
assert wireless["macaddressBLE"] == REDACTED
def test_redact_resources_does_not_mutate_input():
resources = _load("dishwasher_device.json")
original_serial = resources["/information/vs/0"]["x.com.samsung.da.serialNum"]
redact_resources(resources)
assert resources["/information/vs/0"]["x.com.samsung.da.serialNum"] == original_serial
def test_keeps_ocf_identity_uuids_but_redacts_the_owner_set_name():
"""/oic/d's `di` and /oic/p's `pi` survive redaction.
They are randomly-assigned per-unit UUIDs, not account data, and they
are what the entry's registry keys are minted from (issue #381) -- a
report that blanks them hides the identity every entity in it is named
after, and can't answer the one question a duplicate-serial report
exists to ask: whether two units differ here at all.
`n` is the opposite case and stays redacted: free text the owner sets
from the SmartThings app, so it can carry a person's name.
"""
redacted = redact_resources(
{
"/oic/d": {
"di": "ab-cd-ef",
"n": "Marc's Fridge",
"rt": ["oic.wk.d", "oic.d.refrigerator"],
},
"/oic/p": {"pi": "12-34-56", "mnmo": "RF9000B"},
}
)
assert redacted["/oic/d"]["di"] == "ab-cd-ef"
assert redacted["/oic/p"]["pi"] == "12-34-56"
assert redacted["/oic/d"]["n"] == REDACTED
# `rt`, the device-type signal we actually want out of /oic/d, is kept.
assert redacted["/oic/d"]["rt"] == ["oic.wk.d", "oic.d.refrigerator"]
assert redacted["/oic/p"]["mnmo"] == "RF9000B"
def test_bare_key_redaction_does_not_leak_into_substring_matching():
"""The bare keys are whole-key matches only -- plenty of ordinary
appliance fields contain those letters and must survive untouched."""
redacted = redact_resources(
{
"/x": {
"condition": "Normal",
"display": "On",
"dispenser": "Cubed",
"humidity": "45",
"spinSpeed": "1200",
"name": "FilterProgress",
},
}
)
assert redacted["/x"] == {
"condition": "Normal",
"display": "On",
"dispenser": "Cubed",
"humidity": "45",
"spinSpeed": "1200",
"name": "FilterProgress",
}