SmartestHome/identity/README.md

14 KiB

identity

The household's person <-> BLE-identifier registry, from Phase 6 of the project plan. Solves two concrete problems in one design:

  1. Multiple phones per person (the classic private/work phone situation).
  2. MAC address spoofing/randomization, so registration can't be tricked or accidentally fed garbage by a phone's own privacy features.
  3. People with no device at all (a grandmother without a smartphone, a one-off guest) — the system stays useful instead of just not knowing they exist.

Also backs the "who's home" and "register me" pieces of hosts/door-panel/ and hosts/kitchen-display/, and proxies the household weather topic those two dashboards both need.

The model

A person has zero or more identifiers. An identifier is a Home Assistant entity_id that resolves presence for one physical device. That's the entire schema (server.py's people/identifiers tables) — multi-phone support isn't a special case, it falls straight out of it: register once with your private phone, register again later with your work phone in hand, same spoken name, and you now have two identifiers under one person.

Anti-spoofing — the actual security boundary

A raw Bluetooth MAC address is never accepted as an identifier by itself, especially not a randomized one (the iOS/Android default — a phone's advertised MAC rotates every few minutes specifically so it can't be tracked as a stable identifier by anyone, including this system). Registration only ever looks at entity_ids matching TRUSTED_ENTITY_PREFIXES — meant to contain only:

  • Home Assistant's Private BLE Device integration entities (Bermuda/HA resolve the rotating MAC back to a stable identity via the device's IRK — a cryptographic resolution, not string-matching a MAC), or
  • manually provisioned fixed-MAC BLE tag entities (a physical tag handed to a person specifically because its MAC doesn't rotate).

An attacker broadcasting an arbitrary spoofed MAC never produces a trusted candidate — it just doesn't show up in TRUSTED_ENTITY_PREFIXES at all, because the untrusted raw entity is a different entity_id than the resolved one. Spoofing a specific person's resolved identity would require their device's actual IRK secret, a materially higher bar than MAC spoofing. This is a defense against passive/ opportunistic spoofing, not a claim of cryptographic non-repudiation — if a household member's phone (and its IRK) is itself compromised, this system has no way to know that. Threat-model it as "keeps a stranger's phone from registering itself as you," not "biometric-grade proof of identity."

Never auto-commit on ambiguity

If a registration attempt finds zero, more than one, or an already-claimed candidate, nothing is written. The caller gets a reason back (and, for the ambiguous case, the candidate list) and a human disambiguates on the touchscreen — calling POST /register again with an explicit entity_id. The one case that does commit within a single call is the clean one (exactly one trusted, unclaimed candidate) — because the spoken "register me as <name>" command is the human confirmation; requiring a second round-trip for the unambiguous case would be pure friction with no safety benefit. This mirrors, rather than weakens, this project's existing "an identity merge must never auto-commit silently" rule: ambiguity is exactly the case that still needs a person.

The photo is an audit trail, not face recognition

POST /register/photo stores whatever the calling kiosk's camera captured at registration time, purely as a "who did this, when" reference photo — it is never run through any face-matching or biometric pipeline. Building that would mean either standing up a new ML pipeline from scratch or wiring this device's camera into Frigate's existing face recognition (Phase 5) as a second camera source — both real, both out of scope for this pass. BLE/IRK resolution, not the camera, is what actually decides who's registering. If you want camera-based identity later, Frigate's own face-recognition + enrollment (0.16+) is the piece to wire in, not a new pipeline here.

People without a device

Two paths, distinct on purpose because they solve different problems:

  • A known person with no device (the grandmother case) — POST /register with "no_device": true and a real name. Skips candidate lookup entirely; creates the person (or reuses them by name, same dedup as the normal path) with zero identifiers. On the touchscreen this is the "I don't have a phone or tag" checkbox next to the name field; there's no voice phrasing for it yet (a boolean flag doesn't fit the single-utterance design cleanly — say it on the touchscreen for now). The long-term fix for this exact case is a physical fixed-address BLE tag (docs/components.md's "Fixed BLE tags" line) so they do get automatic presence eventually — this flag is what makes the household not have to wait for that before the person exists in the system at all.
  • Someone the system doesn't need to identify (a one-off guest) — POST /register/guest, no name, no device. Always creates a new record ("Guest 1", "Guest 2", ...; never reused/deduped, unlike named people) — the touchscreen's "Add a guest" button. DELETE /people/<id> cleans up a stale one afterwards.

Neither path can ever resolve automatic presence (there's no identifier to check a state on) — that's what POST /presence/manual ({"person_id", "home"}) is for: a hand-operated Home/Away toggle, surfaced directly on hosts/door-panel/'s dashboard next to anyone with has_device: false in /presence's response. Until it's tapped at least once, /presence reports home: null ("unknown") for that person — never false, since defaulting a device-less person to "away" would be actively wrong the moment they're actually sitting in the next room, not just uninformative.

Floor-plan groundwork (not the floor plan itself)

/presence also reports a best-effort room per person (server.py's AREA_ATTRIBUTE, default area_id) — read from whichever HA area/room attribute your room-presence integration (Bermuda) attaches to a trusted entity's state, so a future floor-plan UI has live room-level data to plot without this service changing again. The floor plan itself — an image, a room<->coordinate mapping, any rendering — is deliberately not built here. There's no floor plan or fixed room list to design a coordinate format against yet; building one now would be guessing, not engineering. AREA_ATTRIBUTE's exact name is also a guess — verify it against a real Bermuda-tracked entity's attributes (Developer Tools -> States) before relying on room being populated at all; it degrades to null if missing, never breaks the response.

Voice: single-utterance, not multi-turn

The whole flow is designed around one spoken sentence: "register me as <name>" — not a multi-turn conversation ("what's your name?" / reply / "confirm?"). This is deliberate: HA Assist's multi-turn/continue-conversation support is newer and more version-sensitive than a single custom-sentence intent with a captured {name} slot, and a one-shot command is materially more robust to build against. The tradeoff is explicit up front: say your name in the same breath as the command, or use the touchscreen's own form instead.

Nothing under this repo builds the HA-side wiring — same convention as digest-engine's/admin-canvas's HA integration points. You need, in Home Assistant's own config:

# configuration.yaml (excerpt) — a custom sentence + intent script that calls this
# service's /register endpoint. VERIFY against your own HA version; this is a worked
# example, not a tested one.
intent_script:
  RegisterPerson:
    speech:
      text: "{{ message }}"
    action:
      - service: rest_command.identity_register
        data:
          name: "{{ name }}"
          device_id: "{{ trigger.device_id | default('unknown') }}"
        response_variable: reg_result
      - variables:
          message: "{{ reg_result.content.message }}"

rest_command:
  identity_register:
    url: "http://<container-host>:8097/register"
    method: POST
    headers:
      Authorization: "Bearer !secret identity_token"
      Content-Type: "application/json"
    payload: '{"name": "{{ name }}", "device_id": "{{ device_id }}"}'

Plus a custom sentence file (custom_sentences/en/register.yaml) mapping "register me as {name}" to the RegisterPerson intent — see HA's custom sentences docs. The same IDENTITY_TOKEN from identity.env has to be pasted into HA's secrets.yaml by hand; there's no way for this repo to push it there for you.

Configure

cp identity/identity.env.example /opt/smart-home/identity/identity.env
openssl rand -hex 32   # IDENTITY_TOKEN
chmod 600 /opt/smart-home/identity/identity.env
$EDITOR /opt/smart-home/identity/identity.env

Two things that must be filled in with real values before this does anything useful:

  • HA_TOKEN — a Long-Lived Access Token from HA's own UI (profile -> Security).
  • TRUSTED_ENTITY_PREFIXES — the actual entity_id prefixes your Private BLE Device / fixed-tag setup produces. The shipped default (device_tracker.pble_,device_tracker.bletag_) is a plausible guess, not confirmed against a real HA instance — check Developer Tools -> States yourself.

API

All endpoints are bearer-token gated (Authorization: Bearer <IDENTITY_TOKEN>), including the GETs — same reasoning as pantry-vision: this service has a published port because kiosk browsers call it directly, so the token is the actual boundary, not network placement.

Endpoint What it does
POST /register/photo raw image bytes -> {"photo_id": "..."} — an audit artifact, and also becomes the person's profile picture (see below)
POST /register {"name", "device_id", "photo_id"?, "entity_id"?, "no_device"?} -> registers, or returns a reason it couldn't (see above)
POST /register/guest {"device_id", "photo_id"?} -> registers "Guest N", no name needed
GET /people admin/audit list of every registered person + their identifiers + has_photo
GET /people/<id>/photo the person's profile picture (raw JPEG) — their most recent registration photo
DELETE /people/<id>/identifiers/<id> revoke a mistaken or compromised identifier
DELETE /people/<id> remove a person entirely (their identifiers go with them) — mainly for cleaning up stale Guest records
POST /presence/manual {"person_id", "home"} — hand-operated Home/Away for anyone with no identifiers
GET /presence {"people": [{"id", "name", "home", "room", "has_device", "has_photo"}], "generated_at"}home is true/false/null (unknown), room is best-effort floor-plan groundwork (see below)
GET /weather proxies smarthome/weather/current, same JSON shape (temperature/condition/location) hosts/thin-client's weather overlay already uses

Every person gets a profile picture, automatically — whichever registration photo was captured most recently for them (_set_profile_photo() in server.py), no separate upload step. A device-less registration or a flaky camera just means no photo yet, not a missing feature — both frontends fall back to a plain circular placeholder (👤) until one exists. Fetched via GET /people/<id>/photo with a blob

  • createObjectURL() on the frontend side, not a bare <img src="..."> — the endpoint is bearer-token gated like everything else here, and a plain <img> tag has no way to send an Authorization header.

Manual verification still outstanding

  1. TRUSTED_ENTITY_PREFIXES' defaults are guessed, not confirmed against a real Private BLE Device / Bermuda setup — the single biggest thing to check before trusting registration at all.
  2. The worked intent_script/rest_command/custom-sentence YAML above is written against HA's documented shape, not tested against a running HA instance.
  3. PRESENT_STATES = {"home"} assumes Private BLE Device's device_tracker entities use the standard home/not_home vocabulary — check yours actually does.
  4. The >1 candidate (ambiguous) and already_claimed (conflict) paths are logically covered but never exercised against two real phones in the same room.
  5. Multi-device dedup relies on exact-name case-insensitive matching (WHERE name = ? COLLATE NOCASE) — two different people who happen to share a first name would collide into one record. Register with full names if that's a real risk in your household; nothing here disambiguates same-name people.
  6. SQLite at /data/identity.db has no backup wiring yet — if ENABLE_BACKUPS is on in setup-container-host.sh, confirm /opt/smart-home/identity is actually covered by whatever paths restic is pointed at (photos under /data/photos too — losing them just loses profile pictures, not the person records themselves, but still worth covering).
  7. AREA_ATTRIBUTE's default (area_id) is a guess at what Bermuda actually attaches to a trusted entity's state — unconfirmed, and the whole room field in /presence degrades to null silently if it's wrong, so this could easily go unnoticed until someone builds the actual floor-plan UI and finds it empty.
  8. The blob+createObjectURL() profile-picture fetch (both frontends) has not been checked for a memory leak from never calling URL.revokeObjectURL() on the old blob URL when /people//presence refreshes and re-fetches the same photo — likely fine at household scale and dashboard.js's 60s poll cadence, not measured over a multi-day uptime.