SmartestHome/identity/README.md

291 lines
17 KiB
Markdown

# identity
The household's person <-> BLE-identifier registry, from
[Phase 6 of the project plan](../docs/project-plan.md). 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_id`s 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.
## Chore-system settings — owned here, used by `chores/`
Two per-person fields, set via `POST /people/<id>/chore-settings`. **No frontend
for this exists yet** — neither `register.html` nor `dashboard.html` expose a way
to set them — call the endpoint directly (an HA script/automation, or `curl`) until
one is built. Read by `chores/` off `GET /presence`:
- **`chore_exempt`** — a household member who's tracked for presence/identity like
anyone else but never nudged about chores in general (the "cousin visits often
but doesn't owe me chores" case). **Litter is the deliberate exception**
`chores/check.py`'s `_EXEMPTIONS_DONT_APPLY` still nudges an exempt person about
putting trash they left out into the bin, because that responsibility isn't
"doing a chore," it's cleaning up after yourself.
- **`chore_reminder_style`** — free text describing how a person wants to be
reminded ("be assertive, don't let up" / "be gentle, give me a few minutes of
grace"). `chores/` passes this to an LLM that **phrases** the reminder message in
that style — it never decides *who* or *when* to nudge, only *how the words come
out*, per that system's own hard rule that presence/schedule drives every
assignment decision (see `chores/README.md`). Empty/unset falls back to a plain,
un-styled template with no LLM call at all.
Both fields live on `people` (not a separate table) because they're household-
standing facts about a person, same category as their name or photo — `identity` is
already this project's source of truth for who someone is, so this is where "how do
I relate to this specific household member" facts belong, not duplicated into
`chores/`'s own database.
## Camera face recognition — a second presence signal, never a registration one
If Tapo pan/tilt cameras are wired into Frigate as additional camera sources
(`docs/project-plan.md` Phase 20) and their faces are enrolled in Frigate's own
0.16+ face recognition, `identity` subscribes to `FRIGATE_EVENTS_TOPIC`
(`frigate/events` by default) and treats a recognized name matching a registered
person (case-insensitive) as a **corroborating** presence signal — `home` becomes
`true` if either their BLE identifier reports present *or* their face was seen in
the last `FACE_PRESENCE_WINDOW_SECONDS`. This is genuinely useful for the
device-less case too (a grandmother with no phone can now show as home the moment a
camera recognizes her, not just via the manual toggle).
**It is never a registration signal**`/register` never reads
`_last_face_seen`, and there's no path from "camera saw a face" to "a new person
got created." That stays BLE/IRK-only and human-confirmed, per this file's
"Anti-spoofing" section above; the camera can corroborate an existing person's
presence, never mint a new identity.
`sub_label = ["name", confidence]` is Frigate's documented shape for object
sub-labels generally (used for both face and license-plate recognition plugins);
whether Frigate 0.16+'s specific face-recognition feature publishes into that exact
field on `frigate/events` is **not verified against a real deployment** — a wrong
topic or field name just means this signal never fires, degrading silently back to
BLE/manual presence only.
## 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:
```yaml
# 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](https://www.home-assistant.io/voice_control/custom_sentences/).
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
```sh
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 |
| `POST /people/<id>/chore-settings` | `{"chore_exempt"?, "chore_reminder_style"?}` — see below; either field omitted/`null` leaves it unchanged |
| `GET /presence` | `{"people": [{"id", "name", "home", "room", "has_device", "has_photo", "chore_exempt", "chore_reminder_style"}], "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.