238 lines
14 KiB
Markdown
238 lines
14 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.
|
|
|
|
## 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 |
|
|
| `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.
|