From 273c9a2e1b52fcfebf9c97a8666f4e0f37ba3a41 Mon Sep 17 00:00:00 2001
From: Fredrik Ahlgren
Date: Fri, 7 Aug 2026 09:46:53 +0200
Subject: [PATCH 1/3] feat(api): let the app reach the box's own API, with a
role attached
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The app could name six things to ask its box. The box's own page can name 132,
so every new view in the app cost a box release. The session now carries the
box's HTTP API directly — api.req in, a status and a byte stream back — and a
view over a route the box already serves is the app's own work alone.
This is not a widening of the trust boundary. Those 132 routes are already
served on the home LAN with no authentication at all: anything on the network
can call them. Reaching the same handlers through a Noise session, pinned to a
device the box enrolled optically, with a role attached and a passkey ceremony
in front of every write, is strictly stronger than what households run today.
Five things keep it from becoming a hole, and the tier model is the one that
took the work. Deciding a route's tier from its HTTP method looked obvious and
was wrong twice: GET /api/caldav/credentials hands back a password that is a
write channel into dispatch, and POST /api/self_tune/start drives every battery
through ±3000 W. Both were reproduced against a viewer and an ordinary owner.
So every one of the 132 routes now carries an explicit tier declared beside its
handler, forgetting one does not compile, and a path no route claims is closed
rather than served. The sweep moved 15 routes to local-only — anything whose
answer is a credential or a whole file, or whose act needs somebody at the box.
Roles were named in contract/registry.yaml and enforced nowhere: every enrolment
was effectively an owner. An enrolment now carries one, the box checks the scope,
and a viewer is a viewer because the box says no rather than because the app
hides a button.
Two doors, and what each proves decides what it opens. The LAN proves presence,
so it is the only door that admits a new owner or mints a spoken code. A session
proves enrolment and nothing about location, so it sees the roster, invites a
viewer and revokes. The role is never a default: absent or unparseable is a 400.
Without that, opening the door would have turned every "invite the family to
look" into handing over the house — the app asked in a query string, the box
read a body, and an absent body meant owner.
A code the box shows completes the set: 40 bits of Crockford base32, minted only
on the LAN, five-minute life, burned after five wrong tries, redeemed in Noise
message 1. It is the way back when there is no other device.
Co-Authored-By: Claude Opus 5
---
.changeset/app-api-passthrough.md | 17 +
.changeset/app-roles-sharing-and-box-code.md | 19 +
.github/workflows/test.yml | 59 +-
contract/registry.yaml | 69 +-
docs/architecture.md | 177 ++++-
go/cmd/ftw/app_link.go | 124 ++-
go/cmd/ftw/bootstrap.go | 2 +-
go/cmd/ftw/main.go | 9 +-
go/internal/api/api.go | 431 +++++++----
go/internal/api/api_app_link.go | 325 +++++++-
go/internal/api/api_app_link_session_test.go | 536 +++++++++++++
go/internal/api/api_app_link_sharing_test.go | 334 ++++++++
go/internal/api/api_app_link_test.go | 60 +-
go/internal/api/api_passthrough_test.go | 732 ++++++++++++++++++
go/internal/api/api_tiers_test.go | 354 +++++++++
go/internal/api/api_viewer_writes_test.go | 218 ++++++
go/internal/api/security.go | 50 +-
go/internal/api/security_test.go | 52 +-
go/internal/apiauth/apiauth.go | 217 ++++++
go/internal/apiauth/contract_gen.go | 33 +
go/internal/apiauth/contract_test.go | 60 ++
go/internal/apiauth/generate.go | 8 +
go/internal/appenroll/boxcode.go | 129 +++
go/internal/appenroll/enroll.go | 372 ++++++++-
go/internal/appenroll/enroll_test.go | 148 +++-
go/internal/appenroll/roles_test.go | 599 ++++++++++++++
go/internal/appproto/command_test.go | 120 +++
go/internal/appproto/contract_gen.go | 24 +
go/internal/appproto/contract_test.go | 2 +-
go/internal/appproto/gencontract/cmd/main.go | 19 +-
.../appproto/gencontract/gencontract.go | 87 ++-
go/internal/appproto/generate.go | 2 +-
go/internal/appproto/handler.go | 143 ++++
go/internal/appproto/handshake_test.go | 1 +
go/internal/appproto/harness_test.go | 94 ++-
go/internal/appproto/history_test.go | 1 +
go/internal/appproto/messages.go | 96 +++
go/internal/appproto/passthrough.go | 603 +++++++++++++++
go/internal/appproto/passthrough_test.go | 625 +++++++++++++++
go/internal/appproto/price_test.go | 1 +
go/internal/appuplink/client.go | 13 +-
go/internal/appuplink/session.go | 64 +-
go/internal/appuplink/uplink_test.go | 177 ++++-
web/app-link-sharing.test.mjs | 372 +++++++++
web/settings/tabs/app.js | 227 +++++-
web/style.css | 45 ++
46 files changed, 7509 insertions(+), 341 deletions(-)
create mode 100644 .changeset/app-api-passthrough.md
create mode 100644 .changeset/app-roles-sharing-and-box-code.md
create mode 100644 go/internal/api/api_app_link_session_test.go
create mode 100644 go/internal/api/api_app_link_sharing_test.go
create mode 100644 go/internal/api/api_passthrough_test.go
create mode 100644 go/internal/api/api_tiers_test.go
create mode 100644 go/internal/api/api_viewer_writes_test.go
create mode 100644 go/internal/apiauth/apiauth.go
create mode 100644 go/internal/apiauth/contract_gen.go
create mode 100644 go/internal/apiauth/contract_test.go
create mode 100644 go/internal/apiauth/generate.go
create mode 100644 go/internal/appenroll/boxcode.go
create mode 100644 go/internal/appenroll/roles_test.go
create mode 100644 go/internal/appproto/passthrough.go
create mode 100644 go/internal/appproto/passthrough_test.go
create mode 100644 web/app-link-sharing.test.mjs
diff --git a/.changeset/app-api-passthrough.md b/.changeset/app-api-passthrough.md
new file mode 100644
index 00000000..154f43a5
--- /dev/null
+++ b/.changeset/app-api-passthrough.md
@@ -0,0 +1,17 @@
+---
+"ftw": patch
+---
+
+The FTW app can reach the box's own HTTP API over its session, and the box now knows which phone is asking. An `api.req` carries a method, a path under `/api/`, a parsed query and an optional body; the answer comes back as a status, then chunks, then an end. It runs in process through the same handler the LAN listener serves, trust boundary included. This is a security improvement rather than a relaxation: that API is already served on the home LAN with no authentication at all, and this door is pinned to an enrolled device.
+
+Who is asking now exists as a value. `appenroll.Authorise` returns the grant it has always known — device, role, enrolment epoch — instead of throwing it away on a yes-or-no answer, and it reaches the HTTP layer on the request context, never on the wire: `api.req` has no headers field, so there is no client byte that could become a caller claim. `api.SecureMutations` becomes `api.Authenticate`, which keeps a caller a session already authenticated and mints a local owner for anything off the LAN. That second branch writes down what the LAN already is; authenticating it later is a change to that one branch, because every handler from here on reads `apiauth.From`.
+
+A viewer cannot write, and the box is what refuses it. `cmd` finally checks the scope its own operation table has declared since the day it was written and never read, so `site.mode.set` from a viewer is rejected with the mode controller untouched. Configuration through the HTTP door needs the owner role and a step-up.
+
+Every one of the 132 routes names what it costs, beside the handler it governs, and the request's method is never consulted. 55 are reads, 40 configuration, 22 actuation and 15 local. The method is not asked because it does not know: `GET /api/caldav/credentials` hands out a password that is a write channel back into dispatch, and `POST /api/self_tune/start` pauses control and drives every battery through ±3000 W for minutes. Both read as ordinary from their verb alone.
+
+Anything that moves energy stays on `cmd`, naming the command to send instead where one exists — a command carries an expiry and the box revalidates against fresh state, and an HTTP request carries neither. Fifteen routes are local: their answer holds a credential or a whole file, or doing them needs somebody standing at the box, and the app is told so with `E_LOCAL_ONLY`. `POST /api/config` is refused for a third reason: it replaces the whole configuration, so a phone a year behind the box would silently drop every field it never knew about.
+
+The cost of naming every route is that a read view written in the app next year needs the box to have heard of the path. That is the direction worth being wrong in, and two things hold it there: `api.handle` takes the tier as a required argument, so leaving it out does not compile and an unknown value stops the box at startup; and a route that reaches the gate with no tier the gate knows is refused rather than served. The gate is one switch with a branch for every tier and a closed default — it replaced a chain of cases with no read branch at all, which is how a credential reached a shared viewer's phone.
+
+Revocation bites at once. The grant is re-read on every privileged request, so a socket cannot outlive a revoke, and tearing down a session now cancels the call it is making rather than only the next one. An answer in a media type the session cannot carry is refused before a byte streams, an oversized one stops at the ceiling and says it was truncated, and a handler that panics costs one request instead of the box.
diff --git a/.changeset/app-roles-sharing-and-box-code.md b/.changeset/app-roles-sharing-and-box-code.md
new file mode 100644
index 00000000..a0bf84fa
--- /dev/null
+++ b/.changeset/app-roles-sharing-and-box-code.md
@@ -0,0 +1,19 @@
+---
+"ftw": patch
+---
+
+A household can share its home, and the box is what enforces the difference. An enrolment now carries a role — `owner` or `viewer`, from `contract/registry.yaml`, with the role table generated rather than hand-written on either side. A row loaded from a file written before roles existed reads as an owner, so an update never silently demotes every paired phone.
+
+An invite is not a new cryptographic object. It is the same single-use pairing code with a different role behind it, so the QR payload does not change shape and the app's scanner learns nothing about sharing: a guest scans what an owner scans and is told what they are in `hello_ok`. The role is remembered by the box and stamped when the code is spent, never carried in the payload, because a role its holder can edit is not a role. One code is live at a time, across kinds, so asking for a guest pass cancels a pairing code still on a screen.
+
+Two rules stop a household locking itself out, both in `appenroll` rather than in the API layer — otherwise the box's own page could do what the app cannot. The first enrolment on a box is an owner whatever code it used, because a box with no owner can never be administered again. The last owner cannot be removed or stepped down, and the device list says so on the row instead of offering a button that fails.
+
+The last-owner refusal now carries a code as well as a sentence. Two audiences read these bodies: the box's own page prints the sentence, and the app owns every word it shows and needs a name to branch on. A 409 alone is a conflict and nothing more specific, so the app read a `code` key — which this floor had never sent, leaving the one refusal a household can meet through the app as the one refusal it could not explain. The code is `E_LAST_OWNER_PROTECTED` from `contract/registry.yaml`, through the generated constant, never a literal at the call site.
+
+Sharing has no screen of its own: a guest's phone is a paired phone, so it is a row in the same device list, with the same Remove. Locking out a stray key and taking a guest's access away are one action. A role change takes effect on a session that is already open, because both doors re-read the grant on every privileged request — a demoted owner loses their writes at the next one and keeps the readings they still have every right to see.
+
+A household does all of that from the app, and the box decides what the app may hand out. These routes have two doors and the doors prove different things: the LAN proves somebody is in the building, and an app session proves a phone is enrolled while saying nothing about where it is. So a session sees the roster, invites a viewer and locks a phone out — gated on `ftw.members.read` and `ftw.members.write`, neither of which a viewer's grant carries — and making another owner, by minting an owner's code or by promoting a row, still needs somebody at the box.
+
+The role is no longer defaulted anywhere. A request that named none used to mint an owner, on the reasoning that a page which has not been updated should keep meaning what it used to mean. What that reasoning costs is a default that hands over a house whenever a field goes missing, and the field did go missing: the app sent its role in a query string this endpoint does not read, so every "invite someone to view" arrived here naming no role. It is a 400 now. What a caller did not say is a question, not a blank to fill in on their behalf.
+
+There is now a way back in without a camera: a code the box shows, `XXXX-XXXX`, forty bits in Crockford base32 so I, L and O fold back to 1 and 0 for whoever wrote down what they heard. It is redeemed where a scanned code is, inside Noise handshake message 1, so there is no new endpoint and no new carrier. It re-admits a phone that already knows this box; a phone that has never seen it still has to scan, because the box's own key travels only in the square, and the page says that rather than offering a path that cannot work. What makes forty spoken bits safe is not their size: it is minted only on the LAN, shown only on the box's own page, spent once, five minutes long, and burned by five wrong guesses. The counter is on the code and not on the caller, because an address-keyed counter would inherit the relay's own bug, where the documented TLS terminator makes the whole fleet one address.
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 903f0340..0444d427 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -57,7 +57,12 @@ jobs:
while IFS= read -r file; do
[ -n "${file}" ] || continue
case "${file}" in
- go/*|drivers/*|config*.yaml|Dockerfile|Dockerfile.updater|.dockerignore)
+ # contract/ is here because the Go suite is what checks the
+ # generated constants against the registry. Without it, a pull
+ # request touching only contract/registry.yaml selected no
+ # suite at all — and TestContractGenIsCurrent, the one test
+ # that catches a stale contract_gen.go, never ran.
+ go/*|contract/*|drivers/*|config*.yaml|Dockerfile|Dockerfile.updater|.dockerignore)
core=true
;;
optimizer/*|Dockerfile.optimizer|go/internal/mpc/*|go/cmd/ftw/main.go)
@@ -396,6 +401,53 @@ jobs:
- uses: actions/checkout@v5
- run: make compose-migration-test container-boundary-test
+ # contract/registry.yaml is one file that lives in this repository and in
+ # srcfl/ftw-webapp, and only a job with both checked out can tell whether it
+ # still is one file. The header has always claimed CI fails when the two
+ # drift; nothing compared them, and they drifted three ways — a code each
+ # side had that the other had never heard of, and a retryable flag that
+ # disagreed in the direction that decides what a phone offers a user.
+ #
+ # Not gated on changed paths. The whole failure was a check that ran only
+ # sometimes, and the app can change its copy without a file here moving.
+ contract:
+ name: the registry has not drifted from the app
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ # One file, not the app's whole history.
+ - name: Check out the app
+ uses: actions/checkout@v5
+ with:
+ repository: srcfl/ftw-webapp
+ ref: main
+ path: .app
+ token: ${{ secrets.FTW_CONTRACT_TOKEN || github.token }}
+ sparse-checkout: contract/registry.yaml
+ sparse-checkout-cone-mode: false
+
+ - name: Compare the two copies
+ run: |
+ set -euo pipefail
+ # A missing checkout is not agreement. Said out loud here because the
+ # obvious way to write this step — diff and hope — passes silently
+ # when the path is wrong, which is exactly how the last guard failed.
+ if [ ! -f .app/contract/registry.yaml ]; then
+ echo "the app's copy is not at .app/contract/registry.yaml."
+ echo "the checkout above failed or moved; this check will not guess."
+ exit 1
+ fi
+ if ! diff -u contract/registry.yaml .app/contract/registry.yaml; then
+ echo
+ echo "This file is one file in two repositories. Decide which side"
+ echo "is right by reading what each side's code does, then change"
+ echo "both copies in the same pair of pull requests — and rerun"
+ echo "go generate ./internal/... here."
+ exit 1
+ fi
+ echo "byte for byte the same file"
+
e2e:
name: full stack
needs: changes
@@ -417,7 +469,8 @@ jobs:
test:
name: go test + vet
if: always()
- needs: [changes, core, optimizer, web, drivers, device-support-contract, compose, e2e]
+ needs:
+ [changes, core, optimizer, web, drivers, device-support-contract, compose, e2e, contract]
runs-on: ubuntu-latest
env:
RESULTS: >-
@@ -425,7 +478,7 @@ jobs:
${{ needs.optimizer.result }} ${{ needs.web.result }}
${{ needs.drivers.result }} ${{ needs.device-support-contract.result }}
${{ needs.compose.result }}
- ${{ needs.e2e.result }}
+ ${{ needs.e2e.result }} ${{ needs.contract.result }}
steps:
- name: Require every selected suite to pass
run: |
diff --git a/contract/registry.yaml b/contract/registry.yaml
index 1ef4d8f4..f5df3205 100644
--- a/contract/registry.yaml
+++ b/contract/registry.yaml
@@ -1,12 +1,29 @@
# FTW shared contract registry.
#
-# The single source for every name shared between this app and the box.
-# Generates TypeScript (src/lib/contract/generated.ts) and Go constants in
-# srcfl/ftw. CI fails when the two drift.
+# The single source for every name shared between this app and the box, and
+# the same file in both repositories — byte for byte.
+#
+# In srcfl/ftw it generates Go constants: go/internal/appproto/contract_gen.go
+# and go/internal/apiauth/contract_gen.go, from `go generate ./internal/...`.
+# In srcfl/ftw-webapp there is no generator — src/lib/protocol/contract.ts and
+# the error table in src/lib/protocol/messages.ts are written by hand and read
+# back against this file by tests/registry-contract.test.ts.
+#
+# The two copies are compared in CI on both sides: the app runs
+# scripts/check-contract-drift.mjs against a checkout of the box, and the box's
+# test workflow runs the same comparison against a checkout of the app. Neither
+# passes when the other repository is missing. Change one copy and change the
+# other in the same pair of pull requests.
#
# Never hand-write one of these names in either language. Three separate
# namespaces for authorisation already exist in the codebase; this file is
# what stops that from happening again.
+#
+# One YAML trap, learned the hard way: a `desc` is a flow-mapping value, so an
+# unquoted comma ends it. Go read `Route replaces a whole document, not part of
+# one` as `Route replaces a whole document` and generated a truncated comment
+# while the app's line-based reader saw the whole sentence. Keep commas out of
+# a desc, or quote it.
version: 1
@@ -54,6 +71,11 @@ capabilities:
# Electricity prices, when the box has a zone configured and rows stored.
# Absent means the app draws no price view rather than an empty one.
- price.spot
+ # The box's own HTTP API, carried over the session. Absent means the app
+ # hides every view that needs it and never crashes — the same rule as
+ # history.5m. Present does not mean every path is reachable: reads and
+ # configuration go through, anything that moves energy stays on cmd.
+ - api.passthrough
# ---------------------------------------------------------------------------
# Scopes. One object axis, two verb axes: ..
@@ -109,8 +131,8 @@ modes:
- { key: weighted, tier: hidden }
# ---------------------------------------------------------------------------
-# Error codes. The box sends the code and args; this app owns all prose.
-# retryable tells the client whether to offer a retry at all.
+# Error codes the box sends. The box sends the code and args; this app owns all
+# prose. retryable tells the client whether to offer a retry at all.
# ---------------------------------------------------------------------------
errors:
- { code: E_BOOTING, retryable: true, desc: Box is starting up }
@@ -123,6 +145,43 @@ errors:
- { code: E_LAST_OWNER_PROTECTED, retryable: false, desc: Cannot remove the only owner }
- { code: E_RANGE_TOO_LARGE, retryable: false, desc: History window exceeds the limit }
- { code: E_UNAVAILABLE, retryable: true, desc: Source or subsystem is down }
+ # The passthrough's refusals. Each is a different sentence to a user, which
+ # is why none of them reuses a code above: a shared name that means two
+ # things is what this file exists to prevent.
+ #
+ # E_NEEDS_STEP_UP is the one retryable refusal here, and it is retryable
+ # because the very same request goes through once the passkey ceremony has
+ # run — the box refuses on `!req.StepUp` alone and the app sends it again
+ # itself. The others are the box's answer about the route, and asking a
+ # second time gets the same answer.
+ - { code: E_NEEDS_STEP_UP, retryable: true, desc: Request needs a fresh passkey ceremony }
+ - { code: E_USE_CMD, retryable: false, desc: Route moves energy and belongs on cmd }
+ - { code: E_UNSUPPORTED_MEDIA, retryable: false, desc: Answer is not a kind the session carries }
+ - { code: E_WHOLE_DOCUMENT, retryable: false, desc: Route replaces a whole document rather than part of one }
+ # A route the session does not carry at all: its answer holds a credential,
+ # or doing it needs somebody standing at the box. Not a permission the owner
+ # is missing, so neither a role nor a ceremony changes the answer.
+ - { code: E_LOCAL_ONLY, retryable: false, desc: Route is served only on the box's own page }
+
+# ---------------------------------------------------------------------------
+# Error codes the client raises for itself.
+#
+# These never cross the wire. The box generates nothing from this block and
+# must never send one of them; the app raises them locally and they meet the
+# same prose and retry rules as everything above.
+#
+# They are written down here rather than in the app alone because the app's
+# error table is checked against this file in full. A code with no home here
+# would need an exemption in that check, and an exemption list is exactly the
+# thing this file exists to avoid.
+# ---------------------------------------------------------------------------
+client_errors:
+ # A cut-off answer is not an error on the wire: by the time the box knows it
+ # has run out of room, a status has gone out and the app is committed to it,
+ # so the box reports it as `truncated: true` on api.end. The app turns that
+ # into a code because half a document is not an answer and a view needs one
+ # thing to catch.
+ - { code: E_RESPONSE_TOO_LARGE, retryable: false, desc: The answer arrived cut off }
# ---------------------------------------------------------------------------
# Source states. Orthogonal to carrier state — see docs/protocol.md. Merging
diff --git a/docs/architecture.md b/docs/architecture.md
index 6d3d9d47..9713d958 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -163,8 +163,11 @@ NAT-traversal layer, no cloud account and no browser-managed site directory.
See [ADR 0006](adr/0006-app-uplink.md) for why Home Link was removed rather
than kept alongside it.
-The path is four packages:
+The path is five packages:
+- [`go/internal/apiauth`](../go/internal/apiauth) — who is asking. One value,
+ one context key and a scope set, importing nothing of the box so that every
+ source of callers can produce one;
- [`go/internal/appenroll`](../go/internal/appenroll) — the Noise static key,
the rotatable rendezvous secret, the single-use pairing code, the QR payload
and the list of app keys that have been let in. All of it is boot-time
@@ -198,6 +201,178 @@ The properties that matter:
and dispatch. An unavailable relay leaves local control and local recovery
intact.
+### Who is let in, and as what
+
+An enrolment carries a role from `contract/registry.yaml`: `owner`, which may
+change things, or `viewer`, which may only look. It is stamped by the box when
+a pairing code is spent, and it is stamped from the role the box minted that
+code for — never from anything the app sends, because a role a holder can edit
+is not a role.
+
+An invite is therefore not a new cryptographic object. It is the same
+single-use pairing code with a different role behind it, so the QR payload does
+not change shape and the app's scanner needs to know nothing about sharing. A
+guest scans what an owner scans and learns what they are from `hello_ok`. What
+stops a code being replayed is unchanged and holds for both: it is spent at the
+first success, it expires, it survives five wrong guesses and is then burned,
+and it rides encrypted inside Noise handshake message 1, so the relay carrying
+it never sees it.
+
+Two rules protect a household from locking itself out, and both live in
+`appenroll` rather than in the API layer — otherwise the box's own web UI could
+do what the app cannot:
+
+- **the first enrolment on a box is an owner**, whatever code it used. A box
+ with no owner cannot be administered by anybody, from anywhere, ever again;
+- **the last owner cannot be removed or stepped down.** A household can always
+ pair a second owner first and then remove the first.
+
+Sharing has no screen of its own. A guest's phone is a paired phone, so it is a
+row in the same device list, with the same Remove button: locking out a stray
+key and taking a guest's access away are one action, not two with two sets of
+bugs. Changing a role takes effect on a session that is already open, because
+both doors re-read the grant on every privileged request.
+
+The routes behind that list have two doors, and what each one PROVES decides
+what it opens:
+
+- **the LAN proves presence.** Somebody is in the building. That is the whole
+ authority behind a printed square, a guest pass and a spoken code, so it is
+ the only door that admits a new owner — by minting an owner's code or by
+ promoting a row — and the only one that mints a code to read aloud;
+- **an app session proves enrolment.** This is a phone the box already trusts,
+ authenticated by its Noise static key, carrying a role re-read on every
+ request. It proves nothing about where the phone is. It may see the roster,
+ invite a viewer and lock a phone out, gated on `ftw.members.read` and
+ `ftw.members.write`, which a viewer's grant does not carry.
+
+The role is never a default. A request that names none is refused rather than
+filled in for: the fallback used to be `owner`, so a caller whose role went
+missing — the app put it in a query string the box does not read — asked to
+share a view and was handed a house. What a caller did not say is a question,
+and the answer is 400.
+
+### The code that can be read aloud
+
+A phone with no printed QR and no other paired device still has to be able to
+get back into its home. The floor is somebody standing at the box, reading
+eight characters down a phone: `XXXX-XXXX`, forty bits from `crypto/rand`, in
+Crockford base32 so that I, L and O fold back to 1 and 0 for a listener who
+wrote down what they heard.
+
+It is redeemed exactly where a scanned code is, in Noise handshake message 1,
+so there is no new endpoint and no new carrier. The wire does not change: the
+app decodes the characters back to five bytes and sends those.
+
+**What it cannot do, and the box's page says so on the screen:** it does not
+let in a phone that has never seen this box. The typed characters are the
+pairing code and nothing else, while the box's static key and its rendezvous
+secret travel only in the QR payload — so a phone with no record of this box
+has no way to find it and no way to be sure the box answering is the right one.
+A typed code re-admits a phone that already holds those, from its own site
+record or from a recovery copy. Closing that gap would mean putting a key in a
+code somebody reads aloud, or trusting the relay to hand one over, and the
+second gives up the property the optical anchor exists for.
+
+Forty bits is safe to say out loud because of what surrounds it, not because of
+its size:
+
+- it is minted only through `POST /api/app-link/pairing`, and only from the
+ LAN. An app session reaches that route to invite a viewer by QR and is
+ refused a spoken code whatever role it asks for, because this bullet is the
+ argument that makes forty bits enough and a code mintable from anywhere in
+ the world would take it away. A forwarding header is grounds for refusal
+ rather than something to parse. Every minting costs somebody a walk to the
+ box;
+- it is shown only on the box's own page, it is spent on first use, and it
+ lives five minutes rather than a scanned code's ten;
+- **five wrong guesses burn it.** The counter is on the code, not on the
+ caller: an address-keyed counter would inherit the relay's own limiter bug,
+ where the documented TLS terminator makes the whole fleet one address. So a
+ guesser gets five tries per minting, and each minting needs a person standing
+ at the box;
+- a wrong guess costs a full Noise handshake and returns nothing at all.
+
+The cost is real and accepted: anyone who can reach the box can burn a live
+code by guessing at it, and the household has to ask for another. Denying
+somebody a code they can re-mint in a second is a far smaller harm than a code
+that can be ground down at leisure.
+
+### The app's window onto the HTTP API
+
+The app can ask the message layer for a handful of things; the box's own web UI
+asks its HTTP API for 124. Rather than grow the message layer one view at a
+time, an app session can carry an ordinary HTTP request: `api.req` in,
+`api.head`, `api.chunk` and `api.end` back, all on the bulk lane because every
+one of those varies in length with what was asked.
+
+It runs in process — no socket, no port, no TLS — through
+`api.Server.ServeHTTP`, which is the same handler the LAN listener serves,
+trust boundary included. This is a security improvement rather than a
+relaxation: that API is already served on the home LAN with no authentication
+at all, and this door is pinned to an enrolled device, gated by role and tier,
+and refused outright for anything that moves energy.
+
+Every route names its own tier, beside the handler it governs, in
+`api.routes`. The tier is a fact about what the handler DOES, and the request's
+method is never consulted:
+
+- **`Read`** answers a question, changes nothing, and hands back nothing that
+ could be replayed as authority. A shared viewer may ask for it;
+- **`Configure`** changes a setting. Owner role, and `stepUp` on the request. A
+ late execution is the same instruction, only later. `POST /api/config` also
+ carries `api.ReplacesAll` and is refused with `E_WHOLE_DOCUMENT`, because it
+ writes the whole document and a phone a year behind the box would drop every
+ field it never knew about;
+- **`Actuate`** moves energy, or takes control of what is moving it. Refused
+ with `E_USE_CMD` for everybody. Actuation has one door and it is `cmd`: a
+ command carries an expiry and the box revalidates against fresh state, and an
+ HTTP request carries neither. `api.Via(op)` names the command that does it,
+ where one exists;
+- **`Local`** is served only on the box's own page, at home. Either the answer
+ holds a credential or a whole file, or doing it needs somebody standing at
+ the box. Refused with `E_LOCAL_ONLY`, and neither a role nor a ceremony
+ changes that.
+
+The method used to decide this, and it was wrong twice. `GET
+/api/caldav/credentials` was priced as a read and handed a shared viewer a
+password that is a write channel back into dispatch. `POST
+/api/self_tune/start` was priced as ordinary configuration while it pauses
+control and drives every battery through ±3000 W for minutes. A verb cannot
+know what a handler does, so it is no longer asked.
+
+The cost is one line per route and no free reads: a view written in the app
+next year needs the box to have named the path. Two things make that the right
+direction to be wrong in. `api.handle` takes the tier as a required argument,
+so leaving it out does not compile and an unknown value panics at startup. And
+a route that reaches the gate with no tier the gate knows — registered any
+other way — is refused as `Local` rather than served. Allow-list over
+deny-list, one level up.
+
+The gate itself is one switch in `appproto.gateAPI`, with a branch for every
+tier and a closed default. That shape is deliberate: it replaced a chain of
+cases with no read branch at all, which is why a route the router happened to
+call a read met no check to fail.
+
+The honest limits, which belong here rather than in a comment nobody reads:
+
+- **step-up is a client-side gate.** `api.req` carries `stepUp`, and the box
+ cannot verify that a passkey ceremony happened — it has no relationship with
+ the authenticator, and being a WebAuthn relying party would need an origin,
+ which the box deliberately never has. It stops a phone left unlocked on a
+ table from being used to reconfigure the site. It stops nothing that a
+ modified client on an enrolled device could not already do through `cmd`;
+- **revocation is immediate at the box.** Three layers: the session is torn
+ down and the call it was making is cancelled, the grant is re-read from
+ `appenroll` on every privileged request so a socket cannot outlive a revoke,
+ and the next handshake fails. Nothing can un-send bytes already in a phone's
+ cache;
+- **the LAN is still unauthenticated.** `api.Authenticate` mints a local owner
+ for anything that arrives without a caller. That writes down what the LAN
+ already is rather than changing it, and it is the one branch that has to
+ change to authenticate the LAN later — every handler downstream reads its
+ caller from `apiauth.From`.
+
## Fleet ping
The box's other outbound path to Sourceful, and the only one that carries
diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go
index bca19092..3b3d5c44 100644
--- a/go/cmd/ftw/app_link.go
+++ b/go/cmd/ftw/app_link.go
@@ -4,10 +4,13 @@ import (
"context"
"errors"
"log/slog"
+ "net/http"
"sync"
+ "sync/atomic"
"time"
"github.com/srcfl/ftw/go/internal/api"
+ "github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/appenroll"
"github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/appuplink"
@@ -397,6 +400,7 @@ func startAppLink(
ctrlMu *sync.Mutex,
revision *control.Revision,
siteMeterStale time.Duration,
+ gateway *lateAPI,
) (*appenroll.Identity, *appuplink.Uplink, bool, error) {
enabled := cfg != nil && cfg.AppLink != nil && cfg.AppLink.Enabled
@@ -444,9 +448,19 @@ func startAppLink(
caps = append(caps, appproto.CapPriceSpot)
}
+ // The box's own HTTP API, reachable over the session. Bound after this
+ // returns, because the API server is built from dependencies that are not
+ // assembled yet; until then it answers 503 and the app says the box is
+ // starting, which is true.
+ caps = append(caps, appproto.CapApiPassthrough)
+
uplink, err := appuplink.New(appuplink.Options{
Enroll: enroll,
- Handler: func(sender appproto.Sender) (*appproto.Handler, error) {
+ Handler: func(
+ sender appproto.Sender,
+ caller apiauth.Caller,
+ grants appproto.GrantReader,
+ ) (*appproto.Handler, error) {
// One handler per app session. They share the ports above, which
// are read-only apart from the mode, and the mode goes through
// control's own validation.
@@ -458,6 +472,9 @@ func startAppLink(
Plans: plans,
History: history,
Prices: priceReader,
+ API: gateway,
+ Caller: caller,
+ Grants: grants,
Caps: caps,
Codec: appuplink.Codec(),
Sender: sender,
@@ -489,6 +506,49 @@ func siteMeterName(ctrl *control.State, ctrlMu *sync.Mutex) string {
return ctrl.SiteMeterDriver
}
+// lateAPI is the box's HTTP API as the app session sees it.
+//
+// Late-bound because the uplink starts before the API server is assembled.
+// Until it is bound every request answers 503, which the app already renders
+// as "your box is starting, this takes a few minutes after an update" — an
+// honest sentence rather than a silent hang.
+//
+// It is the same handler the LAN listener serves, trust boundary and all. The
+// passthrough deliberately does not reach past it to the bare mux: one door
+// with one set of checks, whichever side the request came in on.
+type lateAPI struct {
+ srv atomic.Pointer[api.Server]
+}
+
+func (l *lateAPI) bind(srv *api.Server) { l.srv.Store(srv) }
+
+func (l *lateAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ srv := l.srv.Load()
+ if srv == nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte(`{"error":"the box is still starting"}`))
+ return
+ }
+ srv.ServeHTTP(w, r)
+}
+
+func (l *lateAPI) Route(r *http.Request) apiauth.RouteFacts {
+ srv := l.srv.Load()
+ if srv == nil {
+ // No routes are registered yet, so nothing can be recognised as
+ // actuation — and nothing can be served either, since every request
+ // in this window meets the 503 above. The method still decides the
+ // tier, mirroring api.Route's first rule, so a write is not waved
+ // through in the seconds before the server exists.
+ if r.Method == http.MethodGet || r.Method == http.MethodHead {
+ return apiauth.RouteFacts{Tier: apiauth.TierRead}
+ }
+ return apiauth.RouteFacts{Tier: apiauth.TierConfigure}
+ }
+ return srv.Route(r)
+}
+
// appLinkAPI is the API's view of enrollment: the identity plus, when the
// uplink runs, the ability to tear a revoked phone's sessions down at once
// rather than at its next reconnect.
@@ -497,8 +557,24 @@ type appLinkAPI struct {
uplink *appuplink.Uplink
}
-func (a *appLinkAPI) MintPairingCode() ([]byte, time.Time, error) {
- return a.enroll.MintPairingCode()
+// MintPairingCode issues an owner's or a viewer's QR code.
+//
+// An invite is not a new cryptographic object: it is this same code, with a
+// role the box remembers and stamps when the code is spent. The payload does
+// not change shape, so the app's scanner needs to know nothing about sharing —
+// a guest scans what an owner scans and learns what they are from hello_ok.
+func (a *appLinkAPI) MintPairingCode(role string) ([]byte, time.Time, error) {
+ ttl := appenroll.PairingTTL
+ if role != apiauth.RoleOwner {
+ ttl = appenroll.InviteTTL
+ }
+ code, expires, err := a.enroll.MintPairingCode(role, ttl)
+ return code, expires, appLinkError(err)
+}
+
+func (a *appLinkAPI) MintSpokenCode(role string) (string, time.Time, error) {
+ code, expires, err := a.enroll.MintSpokenCode(role)
+ return code, expires, appLinkError(err)
}
func (a *appLinkAPI) EnrollmentURL(code []byte, lanHint string) (string, error) {
@@ -511,18 +587,30 @@ func (a *appLinkAPI) Devices() []api.AppDevice {
infos := a.enroll.Devices()
out := make([]api.AppDevice, 0, len(infos))
for _, d := range infos {
- out = append(out, api.AppDevice{ID: d.ID, AddedAtMs: d.AddedAtMs, LastSeenMs: d.LastSeenMs})
+ out = append(out, api.AppDevice{
+ ID: d.ID, AddedAtMs: d.AddedAtMs, LastSeenMs: d.LastSeenMs,
+ Role: d.Role, LastOwner: d.LastOwner,
+ })
}
return out
}
+// SetDeviceRole changes what one phone may do, and leaves its session up.
+//
+// No session is dropped, deliberately. The epoch moved with the role, and both
+// doors re-read the grant on every privileged request — so a demoted owner
+// loses their writes at the next one and keeps the readings they still have
+// every right to see. Tearing the session down would tell them their access
+// was withdrawn, which for a demotion is not true and for a promotion is the
+// opposite of true.
+func (a *appLinkAPI) SetDeviceRole(id, role string) error {
+ return appLinkError(a.enroll.SetRole(id, role))
+}
+
func (a *appLinkAPI) RevokeDevice(id string) error {
key, err := a.enroll.Revoke(id)
if err != nil {
- if errors.Is(err, appenroll.ErrUnknownDevice) {
- return api.ErrUnknownAppDevice
- }
- return err
+ return appLinkError(err)
}
// Forgetting the key locks the next handshake out; dropping the live
// sessions locks out the one running now. Both, or "remove" quietly
@@ -533,6 +621,26 @@ func (a *appLinkAPI) RevokeDevice(id string) error {
return nil
}
+// appLinkError translates enrollment's refusals into the API's.
+//
+// Two vocabularies because the API package does not import appenroll — and
+// because the refusals are the part a screen has to explain, so they are worth
+// naming once on each side rather than passing a package's errors through.
+func appLinkError(err error) error {
+ switch {
+ case err == nil:
+ return nil
+ case errors.Is(err, appenroll.ErrUnknownDevice):
+ return api.ErrUnknownAppDevice
+ case errors.Is(err, appenroll.ErrLastOwnerProtected):
+ return api.ErrLastAppOwnerProtected
+ case errors.Is(err, appenroll.ErrUnknownRole):
+ return api.ErrUnknownAppRole
+ default:
+ return err
+ }
+}
+
// appEnrollForAPI hands the enroller to the API, or nothing.
//
// A typed nil in an interface is not nil, so a disabled app link would give
diff --git a/go/cmd/ftw/bootstrap.go b/go/cmd/ftw/bootstrap.go
index bae59789..6fb40fdd 100644
--- a/go/cmd/ftw/bootstrap.go
+++ b/go/cmd/ftw/bootstrap.go
@@ -184,7 +184,7 @@ func runBootstrap(configPath, webDir, driverDir string) {
}
func secureBootstrapMutations(next http.Handler) http.Handler {
- return api.SecureMutations(next, apiMutationPolicy())
+ return api.Authenticate(next, apiMutationPolicy())
}
func serveStatic(w http.ResponseWriter, r *http.Request, webDir string) {
diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go
index eec2fe71..ece918d2 100644
--- a/go/cmd/ftw/main.go
+++ b/go/cmd/ftw/main.go
@@ -349,7 +349,7 @@ func main() {
apiHandler := newSwappableHandler(bootPhaseHandler())
httpSrv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.API.Port),
- Handler: api.SecureMutations(apiHandler, apiMutationPolicy()),
+ Handler: api.Authenticate(apiHandler, apiMutationPolicy()),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
@@ -2123,9 +2123,13 @@ func main() {
if identityState.Nova != nil {
boxID = identityState.Nova.PublicKeyHex()[:16]
}
+ // The app's window onto this box's own HTTP API. Handed to the uplink
+ // now and filled in below, once the API server exists.
+ appAPI := &lateAPI{}
appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink(
ctx, cfg, identityKeyPath, boxID, Version,
st, tel, mpcSvc, priceSvc, ctrl, ctrlMu, controlRev, appLinkWatchdog,
+ appAPI,
)
switch {
case appLinkErr != nil:
@@ -2222,6 +2226,9 @@ func main() {
Version: Version,
}
srv := api.New(deps)
+ // From here an app session reaches the same handler the LAN does, with
+ // the same trust boundary, carrying the enrolled device's identity.
+ appAPI.bind(srv)
// Dev-mode proxy: when FTW_PROXY_UPSTREAM is set (e.g.
// http://192.168.1.139:8080), /api/* is forwarded to that instance so
// the local UI renders live data without owning the control loop.
diff --git a/go/internal/api/api.go b/go/internal/api/api.go
index 579f6986..cdcbebe2 100644
--- a/go/internal/api/api.go
+++ b/go/internal/api/api.go
@@ -24,6 +24,12 @@ import (
"sync"
"time"
+ "github.com/srcfl/ftw/go/internal/apiauth"
+ // appproto for one constant: the name of the command that changes the
+ // site mode, so Via can tell the app what to send instead of
+ // POST /api/mode. The dependency runs this way only — appproto reaches
+ // this package through an interface and must never import it.
+ "github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/battery"
"github.com/srcfl/ftw/go/internal/calendar"
"github.com/srcfl/ftw/go/internal/config"
@@ -243,6 +249,11 @@ type Server struct {
// window. The record on disk is what survives a restart; these only make
// the revert prompt while the process lives.
drafts *driverDrafts
+
+ // marks is what a route says about itself beyond its method and path,
+ // keyed by the mux pattern. Written once at registration and read-only
+ // afterwards. See RouteMark.
+ marks map[string]routeMark
}
// New creates a new API server.
@@ -259,6 +270,7 @@ func New(deps *Deps) *Server {
dailyCache: make(map[string]state.DayEnergy),
controlStates: make(map[string]*controlDriverState),
drafts: newDriverDrafts(),
+ marks: make(map[string]routeMark),
}
if deps.Registry != nil {
// Registry removal is the lifecycle boundary for a driver generation.
@@ -275,160 +287,305 @@ func New(deps *Deps) *Server {
// Handler returns the http.Handler suitable for http.ListenAndServe.
func (s *Server) Handler() http.Handler {
- return SecureMutations(s.mux, s.deps.MutationPolicy)
+ return Authenticate(s.mux, s.deps.MutationPolicy)
}
+// ServeHTTP runs one request through the same handler the listener serves,
+// trust boundary included.
+//
+// It exists so an in-process caller — the app session's passthrough — reaches
+// the API by the same door as everything else. Reaching past it to the bare
+// mux would be a second door with fewer checks on the same handlers.
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ s.Handler().ServeHTTP(w, r)
+}
+
+// Route says what a route costs before it runs.
+//
+// The tier is read off the route's own registration and nowhere else. The
+// method is not consulted: a GET can hand out a password and a POST can drive
+// a battery, and both did. See apiauth.Tier.
+//
+// A path with no tier — registered past handle(), or matched by nothing at
+// all — comes back TierLocal, which the passthrough refuses. That is the
+// wrong-safe direction: the cost of getting it wrong is a view the app cannot
+// draw until somebody names the path, not a control a stranger can reach.
+//
+// The caller is the passthrough, which needs the answer before it runs
+// anything. Nothing on the LAN path consults it: the LAN is served with no
+// authentication at all, and pretending otherwise here would be a claim the
+// deployment does not support.
+func (s *Server) Route(r *http.Request) apiauth.RouteFacts {
+ facts := apiauth.RouteFacts{Tier: apiauth.TierLocal}
+
+ // The mux is asked which pattern matched rather than a second matcher
+ // being written here. A route marked "actuate" that a hand-rolled matcher
+ // failed to recognise would be an actuation the passthrough let through,
+ // so the only acceptable matcher is the one that also picks the handler.
+ if _, pattern := s.mux.Handler(r); pattern != "" {
+ if mark, ok := s.marks[pattern]; ok {
+ if mark.tier.Known() {
+ facts.Tier = mark.tier
+ }
+ facts.CmdOp = mark.cmdOp
+ facts.ReplacesAll = mark.replacesAll
+ facts.Static = mark.static
+ }
+ }
+ return facts
+}
+
+// routes is the whole route table, and every line names what its route costs.
+//
+// The tier is a fact about the HANDLER, never about the verb. Ask what the
+// code on the other side does:
+//
+// Read answers a question, changes nothing, and hands back nothing that
+// could be replayed as authority. A shared viewer may ask for it.
+// Configure changes a setting. Owner, with a step-up. A late execution is
+// the same instruction, only later.
+// Actuate moves energy, or takes control of what is moving it. Refused
+// through the passthrough — the app sends a cmd, which carries an
+// expiry the box revalidates. Add Via(op) to name that command.
+// Local the session does not carry it at all: the answer holds a
+// credential or a whole file, or doing it needs somebody standing
+// at the box. Neither a role nor a ceremony changes that.
+//
+// The method used to decide this, and it was wrong twice in one review: a GET
+// that hands out a CalDAV password, which is a write channel into dispatch,
+// and a POST that drives every battery through ±3000 W for minutes. Both read
+// as ordinary from their verb alone. Neither is.
+//
+// ReplacesAll is a separate mark rather than a tier: it says the body replaces
+// a whole document instead of editing part of one.
func (s *Server) routes() {
// ---- JSON endpoints ----
- s.handle("GET /api/health", s.handleHealth)
- s.handle("GET /api/status", s.handleStatus)
- s.handle("GET /api/system/info", s.handleSysInfo)
- s.handle("GET /api/storage/inventory", s.handleStorageInventory)
- s.handle("GET /api/config", s.handleGetConfig)
- s.handle("POST /api/config", s.handlePostConfig)
- s.handle("POST /api/drivers/verify_tesla", s.handleVerifyTesla)
- s.handle("GET /api/oauth/myuplink/start", s.handleMyUplinkOAuthStart)
- s.handle("GET /api/oauth/myuplink/callback", s.handleMyUplinkOAuthCallback)
- s.handle("POST /api/oauth/myuplink/exchange", s.handleMyUplinkOAuthExchange)
- s.handle("GET /api/mode", s.handleGetMode)
- s.handle("GET /api/app-link/status", s.handleAppLinkStatus)
- s.handle("POST /api/app-link/pairing", s.handleAppLinkPairing)
- s.handle("GET /api/app-link/devices", s.handleAppLinkDevices)
- s.handle("DELETE /api/app-link/devices/{id}", s.handleAppLinkDeviceRevoke)
- s.handle("GET /api/fleet-ping", s.handleFleetPing)
- s.handle("POST /api/mode", s.handleSetMode)
- s.handle("GET /api/modes", s.handleModes)
- s.handle("POST /api/target", s.handleSetTarget)
- s.handle("POST /api/peak_limit", s.handleSetPeakLimit)
- s.handle("POST /api/peak_import_ceiling", s.handleSetPeakImportCeiling)
- s.handle("POST /api/ev_charging", s.handleSetEVCharging)
- s.handle("POST /api/battery_covers_ev", s.handleSetBatteryCoversEV)
- s.handle("GET /api/drivers", s.handleDrivers)
- s.handle("GET /api/drivers/catalog", s.handleDriversCatalog)
- s.handle("POST /api/drivers/test", s.handleDriverTest)
- s.handle("GET /api/drivers/{id}/source", s.handleDriverSource)
- s.handle("POST /api/drivers/{id}/lint", s.handleDriverLint)
- s.handle("POST /api/drivers/{id}/draft", s.handleDriverDraft)
- s.handle("GET /api/drivers/{id}/draft", s.handleDriverDraftStatus)
- s.handle("POST /api/drivers/{id}/draft/keep", s.handleDriverDraftKeep)
- s.handle("POST /api/drivers/{id}/draft/revert", s.handleDriverDraftRevert)
- s.handle("POST /api/drivers/fingerprint", s.handleDriverFingerprint)
- s.handle("GET /api/drivers/{name}", s.handleDriverDetail)
- s.handle("GET /api/drivers/{name}/logs", s.handleDriverLogs)
- s.handle("GET /api/logs", s.handleGlobalLogs)
- s.handle("GET /api/support/dump", s.handleSupportDump)
- s.handle("GET /api/support/report", s.handleSupportReport)
- s.handle("POST /api/drivers/{name}/control", s.handleDriverControl)
- s.handle("DELETE /api/drivers/{name}/control", s.handleDriverControlRelease)
- s.handle("POST /api/drivers/{name}/restart", s.handleDriverRestart)
- s.handle("POST /api/drivers/{name}/disable", s.handleDriverDisable)
- s.handle("POST /api/drivers/{name}/enable", s.handleDriverEnable)
- s.handle("GET /api/device_repository/status", s.handleDeviceRepositoryStatus)
- s.handle("GET /api/device_repository/catalog", s.handleDeviceRepositoryCatalog)
- s.handle("POST /api/device_repository/refresh", s.handleDeviceRepositoryRefresh)
- s.handle("POST /api/device_repository/drivers/{id}/install", s.handleDeviceRepositoryInstall)
- s.handle("POST /api/device_repository/drivers/{id}/rollback", s.handleDeviceRepositoryRollback)
- s.handle("POST /api/device_repository/drivers/{id}/use_bundled", s.handleDeviceRepositoryUseBundled)
- s.handle("GET /api/device_repository/drivers/{id}/versions", s.handleDeviceRepositoryVersions)
- s.handle("POST /api/device_repository/drivers/{id}/activate", s.handleDeviceRepositoryActivate)
- s.handle("GET /api/components", s.handleComponents)
- s.handle("GET /api/components/history", s.handleComponentHistory)
- s.handle("POST /api/components/optimizer/update", s.handleOptimizerComponentUpdate)
- s.handle("POST /api/components/optimizer/rollback", s.handleOptimizerComponentRollback)
- s.handle("POST /api/components/optimizer/channel", s.handleOptimizerComponentChannel)
- s.handle("GET /api/ha/status", s.handleHAStatus)
- s.handle("GET /api/caldav/status", s.handleCalDAVStatus)
- s.handle("GET /api/caldav/credentials", s.handleCalDAVCredentials)
- s.handle("GET /api/notifications/status", s.handleNotificationsStatus)
- s.handle("GET /api/notifications/defaults", s.handleNotificationsDefaults)
- s.handle("GET /api/notifications/history", s.handleNotificationsHistory)
- s.handle("POST /api/notifications/test", s.handleNotificationsTest)
- s.handle("GET /api/battery_models", s.handleGetModels)
- s.handle("POST /api/battery_models/reset", s.handleResetModel)
- s.handle("POST /api/self_tune/start", s.handleSelfTuneStart)
- s.handle("GET /api/self_tune/status", s.handleSelfTuneStatus)
- s.handle("POST /api/self_tune/cancel", s.handleSelfTuneCancel)
- s.handle("GET /api/history", s.handleHistory)
- s.handle("GET /api/energy/daily", s.handleEnergyDaily)
- s.handle("GET /api/energy/assets", s.handleEnergyAssets)
- s.handle("GET /api/energy/history", s.handleEnergyHistory)
- s.handle("GET /api/energy/history.csv", s.handleEnergyHistoryCSV)
- s.handle("GET /api/savings/daily", s.handleSavingsDaily)
- s.handle("GET /api/prices", s.handlePrices)
- s.handle("GET /api/prices/zones", s.handlePriceZones)
- s.handle("GET /api/forecast", s.handleForecast)
- s.handle("GET /api/mpc/plan", s.handleMPCPlan)
- s.handle("POST /api/mpc/replan", s.handleMPCReplan)
- s.handle("GET /api/mpc/diagnose", s.handleMPCDiagnose)
- s.handle("GET /api/mpc/diagnose/history", s.handleMPCDiagnoseHistory)
- s.handle("GET /api/mpc/diagnose/at", s.handleMPCDiagnoseAt)
- s.handle("GET /api/pvmodel", s.handlePVModel)
- s.handle("POST /api/pvmodel/reset", s.handlePVModelReset)
- s.handle("GET /api/loadmodel", s.handleLoadModel)
- s.handle("POST /api/loadmodel/profile", s.handleLoadModelProfile)
- s.handle("POST /api/loadmodel/reset", s.handleLoadModelReset)
- s.handle("GET /api/research/load/dump", s.handleLoadResearchDump)
- s.handle("GET /api/series", s.handleSeries)
- s.handle("GET /api/series/catalog", s.handleSeriesCatalog)
- s.handle("GET /api/devices", s.handleDevices)
- s.handle("GET /api/scan", s.handleScan)
- s.handle("GET /api/ev/status", s.handleEVStatus)
- s.handle("POST /api/ev/command", s.handleEVCommand)
- s.handle("GET /api/v2x/policy", s.handleV2XPolicy)
- s.handle("POST /api/v2x/command", s.handleV2XCommand)
- s.handle("POST /api/ev/chargers", s.handleEVChargers)
- s.handle("GET /api/ev/providers", s.handleEVProviders)
- s.handle("GET /api/loadpoints", s.handleLoadpoints)
- s.handle("POST /api/loadpoints/{id}/target", s.handleLoadpointTarget)
- s.handle("POST /api/loadpoints/{id}/soc", s.handleLoadpointSoC)
- s.handle("POST /api/loadpoints/{id}/force_start", s.handleLoadpointForceStart)
- s.handle("POST /api/loadpoints/{id}/manual_hold", s.handleLoadpointManualHold)
- s.handle("DELETE /api/loadpoints/{id}/manual_hold", s.handleLoadpointManualHoldClear)
- s.handle("GET /api/loadpoints/{id}/manual_hold", s.handleLoadpointManualHoldGet)
- s.handle("POST /api/loadpoints/{id}/battery_boost", s.handleLoadpointBatteryBoostEnable)
- s.handle("DELETE /api/loadpoints/{id}/battery_boost", s.handleLoadpointBatteryBoostCancel)
- s.handle("GET /api/loadpoints/{id}/battery_boost", s.handleLoadpointBatteryBoostStatus)
- s.handle("POST /api/battery/manual_hold", s.handleBatteryManualHold)
- s.handle("DELETE /api/battery/manual_hold", s.handleBatteryManualHoldClear)
- s.handle("GET /api/battery/manual_hold", s.handleBatteryManualHoldGet)
- s.handle("POST /api/pv/manual_hold", s.handlePVManualHold)
- s.handle("DELETE /api/pv/manual_hold", s.handlePVManualHoldClear)
- s.handle("GET /api/pv/manual_hold", s.handlePVManualHoldGet)
- s.handle("GET /api/version/check", s.handleVersionCheck)
- s.handle("POST /api/version/channel", s.handleVersionChannel)
- s.handle("POST /api/version/skip", s.handleVersionSkip)
- s.handle("POST /api/version/unskip", s.handleVersionUnskip)
- s.handle("POST /api/version/update", s.handleVersionUpdate)
- s.handle("POST /api/version/restart", s.handleVersionRestart)
- s.handle("GET /api/version/update/status", s.handleVersionUpdateStatus)
- s.handle("GET /api/version/snapshots", s.handleVersionSnapshots)
- s.handle("POST /api/version/snapshots", s.handleVersionSnapshotCreate)
- s.handle("DELETE /api/version/snapshots/{id}", s.handleVersionSnapshotDelete)
- s.handle("GET /api/backups", s.handleBackups)
- s.handle("POST /api/backups", s.handleBackupCreate)
- s.handle("GET /api/backups/{id}", s.handleBackupDownload)
- s.handle("DELETE /api/backups/{id}", s.handleBackupDelete)
- s.handle("POST /api/backups/{id}/verify", s.handleBackupVerify)
- s.handle("POST /api/version/rollback", s.handleVersionRollback)
- s.handle("POST /api/restart", s.handleRestart)
+ s.handle("GET /api/health", Read, s.handleHealth)
+ s.handle("GET /api/status", Read, s.handleStatus)
+ s.handle("GET /api/system/info", Read, s.handleSysInfo)
+ s.handle("GET /api/storage/inventory", Read, s.handleStorageInventory)
+ s.handle("GET /api/config", Local, s.handleGetConfig)
+ s.handle("POST /api/config", Configure, s.handlePostConfig, ReplacesAll)
+ s.handle("POST /api/drivers/verify_tesla", Configure, s.handleVerifyTesla)
+ s.handle("GET /api/oauth/myuplink/start", Local, s.handleMyUplinkOAuthStart)
+ s.handle("GET /api/oauth/myuplink/callback", Local, s.handleMyUplinkOAuthCallback)
+ s.handle("POST /api/oauth/myuplink/exchange", Local, s.handleMyUplinkOAuthExchange)
+ s.handle("GET /api/mode", Read, s.handleGetMode)
+ s.handle("GET /api/app-link/status", Read, s.handleAppLinkStatus)
+ s.handle("POST /api/app-link/pairing", Configure, s.handleAppLinkPairing)
+ s.handle("GET /api/app-link/devices", Read, s.handleAppLinkDevices)
+ s.handle("DELETE /api/app-link/devices/{id}", Configure, s.handleAppLinkDeviceRevoke)
+ s.handle("PATCH /api/app-link/devices/{id}", Configure, s.handleAppLinkDeviceRole)
+ s.handle("GET /api/fleet-ping", Read, s.handleFleetPing)
+ s.handle("POST /api/mode", Actuate, s.handleSetMode, Via(appproto.OpSetMode))
+ s.handle("GET /api/modes", Read, s.handleModes)
+ s.handle("POST /api/target", Actuate, s.handleSetTarget)
+ s.handle("POST /api/peak_limit", Actuate, s.handleSetPeakLimit)
+ s.handle("POST /api/peak_import_ceiling", Actuate, s.handleSetPeakImportCeiling)
+ s.handle("POST /api/ev_charging", Actuate, s.handleSetEVCharging)
+ s.handle("POST /api/battery_covers_ev", Configure, s.handleSetBatteryCoversEV)
+ s.handle("GET /api/drivers", Read, s.handleDrivers)
+ s.handle("GET /api/drivers/catalog", Read, s.handleDriversCatalog)
+ s.handle("POST /api/drivers/test", Configure, s.handleDriverTest)
+ s.handle("GET /api/drivers/{id}/source", Local, s.handleDriverSource)
+ s.handle("POST /api/drivers/{id}/lint", Local, s.handleDriverLint)
+ s.handle("POST /api/drivers/{id}/draft", Local, s.handleDriverDraft)
+ s.handle("GET /api/drivers/{id}/draft", Read, s.handleDriverDraftStatus)
+ s.handle("POST /api/drivers/{id}/draft/keep", Local, s.handleDriverDraftKeep)
+ s.handle("POST /api/drivers/{id}/draft/revert", Local, s.handleDriverDraftRevert)
+ s.handle("POST /api/drivers/fingerprint", Configure, s.handleDriverFingerprint)
+ s.handle("GET /api/drivers/{name}", Read, s.handleDriverDetail)
+ s.handle("GET /api/drivers/{name}/logs", Local, s.handleDriverLogs)
+ s.handle("GET /api/logs", Local, s.handleGlobalLogs)
+ s.handle("GET /api/support/dump", Local, s.handleSupportDump)
+ s.handle("GET /api/support/report", Local, s.handleSupportReport)
+ s.handle("POST /api/drivers/{name}/control", Actuate, s.handleDriverControl)
+ s.handle("DELETE /api/drivers/{name}/control", Actuate, s.handleDriverControlRelease)
+ s.handle("POST /api/drivers/{name}/restart", Configure, s.handleDriverRestart)
+ s.handle("POST /api/drivers/{name}/disable", Configure, s.handleDriverDisable)
+ s.handle("POST /api/drivers/{name}/enable", Configure, s.handleDriverEnable)
+ s.handle("GET /api/device_repository/status", Read, s.handleDeviceRepositoryStatus)
+ s.handle("GET /api/device_repository/catalog", Read, s.handleDeviceRepositoryCatalog)
+ s.handle("POST /api/device_repository/refresh", Configure, s.handleDeviceRepositoryRefresh)
+ s.handle("POST /api/device_repository/drivers/{id}/install", Configure, s.handleDeviceRepositoryInstall)
+ s.handle("POST /api/device_repository/drivers/{id}/rollback", Configure, s.handleDeviceRepositoryRollback)
+ s.handle("POST /api/device_repository/drivers/{id}/use_bundled", Configure, s.handleDeviceRepositoryUseBundled)
+ s.handle("GET /api/device_repository/drivers/{id}/versions", Read, s.handleDeviceRepositoryVersions)
+ s.handle("POST /api/device_repository/drivers/{id}/activate", Configure, s.handleDeviceRepositoryActivate)
+ s.handle("GET /api/components", Read, s.handleComponents)
+ s.handle("GET /api/components/history", Read, s.handleComponentHistory)
+ s.handle("POST /api/components/optimizer/update", Configure, s.handleOptimizerComponentUpdate)
+ s.handle("POST /api/components/optimizer/rollback", Configure, s.handleOptimizerComponentRollback)
+ s.handle("POST /api/components/optimizer/channel", Configure, s.handleOptimizerComponentChannel)
+ s.handle("GET /api/ha/status", Read, s.handleHAStatus)
+ s.handle("GET /api/caldav/status", Read, s.handleCalDAVStatus)
+ s.handle("GET /api/caldav/credentials", Local, s.handleCalDAVCredentials)
+ s.handle("GET /api/notifications/status", Read, s.handleNotificationsStatus)
+ s.handle("GET /api/notifications/defaults", Read, s.handleNotificationsDefaults)
+ s.handle("GET /api/notifications/history", Read, s.handleNotificationsHistory)
+ s.handle("POST /api/notifications/test", Configure, s.handleNotificationsTest)
+ s.handle("GET /api/battery_models", Read, s.handleGetModels)
+ s.handle("POST /api/battery_models/reset", Configure, s.handleResetModel)
+ s.handle("POST /api/self_tune/start", Actuate, s.handleSelfTuneStart)
+ s.handle("GET /api/self_tune/status", Read, s.handleSelfTuneStatus)
+ s.handle("POST /api/self_tune/cancel", Actuate, s.handleSelfTuneCancel)
+ s.handle("GET /api/history", Read, s.handleHistory)
+ s.handle("GET /api/energy/daily", Read, s.handleEnergyDaily)
+ s.handle("GET /api/energy/assets", Read, s.handleEnergyAssets)
+ s.handle("GET /api/energy/history", Read, s.handleEnergyHistory)
+ s.handle("GET /api/energy/history.csv", Read, s.handleEnergyHistoryCSV)
+ s.handle("GET /api/savings/daily", Read, s.handleSavingsDaily)
+ s.handle("GET /api/prices", Read, s.handlePrices)
+ s.handle("GET /api/prices/zones", Read, s.handlePriceZones)
+ s.handle("GET /api/forecast", Read, s.handleForecast)
+ s.handle("GET /api/mpc/plan", Read, s.handleMPCPlan)
+ s.handle("POST /api/mpc/replan", Configure, s.handleMPCReplan)
+ s.handle("GET /api/mpc/diagnose", Read, s.handleMPCDiagnose)
+ s.handle("GET /api/mpc/diagnose/history", Read, s.handleMPCDiagnoseHistory)
+ s.handle("GET /api/mpc/diagnose/at", Read, s.handleMPCDiagnoseAt)
+ s.handle("GET /api/pvmodel", Read, s.handlePVModel)
+ s.handle("POST /api/pvmodel/reset", Configure, s.handlePVModelReset)
+ s.handle("GET /api/loadmodel", Read, s.handleLoadModel)
+ s.handle("POST /api/loadmodel/profile", Configure, s.handleLoadModelProfile)
+ s.handle("POST /api/loadmodel/reset", Configure, s.handleLoadModelReset)
+ s.handle("GET /api/research/load/dump", Read, s.handleLoadResearchDump)
+ s.handle("GET /api/series", Read, s.handleSeries)
+ s.handle("GET /api/series/catalog", Read, s.handleSeriesCatalog)
+ s.handle("GET /api/devices", Read, s.handleDevices)
+ s.handle("GET /api/scan", Configure, s.handleScan)
+ s.handle("GET /api/ev/status", Read, s.handleEVStatus)
+ s.handle("POST /api/ev/command", Actuate, s.handleEVCommand)
+ s.handle("GET /api/v2x/policy", Read, s.handleV2XPolicy)
+ s.handle("POST /api/v2x/command", Actuate, s.handleV2XCommand)
+ s.handle("POST /api/ev/chargers", Configure, s.handleEVChargers)
+ s.handle("GET /api/ev/providers", Read, s.handleEVProviders)
+ s.handle("GET /api/loadpoints", Read, s.handleLoadpoints)
+ s.handle("POST /api/loadpoints/{id}/target", Actuate, s.handleLoadpointTarget)
+ s.handle("POST /api/loadpoints/{id}/soc", Actuate, s.handleLoadpointSoC)
+ s.handle("POST /api/loadpoints/{id}/force_start", Actuate, s.handleLoadpointForceStart)
+ s.handle("POST /api/loadpoints/{id}/manual_hold", Actuate, s.handleLoadpointManualHold)
+ s.handle("DELETE /api/loadpoints/{id}/manual_hold", Actuate, s.handleLoadpointManualHoldClear)
+ s.handle("GET /api/loadpoints/{id}/manual_hold", Read, s.handleLoadpointManualHoldGet)
+ s.handle("POST /api/loadpoints/{id}/battery_boost", Actuate, s.handleLoadpointBatteryBoostEnable)
+ s.handle("DELETE /api/loadpoints/{id}/battery_boost", Actuate, s.handleLoadpointBatteryBoostCancel)
+ s.handle("GET /api/loadpoints/{id}/battery_boost", Read, s.handleLoadpointBatteryBoostStatus)
+ s.handle("POST /api/battery/manual_hold", Actuate, s.handleBatteryManualHold)
+ s.handle("DELETE /api/battery/manual_hold", Actuate, s.handleBatteryManualHoldClear)
+ s.handle("GET /api/battery/manual_hold", Read, s.handleBatteryManualHoldGet)
+ s.handle("POST /api/pv/manual_hold", Actuate, s.handlePVManualHold)
+ s.handle("DELETE /api/pv/manual_hold", Actuate, s.handlePVManualHoldClear)
+ s.handle("GET /api/pv/manual_hold", Read, s.handlePVManualHoldGet)
+ s.handle("GET /api/version/check", Configure, s.handleVersionCheck)
+ s.handle("POST /api/version/channel", Configure, s.handleVersionChannel)
+ s.handle("POST /api/version/skip", Configure, s.handleVersionSkip)
+ s.handle("POST /api/version/unskip", Configure, s.handleVersionUnskip)
+ s.handle("POST /api/version/update", Configure, s.handleVersionUpdate)
+ s.handle("POST /api/version/restart", Configure, s.handleVersionRestart)
+ s.handle("GET /api/version/update/status", Read, s.handleVersionUpdateStatus)
+ s.handle("GET /api/version/snapshots", Read, s.handleVersionSnapshots)
+ s.handle("POST /api/version/snapshots", Configure, s.handleVersionSnapshotCreate)
+ s.handle("DELETE /api/version/snapshots/{id}", Configure, s.handleVersionSnapshotDelete)
+ s.handle("GET /api/backups", Read, s.handleBackups)
+ s.handle("POST /api/backups", Configure, s.handleBackupCreate)
+ s.handle("GET /api/backups/{id}", Local, s.handleBackupDownload)
+ s.handle("DELETE /api/backups/{id}", Configure, s.handleBackupDelete)
+ s.handle("POST /api/backups/{id}/verify", Configure, s.handleBackupVerify)
+ s.handle("POST /api/version/rollback", Configure, s.handleVersionRollback)
+ s.handle("POST /api/restart", Configure, s.handleRestart)
// ---- Static web UI ----
// Everything not matched above falls through to the static server.
s.mux.HandleFunc("/", s.handleStatic)
+ // Marked so the app's passthrough can refuse it by what the router says
+ // rather than by what happens to be on disk. A path under /api/ that no
+ // handler claims lands here, and the day somebody adds web/api/anything
+ // it would otherwise become reachable from a phone.
+ s.marks[staticPattern] = routeMark{static: true}
}
-// handle wires "METHOD path" to a handler. Uses Go 1.22+ method-scoped
-// routing so GET + POST on the same path can be registered independently.
-func (s *Server) handle(methodPath string, h http.HandlerFunc) {
+// staticPattern is the catch-all the static file server is registered under.
+const staticPattern = "/"
+
+// The four tiers, spelled short so the table above reads as a table. They are
+// apiauth's values, not a second set: a copy here would be the second
+// authorisation namespace this codebase has already paid for once.
+const (
+ Read = apiauth.TierRead
+ Configure = apiauth.TierConfigure
+ Actuate = apiauth.TierActuate
+ Local = apiauth.TierLocal
+)
+
+// handle wires "METHOD path" to a handler at an explicit tier. Uses Go 1.22+
+// method-scoped routing so GET + POST on the same path can be registered
+// independently.
+//
+// The tier is a required argument rather than an optional mark, which is the
+// whole point: leaving it out does not compile. Before this it was optional
+// and inferred from the method, and two routes were quietly mispriced for it —
+// a credential served as a read and a battery step-test served as ordinary
+// configuration.
+//
+// A route may carry further marks. They are written here, beside the handler
+// they govern, and never in a second list that has to be kept in step.
+func (s *Server) handle(methodPath string, tier apiauth.Tier, h http.HandlerFunc, marks ...RouteMark) {
+ if !tier.Known() {
+ // At startup, not on the first request. A box that will not start is a
+ // box nobody is quietly over-trusting.
+ panic(fmt.Sprintf("api: route %q registered at unknown tier %q; it must be one of %v",
+ methodPath, tier, apiauth.Tiers))
+ }
parts := strings.SplitN(strings.TrimSpace(methodPath), " ", 2)
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
method, path := parts[0], parts[1]
- s.mux.HandleFunc(method+" "+path, h)
- _ = fmt.Sprintf // keep fmt import used elsewhere
+ pattern := method + " " + path
+ s.mux.HandleFunc(pattern, h)
+
+ mark := routeMark{tier: tier}
+ for _, apply := range marks {
+ apply(&mark)
+ }
+ s.marks[pattern] = mark
}
+// routeMark is what a route says about itself beyond its method and path.
+type routeMark struct {
+ tier apiauth.Tier
+ cmdOp string
+ replacesAll bool
+ static bool
+}
+
+// RouteMark is one such fact, applied at registration.
+type RouteMark func(*routeMark)
+
+// Via names the command that does what an Actuate route does, so the app is
+// told what to send instead of being told only "no". A route with no Via is
+// one the box has no command for yet, and the honest answer to the app is that
+// this control is not available over the session.
+//
+// The rule for whoever adds the next route, and the reason Actuate exists: if
+// a late execution would be a DIFFERENT INSTRUCTION, it is actuation and
+// belongs on cmd. "Charge at 10 kW", arriving three hours late out of a
+// tunnel, is not the instruction the user gave. If a late execution is merely
+// a LATE SETTING, it is Configure and the passthrough carries it.
+func Via(op string) RouteMark {
+ return func(m *routeMark) { m.cmdOp = op }
+}
+
+// ReplacesAll marks a route whose body replaces a whole document.
+//
+// POST /api/config is the case: it writes the entire configuration, so
+// anything the sender's idea of the document lacked is dropped. On the LAN
+// that is survivable — the browser loaded the whole document from this box
+// seconds earlier — and it has still cost this project one silent regression.
+// Over a session, from a phone that may be running a build older than the box
+// by a year, it is a way to wipe settings its caller never knew about. The
+// passthrough refuses these outright rather than trusting the round trip.
+func ReplacesAll(m *routeMark) { m.replacesAll = true }
+
// ---- Common helpers ----
func writeJSON(w http.ResponseWriter, status int, v any) {
diff --git a/go/internal/api/api_app_link.go b/go/internal/api/api_app_link.go
index 8fd2463f..65e003c1 100644
--- a/go/internal/api/api_app_link.go
+++ b/go/internal/api/api_app_link.go
@@ -5,6 +5,9 @@ import (
"net/http"
"strings"
"time"
+
+ "github.com/srcfl/ftw/go/internal/apiauth"
+ "github.com/srcfl/ftw/go/internal/appproto"
)
// Pairing a phone with this box.
@@ -14,28 +17,37 @@ import (
// That is what lets a hostile or compelled relay deny service without being
// able to impersonate a box.
//
-// Local only, and strictly. Everything below is what a caller needs to add a
-// device that can then control the site, so it is reachable from the LAN and
-// from nowhere else: no forwarding headers honoured, no remote host accepted.
-// The old Home Link admin surface held the same line for the same reason.
+// Two doors, and what each one proves decides what it opens. The LAN proves
+// PRESENCE — somebody is in the building — and it is the only door that admits
+// a new owner or mints a code to read aloud. An app session proves ENROLMENT
+// and nothing about location, so it may see the roster, invite a viewer and
+// lock a phone out, all of it gated on the members scopes its grant carries.
+// See appLinkGate and appLinkRoleAllowed, which are where that is decided.
// AppEnroller is the box's enrollment identity, as this package needs it.
//
// An interface rather than the concrete type so the API package does not
// depend on appenroll, and so a test can hand in something that fails.
type AppEnroller interface {
- // MintPairingCode issues a fresh single-use code and forgets any previous
- // one, so a code left on a screen stops working as soon as another is
- // asked for.
- MintPairingCode() ([]byte, time.Time, error)
+ // MintPairingCode issues a fresh single-use code for the given role and
+ // forgets any previous one, so a code left on a screen stops working as
+ // soon as another is asked for.
+ MintPairingCode(role string) ([]byte, time.Time, error)
+ // MintSpokenCode issues a box code: eight characters somebody can read
+ // down a phone. Same single-use, same replacement of any live code.
+ MintSpokenCode(role string) (string, time.Time, error)
// EnrollmentURL is what goes in the QR.
EnrollmentURL(code []byte, lanHint string) (string, error)
// AuthorisedCount is how many devices may currently connect.
AuthorisedCount() int
// Devices lists the paired phones, most recently seen first.
Devices() []AppDevice
+ // SetDeviceRole changes what one phone may do. Returns
+ // ErrLastAppOwnerProtected when it would leave the box with no owner.
+ SetDeviceRole(id, role string) error
// RevokeDevice forgets one and tears down its live sessions. Returns
- // ErrUnknownAppDevice when no row carries the id.
+ // ErrUnknownAppDevice when no row carries the id, and
+ // ErrLastAppOwnerProtected when it would leave the box with no owner.
RevokeDevice(id string) error
}
@@ -45,10 +57,24 @@ type AppDevice struct {
ID string `json:"id"`
AddedAtMs int64 `json:"added_at_ms,omitempty"`
LastSeenMs int64 `json:"last_seen_ms,omitempty"`
+ // Role is what this phone may do: owner or viewer. Sharing lives in this
+ // list rather than on a screen of its own, because a guest's phone is a
+ // paired phone and removing one is the same action as locking one out.
+ Role string `json:"role"`
+ // LastOwner marks the row that cannot be removed or demoted, so the page
+ // can say why before somebody presses the button.
+ LastOwner bool `json:"last_owner,omitempty"`
}
-// ErrUnknownAppDevice is a revoke aimed at an id no paired phone carries.
-var ErrUnknownAppDevice = errors.New("api: no such app device")
+var (
+ // ErrUnknownAppDevice is a revoke aimed at an id no paired phone carries.
+ ErrUnknownAppDevice = errors.New("api: no such app device")
+ // ErrLastAppOwnerProtected is a change that would leave the box with no
+ // owner at all.
+ ErrLastAppOwnerProtected = errors.New("api: that is the only owner")
+ // ErrUnknownAppRole is a role that is not in contract/registry.yaml.
+ ErrUnknownAppRole = errors.New("api: no such role")
+)
type appLinkStatus struct {
Enabled bool `json:"enabled"`
@@ -64,14 +90,40 @@ type appLinkStatus struct {
type appLinkPairing struct {
// URL is the whole QR payload. The fragment carries the box's static key,
// the rendezvous secret and the pairing code; none of it reaches a server.
- URL string `json:"url"`
+ // Empty for a spoken code, which has no payload to scan.
+ URL string `json:"url,omitempty"`
+ // Code is the box code, grouped as XXXX-XXXX and meant to be read aloud.
+ // Empty for a QR code, whose payload must never be offered as text.
+ Code string `json:"code,omitempty"`
+ // Role is what the code lets in, echoed so the screen can name it in
+ // words above the code. A code whose power is invisible is the one that
+ // gets read to the wrong person.
+ Role string `json:"role"`
// ExpiresAtMs is when the code stops working, so the UI can say so rather
// than leaving a stale square on screen.
ExpiresAtMs int64 `json:"expires_at_ms"`
}
+// appLinkPairingRequest is what a page or an app asks for.
+//
+// Role is required — see appLinkRoleAllowed for why it has no default. Kind
+// still has one: a code that is scanned rather than read aloud is the older
+// flow and the safer of the two, since its payload never becomes text.
+type appLinkPairingRequest struct {
+ // Role is "owner" or "viewer". Validated by the enroller against the
+ // registry, never against a list written here.
+ Role string `json:"role"`
+ // Kind is "qr" or "spoken".
+ Kind string `json:"kind"`
+}
+
+// appLinkRoleRequest changes what one paired phone may do.
+type appLinkRoleRequest struct {
+ Role string `json:"role"`
+}
+
func (s *Server) handleAppLinkStatus(w http.ResponseWriter, r *http.Request) {
- if !s.appLinkLocalRequest(w, r) {
+ if !s.appLinkGate(w, r, appproto.ScopeMembersRead) {
return
}
@@ -86,7 +138,7 @@ func (s *Server) handleAppLinkStatus(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAppLinkPairing(w http.ResponseWriter, r *http.Request) {
- if !s.appLinkLocalRequest(w, r) {
+ if !s.appLinkGate(w, r, appproto.ScopeMembersWrite) {
return
}
@@ -95,9 +147,49 @@ func (s *Server) handleAppLinkPairing(w http.ResponseWriter, r *http.Request) {
return
}
- code, expiresAt, err := s.deps.AppEnroll.MintPairingCode()
+ // A body that will not parse says nothing about the role, which is the
+ // same position as a body that named none. Both meet the same refusal
+ // below rather than being filled in for.
+ var req appLinkPairingRequest
+ _ = readJSON(r, &req)
+ if !s.appLinkRoleAllowed(w, r, req.Role) {
+ return
+ }
+
+ // A spoken code is minted at the box and nowhere else.
+ //
+ // Forty bits are safe to read down a phone line because of what surrounds
+ // them, and the load-bearing part is that every minting costs somebody a
+ // walk to the box: five wrong guesses burn a code, and asking for another
+ // needs a person in the room. A code mintable from a phone anywhere in the
+ // world takes that argument away.
+ //
+ // It would also buy nothing. A typed code carries the pairing code alone —
+ // not the box's static key, not the rendezvous secret — so it re-admits a
+ // phone that already holds those and cannot let in a guest's phone, which
+ // has never seen this box. There is no second, weaker way to mint one.
+ if req.Kind == appPairingKindSpoken {
+ if appLinkOverSession(r) {
+ writeAppLinkError(w, http.StatusForbidden,
+ "a code to read aloud is made on the box, at home.")
+ return
+ }
+ code, expiresAt, err := s.deps.AppEnroll.MintSpokenCode(req.Role)
+ if err != nil {
+ s.writeAppLinkMintError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, appLinkPairing{
+ Code: code,
+ Role: req.Role,
+ ExpiresAtMs: expiresAt.UnixMilli(),
+ })
+ return
+ }
+
+ code, expiresAt, err := s.deps.AppEnroll.MintPairingCode(req.Role)
if err != nil {
- writeAppLinkError(w, http.StatusInternalServerError, "could not mint a pairing code")
+ s.writeAppLinkMintError(w, err)
return
}
@@ -113,12 +205,24 @@ func (s *Server) handleAppLinkPairing(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, appLinkPairing{
URL: url,
+ Role: req.Role,
ExpiresAtMs: expiresAt.UnixMilli(),
})
}
+// appPairingKindSpoken asks for a code somebody can read aloud.
+const appPairingKindSpoken = "spoken"
+
+func (s *Server) writeAppLinkMintError(w http.ResponseWriter, err error) {
+ if errors.Is(err, ErrUnknownAppRole) {
+ writeAppLinkError(w, http.StatusBadRequest, "that is not a kind of access this box grants")
+ return
+ }
+ writeAppLinkError(w, http.StatusInternalServerError, "could not mint a pairing code")
+}
+
func (s *Server) handleAppLinkDevices(w http.ResponseWriter, r *http.Request) {
- if !s.appLinkLocalRequest(w, r) {
+ if !s.appLinkGate(w, r, appproto.ScopeMembersRead) {
return
}
if s.deps.AppEnroll == nil {
@@ -129,7 +233,7 @@ func (s *Server) handleAppLinkDevices(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAppLinkDeviceRevoke(w http.ResponseWriter, r *http.Request) {
- if !s.appLinkLocalRequest(w, r) {
+ if !s.appLinkGate(w, r, appproto.ScopeMembersWrite) {
return
}
if s.deps.AppEnroll == nil {
@@ -142,18 +246,78 @@ func (s *Server) handleAppLinkDeviceRevoke(w http.ResponseWriter, r *http.Reques
writeAppLinkError(w, http.StatusNotFound, "that phone is no longer paired")
return
}
+ if errors.Is(err, ErrLastAppOwnerProtected) {
+ writeAppLinkRefusal(w, http.StatusConflict, appproto.ErrLastOwnerProtected,
+ "that is the only phone that can change anything here. "+
+ "Pair another owner first, then remove this one.")
+ return
+ }
writeAppLinkError(w, http.StatusInternalServerError, "could not remove the phone")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
}
+// handleAppLinkDeviceRole promotes a guest or steps an owner down to one.
+//
+// The same list and the same doors as removing a phone. Sharing is not a
+// separate feature with a separate screen: it is a role on a row, and a
+// household that can see the row can change it or delete it.
+//
+// Stepping somebody down is reachable over a session; promoting them to owner
+// is not, because that is the same power as minting an owner's code and it
+// needs somebody at the box either way.
+func (s *Server) handleAppLinkDeviceRole(w http.ResponseWriter, r *http.Request) {
+ if !s.appLinkGate(w, r, appproto.ScopeMembersWrite) {
+ return
+ }
+ if s.deps.AppEnroll == nil {
+ writeAppLinkError(w, http.StatusServiceUnavailable, "app_link is off — set app_link.enabled and restart")
+ return
+ }
+
+ var req appLinkRoleRequest
+ if err := readJSON(r, &req); err != nil {
+ writeAppLinkError(w, http.StatusBadRequest, "could not read what to change it to")
+ return
+ }
+ // The same rule as minting, for the same reason: a promotion to owner is
+ // a bigger power than an invitation, so it stays at the box.
+ if !s.appLinkRoleAllowed(w, r, req.Role) {
+ return
+ }
+
+ switch err := s.deps.AppEnroll.SetDeviceRole(r.PathValue("id"), req.Role); {
+ case err == nil:
+ writeJSON(w, http.StatusOK, map[string]string{"status": "changed", "role": req.Role})
+ case errors.Is(err, ErrUnknownAppDevice):
+ writeAppLinkError(w, http.StatusNotFound, "that phone is no longer paired")
+ case errors.Is(err, ErrUnknownAppRole):
+ writeAppLinkError(w, http.StatusBadRequest, "that is not a kind of access this box grants")
+ case errors.Is(err, ErrLastAppOwnerProtected):
+ writeAppLinkRefusal(w, http.StatusConflict, appproto.ErrLastOwnerProtected,
+ "that is the only phone that can change anything here. "+
+ "Pair another owner first, then step this one down.")
+ default:
+ writeAppLinkError(w, http.StatusInternalServerError, "could not change what this phone may do")
+ }
+}
+
// appLinkLANHint is the host the caller reached this box on.
//
// Taken from the request rather than from configuration because the box does
// not reliably know its own address, and whatever the browser just used
// demonstrably works from inside the house.
+//
+// A session request has no such host to offer. The passthrough builds it with
+// Host "localhost" so the API's trust boundary sees a local client, and
+// copying that into the payload would hand a guest an address pointing at
+// their own phone. No hint is the honest answer: the field is advisory, the
+// relay is the only carrier today, and a wrong hint is worse than none.
func (s *Server) appLinkLANHint(r *http.Request) string {
+ if appLinkOverSession(r) {
+ return ""
+ }
host := r.Host
if len(host) > 64 {
return ""
@@ -161,20 +325,106 @@ func (s *Server) appLinkLANHint(r *http.Request) string {
return host
}
-// appLinkLocalRequest refuses anything that did not come from the LAN.
+// appLinkGate decides whether a request may reach one of these routes.
//
-// Forwarding headers are grounds for refusal rather than something to parse:
-// their presence means a proxy is in the path, and a proxy in front of this
-// endpoint means a request from outside the house could look like one from
-// inside it.
-func (s *Server) appLinkLocalRequest(w http.ResponseWriter, r *http.Request) bool {
+// There are two doors onto this list and they prove different things.
+//
+// The LAN door proves PRESENCE: somebody is standing in this building. That
+// is the whole authority behind a printed square, a guest pass and a spoken
+// code — it is what makes forty bits read down a phone line safe, because
+// each minting costs a walk to the box. Two things are refused there, and the
+// first is not about addresses: a forwarding header, because a proxy in the
+// path means a request from outside the house can be made to look like one
+// from inside it, which is grounds for refusal rather than something to
+// parse; and a non-local host or client address, the plain case.
+//
+// The session door proves ENROLMENT: this is a phone the box already trusts,
+// authenticated by its Noise static key, carrying a role the box re-reads on
+// every request. It proves nothing whatever about where that phone is.
+//
+// So the doors do not open onto the same things. Both reach the roster, if
+// the grant behind them carries the scope — and a viewer's grant carries
+// neither members scope, so a guest can neither read the household's list nor
+// change it. Only the LAN door admits a new OWNER; see appLinkRoleAllowed.
+//
+// This used to be one door, and a session was refused outright at it. The
+// passthrough's request is built to look local on purpose — Host localhost,
+// loopback address, no forwarding header — so an address check alone would
+// have let a phone on the other side of the world mint an owner's code. That
+// refusal was right about the danger and wrong about the remedy: it left the
+// app's whole sharing screen answering 403 to its own owner. What an address
+// cannot tell us the grant can, so the grant is what is asked.
+func (s *Server) appLinkGate(w http.ResponseWriter, r *http.Request, scope string) bool {
if hasForwardingHeader(r.Header) {
- writeAppLinkError(w, http.StatusForbidden, "pairing is available on your local network only")
+ writeAppLinkError(w, http.StatusForbidden,
+ "pairing is available on your local network only")
return false
}
+
+ if appLinkOverSession(r) {
+ caller, _ := apiauth.FromRequest(r)
+ if !caller.Scopes.Has(scope) {
+ // Not "you are on the wrong network": this phone may well be in
+ // the kitchen, and it is simply not the owner. The app draws none
+ // of these controls for a guest, so arriving here is a phone
+ // demoted while its screen was open — which is precisely when a
+ // sentence about local networks would be a lie.
+ writeAppLinkError(w, http.StatusForbidden,
+ "only this home's owner can see or change who has access")
+ return false
+ }
+ return true
+ }
+
auth, err := parseAuthority(r.Host)
if err != nil || !isLocalAuthority(auth) || !isLocalClient(r.RemoteAddr) {
- writeAppLinkError(w, http.StatusForbidden, "pairing is available on your local network only")
+ writeAppLinkError(w, http.StatusForbidden,
+ "pairing is available on your local network only")
+ return false
+ }
+ return true
+}
+
+// appLinkOverSession reports whether a request arrived over an app session
+// rather than off the box's own network.
+//
+// An unnamed caller is a request that never met Authenticate — a handler
+// called directly by a test. It is treated as the LAN, which is what it would
+// have been named.
+func appLinkOverSession(r *http.Request) bool {
+ caller, named := apiauth.FromRequest(r)
+ return named && caller.Kind != apiauth.KindLAN
+}
+
+// appLinkRoleAllowed refuses a role the door in front of it cannot hand out.
+//
+// Two rules, and neither of them is a default.
+//
+// The role must be NAMED. It used to fall back to owner when a request left
+// it out, on the reasoning that a page which has not been updated should keep
+// meaning what it used to mean. What that reasoning costs is a default that
+// hands over a house whenever a field goes missing: a caller whose role never
+// arrives — because it put it in the query string, or because its body failed
+// to parse — mints an owner while believing it asked for a viewer. A request
+// that does not say is now asked rather than assumed for.
+//
+// Over a session the box grants a VIEWER and nothing more. An owner is
+// admitted at the box, in the house, because presence is the one thing a
+// session cannot prove and the printed square always has. Asking for one from
+// the app is refused rather than quietly downgraded: a caller told yes and
+// handed something smaller is the same defect as one handed something bigger,
+// only pointed the other way, and both end at a screen describing access that
+// nobody actually has.
+func (s *Server) appLinkRoleAllowed(w http.ResponseWriter, r *http.Request, asked string) bool {
+ if asked == "" {
+ writeAppLinkError(w, http.StatusBadRequest,
+ "say which kind of access this is for")
+ return false
+ }
+ if appLinkOverSession(r) && asked != apiauth.RoleViewer {
+ writeAppLinkError(w, http.StatusForbidden,
+ "from the app you can let someone view this home. "+
+ "Making another owner is done on the box, at home.")
return false
}
return true
@@ -184,12 +434,29 @@ func writeAppLinkError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
+// writeAppLinkRefusal is writeAppLinkError plus a name the app can branch on.
+//
+// Two audiences read these bodies. The box's own page prints the sentence,
+// because it is the box's page and there is nobody else to write one. The app
+// owns every word it shows and needs the NAME instead: a status alone cannot
+// carry it, since 409 is a conflict and nothing more specific, and a sentence
+// cannot either — matching on prose is how a wording change becomes a bug in
+// another repository.
+//
+// So: a code wherever the refusal is a rule rather than a mishap, and the code
+// comes from contract/registry.yaml through appproto's generated constants.
+// Never a literal here. The app's own screen for this was dead for a release
+// because it read a "code" key this floor had never sent.
+func writeAppLinkRefusal(w http.ResponseWriter, status int, code, msg string) {
+ writeJSON(w, status, map[string]string{"error": msg, "code": code})
+}
+
// hasForwardingHeader reports whether a proxy touched this request.
//
// Their presence is grounds for refusal rather than something to parse: a
-// proxy in front of a LAN-only endpoint means a request from outside the house
-// can be made to look like one from inside it, and no amount of careful header
-// reading fixes that.
+// proxy in front of a door that decides presence from an address means a
+// request from outside the house can be made to look like one from inside it,
+// and no amount of careful header reading fixes that.
func hasForwardingHeader(header http.Header) bool {
for key := range header {
switch {
diff --git a/go/internal/api/api_app_link_session_test.go b/go/internal/api/api_app_link_session_test.go
new file mode 100644
index 00000000..3dddd880
--- /dev/null
+++ b/go/internal/api/api_app_link_session_test.go
@@ -0,0 +1,536 @@
+package api
+
+// Sharing, from the app, over the session.
+//
+// The other sharing test is the LAN door. This one is the session door, and
+// every test here is written against the box's REAL enrolment — appenroll's
+// own Identity, minting its own codes and stamping its own roles — because
+// the question this file exists to answer cannot be asked of a stub.
+//
+// The question is: what does the guest end up being? Not what the app asked
+// for, not what the answer echoed back, but what the box wrote down when the
+// code was spent. Every earlier version of this feature answered the first two
+// correctly and the third wrongly, and the app's own suite stayed green
+// throughout, because the simulator it tests against agreed with the app about
+// a field the box does not read.
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/apiauth"
+ "github.com/srcfl/ftw/go/internal/appenroll"
+ "github.com/srcfl/ftw/go/internal/appproto"
+)
+
+// --------------------------------------------------------------------------
+// The box's own enrolment, behind the API
+// --------------------------------------------------------------------------
+
+// realEnroller is AppEnroller backed by appenroll.Identity, which is what
+// cmd/ftw wires in production. Only the TTL choice and the error translation
+// are this file's; everything that decides a role is the box's.
+type realEnroller struct{ id *appenroll.Identity }
+
+func (e *realEnroller) MintPairingCode(role string) ([]byte, time.Time, error) {
+ ttl := appenroll.PairingTTL
+ if role != apiauth.RoleOwner {
+ ttl = appenroll.InviteTTL
+ }
+ code, expires, err := e.id.MintPairingCode(role, ttl)
+ return code, expires, enrollError(err)
+}
+
+func (e *realEnroller) MintSpokenCode(role string) (string, time.Time, error) {
+ code, expires, err := e.id.MintSpokenCode(role)
+ return code, expires, enrollError(err)
+}
+
+func (e *realEnroller) EnrollmentURL(code []byte, lanHint string) (string, error) {
+ return e.id.EnrollmentURL(code, lanHint)
+}
+
+func (e *realEnroller) AuthorisedCount() int { return e.id.AuthorisedCount() }
+
+func (e *realEnroller) Devices() []AppDevice {
+ out := []AppDevice{}
+ for _, d := range e.id.Devices() {
+ out = append(out, AppDevice{
+ ID: d.ID, AddedAtMs: d.AddedAtMs, LastSeenMs: d.LastSeenMs,
+ Role: d.Role, LastOwner: d.LastOwner,
+ })
+ }
+ return out
+}
+
+func (e *realEnroller) SetDeviceRole(id, role string) error {
+ return enrollError(e.id.SetRole(id, role))
+}
+
+func (e *realEnroller) RevokeDevice(id string) error {
+ _, err := e.id.Revoke(id)
+ return enrollError(err)
+}
+
+func enrollError(err error) error {
+ switch {
+ case err == nil:
+ return nil
+ case errors.Is(err, appenroll.ErrUnknownDevice):
+ return ErrUnknownAppDevice
+ case errors.Is(err, appenroll.ErrLastOwnerProtected):
+ return ErrLastAppOwnerProtected
+ case errors.Is(err, appenroll.ErrUnknownRole):
+ return ErrUnknownAppRole
+ default:
+ return err
+ }
+}
+
+// newEnrolment is a box with one owner's phone already on it.
+//
+// The owner matters. appenroll makes the FIRST enrolment an owner whatever its
+// code said, because a box with nobody to administer it can never be
+// administered again — so a test that minted a viewer code onto an empty box
+// would be measuring that rule and not this one.
+func newEnrolment(t *testing.T) *realEnroller {
+ t.Helper()
+ id, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "app.key"))
+ if err != nil {
+ t.Fatalf("building the box's enrolment: %v", err)
+ }
+
+ code, _, err := id.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL)
+ if err != nil {
+ t.Fatalf("minting the first owner's code: %v", err)
+ }
+ if _, err := id.Authorise(appKey(1), code); err != nil {
+ t.Fatalf("enrolling the first owner: %v", err)
+ }
+ return &realEnroller{id: id}
+}
+
+func withEnrolment(e *realEnroller) func(*Deps) {
+ return func(d *Deps) { d.AppEnroll = e }
+}
+
+// appKey is a phone's Noise static key. Distinct per number, and its content
+// is never inspected — appenroll keys its list on the bytes.
+func appKey(n byte) []byte {
+ key := make([]byte, 32)
+ for i := range key {
+ key[i] = n
+ }
+ return key
+}
+
+// --------------------------------------------------------------------------
+// Talking to the session
+// --------------------------------------------------------------------------
+
+// call sends one API request over the session and returns the status and body
+// a handler answered with. A refusal that never reached a handler comes back
+// as status 0 with the refusal code, because those are two different facts and
+// a test that conflated them would pass on either.
+func (r *appRig) call(t *testing.T, id uint32, req appproto.APIReq) (int, string, []byte) {
+ t.Helper()
+ r.send(t, appproto.MsgAPIReq, id, req)
+
+ env := awaitID(t, r.frames, id)
+ if env.T == appproto.MsgError {
+ return 0, decode[appproto.ErrorBody](t, env).Code, nil
+ }
+
+ head := decode[appproto.APIHeadMsg](t, env)
+ var body []byte
+ for _, e := range r.frames.snapshot() {
+ if e.T == appproto.MsgAPIChunk && e.ID != nil && *e.ID == id {
+ body = append(body, decode[appproto.APIChunk](t, e).Data...)
+ }
+ }
+ return head.Status, "", body
+}
+
+// pairingCodeIn digs the code out of a QR payload, the way a guest's phone
+// does after the camera reads the square.
+//
+// https://app.ftw.energy/p#v2....
+func pairingCodeIn(t *testing.T, url string) []byte {
+ t.Helper()
+ _, fragment, ok := strings.Cut(url, "#")
+ if !ok {
+ t.Fatalf("no payload in %q", url)
+ }
+ parts := strings.Split(fragment, ".")
+ if len(parts) != 5 || parts[0] != appenroll.PayloadVersion {
+ t.Fatalf("payload has %d segments: %q", len(parts), fragment)
+ }
+ code, err := base64.RawURLEncoding.DecodeString(parts[2])
+ if err != nil {
+ t.Fatalf("decoding the pairing code: %v", err)
+ }
+ return code
+}
+
+func lanHintIn(t *testing.T, url string) string {
+ t.Helper()
+ _, fragment, _ := strings.Cut(url, "#")
+ parts := strings.Split(fragment, ".")
+ if len(parts) != 5 {
+ t.Fatalf("payload has %d segments: %q", len(parts), fragment)
+ }
+ hint, err := base64.RawURLEncoding.DecodeString(parts[3])
+ if err != nil {
+ t.Fatalf("decoding the lan hint: %v", err)
+ }
+ return string(hint)
+}
+
+// --------------------------------------------------------------------------
+// What the guest actually becomes
+// --------------------------------------------------------------------------
+
+// The whole feature, end to end: an owner invites from the app, somebody
+// scans the square, and the box writes down a VIEWER.
+//
+// The last three lines are the test. Everything above them is what the app
+// already believed — and what it believed was true of the request it sent, of
+// the answer it got back, and of nothing at all about the guest.
+func TestAnInviteFromTheAppEnrolsAViewer(t *testing.T) {
+ enrol := newEnrolment(t)
+ rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol))
+
+ status, refusal, body := rig.call(t, 1, appproto.APIReq{
+ Method: appproto.APIPost,
+ Path: "/api/app-link/pairing",
+ Body: []byte(`{"role":"viewer"}`),
+ StepUp: true,
+ })
+ if status != http.StatusOK {
+ t.Fatalf("inviting answered %d %q; the app's invite button is dead", status, refusal)
+ }
+
+ var answer appLinkPairing
+ if err := json.Unmarshal(body, &answer); err != nil {
+ t.Fatalf("decoding the invitation: %v (%q)", err, body)
+ }
+ if answer.Role != apiauth.RoleViewer {
+ t.Fatalf("the answer names %q; the screen prints that above the square", answer.Role)
+ }
+ if answer.URL == "" {
+ t.Fatal("nothing for the guest to scan")
+ }
+
+ // The guest's phone, spending the code it just scanned. This is the only
+ // evidence that counts: what the box wrote down.
+ grant, err := enrol.id.Authorise(appKey(2), pairingCodeIn(t, answer.URL))
+ if err != nil {
+ t.Fatalf("the guest could not pair with the code the app handed out: %v", err)
+ }
+ if grant.Role != apiauth.RoleViewer {
+ t.Fatalf("the guest is a %q; the owner pressed \"Invite someone to view\"", grant.Role)
+ }
+}
+
+// The shape the app actually sent, which the box does not read.
+//
+// inviteViewer() put the role in the QUERY STRING. The box reads it from a
+// JSON body, so the field arrived as absent — and absent used to mean owner.
+// Every "invite a family member to look" was a house handed over, masked only
+// because the door in front of it was shut. It is a 400 now, and the guest
+// that never was is still nowhere on the list.
+func TestARoleInTheQueryStringMintsNothing(t *testing.T) {
+ enrol := newEnrolment(t)
+ rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol))
+
+ status, refusal, _ := rig.call(t, 1, appproto.APIReq{
+ Method: appproto.APIPost,
+ Path: "/api/app-link/pairing",
+ Query: map[string]string{"role": apiauth.RoleViewer},
+ StepUp: true,
+ })
+ if status != http.StatusBadRequest {
+ t.Fatalf("a role the box never read answered %d %q, want 400", status, refusal)
+ }
+
+ // And nothing is live to be spent. A code minted here would admit whoever
+ // scanned it as whatever the box guessed.
+ if _, err := enrol.id.Authorise(appKey(2), make([]byte, appenroll.PairingCodeBytes)); err == nil {
+ t.Fatal("a refused request left a live pairing code behind")
+ }
+ if n := enrol.AuthorisedCount(); n != 1 {
+ t.Fatalf("%d phones are enrolled, want the one owner", n)
+ }
+}
+
+// An owner is admitted at the box, in the house. A session proves enrolment
+// and says nothing about where the phone is, so it cannot make another owner —
+// by minting a code, by reading one down a phone line, or by promoting a guest
+// who is already on the list.
+func TestTheAppCannotMakeAnotherOwner(t *testing.T) {
+ enrol := newEnrolment(t)
+ rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol))
+
+ // A guest to try to promote, admitted the way a guest is.
+ code, _, err := enrol.id.MintPairingCode(apiauth.RoleViewer, appenroll.InviteTTL)
+ if err != nil {
+ t.Fatalf("minting the guest's code: %v", err)
+ }
+ guest, err := enrol.id.Authorise(appKey(2), code)
+ if err != nil {
+ t.Fatalf("enrolling the guest: %v", err)
+ }
+
+ for i, c := range []struct {
+ name string
+ req appproto.APIReq
+ }{
+ {"a scanned owner code", appproto.APIReq{
+ Method: appproto.APIPost, Path: "/api/app-link/pairing",
+ Body: []byte(`{"role":"owner"}`), StepUp: true}},
+ {"a spoken owner code", appproto.APIReq{
+ Method: appproto.APIPost, Path: "/api/app-link/pairing",
+ Body: []byte(`{"role":"owner","kind":"spoken"}`), StepUp: true}},
+ {"promoting the guest", appproto.APIReq{
+ Method: appproto.APIPatch, Path: "/api/app-link/devices/" + guest.DeviceID,
+ Body: []byte(`{"role":"owner"}`), StepUp: true}},
+ } {
+ status, refusal, body := rig.call(t, uint32(i+1), c.req)
+ if status != http.StatusForbidden {
+ t.Fatalf("%s answered %d %q %s, want 403", c.name, status, refusal, body)
+ }
+ }
+
+ // The list is what it was: one owner and one guest, and the guest is still
+ // a guest. A refusal that had already written something would be worse
+ // than no refusal at all.
+ owners := 0
+ for _, d := range enrol.Devices() {
+ if d.Role == apiauth.RoleOwner {
+ owners++
+ }
+ if d.ID == guest.DeviceID && d.Role != apiauth.RoleViewer {
+ t.Fatalf("the guest is now a %q", d.Role)
+ }
+ }
+ if owners != 1 {
+ t.Fatalf("%d owners, want the one who was there before", owners)
+ }
+}
+
+// A code to read aloud is made at the box, whatever role it is for.
+//
+// Forty bits are safe to say down a phone line because of what surrounds
+// them, and the load-bearing part is that every minting costs somebody a walk
+// to the box: five wrong guesses burn a code, and asking for another needs a
+// person in the room. Opening this door to the app would have taken that
+// argument away for a viewer's code — and bought nothing, because a typed code
+// carries the pairing code alone and cannot admit a phone that has never seen
+// this box.
+func TestTheAppCannotMintACodeToReadAloud(t *testing.T) {
+ enrol := newEnrolment(t)
+ rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol))
+
+ status, refusal, body := rig.call(t, 1, appproto.APIReq{
+ Method: appproto.APIPost,
+ Path: "/api/app-link/pairing",
+ Body: []byte(`{"role":"viewer","kind":"spoken"}`),
+ StepUp: true,
+ })
+ if status != http.StatusForbidden {
+ t.Fatalf("a spoken code from the app answered %d %q %s, want 403", status, refusal, body)
+ }
+
+ // Nothing live to guess at. A code minted and then withheld is still five
+ // guesses somebody did not have to walk to the box for.
+ if _, err := enrol.id.Authorise(appKey(3), make([]byte, appenroll.SpokenCodeBytes)); err == nil {
+ t.Fatal("a refused request left a live code behind")
+ }
+}
+
+// A code minted from the app carries no LAN hint.
+//
+// The passthrough builds its request with Host "localhost" so the API's trust
+// boundary sees a local client. Copied into the payload, that would hand the
+// guest an address pointing at their own phone — a square that scans, parses,
+// and then cannot reach anything.
+func TestAnInviteFromTheAppCarriesNoLANHint(t *testing.T) {
+ enrol := newEnrolment(t)
+ rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol))
+
+ status, refusal, body := rig.call(t, 1, appproto.APIReq{
+ Method: appproto.APIPost,
+ Path: "/api/app-link/pairing",
+ Body: []byte(`{"role":"viewer"}`),
+ StepUp: true,
+ })
+ if status != http.StatusOK {
+ t.Fatalf("inviting answered %d %q", status, refusal)
+ }
+ var answer appLinkPairing
+ if err := json.Unmarshal(body, &answer); err != nil {
+ t.Fatalf("decoding the invitation: %v", err)
+ }
+
+ if hint := lanHintIn(t, answer.URL); hint != "" {
+ t.Fatalf("the payload tells the guest to look for the box at %q", hint)
+ }
+}
+
+// --------------------------------------------------------------------------
+// Who may ask
+// --------------------------------------------------------------------------
+
+// The roster is not a guest's to see, and the door is not a guest's to open.
+//
+// Refused inside the handler on the scope, not by the passthrough's tier gate:
+// reading the list is a read, so the tier lets it through and the members
+// scope is what stops it. A viewer's grant carries neither members scope.
+func TestAGuestCannotSeeOrChangeWhoHasAccess(t *testing.T) {
+ enrol := newEnrolment(t)
+ rig := newAppSession(t, apiauth.RoleViewer, withEnrolment(enrol))
+
+ for i, c := range []struct {
+ name string
+ req appproto.APIReq
+ }{
+ {"the roster", appproto.APIReq{
+ Method: appproto.APIGet, Path: "/api/app-link/devices"}},
+ {"an invitation", appproto.APIReq{
+ Method: appproto.APIPost, Path: "/api/app-link/pairing",
+ Body: []byte(`{"role":"viewer"}`), StepUp: true}},
+ {"a revoke", appproto.APIReq{
+ Method: appproto.APIDelete, Path: "/api/app-link/devices/aaaa1111",
+ StepUp: true}},
+ } {
+ status, refusal, body := rig.call(t, uint32(i+1), c.req)
+ if status == http.StatusOK {
+ t.Fatalf("a guest read or changed %s: %s", c.name, body)
+ }
+ // Either door is a refusal: the passthrough's role gate on a write,
+ // or the handler's scope check on the read. What must not happen is a
+ // household's list of phones reaching a guest.
+ if status != 0 && status != http.StatusForbidden {
+ t.Fatalf("a guest asking for %s got %d %s", c.name, status, body)
+ }
+ if status == 0 && refusal != appproto.ErrScopeDenied {
+ t.Fatalf("a guest asking for %s was refused with %q", c.name, refusal)
+ }
+ if strings.Contains(string(body), "local network") {
+ t.Fatalf("a guest is told to go home: %s", body)
+ }
+ }
+
+ if n := enrol.AuthorisedCount(); n != 1 {
+ t.Fatalf("%d phones enrolled after a guest's attempts, want 1", n)
+ }
+}
+
+// An owner may look, which is the other half of the test above. Without it,
+// refusing everybody would pass.
+func TestAnOwnerReadsTheRosterThroughTheApp(t *testing.T) {
+ enrol := newEnrolment(t)
+ rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol))
+
+ status, refusal, body := rig.call(t, 1, appproto.APIReq{
+ Method: appproto.APIGet,
+ Path: "/api/app-link/devices",
+ })
+ if status != http.StatusOK {
+ t.Fatalf("the roster answered %d %q; the sharing screen has nothing to draw",
+ status, refusal)
+ }
+
+ var answer struct {
+ Devices []AppDevice `json:"devices"`
+ }
+ if err := json.Unmarshal(body, &answer); err != nil {
+ t.Fatalf("decoding the roster: %v (%q)", err, body)
+ }
+ if len(answer.Devices) != 1 || answer.Devices[0].Role != apiauth.RoleOwner {
+ t.Fatalf("roster = %+v, want the one owner naming its role", answer.Devices)
+ }
+ // The rows carry key prefixes and never keys, over the session as on the
+ // LAN. It is the same handler, and this is what says so.
+ if strings.Contains(string(body), "noiseSecret") {
+ t.Fatal("the roster leaked key material to the app")
+ }
+}
+
+// An owner may lock a phone out from anywhere. This is the case the feature is
+// for: the phone that was lost is not the phone in your hand, and telling
+// somebody to walk to the box is telling them to do nothing for an hour.
+func TestAnOwnerRevokesAPhoneThroughTheApp(t *testing.T) {
+ enrol := newEnrolment(t)
+ rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol))
+
+ code, _, err := enrol.id.MintPairingCode(apiauth.RoleViewer, appenroll.InviteTTL)
+ if err != nil {
+ t.Fatalf("minting the guest's code: %v", err)
+ }
+ guest, err := enrol.id.Authorise(appKey(2), code)
+ if err != nil {
+ t.Fatalf("enrolling the guest: %v", err)
+ }
+
+ status, refusal, body := rig.call(t, 1, appproto.APIReq{
+ Method: appproto.APIDelete,
+ Path: "/api/app-link/devices/" + guest.DeviceID,
+ StepUp: true,
+ })
+ if status != http.StatusOK {
+ t.Fatalf("revoking answered %d %q %s", status, refusal, body)
+ }
+ if _, still := enrol.id.GrantFor(guest.DeviceID); still {
+ t.Fatal("the box still trusts a phone the owner removed")
+ }
+}
+
+// The last owner cannot remove themselves, from the app any more than from the
+// box's own page — and the refusal has to be one a person can act on.
+//
+// Refused in the enrolment layer, which is what makes this true of both doors
+// at once rather than of whichever one somebody remembered.
+func TestTheLastOwnerCannotRemoveThemselvesThroughTheApp(t *testing.T) {
+ enrol := newEnrolment(t)
+ rig := newAppSession(t, apiauth.RoleOwner, withEnrolment(enrol))
+
+ rows := enrol.Devices()
+ if len(rows) != 1 {
+ t.Fatalf("this test needs exactly one owner, got %+v", rows)
+ }
+ me := rows[0].ID
+
+ for i, c := range []struct {
+ name string
+ req appproto.APIReq
+ }{
+ {"removing myself", appproto.APIReq{
+ Method: appproto.APIDelete, Path: "/api/app-link/devices/" + me,
+ StepUp: true}},
+ {"demoting myself", appproto.APIReq{
+ Method: appproto.APIPatch, Path: "/api/app-link/devices/" + me,
+ Body: []byte(`{"role":"viewer"}`), StepUp: true}},
+ } {
+ status, refusal, body := rig.call(t, uint32(i+1), c.req)
+ if status != http.StatusConflict {
+ t.Fatalf("%s answered %d %q, want 409", c.name, status, refusal)
+ }
+ if !strings.Contains(string(body), "Pair another owner first") {
+ t.Fatalf("%s was refused without saying what to do: %s", c.name, body)
+ }
+ }
+
+ // Still an owner, and still enrolled. A box that talked itself out of its
+ // last owner can never be administered again, from anywhere, by anybody.
+ rows = enrol.Devices()
+ if len(rows) != 1 || rows[0].Role != apiauth.RoleOwner {
+ t.Fatalf("the last owner is now %+v", rows)
+ }
+}
diff --git a/go/internal/api/api_app_link_sharing_test.go b/go/internal/api/api_app_link_sharing_test.go
new file mode 100644
index 00000000..6fd25aee
--- /dev/null
+++ b/go/internal/api/api_app_link_sharing_test.go
@@ -0,0 +1,334 @@
+package api
+
+// Sharing, at the HTTP door.
+//
+// Every route here hands out or takes away access to a house, so every one of
+// them is LAN-only for the same reason the pairing QR is: somebody has to be
+// inside the building. The tests below check that first and the behaviour
+// second, because a share that works from the internet is not a smaller bug
+// than a share that names the wrong role.
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/srcfl/ftw/go/internal/appproto"
+)
+
+func localRequest(method, target, body string) *http.Request {
+ var r *http.Request
+ if body == "" {
+ r = httptest.NewRequest(method, target, nil)
+ } else {
+ r = httptest.NewRequest(method, target, strings.NewReader(body))
+ }
+ r.Host = "192.168.1.1"
+ r.RemoteAddr = "192.168.1.5:1234"
+ return r
+}
+
+// An invite is minted for the role that was asked for, and the answer names
+// that role back so the screen can say it in words above the code. A code
+// whose power is invisible is the one that gets read to the wrong person.
+func TestAnInviteIsMintedForTheRoleThatWasAsked(t *testing.T) {
+ enroll := &stubEnroller{}
+ s := New(&Deps{AppEnroll: enroll})
+
+ w := httptest.NewRecorder()
+ s.handleAppLinkPairing(w, localRequest(
+ http.MethodPost, "/api/app-link/pairing", `{"role":"viewer"}`))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("got %d, want 200: %s", w.Code, w.Body.String())
+ }
+ if enroll.mintedRole != "viewer" {
+ t.Fatalf("minted for %q, want a viewer", enroll.mintedRole)
+ }
+
+ var got appLinkPairing
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decoding the answer: %v", err)
+ }
+ if got.Role != "viewer" {
+ t.Fatalf("the answer says %q; the screen cannot name what it is handing out", got.Role)
+ }
+ if got.URL == "" {
+ t.Fatal("no payload to scan")
+ }
+}
+
+// A request that names no role mints nothing.
+//
+// It used to mint an owner, on the reasoning that a page which has not been
+// updated should keep meaning what it used to mean. What that reasoning cost
+// is a default that hands over a house whenever a field goes missing — and the
+// field did go missing: the app sent its role in a query string this handler
+// never reads, so every "invite someone to view" arrived here naming no role
+// and would have left as an owner's code.
+//
+// A request that does not say is now asked. That is the only direction this
+// particular question can safely be wrong in.
+func TestAPairingRequestThatNamesNoRoleIsRefused(t *testing.T) {
+ enroll := &stubEnroller{}
+ s := New(&Deps{AppEnroll: enroll})
+
+ for _, body := range []string{
+ "", // no body at all
+ "{}", // a body that says nothing
+ `{"kind":"qr"}`, // a body about something else
+ "{not json", // a body that will not parse
+ `{"role":""}`, // a field that arrived empty
+ } {
+ w := httptest.NewRecorder()
+ s.handleAppLinkPairing(w, localRequest(http.MethodPost, "/api/app-link/pairing", body))
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("body %q: got %d, want 400: %s", body, w.Code, w.Body.String())
+ }
+ if enroll.minted != 0 || enroll.spoken != 0 {
+ t.Fatalf("body %q minted a %q code anyway", body, enroll.mintedRole)
+ }
+ }
+
+ // The same request with its role named still works, or the rule above
+ // would be indistinguishable from a broken endpoint.
+ w := httptest.NewRecorder()
+ s.handleAppLinkPairing(w, localRequest(
+ http.MethodPost, "/api/app-link/pairing", `{"role":"owner"}`))
+ if w.Code != http.StatusOK {
+ t.Fatalf("naming the role got %d, want 200: %s", w.Code, w.Body.String())
+ }
+ if enroll.mintedRole != "owner" {
+ t.Fatalf("minted for %q, want the owner that was asked for", enroll.mintedRole)
+ }
+}
+
+// The box code comes back as characters and never as a scannable payload —
+// and the QR payload never comes back as text. They are different objects with
+// different handling: one is meant to be read aloud, the other must only ever
+// travel through a camera.
+func TestABoxCodeIsTextAndAQRCodeIsNot(t *testing.T) {
+ enroll := &stubEnroller{}
+ s := New(&Deps{AppEnroll: enroll})
+
+ w := httptest.NewRecorder()
+ s.handleAppLinkPairing(w, localRequest(
+ http.MethodPost, "/api/app-link/pairing", `{"role":"viewer","kind":"spoken"}`))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("got %d, want 200: %s", w.Code, w.Body.String())
+ }
+ if enroll.spoken != 1 || enroll.minted != 0 {
+ t.Fatalf("minted %d spoken and %d scanned codes, want 1 and 0", enroll.spoken, enroll.minted)
+ }
+
+ var got appLinkPairing
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decoding the answer: %v", err)
+ }
+ if got.Code != "ABCD-EFGH" {
+ t.Fatalf("code = %q, want the grouped characters", got.Code)
+ }
+ if got.URL != "" {
+ t.Fatal("a spoken code came back with a scannable payload as well")
+ }
+
+ // And the other way round: the QR payload is a credential, so it must not
+ // appear in the field a screen would print as text. Decoded into a fresh
+ // value, because an absent field leaves whatever was there before.
+ w = httptest.NewRecorder()
+ s.handleAppLinkPairing(w, localRequest(
+ http.MethodPost, "/api/app-link/pairing", `{"role":"owner"}`))
+ var scanned appLinkPairing
+ if err := json.Unmarshal(w.Body.Bytes(), &scanned); err != nil {
+ t.Fatalf("decoding the answer: %v", err)
+ }
+ if scanned.Code != "" {
+ t.Fatalf("a scanned code came back as readable text: %q", scanned.Code)
+ }
+ if scanned.URL == "" {
+ t.Fatal("a scanned code came back with nothing to scan")
+ }
+}
+
+// Every sharing surface is LAN-only, for the same reason pairing is. A code
+// minted from the internet is a house handed to whoever asked.
+func TestEverySharingRouteIsLocalOnly(t *testing.T) {
+ remote := func(method, target, body string) *http.Request {
+ r := localRequest(method, target, body)
+ r.Host = "app.example.com"
+ r.RemoteAddr = "203.0.113.9:1234"
+ return r
+ }
+ proxied := func(method, target, body string) *http.Request {
+ r := localRequest(method, target, body)
+ r.Header.Set("X-Forwarded-For", "203.0.113.9")
+ return r
+ }
+
+ for _, c := range []struct {
+ name string
+ serve func(*Server, http.ResponseWriter, *http.Request)
+ method string
+ target string
+ body string
+ wrapper func(string, string, string) *http.Request
+ }{
+ {"invite from outside", (*Server).handleAppLinkPairing,
+ http.MethodPost, "/api/app-link/pairing", `{"role":"viewer"}`, remote},
+ {"invite through a proxy", (*Server).handleAppLinkPairing,
+ http.MethodPost, "/api/app-link/pairing", `{"role":"viewer"}`, proxied},
+ {"box code from outside", (*Server).handleAppLinkPairing,
+ http.MethodPost, "/api/app-link/pairing", `{"kind":"spoken"}`, remote},
+ {"box code through a proxy", (*Server).handleAppLinkPairing,
+ http.MethodPost, "/api/app-link/pairing", `{"kind":"spoken"}`, proxied},
+ {"role change from outside", (*Server).handleAppLinkDeviceRole,
+ http.MethodPatch, "/api/app-link/devices/aaaa1111", `{"role":"viewer"}`, remote},
+ {"role change through a proxy", (*Server).handleAppLinkDeviceRole,
+ http.MethodPatch, "/api/app-link/devices/aaaa1111", `{"role":"viewer"}`, proxied},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ enroll := &stubEnroller{}
+ s := New(&Deps{AppEnroll: enroll})
+
+ w := httptest.NewRecorder()
+ r := c.wrapper(c.method, c.target, c.body)
+ r.SetPathValue("id", "aaaa1111")
+ c.serve(s, w, r)
+
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("got %d, want 403 — this route changes who can reach a house", w.Code)
+ }
+ // Refused before anything happened. A code minted and then
+ // withheld has already invalidated the one on somebody's screen.
+ if enroll.minted != 0 || enroll.spoken != 0 || enroll.roleSet != "" {
+ t.Fatalf("a refused request still did something: %+v", enroll)
+ }
+ })
+ }
+}
+
+// Promoting a guest and stepping an owner down happen on the device list, in
+// the same place a phone is locked out. Sharing is a role on a row.
+func TestARoleIsChangedFromTheDeviceList(t *testing.T) {
+ enroll := &stubEnroller{}
+ s := New(&Deps{AppEnroll: enroll})
+
+ w := httptest.NewRecorder()
+ r := localRequest(http.MethodPatch, "/api/app-link/devices/aaaa1111", `{"role":"viewer"}`)
+ r.SetPathValue("id", "aaaa1111")
+ s.handleAppLinkDeviceRole(w, r)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("got %d, want 200: %s", w.Code, w.Body.String())
+ }
+ if enroll.roleSet != "viewer" {
+ t.Fatalf("set the role to %q, want viewer", enroll.roleSet)
+ }
+}
+
+// The last owner is protected, and the refusal has to be one a person can act
+// on: 409 with a sentence saying what to do first, not a 500.
+func TestTheLastOwnerIsRefusedWithSomethingToDo(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ serve func(*Server, http.ResponseWriter, *http.Request)
+ req *http.Request
+ }{
+ {"demote", (*Server).handleAppLinkDeviceRole,
+ localRequest(http.MethodPatch, "/api/app-link/devices/aaaa1111", `{"role":"viewer"}`)},
+ {"remove", (*Server).handleAppLinkDeviceRevoke,
+ localRequest(http.MethodDelete, "/api/app-link/devices/aaaa1111", "")},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ enroll := &stubEnroller{lastOwner: true}
+ s := New(&Deps{AppEnroll: enroll})
+
+ w := httptest.NewRecorder()
+ c.req.SetPathValue("id", "aaaa1111")
+ c.serve(s, w, c.req)
+
+ if w.Code != http.StatusConflict {
+ t.Fatalf("got %d, want 409: %s", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "Pair another owner first") {
+ t.Fatalf("the refusal does not say what to do: %s", w.Body.String())
+ }
+ // And a code beside the sentence, because two audiences read this
+ // body. The box's own page prints the sentence; the app owns all
+ // of its own prose and needs a name to branch on, and a 409 is a
+ // conflict and nothing more specific than that. Without this the
+ // app's last-owner screen is dead against a real box: it reads
+ // "code" and every refusal from here sent only "error".
+ var refusal struct {
+ Error string `json:"error"`
+ Code string `json:"code"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &refusal); err != nil {
+ t.Fatalf("the refusal is not JSON: %v", err)
+ }
+ if refusal.Code != appproto.ErrLastOwnerProtected {
+ t.Fatalf("refusal code %q, want %q — from contract/registry.yaml, "+
+ "never a literal on this floor", refusal.Code, appproto.ErrLastOwnerProtected)
+ }
+ if enroll.revoked != 0 || enroll.roleSet != "" {
+ t.Fatalf("the last owner was changed anyway: %+v", enroll)
+ }
+ })
+ }
+}
+
+// A role the registry does not define is a 400, not a 500 and not a silent
+// success. The list of roles lives in contract/registry.yaml and the answer
+// has to come from there rather than from a literal on this floor.
+func TestAnUnknownRoleIsABadRequest(t *testing.T) {
+ enroll := &stubEnroller{}
+ s := New(&Deps{AppEnroll: enroll})
+
+ w := httptest.NewRecorder()
+ s.handleAppLinkPairing(w, localRequest(
+ http.MethodPost, "/api/app-link/pairing", `{"role":"administrator"}`))
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("minting for an unknown role got %d, want 400: %s", w.Code, w.Body.String())
+ }
+
+ w = httptest.NewRecorder()
+ r := localRequest(http.MethodPatch, "/api/app-link/devices/aaaa1111", `{"role":"administrator"}`)
+ r.SetPathValue("id", "aaaa1111")
+ s.handleAppLinkDeviceRole(w, r)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("setting an unknown role got %d, want 400: %s", w.Code, w.Body.String())
+ }
+ if enroll.roleSet != "" {
+ t.Fatalf("an unknown role was written: %q", enroll.roleSet)
+ }
+}
+
+// The device list carries the role, or a household cannot tell whose phone is
+// a guest and cannot decide which row to remove.
+func TestTheDeviceListNamesEachPhonesRole(t *testing.T) {
+ s := New(&Deps{AppEnroll: &stubEnroller{}})
+
+ w := httptest.NewRecorder()
+ s.handleAppLinkDevices(w, localRequest(http.MethodGet, "/api/app-link/devices", ""))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("got %d, want 200", w.Code)
+ }
+ var body struct {
+ Devices []AppDevice `json:"devices"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decoding: %v", err)
+ }
+ if len(body.Devices) != 1 || body.Devices[0].Role != "owner" {
+ t.Fatalf("devices = %+v, want one row naming its role", body.Devices)
+ }
+ // Still no keys. The rows carry prefixes, and that has not changed.
+ if strings.Contains(w.Body.String(), "noiseSecret") {
+ t.Fatal("the device list leaked key material")
+ }
+}
diff --git a/go/internal/api/api_app_link_test.go b/go/internal/api/api_app_link_test.go
index e1bd33ee..d495a116 100644
--- a/go/internal/api/api_app_link_test.go
+++ b/go/internal/api/api_app_link_test.go
@@ -3,6 +3,7 @@ package api
import (
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"time"
)
@@ -10,39 +11,86 @@ import (
// A stub enroller. Pairing is the one surface that hands out a credential, so
// what matters here is who is allowed to ask, not what comes back.
type stubEnroller struct {
- minted int
- revoked int
- err error
+ minted int
+ spoken int
+ revoked int
+ mintedRole string
+ roleSet string
+ err error
+ // lastOwner makes the stub refuse to remove or demote its one row, the
+ // way appenroll does when it is the only owner left.
+ lastOwner bool
}
-func (s *stubEnroller) MintPairingCode() ([]byte, time.Time, error) {
+func (s *stubEnroller) MintPairingCode(role string) ([]byte, time.Time, error) {
if s.err != nil {
return nil, time.Time{}, s.err
}
+ if role != "owner" && role != "viewer" {
+ return nil, time.Time{}, ErrUnknownAppRole
+ }
s.minted++
+ s.mintedRole = role
return make([]byte, 16), time.Now().Add(10 * time.Minute), nil
}
+func (s *stubEnroller) MintSpokenCode(role string) (string, time.Time, error) {
+ if s.err != nil {
+ return "", time.Time{}, s.err
+ }
+ if role != "owner" && role != "viewer" {
+ return "", time.Time{}, ErrUnknownAppRole
+ }
+ s.spoken++
+ s.mintedRole = role
+ return "ABCD-EFGH", time.Now().Add(5 * time.Minute), nil
+}
+
func (s *stubEnroller) EnrollmentURL(code []byte, lanHint string) (string, error) {
return "https://app.ftw.energy/p#v2.aaa.bbb.ccc.ddd", nil
}
func (s *stubEnroller) Devices() []AppDevice {
- return []AppDevice{{ID: "aaaa1111", AddedAtMs: 1, LastSeenMs: 2}}
+ return []AppDevice{{ID: "aaaa1111", AddedAtMs: 1, LastSeenMs: 2, Role: "owner"}}
+}
+
+func (s *stubEnroller) SetDeviceRole(id, role string) error {
+ if id != "aaaa1111" {
+ return ErrUnknownAppDevice
+ }
+ if role != "owner" && role != "viewer" {
+ return ErrUnknownAppRole
+ }
+ if s.lastOwner {
+ return ErrLastAppOwnerProtected
+ }
+ s.roleSet = role
+ return nil
}
func (s *stubEnroller) RevokeDevice(id string) error {
if id != "aaaa1111" {
return ErrUnknownAppDevice
}
+ if s.lastOwner {
+ return ErrLastAppOwnerProtected
+ }
s.revoked++
return nil
}
func (s *stubEnroller) AuthorisedCount() int { return 2 }
+// pairingRequest asks for an owner's QR code, which is what the box's own page
+// asks for when somebody presses "pair my phone".
+//
+// The role is named rather than left out. It has to be: a request that names
+// none is refused now, because a default at this endpoint decides who owns a
+// house.
func pairingRequest(host, remote string, headers map[string]string) *http.Request {
- r := httptest.NewRequest(http.MethodPost, "/api/app-link/pairing", nil)
+ r := httptest.NewRequest(http.MethodPost, "/api/app-link/pairing",
+ strings.NewReader(`{"role":"owner"}`))
+ r.Header.Set("Content-Type", "application/json")
r.Host = host
r.RemoteAddr = remote
for k, v := range headers {
diff --git a/go/internal/api/api_passthrough_test.go b/go/internal/api/api_passthrough_test.go
new file mode 100644
index 00000000..addfec2b
--- /dev/null
+++ b/go/internal/api/api_passthrough_test.go
@@ -0,0 +1,732 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/apiauth"
+ "github.com/srcfl/ftw/go/internal/appproto"
+ "github.com/srcfl/ftw/go/internal/appuplink"
+ "github.com/srcfl/ftw/go/internal/config"
+ "github.com/srcfl/ftw/go/internal/control"
+ "github.com/srcfl/ftw/go/internal/mpc"
+ "github.com/srcfl/ftw/go/internal/telemetry"
+)
+
+// The app's passthrough, against this box's real HTTP handler.
+//
+// Everything here runs the request the box would run, through the handler the
+// LAN listener serves. A fake gateway that agreed with the gate would prove
+// nothing: what is being asserted is that a viewer cannot change this box, and
+// the only convincing evidence is the box not changing.
+
+// --------------------------------------------------------------------------
+// A session, wired to the real API
+// --------------------------------------------------------------------------
+
+type appRig struct {
+ handler *appproto.Handler
+ frames *appFrames
+ ctrl *control.State
+ saved *savedConfigs
+}
+
+// savedConfigs records what POST /api/config managed to write, so a test can
+// assert on the box rather than on the error message.
+type savedConfigs struct {
+ mu sync.Mutex
+ runs int
+}
+
+func (s *savedConfigs) save(string, *config.Config) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.runs++
+ return nil
+}
+
+func (s *savedConfigs) count() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.runs
+}
+
+type appFrames struct {
+ mu sync.Mutex
+ sent []appproto.Envelope
+}
+
+func (f *appFrames) Send(raw []byte) error {
+ frame, err := appuplink.Codec().DecodeFrame(raw)
+ if err != nil {
+ return err
+ }
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.sent = append(f.sent, frame.Envelope)
+ return nil
+}
+
+func (f *appFrames) snapshot() []appproto.Envelope {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]appproto.Envelope(nil), f.sent...)
+}
+
+// await returns the first frame of a type, or fails. The passthrough answers
+// on its own goroutine, so there is nothing to wait on but the answer.
+func (f *appFrames) await(t *testing.T, msgType string) appproto.Envelope {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ for _, env := range f.snapshot() {
+ if env.T == msgType {
+ return env
+ }
+ }
+ time.Sleep(time.Millisecond)
+ }
+ var seen []string
+ for _, env := range f.snapshot() {
+ seen = append(seen, env.T)
+ }
+ t.Fatalf("no %s frame; got %v", msgType, seen)
+ return appproto.Envelope{}
+}
+
+func (f *appFrames) has(msgType string) bool {
+ for _, env := range f.snapshot() {
+ if env.T == msgType {
+ return true
+ }
+ }
+ return false
+}
+
+func decode[T any](t *testing.T, env appproto.Envelope) T {
+ t.Helper()
+ var v T
+ if err := appproto.Unmarshal(env.B, &v); err != nil {
+ t.Fatalf("decode %s: %v", env.T, err)
+ }
+ return v
+}
+
+// stillEnrolled is the grant as the box holds it, unchanged for the life of
+// these tests. Revocation and demotion have their own tests in appproto.
+type stillEnrolled struct{ role string }
+
+func (s stillEnrolled) Grant() (string, uint64, bool) { return s.role, 1, true }
+
+// newAppSession builds a real API server and one app session onto it.
+//
+// The options are for the dependencies a particular test needs the box to
+// actually have — enrolment is one, and the sharing tests hand in the real
+// appenroll.Identity so that what a guest ends up being is measured on the
+// box rather than on a stub that agreed with the request.
+func newAppSession(t *testing.T, role string, opts ...func(*Deps)) *appRig {
+ t.Helper()
+
+ ctrl := control.NewState(0, 50, "meter")
+ tel := telemetry.NewStore()
+ tel.DriverHealthMut("meter").RecordSuccess()
+ saved := &savedConfigs{}
+ cfg := &config.Config{}
+
+ deps := &Deps{
+ Ctrl: ctrl, CtrlMu: &sync.Mutex{},
+ Tel: tel, LogRing: telemetry.NewLogRing(), Version: "test",
+ CfgMu: &sync.RWMutex{}, Cfg: cfg,
+ ConfigPath: filepath.Join(t.TempDir(), "config.yaml"),
+ SaveConfig: saved.save,
+ }
+ for _, opt := range opts {
+ opt(deps)
+ }
+ srv := New(deps)
+
+ frames := &appFrames{}
+ box := &appBox{ctrl: ctrl}
+ handler, err := appproto.New(appproto.Config{
+ Clock: appproto.SystemClock{StartedAt: time.Now(), Source: "ntp"},
+ Site: box,
+ Info: box,
+ Modes: box,
+ Plans: box,
+ Codec: appuplink.Codec(),
+ Sender: frames,
+ API: srv,
+ Caller: apiauth.Caller{
+ Subject: apiauth.KindApp + ":aBcD1234",
+ Kind: apiauth.KindApp,
+ Role: role,
+ Scopes: appproto.ScopesForRole(role),
+ Epoch: 1,
+ },
+ Grants: stillEnrolled{role: role},
+ SrcGrid: "meter",
+ SrcPV: "meter",
+ SrcBattery: "meter",
+ Logger: slog.New(slog.DiscardHandler),
+ })
+ if err != nil {
+ t.Fatalf("building the app session: %v", err)
+ }
+ t.Cleanup(handler.Close)
+
+ return &appRig{handler: handler, frames: frames, ctrl: ctrl, saved: saved}
+}
+
+// send hands one message to the session as bytes, the way the relay would.
+func (r *appRig) send(t *testing.T, msgType string, id uint32, body any) {
+ t.Helper()
+ raw, err := appproto.Marshal(body)
+ if err != nil {
+ t.Fatalf("marshal %s: %v", msgType, err)
+ }
+ frame, err := appuplink.Codec().EncodeFrame(appproto.Frame{
+ Lane: appproto.LaneBulk,
+ Bucket: 16384,
+ Envelope: appproto.Envelope{T: msgType, ID: &id, B: raw},
+ })
+ if err != nil {
+ t.Fatalf("encode %s: %v", msgType, err)
+ }
+ if err := r.handler.Handle(context.Background(), frame); err != nil {
+ t.Fatalf("handle %s: %v", msgType, err)
+ }
+}
+
+// appBox is the least box a session needs. The control state is real, because
+// it is what the assertions are about.
+type appBox struct{ ctrl *control.State }
+
+func (b *appBox) Snapshot() appproto.Snapshot {
+ return appproto.Snapshot{Mode: b.ctrl.Mode, ControlRev: 1, DispatchBlockedBy: []string{}}
+}
+func (b *appBox) Identity() appproto.Identity {
+ return appproto.Identity{ID: "box", Build: "test", TZ: "Europe/Stockholm"}
+}
+func (b *appBox) Boot() *appproto.BootProgress { return nil }
+func (b *appBox) Latest() *mpc.Plan { return nil }
+func (b *appBox) Rev() uint64 { return 0 }
+func (b *appBox) CeilingW() *int64 { return nil }
+func (b *appBox) SetMode(_ context.Context, m control.Mode) error {
+ return b.ctrl.ApplyMode(m)
+}
+func (b *appBox) ObservedMode() (control.Mode, bool) { return b.ctrl.Mode, true }
+
+// --------------------------------------------------------------------------
+// A viewer cannot write. This is the one a reviewer will try hardest to break.
+// --------------------------------------------------------------------------
+
+func TestAViewerCannotWriteThroughEitherDoor(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleViewer)
+ before := rig.ctrl.Mode
+
+ // The command lane, with everything else about the command correct.
+ rig.send(t, appproto.MsgCmd, 1, appproto.Cmd{
+ CmdID: "01920000-0000-7000-8000-000000000001",
+ Op: appproto.OpSetMode,
+ Args: map[string]any{"mode": string(control.ModeSelfConsumption)},
+ NotValidAfterMs: 3_600_000, // box uptime, not wall clock
+ Expect: appproto.Expect{Rev: 1},
+ })
+ result := decode[appproto.CmdResult](t, rig.frames.await(t, appproto.MsgCmdResult))
+ if result.State != appproto.CmdRejected {
+ t.Fatalf("cmd state = %q, want rejected", result.State)
+ }
+ if result.Error == nil || result.Error.Code != appproto.ErrScopeDenied {
+ t.Fatalf("cmd refusal = %+v, want E_SCOPE_DENIED", result.Error)
+ }
+
+ // The HTTP door, at a route that is only configuration.
+ rig.send(t, appproto.MsgAPIReq, 2, appproto.APIReq{
+ Method: appproto.APIPost,
+ Path: "/api/battery_covers_ev",
+ Body: []byte(`{"enabled":true}`),
+ StepUp: true,
+ })
+ refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError))
+ if refusal.Code != appproto.ErrScopeDenied {
+ t.Fatalf("api refusal = %+v, want E_SCOPE_DENIED", refusal)
+ }
+ if refusal.Args["needRole"] != apiauth.RoleOwner {
+ t.Fatalf("refusal args = %v, want the role it needs", refusal.Args)
+ }
+
+ // The box, which is what the test is actually about.
+ if rig.ctrl.Mode != before {
+ t.Fatalf("a viewer changed the mode to %q", rig.ctrl.Mode)
+ }
+ if rig.ctrl.BatteryCoversEV {
+ t.Fatal("a viewer changed a dispatch setting")
+ }
+ if rig.frames.has(appproto.MsgAPIHead) {
+ t.Fatal("a viewer's write reached a handler")
+ }
+}
+
+// The same route, the same body, an owner. Without this the test above would
+// pass on a passthrough that refuses everything.
+func TestAnOwnerConfiguresAndTheBoxChanges(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleOwner)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIPost,
+ Path: "/api/battery_covers_ev",
+ Body: []byte(`{"enabled":true}`),
+ StepUp: true,
+ })
+ head := decode[appproto.APIHeadMsg](t, rig.frames.await(t, appproto.MsgAPIHead))
+ if head.Status != 200 {
+ t.Fatalf("status = %d, want 200", head.Status)
+ }
+ rig.frames.await(t, appproto.MsgAPIEnd)
+
+ if !rig.ctrl.BatteryCoversEV {
+ t.Fatal("the owner's write reached a 200 but the box did not change")
+ }
+ if rig.frames.has(appproto.MsgError) {
+ t.Fatal("an accepted request also produced an error")
+ }
+}
+
+// Step-up costs one round trip on the first write of a session and nothing
+// after. What it buys is a phone left unlocked on a table; the comment above
+// the check says what it does not buy.
+func TestAConfigureWithoutStepUpAsksForOne(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleOwner)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIPost,
+ Path: "/api/battery_covers_ev",
+ Body: []byte(`{"enabled":true}`),
+ })
+ refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError))
+
+ if refusal.Code != appproto.ErrNeedsStepUp {
+ t.Fatalf("refusal = %+v, want E_NEEDS_STEP_UP", refusal)
+ }
+ if refusal.Args["tier"] != string(apiauth.TierConfigure) {
+ t.Fatalf("refusal args = %v, want the tier that needs it", refusal.Args)
+ }
+ if rig.ctrl.BatteryCoversEV {
+ t.Fatal("a request that was told to step up changed the box anyway")
+ }
+}
+
+// --------------------------------------------------------------------------
+// The second door: actuation stays on cmd
+// --------------------------------------------------------------------------
+
+// An HTTP request carries no expiry, so it must never move energy — however
+// well authenticated the phone sending it is.
+func TestActuationThroughThePassthroughIsRefused(t *testing.T) {
+ cases := []struct {
+ name string
+ req appproto.APIReq
+ wantOp string
+ }{
+ {
+ name: "the mode, which has a command",
+ req: appproto.APIReq{
+ Method: appproto.APIPost, Path: "/api/mode",
+ Body: []byte(`{"mode":"self_consumption"}`), StepUp: true,
+ },
+ wantOp: appproto.OpSetMode,
+ },
+ {
+ name: "forcing a charge to start",
+ req: appproto.APIReq{
+ Method: appproto.APIPost, Path: "/api/loadpoints/1/force_start",
+ Body: []byte(`{}`), StepUp: true,
+ },
+ },
+ {
+ name: "holding the battery",
+ req: appproto.APIReq{
+ Method: appproto.APIPost, Path: "/api/battery/manual_hold",
+ Body: []byte(`{"power_w":3000}`), StepUp: true,
+ },
+ },
+ {
+ name: "the import ceiling that defends a fuse",
+ req: appproto.APIReq{
+ Method: appproto.APIPost, Path: "/api/peak_import_ceiling",
+ Body: []byte(`{"peak_import_ceiling_w":1000}`), StepUp: true,
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleOwner)
+ before := rig.ctrl.Mode
+
+ rig.send(t, appproto.MsgAPIReq, 1, tc.req)
+ refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError))
+
+ if refusal.Code != appproto.ErrUseCmd {
+ t.Fatalf("refusal = %+v, want E_USE_CMD", refusal)
+ }
+ if tc.wantOp != "" && refusal.Args["op"] != tc.wantOp {
+ t.Fatalf("refusal args = %v, want op %q", refusal.Args, tc.wantOp)
+ }
+ if rig.frames.has(appproto.MsgAPIHead) {
+ t.Fatal("an actuating route ran through the passthrough")
+ }
+ if rig.ctrl.Mode != before || rig.ctrl.PeakImportCeilingW != 0 {
+ t.Fatal("an actuating route reached the box through the passthrough")
+ }
+ })
+ }
+}
+
+// The same command, on the lane that carries an expiry, still works.
+func TestTheCommandLaneStillMovesTheMode(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleOwner)
+
+ rig.send(t, appproto.MsgCmd, 1, appproto.Cmd{
+ CmdID: "01920000-0000-7000-8000-000000000002",
+ Op: appproto.OpSetMode,
+ Args: map[string]any{"mode": string(control.ModeSelfConsumption)},
+ NotValidAfterMs: 3_600_000, // box uptime, not wall clock
+ Expect: appproto.Expect{Rev: 1},
+ })
+ result := decode[appproto.CmdResult](t, rig.frames.await(t, appproto.MsgCmdResult))
+
+ if result.State != appproto.CmdApplied {
+ t.Fatalf("cmd state = %q (%+v), want applied", result.State, result.Error)
+ }
+ if rig.ctrl.Mode != control.ModeSelfConsumption {
+ t.Fatalf("mode = %q, want self_consumption", rig.ctrl.Mode)
+ }
+}
+
+// --------------------------------------------------------------------------
+// A write must not wipe settings its caller never knew about
+// --------------------------------------------------------------------------
+
+// POST /api/config replaces the whole configuration, so a body built from an
+// older idea of it drops every field the sender had not heard of. That has
+// already cost this project one silent regression on the LAN, where the
+// browser had at least just loaded the document from this box.
+func TestAWholeConfigWriteIsRefused(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleOwner)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIPost,
+ Path: "/api/config",
+ Body: []byte(`{"site":{"name":"home"}}`),
+ StepUp: true,
+ })
+ refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError))
+
+ if refusal.Code != appproto.ErrWholeDocument {
+ t.Fatalf("refusal = %+v, want E_WHOLE_DOCUMENT", refusal)
+ }
+ if rig.saved.count() != 0 {
+ t.Fatalf("the configuration was written %d times", rig.saved.count())
+ }
+ if rig.frames.has(appproto.MsgAPIHead) {
+ t.Fatal("a whole-document write reached the handler")
+ }
+}
+
+// Reading it is fine, and is what the app needs to draw a settings screen.
+func TestReadingTheConfigIsAllowedForAnyRole(t *testing.T) {
+ for _, role := range []string{apiauth.RoleOwner, apiauth.RoleViewer} {
+ t.Run(role, func(t *testing.T) {
+ rig := newAppSession(t, role)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIGet, Path: "/api/modes",
+ })
+ head := decode[appproto.APIHeadMsg](t, rig.frames.await(t, appproto.MsgAPIHead))
+ if head.Status != 200 {
+ t.Fatalf("status = %d, want 200", head.Status)
+ }
+ if head.Headers["Content-Type"] != "application/json" {
+ t.Fatalf("headers = %v, want the content type", head.Headers)
+ }
+
+ var out []byte
+ for _, env := range rig.frames.snapshot() {
+ if env.T == appproto.MsgAPIChunk {
+ out = append(out, decode[appproto.APIChunk](t, env).Data...)
+ }
+ }
+ rig.frames.await(t, appproto.MsgAPIEnd)
+
+ var answer struct {
+ Modes []struct {
+ Key string `json:"key"`
+ } `json:"modes"`
+ }
+ if err := json.Unmarshal(out, &answer); err != nil {
+ t.Fatalf("the answer was not the handler's JSON: %v (%q)", err, out)
+ }
+ if len(answer.Modes) == 0 {
+ t.Fatal("the mode catalogue came back empty")
+ }
+ })
+ }
+}
+
+// --------------------------------------------------------------------------
+// What the box's own answers look like from the app's side
+// --------------------------------------------------------------------------
+
+// A 404 from a handler is an answer, not a refusal. The two are told apart by
+// which message arrived, never by reading a body — and they are different
+// sentences to a user: this box has no such route, versus this box looked and
+// there is no such driver.
+func TestAHandlersOwnNotFoundComesBackAsAStatus(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleOwner)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIGet, Path: "/api/drivers/no-such-driver/draft",
+ })
+ head := decode[appproto.APIHeadMsg](t, rig.frames.await(t, appproto.MsgAPIHead))
+
+ if head.Status != 404 {
+ t.Fatalf("status = %d, want the handler's own 404", head.Status)
+ }
+ if rig.frames.has(appproto.MsgError) {
+ t.Fatal("a 404 from a handler was also reported as a protocol error")
+ }
+}
+
+// A route this box does not have never reaches a handler at all: it falls
+// through to the static file server, which the passthrough does not carry.
+func TestARouteThisBoxDoesNotHaveIsRefused(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleOwner)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIGet, Path: "/api/no-such-thing",
+ })
+ refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError))
+
+ if refusal.Code != appproto.ErrUnknownOp || refusal.Args["field"] != "path" {
+ t.Fatalf("refusal = %+v, want E_UNKNOWN_OP on the path", refusal)
+ }
+ if rig.frames.has(appproto.MsgAPIHead) {
+ t.Fatal("a route this box does not have still produced a status")
+ }
+}
+
+// The support dump is a multi-megabyte ZIP of logs, telemetry and config, and
+// a PWA inside a Noise session has nothing useful to do with one.
+//
+// It used to be refused for its media type, which meant the handler ran and
+// started building the archive before anything said no. Naming the route Local
+// refuses it before that, and for the reason a person would give: this one
+// lives on your box's own page, from home. The media-type guard is still
+// there — TestAnAnswerTheSessionCannotCarryIsRefusedAtTheHead in appproto is
+// what exercises it — but nothing in the route table now depends on it.
+func TestTheSupportDumpIsRefusedBeforeItIsBuilt(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleOwner)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIGet, Path: "/api/support/dump", StepUp: true,
+ })
+ refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError))
+
+ if refusal.Code != appproto.ErrLocalOnly {
+ t.Fatalf("refusal = %+v, want E_LOCAL_ONLY", refusal)
+ }
+ if rig.frames.has(appproto.MsgAPIHead) || rig.frames.has(appproto.MsgAPIChunk) {
+ t.Fatal("a refused answer still put bytes on the wire")
+ }
+}
+
+// An answer the session cannot carry whole stops at the ceiling and says so.
+// The app treats a truncated answer as a failure rather than drawing part of
+// one as though it were the whole.
+func TestAnAnswerPastTheCeilingIsTruncatedNotSilentlyShort(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleOwner)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIGet, Path: "/api/modes", MaxBytes: 64,
+ })
+ end := decode[appproto.APIEnd](t, rig.frames.await(t, appproto.MsgAPIEnd))
+
+ if !end.Truncated {
+ t.Fatal("an answer cut off at the ceiling was reported whole")
+ }
+ if end.Bytes != 64 {
+ t.Fatalf("carried %d bytes, want the ceiling of 64", end.Bytes)
+ }
+}
+
+// --------------------------------------------------------------------------
+// The tier a route reports
+// --------------------------------------------------------------------------
+
+// The tier is a fact about the handler, and the verb is not consulted.
+//
+// Each pair below is one path where the two answers differ. If the method ever
+// creeps back into Route, every one of them changes at once.
+func TestRouteTierIgnoresTheMethod(t *testing.T) {
+ srv := New(&Deps{})
+
+ cases := []struct {
+ method, path string
+ want apiauth.Tier
+ why string
+ }{
+ {"GET", "/api/status", apiauth.TierRead, "an ordinary read is still a read"},
+ {"HEAD", "/api/status", apiauth.TierRead, "and so is a HEAD of one"},
+ {"GET", "/api/energy/history", apiauth.TierRead, ""},
+
+ // A GET that is not a read. The reviewer's case: this hands out a
+ // password that is a write channel into dispatch.
+ {"GET", "/api/caldav/credentials", apiauth.TierLocal, "a credential is not a read"},
+ {"GET", "/api/config", apiauth.TierLocal, "masking fails open when the driver catalogue cannot be read"},
+ {"GET", "/api/logs", apiauth.TierLocal, "the box promises nothing about what a driver logged"},
+ {"GET", "/api/backups/1", apiauth.TierLocal, "the archive holds config and state.db"},
+ {"GET", "/api/scan", apiauth.TierConfigure, "it sweeps the home network"},
+
+ // A POST that is not configuration.
+ {"POST", "/api/self_tune/start", apiauth.TierActuate, "it drives every battery through a step pattern"},
+ {"POST", "/api/notifications/test", apiauth.TierConfigure, "a late test message is the same message"},
+ {"POST", "/api/mode", apiauth.TierActuate, ""},
+ {"DELETE", "/api/battery/manual_hold", apiauth.TierActuate, ""},
+ }
+ for _, tc := range cases {
+ t.Run(tc.method+" "+tc.path, func(t *testing.T) {
+ req := newSyntheticRequest(tc.method, tc.path)
+ if got := srv.Route(req).Tier; got != tc.want {
+ t.Fatalf("tier = %q, want %q (%s)", got, tc.want, tc.why)
+ }
+ })
+ }
+}
+
+// Every mark must name a route the router actually has. A mark whose pattern
+// no longer matches is an actuating route the passthrough would wave through,
+// and it would fail silently.
+func TestEveryMarkedRouteIsReachable(t *testing.T) {
+ srv := New(&Deps{})
+ if len(srv.marks) == 0 {
+ t.Fatal("no route carries a mark")
+ }
+ for pattern, mark := range srv.marks {
+ method, path, ok := strings.Cut(pattern, " ")
+ if !ok {
+ // The static catch-all is registered for every method.
+ method, path = "GET", pattern
+ }
+ // Wildcards stand in for a concrete id, the way a real call would.
+ concrete := wildcard.ReplaceAllString(path, "1")
+ facts := srv.Route(newSyntheticRequest(method, concrete))
+ if facts.Tier != mark.tier && mark.tier != "" {
+ t.Fatalf("%s resolves to tier %q, but is marked %q", pattern, facts.Tier, mark.tier)
+ }
+ if facts.ReplacesAll != mark.replacesAll {
+ t.Fatalf("%s resolves to replacesAll=%v, but is marked %v",
+ pattern, facts.ReplacesAll, mark.replacesAll)
+ }
+ if facts.Static != mark.static {
+ t.Fatalf("%s resolves to static=%v, but is marked %v",
+ pattern, facts.Static, mark.static)
+ }
+ }
+}
+
+var wildcard = regexp.MustCompile(`\{[^}]*\}`)
+
+func newSyntheticRequest(method, path string) *http.Request {
+ return &http.Request{
+ Method: method,
+ URL: &url.URL{Path: path},
+ Host: "localhost",
+ RemoteAddr: "127.0.0.1:0",
+ Header: http.Header{},
+ }
+}
+
+// --------------------------------------------------------------------------
+// Who the LAN is
+// --------------------------------------------------------------------------
+
+// The LAN branch writes down what the LAN already is. Every handler from here
+// on reads its caller from the context, so authenticating the LAN later is a
+// change to this one branch and nothing else.
+func TestALANRequestArrivesAsALocalOwner(t *testing.T) {
+ var got apiauth.Caller
+ var found bool
+ handler := Authenticate(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ got, found = apiauth.FromRequest(r)
+ }), MutationPolicy{})
+
+ req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
+ handler.ServeHTTP(httptest.NewRecorder(), req)
+
+ if !found {
+ t.Fatal("a LAN request reached a handler with no caller")
+ }
+ if got.Kind != apiauth.KindLAN || got.Role != apiauth.RoleOwner {
+ t.Fatalf("caller = %+v, want a local owner", got)
+ }
+ if !got.Scopes.Has("ftw.dispatch.write") {
+ t.Fatal("the local caller lost a scope the LAN already has")
+ }
+}
+
+// A caller the session already authenticated must not be replaced by the
+// local one, or the passthrough would hand every phone full authority.
+func TestAnAuthenticatedCallerIsNotOverwritten(t *testing.T) {
+ var got apiauth.Caller
+ handler := Authenticate(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ got, _ = apiauth.FromRequest(r)
+ }), MutationPolicy{})
+
+ viewer := apiauth.Caller{
+ Subject: "app:aBcD1234",
+ Kind: apiauth.KindApp,
+ Role: apiauth.RoleViewer,
+ Scopes: apiauth.NewScopeSet("ftw.live.read"),
+ }
+ req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
+ req = req.WithContext(apiauth.WithCaller(req.Context(), viewer))
+ handler.ServeHTTP(httptest.NewRecorder(), req)
+
+ if got.Role != apiauth.RoleViewer || got.Kind != apiauth.KindApp {
+ t.Fatalf("caller = %+v, want the session's own viewer", got)
+ }
+ if got.Scopes.Has("ftw.dispatch.write") {
+ t.Fatal("a viewer was handed the LAN's authority")
+ }
+}
+
+// A path under /api/ that no handler claims falls through to the box's own
+// file server. Serving the box's HTML through a phone's session would be a
+// second origin under another name, so the router's own answer — that this is
+// the static catch-all — is what refuses it.
+func TestThePassthroughNeverReachesTheStaticServer(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleOwner)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIGet, Path: "/api/index.html",
+ })
+ refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError))
+
+ if refusal.Code != appproto.ErrUnknownOp || refusal.Args["field"] != "path" {
+ t.Fatalf("refusal = %+v, want E_UNKNOWN_OP on the path", refusal)
+ }
+ if rig.frames.has(appproto.MsgAPIHead) {
+ t.Fatal("the static file server answered an app session")
+ }
+}
diff --git a/go/internal/api/api_tiers_test.go b/go/internal/api/api_tiers_test.go
new file mode 100644
index 00000000..52bc2dda
--- /dev/null
+++ b/go/internal/api/api_tiers_test.go
@@ -0,0 +1,354 @@
+package api
+
+// What a route costs, decided by what its handler does.
+//
+// The tests here exist because the box used to decide it from the HTTP method,
+// and a method does not know what a handler does. Two routes proved it: a GET
+// that hands out a password, and a POST that drives every battery in the house
+// through a step pattern.
+
+import (
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/apiauth"
+ "github.com/srcfl/ftw/go/internal/appproto"
+ "github.com/srcfl/ftw/go/internal/appuplink"
+ "github.com/srcfl/ftw/go/internal/battery"
+ "github.com/srcfl/ftw/go/internal/calendar"
+ "github.com/srcfl/ftw/go/internal/config"
+ "github.com/srcfl/ftw/go/internal/control"
+ "github.com/srcfl/ftw/go/internal/selftune"
+ "github.com/srcfl/ftw/go/internal/telemetry"
+)
+
+// caldavPassword is the credential the reviewer walked away with. It is a
+// literal on purpose: the assertion is that these bytes never cross the
+// session, and matching on them is the only way to say that.
+const caldavPassword = "S3CRET-CALDAV-PASSWORD"
+
+// tieredRig is a session onto a box that has the two subsystems these tests
+// are about. The other passthrough tests use a bare box; a bare box has no
+// credential to leak and no battery to drive, which is why they missed both.
+type tieredRig struct {
+ *appRig
+ srv *Server
+ selfTune *selftune.Coordinator
+}
+
+func newTieredSession(t *testing.T, role string) *tieredRig {
+ t.Helper()
+
+ ctrl := control.NewState(0, 50, "meter")
+ tel := telemetry.NewStore()
+ tel.DriverHealthMut("meter").RecordSuccess()
+
+ cfg := &config.Config{
+ Drivers: []config.Driver{{Name: "pixii-1", BatteryCapacityWh: 16000}},
+ CalDAV: &config.CalDAV{
+ Enabled: true,
+ Username: "ftw",
+ Password: caldavPassword,
+ },
+ }
+ coordinator := selftune.NewCoordinator()
+
+ srv := New(&Deps{
+ Ctrl: ctrl, CtrlMu: &sync.Mutex{},
+ Tel: tel, LogRing: telemetry.NewLogRing(), Version: "test",
+ CfgMu: &sync.RWMutex{}, Cfg: cfg,
+ CalDAV: calendar.New(*cfg.CalDAV, nil, nil, "lp1"),
+ SelfTune: coordinator,
+ Models: map[string]*battery.Model{"pixii-1": battery.New("pixii-1")},
+ ModelsMu: &sync.Mutex{},
+ DtS: 5,
+ WebDir: t.TempDir(),
+ })
+
+ frames := &appFrames{}
+ box := &appBox{ctrl: ctrl}
+ handler, err := appproto.New(appproto.Config{
+ Clock: appproto.SystemClock{StartedAt: time.Now(), Source: "ntp"},
+ Site: box,
+ Info: box,
+ Modes: box,
+ Plans: box,
+ Codec: appuplink.Codec(),
+ Sender: frames,
+ API: srv,
+ Caller: apiauth.Caller{
+ Subject: apiauth.KindApp + ":aBcD1234",
+ Kind: apiauth.KindApp,
+ Role: role,
+ Scopes: appproto.ScopesForRole(role),
+ Epoch: 1,
+ },
+ Grants: stillEnrolled{role: role},
+ SrcGrid: "meter",
+ SrcPV: "meter",
+ SrcBattery: "meter",
+ Logger: slog.New(slog.DiscardHandler),
+ })
+ if err != nil {
+ t.Fatalf("building the app session: %v", err)
+ }
+ t.Cleanup(handler.Close)
+
+ return &tieredRig{
+ appRig: &appRig{handler: handler, frames: frames, ctrl: ctrl, saved: &savedConfigs{}},
+ srv: srv,
+ selfTune: coordinator,
+ }
+}
+
+// carried is every byte the session sent back, head, chunks and all. What a
+// leak test needs is the wire, not one message.
+func (r *tieredRig) carried(t *testing.T) string {
+ t.Helper()
+ var out strings.Builder
+ for _, env := range r.frames.snapshot() {
+ if env.T == appproto.MsgAPIChunk {
+ out.Write(decode[appproto.APIChunk](t, env).Data)
+ }
+ }
+ return out.String()
+}
+
+// --------------------------------------------------------------------------
+// A read that hands out a credential is not a read
+// --------------------------------------------------------------------------
+
+// The CalDAV credential is a write channel into dispatch: the calendar it
+// unlocks is what tells this box when the house is away and when the car has
+// to be full. A family member given read-only access to watch the house walked
+// away able to drive it.
+func TestAViewerCannotReadACredential(t *testing.T) {
+ rig := newTieredSession(t, apiauth.RoleViewer)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIGet, Path: "/api/caldav/credentials",
+ })
+ refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError))
+
+ if refusal.Code != appproto.ErrLocalOnly {
+ t.Fatalf("refusal = %+v, want E_LOCAL_ONLY", refusal)
+ }
+ if rig.frames.has(appproto.MsgAPIHead) {
+ t.Fatal("the credential handler answered an app session")
+ }
+ if body := rig.carried(t); strings.Contains(body, caldavPassword) {
+ t.Fatalf("the CalDAV password crossed the session: %q", body)
+ }
+}
+
+// An owner is refused too, and that is the point of the tier rather than a
+// role check. The credential is the same credential whoever asks for it, and
+// the box's own page — which needs somebody at home — is where it is shown.
+func TestAnOwnerCannotReadACredentialEither(t *testing.T) {
+ rig := newTieredSession(t, apiauth.RoleOwner)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIGet, Path: "/api/caldav/credentials", StepUp: true,
+ })
+ refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError))
+
+ if refusal.Code != appproto.ErrLocalOnly {
+ t.Fatalf("refusal = %+v, want E_LOCAL_ONLY", refusal)
+ }
+ if body := rig.carried(t); strings.Contains(body, caldavPassword) {
+ t.Fatalf("the CalDAV password crossed the session: %q", body)
+ }
+}
+
+// The LAN still serves it. The claim being made is "only from your box's own
+// page, from home" — if the box's page could not show it either, the sentence
+// the app says would be a lie and the calendar feature would be unusable.
+func TestTheBoxsOwnPageStillShowsTheCredential(t *testing.T) {
+ rig := newTieredSession(t, apiauth.RoleOwner)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/caldav/credentials", nil)
+ rec := httptest.NewRecorder()
+ rig.srv.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("the LAN got %d for the credential", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), caldavPassword) {
+ t.Fatalf("the LAN no longer sees the credential: %s", rec.Body.String())
+ }
+}
+
+// Every route whose answer carries a reusable secret, or a whole file this box
+// cannot vouch for, swept as a viewer. None of them reaches a handler.
+func TestNoSecretBearingReadCrossesTheSession(t *testing.T) {
+ secretBearing := []string{
+ "/api/caldav/credentials",
+ "/api/config",
+ "/api/backups/x",
+ "/api/support/dump",
+ "/api/oauth/myuplink/start",
+ "/api/oauth/myuplink/callback",
+ }
+
+ rig := newTieredSession(t, apiauth.RoleViewer)
+ for i, path := range secretBearing {
+ id := uint32(i + 1)
+ rig.send(t, appproto.MsgAPIReq, id, appproto.APIReq{
+ Method: appproto.APIGet, Path: path,
+ })
+ env := awaitID(t, rig.frames, id)
+ if env.T != appproto.MsgError {
+ t.Fatalf("GET %s answered %s; a secret-bearing read reached a handler", path, env.T)
+ }
+ if code := decode[appproto.ErrorBody](t, env).Code; code != appproto.ErrLocalOnly {
+ t.Fatalf("GET %s was refused with %q, want E_LOCAL_ONLY", path, code)
+ }
+ }
+ if rig.frames.has(appproto.MsgAPIHead) {
+ t.Fatal("a secret-bearing read reached a handler")
+ }
+}
+
+// --------------------------------------------------------------------------
+// A POST that drives batteries is not configuration
+// --------------------------------------------------------------------------
+
+// Self-tune pauses normal control and drives each battery through ±1000 W and
+// ±3000 W for about two and a half minutes. That is the house's batteries
+// moving, so it belongs on the door with the lease — and there is no command
+// for it, which is the honest answer the app gets.
+func TestSelfTuneNeverRunsThroughThePassthrough(t *testing.T) {
+ for _, role := range []string{apiauth.RoleOwner, apiauth.RoleViewer} {
+ t.Run(role, func(t *testing.T) {
+ rig := newTieredSession(t, role)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIPost,
+ Path: "/api/self_tune/start",
+ Body: []byte(`{"batteries":["pixii-1"]}`),
+ // Step-up asserted, so the refusal cannot be the step-up gate
+ // standing in for the tier.
+ StepUp: true,
+ })
+ refusal := decode[appproto.ErrorBody](t, rig.frames.await(t, appproto.MsgError))
+
+ if role == apiauth.RoleOwner && refusal.Code != appproto.ErrUseCmd {
+ t.Fatalf("refusal = %+v, want E_USE_CMD", refusal)
+ }
+ if rig.frames.has(appproto.MsgAPIHead) {
+ t.Fatal("self-tune reached its handler through the passthrough")
+ }
+ // The box, which is what the test is about.
+ if rig.selfTune.Status().Active {
+ t.Fatal("the batteries were put under a step pattern from a phone")
+ }
+ })
+ }
+}
+
+// Without step-up it is refused too. Both gates hold; neither is standing in
+// for the other.
+func TestSelfTuneIsRefusedWithoutStepUpAsWell(t *testing.T) {
+ rig := newTieredSession(t, apiauth.RoleOwner)
+
+ rig.send(t, appproto.MsgAPIReq, 1, appproto.APIReq{
+ Method: appproto.APIPost,
+ Path: "/api/self_tune/start",
+ Body: []byte(`{"batteries":["pixii-1"]}`),
+ })
+ rig.frames.await(t, appproto.MsgError)
+
+ if rig.selfTune.Status().Active {
+ t.Fatal("the batteries were put under a step pattern from a phone")
+ }
+}
+
+// The box's own page still starts one. Same reason as the credential: a tier
+// that made the feature unreachable everywhere would be a different change.
+func TestTheBoxsOwnPageStillStartsSelfTune(t *testing.T) {
+ rig := newTieredSession(t, apiauth.RoleOwner)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/self_tune/start",
+ strings.NewReader(`{"batteries":["pixii-1"]}`))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ rig.srv.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("the LAN got %d starting self-tune: %s", rec.Code, rec.Body.String())
+ }
+ if !rig.selfTune.Status().Active {
+ t.Fatal("the LAN's self-tune did not start")
+ }
+ rig.selfTune.Cancel()
+}
+
+// --------------------------------------------------------------------------
+// A route with no tier is refused, not served
+// --------------------------------------------------------------------------
+
+// The wrong-safe default, and the whole argument for it: a route somebody adds
+// without saying what it costs must fail loudly rather than be handed to
+// whoever asks. This is allow-list over deny-list, one level up.
+func TestARouteRegisteredWithoutATierIsRefused(t *testing.T) {
+ srv := New(&Deps{Version: "test", WebDir: t.TempDir()})
+
+ // Registered past handle(), which is the only way to end up with no tier
+ // now that handle() demands one. It stands in for the next person's
+ // mistake.
+ var ran bool
+ srv.mux.HandleFunc("GET /api/untiered", func(w http.ResponseWriter, _ *http.Request) {
+ ran = true
+ writeJSON(w, 200, map[string]string{"secret": "served anyway"})
+ })
+
+ facts := srv.Route(newSyntheticRequest(http.MethodGet, "/api/untiered"))
+ if facts.Tier != apiauth.TierLocal {
+ t.Fatalf("an untiered route reports tier %q, want the closed one", facts.Tier)
+ }
+ if ran {
+ t.Fatal("asking what a route costs ran it")
+ }
+}
+
+// handle() refuses a tier it does not know, at startup rather than on the
+// first request. A box that will not start is a box nobody is quietly
+// over-trusting.
+func TestHandleRefusesARouteWithoutAKnownTier(t *testing.T) {
+ for _, tier := range []apiauth.Tier{"", "kind-of-read"} {
+ t.Run(string(tier), func(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatalf("registering a route at tier %q was allowed", tier)
+ }
+ }()
+ srv := New(&Deps{Version: "test", WebDir: t.TempDir()})
+ srv.handle("GET /api/nonsense", tier, func(http.ResponseWriter, *http.Request) {})
+ })
+ }
+}
+
+// Every route the table registers names a tier the box knows. Read out of the
+// server rather than out of the source, so a route registered anywhere is in
+// this test the day it is written.
+func TestEveryRouteCarriesAKnownTier(t *testing.T) {
+ srv := New(&Deps{Version: "test", WebDir: t.TempDir()})
+
+ if len(srv.marks) < 100 {
+ t.Fatalf("only %d routes carry a mark; the table has stopped registering them", len(srv.marks))
+ }
+ for pattern, mark := range srv.marks {
+ if pattern == staticPattern {
+ continue
+ }
+ if !mark.tier.Known() {
+ t.Fatalf("%s carries tier %q, which is not one the gate has a branch for",
+ pattern, mark.tier)
+ }
+ }
+}
diff --git a/go/internal/api/api_viewer_writes_test.go b/go/internal/api/api_viewer_writes_test.go
new file mode 100644
index 00000000..9ff91e72
--- /dev/null
+++ b/go/internal/api/api_viewer_writes_test.go
@@ -0,0 +1,218 @@
+package api
+
+// Every write the passthrough exposes, attempted by a viewer.
+//
+// The other viewer test picks one route and proves the box did not move. This
+// one is the sweep: it reads every route this box registers out of the source
+// that registers them, asks each one through a real viewer session, and
+// insists on a refusal. A write route added next year is in this test the day
+// it is written, without anybody remembering to add it.
+//
+// It is deliberately not a list. A list of routes in a test is the same
+// maintenance burden as a list of routes in the app, and it rots the same way.
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/apiauth"
+ "github.com/srcfl/ftw/go/internal/appproto"
+)
+
+// handleCall matches one line of routes(): the method and the path, exactly as
+// the mux is given them.
+var handleCall = regexp.MustCompile(`s\.handle\("\s*([A-Z]+)\s+(/api/[^"]*)"`)
+
+type route struct{ method, path string }
+
+// registeredRoutes reads the route table out of api.go.
+//
+// Out of the source rather than out of the mux, because net/http's ServeMux
+// does not enumerate what it holds, and a hand-kept copy in this file would be
+// the very list this test exists to avoid.
+func registeredRoutes(t *testing.T) []route {
+ t.Helper()
+ raw, err := os.ReadFile("api.go")
+ if err != nil {
+ t.Fatalf("reading the route table: %v", err)
+ }
+ matches := handleCall.FindAllStringSubmatch(string(raw), -1)
+ if len(matches) < 100 {
+ t.Fatalf("found %d routes; the pattern above has stopped matching routes()", len(matches))
+ }
+
+ out := make([]route, 0, len(matches))
+ for _, m := range matches {
+ out = append(out, route{method: m[1], path: m[2]})
+ }
+ return out
+}
+
+// concrete fills in a pattern's wildcards, so the request reaches the same
+// pattern the mux would pick for a real one.
+func concrete(path string) string {
+ for _, name := range []string{"{id}", "{name}", "{driver}", "{key}"} {
+ path = strings.ReplaceAll(path, name, "x")
+ }
+ // Anything left is a wildcard this test has not seen. Filled with a
+ // harmless segment rather than left as braces, which would not route.
+ for strings.Contains(path, "{") {
+ open := strings.Index(path, "{")
+ close := strings.Index(path[open:], "}")
+ if close < 0 {
+ break
+ }
+ path = path[:open] + "x" + path[open+close+1:]
+ }
+ return path
+}
+
+// awaitID waits for the first frame carrying this request id, so one session
+// can be swept across every route without the answers running together.
+func awaitID(t *testing.T, frames *appFrames, id uint32) appproto.Envelope {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ for _, env := range frames.snapshot() {
+ if env.ID != nil && *env.ID == id {
+ return env
+ }
+ }
+ time.Sleep(time.Millisecond)
+ }
+ t.Fatalf("no answer to request %d", id)
+ return appproto.Envelope{}
+}
+
+// A viewer is refused at every door that changes anything.
+//
+// The refusal may be any of five, and which one is not the point — what
+// matters is that no handler ran. E_SCOPE_DENIED is the role gate, E_USE_CMD
+// is a route that moves energy, E_WHOLE_DOCUMENT is a route that replaces a
+// document, E_LOCAL_ONLY is a route the session does not carry, E_UNKNOWN_OP
+// is a path it does not have at all.
+func TestAViewerIsRefusedAtEveryWrite(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleViewer)
+ srv := New(&Deps{Version: "test", WebDir: t.TempDir()})
+
+ refusals := map[string]bool{
+ appproto.ErrScopeDenied: true,
+ appproto.ErrUseCmd: true,
+ appproto.ErrWholeDocument: true,
+ appproto.ErrLocalOnly: true,
+ appproto.ErrUnknownOp: true,
+ }
+
+ var id uint32
+ var swept int
+ for _, r := range registeredRoutes(t) {
+ path := concrete(r.path)
+
+ // What the box itself says this route costs. Reads are a viewer's
+ // right and are not what this test is about — and because the tier now
+ // comes from the handler rather than from the verb, a GET that hands
+ // out a credential is not a read and is swept here with the writes.
+ probe := httptest.NewRequest(r.method, path, nil)
+ if srv.Route(probe).Tier == apiauth.TierRead {
+ continue
+ }
+ swept++
+
+ id++
+ rig.send(t, appproto.MsgAPIReq, id, appproto.APIReq{
+ Method: r.method,
+ Path: path,
+ // Step-up asserted, so a refusal cannot be the step-up gate
+ // standing in for the role gate. The box cannot verify this and
+ // never claims to; here it only removes one reason to refuse.
+ StepUp: true,
+ })
+
+ env := awaitID(t, rig.frames, id)
+ if env.T != appproto.MsgError {
+ t.Fatalf("%s %s answered %s; a viewer reached a handler",
+ r.method, path, env.T)
+ }
+ body := decode[appproto.ErrorBody](t, env)
+ if !refusals[body.Code] {
+ t.Fatalf("%s %s was refused with %q, which is not a refusal to write",
+ r.method, path, body.Code)
+ }
+
+ // On a route that is only configuration, the refusal has to be the
+ // role gate itself. Without this the sweep would still pass if every
+ // route were refused for some other reason — a missing dependency on
+ // this bare test box, say — and would prove nothing about roles.
+ facts := srv.Route(probe)
+ if facts.Tier == apiauth.TierConfigure && !facts.ReplacesAll && !facts.Static {
+ if body.Code != appproto.ErrScopeDenied {
+ t.Fatalf("%s %s is plain configuration but refused a viewer with %q, "+
+ "not the role gate", r.method, path, body.Code)
+ }
+ if body.Args["needRole"] != apiauth.RoleOwner {
+ t.Fatalf("%s %s refused without naming the role it needs: %v",
+ r.method, path, body.Args)
+ }
+ }
+ }
+
+ if swept < 50 {
+ t.Fatalf("only %d write routes were swept; the tier probe is letting writes through", swept)
+ }
+
+ // Not one handler ever answered. This is the assertion that survives
+ // somebody deleting the role gate and leaving the error codes alone.
+ if rig.frames.has(appproto.MsgAPIHead) {
+ t.Fatal("a viewer's write reached a handler")
+ }
+ if rig.saved.count() != 0 {
+ t.Fatalf("a viewer wrote the configuration %d times", rig.saved.count())
+ }
+ if rig.ctrl.BatteryCoversEV {
+ t.Fatal("a viewer changed a dispatch setting")
+ }
+}
+
+// What an app session may do with the sharing routes lives in
+// api_app_link_session_test.go, against the box's real enrolment.
+//
+// It used to be asserted here as a blanket 403 on all six of them, which was
+// right about the danger — a phone on the far side of a relay must not mint an
+// OWNER's code, or every claim about physical presence becomes false — and
+// wrong about the remedy. Refusing the whole surface left the app's sharing
+// screen showing three buttons that answered 403 to the household's own
+// owner, and it hid the defect underneath: the app asked for a viewer in a
+// query string the box never read, so opening the door alone would have minted
+// owners. Both halves are tested there now, on what the guest actually became.
+
+// A viewer may read. Refusing everything would pass the sweep above and be a
+// different product: sharing exists so somebody can watch the house.
+//
+// The counterpart — an owner writing and the box changing — is
+// TestAnOwnerConfiguresAndTheBoxChanges. It is one route on purpose: a sweep
+// that ran every write handler as an owner would restart drivers, start a
+// network scan and take an update channel with it.
+func TestAViewerMayStillRead(t *testing.T) {
+ rig := newAppSession(t, apiauth.RoleViewer)
+
+ // Several, because one read working could be one handler that happens to
+ // need nothing. These are the readings a guest is shared the house for.
+ for i, path := range []string{"/api/health", "/api/mode", "/api/modes", "/api/system/info"} {
+ id := uint32(i + 1)
+ rig.send(t, appproto.MsgAPIReq, id, appproto.APIReq{
+ Method: appproto.APIGet, Path: path,
+ })
+ env := awaitID(t, rig.frames, id)
+ if env.T != appproto.MsgAPIHead {
+ t.Fatalf("a viewer's read of %s answered %s, want a head", path, env.T)
+ }
+ if head := decode[appproto.APIHeadMsg](t, env); head.Status != http.StatusOK {
+ t.Fatalf("a viewer's read of %s answered %d, want 200", path, head.Status)
+ }
+ }
+}
diff --git a/go/internal/api/security.go b/go/internal/api/security.go
index 479c4a4c..683852b9 100644
--- a/go/internal/api/security.go
+++ b/go/internal/api/security.go
@@ -7,6 +7,8 @@ import (
"net/http"
"net/url"
"strings"
+
+ "github.com/srcfl/ftw/go/internal/apiauth"
)
// MutationPolicy is the trust boundary for state-changing HTTP requests.
@@ -17,12 +19,31 @@ type MutationPolicy struct {
Token string
}
-// SecureMutations rejects browser cross-site writes, non-JSON request bodies,
-// malformed Host/Origin metadata, and unauthenticated writes addressed through
-// non-local hostnames. Semantically active GET/HEAD requests are protected too;
-// ordinary read-only requests are intentionally unaffected.
-func SecureMutations(next http.Handler, policy MutationPolicy) http.Handler {
+// Authenticate names the caller and guards state-changing requests.
+//
+// Naming the caller is the new half. This is the one place in the box that
+// decides who is asking:
+//
+// - a Caller already on the context was put there by whoever authenticated
+// the request — today the app session, which proved possession of an
+// enrolled device's Noise static key before a byte of this request
+// existed. It is kept as it is.
+// - anything else arrived on the LAN listener and is minted as a local
+// owner. That is not a weakening. It writes down what the LAN already is:
+// 124 endpoints served with no authentication whatsoever. Every handler
+// from here on is written against apiauth.From, so the whole future job
+// of authenticating the LAN API is changing this one branch.
+//
+// The guarding half is unchanged: browser cross-site writes, non-JSON request
+// bodies, malformed Host/Origin metadata and unauthenticated writes addressed
+// through non-local hostnames are refused. Semantically active GET/HEAD
+// requests are protected too; ordinary read-only requests are unaffected.
+func Authenticate(next http.Handler, policy MutationPolicy) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if _, ok := apiauth.From(r.Context()); !ok {
+ r = r.WithContext(apiauth.WithCaller(r.Context(), localCaller(r)))
+ }
+
if !requiresMutationProtection(r) {
next.ServeHTTP(w, r)
return
@@ -61,6 +82,25 @@ func SecureMutations(next http.Handler, policy MutationPolicy) http.Handler {
})
}
+// localCaller is what a request off the LAN listener carries.
+//
+// Full authority, because that is the truth of the deployment as it stands
+// today: anyone who can reach the box's port can already do all of this with
+// curl. The Subject records the address so an audit line can say where a
+// change came from, which is more than the box could say before.
+func localCaller(r *http.Request) apiauth.Caller {
+ host := r.RemoteAddr
+ if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
+ host = h
+ }
+ return apiauth.Caller{
+ Subject: apiauth.KindLAN + ":" + host,
+ Kind: apiauth.KindLAN,
+ Role: apiauth.RoleOwner,
+ Scopes: apiauth.EveryScope(),
+ }
+}
+
func requiresMutationProtection(r *http.Request) bool {
switch r.Method {
case http.MethodOptions:
diff --git a/go/internal/api/security_test.go b/go/internal/api/security_test.go
index 866ccaac..1e682479 100644
--- a/go/internal/api/security_test.go
+++ b/go/internal/api/security_test.go
@@ -23,11 +23,11 @@ var sensitiveMutations = []sensitiveMutation{
{name: "restart", path: "/api/restart"},
}
-func TestSecureMutationsBlocksBrowserCrossSiteSensitiveRoutes(t *testing.T) {
+func TestAuthenticateBlocksBrowserCrossSiteSensitiveRoutes(t *testing.T) {
for _, endpoint := range sensitiveMutations {
t.Run(endpoint.name, func(t *testing.T) {
called := false
- h := SecureMutations(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ h := Authenticate(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}), MutationPolicy{})
@@ -51,7 +51,7 @@ func TestSecureMutationsBlocksBrowserCrossSiteSensitiveRoutes(t *testing.T) {
}
}
-func TestSecureMutationsTreatsEveryUnsafeHTTPMethodAsMutation(t *testing.T) {
+func TestAuthenticateTreatsEveryUnsafeHTTPMethodAsMutation(t *testing.T) {
for _, method := range []string{
http.MethodPost,
http.MethodPut,
@@ -67,7 +67,7 @@ func TestSecureMutationsTreatsEveryUnsafeHTTPMethodAsMutation(t *testing.T) {
req.Header.Set("Origin", "https://attacker.example")
req.Header.Set("Sec-Fetch-Site", "cross-site")
rr := httptest.NewRecorder()
- SecureMutations(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ Authenticate(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}), MutationPolicy{}).ServeHTTP(rr, req)
@@ -82,7 +82,7 @@ func TestSecureMutationsTreatsEveryUnsafeHTTPMethodAsMutation(t *testing.T) {
}
}
-func TestSecureMutationsGuardsSemanticallyActiveReads(t *testing.T) {
+func TestAuthenticateGuardsSemanticallyActiveReads(t *testing.T) {
guarded := []struct {
name string
method string
@@ -104,7 +104,7 @@ func TestSecureMutationsGuardsSemanticallyActiveReads(t *testing.T) {
req.Header.Set("Origin", "https://attacker.example")
req.Header.Set("Sec-Fetch-Site", "cross-site")
rr := httptest.NewRecorder()
- SecureMutations(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
+ Authenticate(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (body=%s)", rr.Code, rr.Body.String())
}
@@ -116,7 +116,7 @@ func TestSecureMutationsGuardsSemanticallyActiveReads(t *testing.T) {
req.Header.Set("Origin", "http://ftw.local:8080")
req.Header.Set("Sec-Fetch-Site", "same-origin")
rr := httptest.NewRecorder()
- SecureMutations(statusHandler(http.StatusNoContent), MutationPolicy{RequireTokenForRemote: true}).ServeHTTP(rr, req)
+ Authenticate(statusHandler(http.StatusNoContent), MutationPolicy{RequireTokenForRemote: true}).ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body=%s)", rr.Code, rr.Body.String())
}
@@ -124,7 +124,7 @@ func TestSecureMutationsGuardsSemanticallyActiveReads(t *testing.T) {
}
}
-func TestSecureMutationsLeavesOrdinaryReadsAndOAuthCallbackCompatible(t *testing.T) {
+func TestAuthenticateLeavesOrdinaryReadsAndOAuthCallbackCompatible(t *testing.T) {
for _, tc := range []struct {
method string
path string
@@ -141,7 +141,7 @@ func TestSecureMutationsLeavesOrdinaryReadsAndOAuthCallbackCompatible(t *testing
req.Header.Set("Origin", "https://identity.example")
req.Header.Set("Sec-Fetch-Site", "cross-site")
rr := httptest.NewRecorder()
- SecureMutations(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
+ Authenticate(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body=%s)", rr.Code, rr.Body.String())
}
@@ -149,7 +149,7 @@ func TestSecureMutationsLeavesOrdinaryReadsAndOAuthCallbackCompatible(t *testing
}
}
-func TestSecureMutationsRequiresRemoteTokenForSemanticallyActiveRead(t *testing.T) {
+func TestAuthenticateRequiresRemoteTokenForSemanticallyActiveRead(t *testing.T) {
policy := MutationPolicy{RequireTokenForRemote: true, Token: testMutationToken}
request := func(auth string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "https://ftw.example.com/api/scan", nil)
@@ -160,7 +160,7 @@ func TestSecureMutationsRequiresRemoteTokenForSemanticallyActiveRead(t *testing.
req.Header.Set("Authorization", auth)
}
rr := httptest.NewRecorder()
- SecureMutations(statusHandler(http.StatusNoContent), policy).ServeHTTP(rr, req)
+ Authenticate(statusHandler(http.StatusNoContent), policy).ServeHTTP(rr, req)
return rr
}
@@ -172,10 +172,10 @@ func TestSecureMutationsRequiresRemoteTokenForSemanticallyActiveRead(t *testing.
}
}
-func TestSecureMutationsAllowsSameOriginAndLocalCLIFlows(t *testing.T) {
+func TestAuthenticateAllowsSameOriginAndLocalCLIFlows(t *testing.T) {
for _, endpoint := range sensitiveMutations {
t.Run(endpoint.name+" same-origin browser", func(t *testing.T) {
- h := SecureMutations(statusHandler(http.StatusNoContent), MutationPolicy{RequireTokenForRemote: true})
+ h := Authenticate(statusHandler(http.StatusNoContent), MutationPolicy{RequireTokenForRemote: true})
req := mutationRequest(endpoint, "http://ftw.local:8080")
req.Header.Set("Origin", "http://ftw.local:8080")
req.Header.Set("Sec-Fetch-Site", "same-origin")
@@ -187,7 +187,7 @@ func TestSecureMutationsAllowsSameOriginAndLocalCLIFlows(t *testing.T) {
})
t.Run(endpoint.name+" private-address CLI", func(t *testing.T) {
- h := SecureMutations(statusHandler(http.StatusNoContent), MutationPolicy{RequireTokenForRemote: true})
+ h := Authenticate(statusHandler(http.StatusNoContent), MutationPolicy{RequireTokenForRemote: true})
req := mutationRequest(endpoint, "http://192.168.1.42:8080")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
@@ -198,7 +198,7 @@ func TestSecureMutationsAllowsSameOriginAndLocalCLIFlows(t *testing.T) {
}
}
-func TestSecureMutationsRequiresBearerTokenForRemoteHost(t *testing.T) {
+func TestAuthenticateRequiresBearerTokenForRemoteHost(t *testing.T) {
policy := MutationPolicy{RequireTokenForRemote: true, Token: testMutationToken}
request := func(auth string) *httptest.ResponseRecorder {
req := mutationRequest(sensitiveMutations[0], "https://ftw.example.com")
@@ -208,7 +208,7 @@ func TestSecureMutationsRequiresBearerTokenForRemoteHost(t *testing.T) {
req.Header.Set("Authorization", auth)
}
rr := httptest.NewRecorder()
- SecureMutations(statusHandler(http.StatusNoContent), policy).ServeHTTP(rr, req)
+ Authenticate(statusHandler(http.StatusNoContent), policy).ServeHTTP(rr, req)
return rr
}
@@ -226,23 +226,23 @@ func TestSecureMutationsRequiresBearerTokenForRemoteHost(t *testing.T) {
req := mutationRequest(sensitiveMutations[0], "https://ftw.example.com")
req.Header.Set("Origin", "https://ftw.example.com")
rr := httptest.NewRecorder()
- SecureMutations(statusHandler(http.StatusNoContent), locked).ServeHTTP(rr, req)
+ Authenticate(statusHandler(http.StatusNoContent), locked).ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("unconfigured remote policy status = %d, want 403", rr.Code)
}
}
-func TestSecureMutationsRemoteClientCannotSpoofLocalHost(t *testing.T) {
+func TestAuthenticateRemoteClientCannotSpoofLocalHost(t *testing.T) {
req := mutationRequest(sensitiveMutations[4], "http://192.168.1.42:8080")
req.RemoteAddr = "203.0.113.10:43210"
rr := httptest.NewRecorder()
- SecureMutations(statusHandler(http.StatusNoContent), MutationPolicy{RequireTokenForRemote: true}).ServeHTTP(rr, req)
+ Authenticate(statusHandler(http.StatusNoContent), MutationPolicy{RequireTokenForRemote: true}).ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (body=%s)", rr.Code, rr.Body.String())
}
}
-func TestSecureMutationsRequiresJSONContentTypeForBodies(t *testing.T) {
+func TestAuthenticateRequiresJSONContentTypeForBodies(t *testing.T) {
for _, endpoint := range sensitiveMutations {
if endpoint.body == "" {
continue
@@ -250,7 +250,7 @@ func TestSecureMutationsRequiresJSONContentTypeForBodies(t *testing.T) {
t.Run(endpoint.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "http://ftw.local:8080"+endpoint.path, strings.NewReader(endpoint.body))
rr := httptest.NewRecorder()
- SecureMutations(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
+ Authenticate(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
if rr.Code != http.StatusUnsupportedMediaType {
t.Fatalf("missing Content-Type status = %d, want 415", rr.Code)
}
@@ -258,7 +258,7 @@ func TestSecureMutationsRequiresJSONContentTypeForBodies(t *testing.T) {
req = mutationRequest(endpoint, "http://ftw.local:8080")
req.Header.Set("Content-Type", "application/json; charset=utf-8")
rr = httptest.NewRecorder()
- SecureMutations(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
+ Authenticate(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("JSON Content-Type status = %d, want 204", rr.Code)
}
@@ -266,18 +266,18 @@ func TestSecureMutationsRequiresJSONContentTypeForBodies(t *testing.T) {
}
}
-func TestSecureMutationsRejectsOriginMismatchEvenWhenFetchSiteClaimsSameOrigin(t *testing.T) {
+func TestAuthenticateRejectsOriginMismatchEvenWhenFetchSiteClaimsSameOrigin(t *testing.T) {
req := mutationRequest(sensitiveMutations[1], "http://192.168.1.42:8080")
req.Header.Set("Origin", "http://192.168.1.99:8080")
req.Header.Set("Sec-Fetch-Site", "same-origin")
rr := httptest.NewRecorder()
- SecureMutations(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
+ Authenticate(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rr.Code)
}
}
-func TestSecureMutationsRejectsInvalidHostAndFetchMetadata(t *testing.T) {
+func TestAuthenticateRejectsInvalidHostAndFetchMetadata(t *testing.T) {
tests := []struct {
name string
host string
@@ -301,7 +301,7 @@ func TestSecureMutationsRejectsInvalidHostAndFetchMetadata(t *testing.T) {
req.Header.Set("Sec-Fetch-Site", tc.fetchSite)
}
rr := httptest.NewRecorder()
- SecureMutations(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
+ Authenticate(statusHandler(http.StatusNoContent), MutationPolicy{}).ServeHTTP(rr, req)
if rr.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rr.Code, tc.wantStatus)
}
diff --git a/go/internal/apiauth/apiauth.go b/go/internal/apiauth/apiauth.go
new file mode 100644
index 00000000..723c608d
--- /dev/null
+++ b/go/internal/apiauth/apiauth.go
@@ -0,0 +1,217 @@
+// Package apiauth says who is asking the box's HTTP API for something.
+//
+// Until now nobody was: the API is served on the home LAN with no
+// authentication at all, and no handler has ever had a way to name its caller.
+// This package is the seam that changes that, and it is deliberately the
+// smallest thing that can be one — a value, a context key and a scope set.
+//
+// It imports nothing of the box, so every source of callers can produce one
+// without dragging the others in: the app uplink today, a LAN token or a Home
+// Assistant add-on later. The whole future job of authenticating the LAN API
+// is replacing one branch in api.Authenticate; every handler downstream is
+// already written against apiauth.From.
+//
+// A Caller never travels on a wire. It is put on the request context inside
+// the box process by whoever authenticated the request, and there is no
+// serialised form of it for anything to forge.
+package apiauth
+
+import (
+ "context"
+ "net/http"
+ "sort"
+)
+
+// Caller is one authenticated origin of a request.
+type Caller struct {
+ // Subject is opaque and stable, and it is what an audit line names:
+ // "app:" + the enrolled device id for a phone.
+ Subject string
+
+ // Kind is where the authority came from. It is not a permission — two
+ // callers of different kinds can hold identical scopes.
+ Kind string
+
+ // Role is a registry role key: "owner" or "viewer". It decides
+ // presentation and the passthrough's configure gate; scopes decide
+ // named operations.
+ Role string
+
+ // Scopes is the role expanded through the registry's role table.
+ Scopes ScopeSet
+
+ // StepUp says this request carried a fresh passkey ceremony on the
+ // client. The box cannot verify that and must never claim to — see the
+ // note above the check in appproto's passthrough for what it does and
+ // does not buy.
+ StepUp bool
+
+ // Epoch is how many times this enrolment has changed. It is stamped at
+ // admission and refreshed whenever the grant is re-read, so a log line
+ // can say which version of a grant a request ran under.
+ //
+ // It is not what stops a revoked phone, and nothing compares it. What
+ // stops one is the grant being re-read on every privileged request: a
+ // row that is gone ends the session, and a row whose role changed is
+ // obeyed as it now reads. Comparing the epoch as well would refuse a
+ // demoted owner's reads, which they still have every right to.
+ Epoch uint64
+}
+
+// Kinds of caller. More will exist; each one is a different authenticator,
+// never a different level of trust.
+const (
+ // KindApp is a phone enrolled over the app link, authenticated by its
+ // Noise static key.
+ KindApp = "app"
+ // KindLAN is an unauthenticated request off the box's own LAN listener.
+ // Naming it is not a promotion: it writes down what the LAN already is.
+ KindLAN = "lan"
+)
+
+// Tier is how much a route costs before it runs.
+//
+// It comes from what the handler DOES, declared beside the handler, and never
+// from the request's method. The method was tried and it failed twice in one
+// review: GET /api/caldav/credentials hands out a password that is a write
+// channel into dispatch, and POST /api/self_tune/start drives every battery in
+// the house through ±3000 W for minutes. A verb cannot know either of those,
+// so it is not asked.
+//
+// The cost of that is one line per route and no free reads: a view written in
+// the app next year needs the box to have named the path. That is the right
+// direction to be wrong in — a route nobody has priced is refused rather than
+// served, which is allow-list over deny-list one level up.
+type Tier string
+
+const (
+ // TierRead answers a question, changes nothing, and carries nothing back
+ // that could be replayed as authority. A shared viewer may ask for it.
+ TierRead Tier = "read"
+ // TierConfigure changes a setting. A late execution is the same
+ // instruction, only later. Owner, with a step-up.
+ TierConfigure Tier = "configure"
+ // TierActuate moves energy, or takes control of what is moving it. It
+ // never travels over the passthrough, because an HTTP request carries no
+ // expiry and a request with no expiry must not move energy. A late
+ // execution here is a DIFFERENT instruction.
+ TierActuate Tier = "actuate"
+ // TierLocal is served only on the box's own page, at home. Either its
+ // answer carries a credential or a whole file the box cannot vouch for,
+ // or doing it needs somebody standing at the box — a browser origin, a
+ // person in the room, or code about to run on a live battery.
+ //
+ // It is not a permission an owner is missing. No role and no ceremony
+ // changes the answer, which is why it is a tier and not a role check.
+ TierLocal Tier = "local"
+)
+
+// Tiers is every tier the box knows, and the gate has a branch for each. A
+// value outside this set is a route nobody priced, and both the router and the
+// gate treat that as TierLocal — closed.
+var Tiers = []Tier{TierRead, TierConfigure, TierActuate, TierLocal}
+
+// Known reports whether a tier is one of the four. The zero Tier is not, so a
+// route that never named one cannot be mistaken for a read.
+func (t Tier) Known() bool {
+ for _, known := range Tiers {
+ if t == known {
+ return true
+ }
+ }
+ return false
+}
+
+// RouteFacts is what the box knows about a route before it runs it.
+type RouteFacts struct {
+ Tier Tier
+ // CmdOp names the command that does this instead, when one exists. Empty
+ // means the box has no command for it yet, and the honest answer to the
+ // app is that this control is not available over the session.
+ CmdOp string
+ // Static marks the catch-all that serves the box's own web UI. Nothing
+ // reachable through it belongs in an app session: the box's HTML served
+ // under another name would be a second origin, which the architecture
+ // rejected. The passthrough refuses it as a path it does not carry.
+ Static bool
+
+ // ReplacesAll marks a route whose body replaces a whole document rather
+ // than editing part of one. POST /api/config is the example: it writes
+ // the entire configuration, so a client that sent a body built from an
+ // older idea of the document silently drops every field it never knew
+ // about. That has already cost this project one regression, on the LAN,
+ // where at least the browser had just loaded the whole document from the
+ // same box. A phone on a relay has no such guarantee.
+ ReplacesAll bool
+}
+
+// ScopeSet is what a caller may ask for.
+type ScopeSet struct {
+ // every short-circuits Has. It exists for the LAN, which is served with
+ // no authentication at all; writing the list out instead would be a copy
+ // of the registry's scope table in a place the generator cannot reach.
+ every bool
+ names map[string]struct{}
+}
+
+// NewScopeSet builds a set from explicit names, which come from the generated
+// role table and never from a literal at the call site.
+func NewScopeSet(names ...string) ScopeSet {
+ set := ScopeSet{names: make(map[string]struct{}, len(names))}
+ for _, n := range names {
+ set.names[n] = struct{}{}
+ }
+ return set
+}
+
+// EveryScope is unrestricted authority.
+func EveryScope() ScopeSet { return ScopeSet{every: true} }
+
+// Has reports whether the set carries a scope. The zero ScopeSet carries
+// none, so a Caller nobody filled in can do nothing.
+func (s ScopeSet) Has(scope string) bool {
+ if s.every {
+ return true
+ }
+ _, ok := s.names[scope]
+ return ok
+}
+
+// Names lists the set, sorted. An unrestricted set has no list to give and
+// returns nil — the app is only ever told its own role's expansion, which is
+// always explicit.
+func (s ScopeSet) Names() []string {
+ if s.every || len(s.names) == 0 {
+ return nil
+ }
+ out := make([]string, 0, len(s.names))
+ for n := range s.names {
+ out = append(out, n)
+ }
+ sort.Strings(out)
+ return out
+}
+
+type contextKey struct{}
+
+// WithCaller puts a caller on a request context. Only an authenticator calls
+// this, and only inside the box process.
+func WithCaller(ctx context.Context, c Caller) context.Context {
+ return context.WithValue(ctx, contextKey{}, c)
+}
+
+// From reads the caller back. The second result is false when nothing
+// authenticated the request, which a handler must treat as no authority
+// rather than as full authority.
+func From(ctx context.Context) (Caller, bool) {
+ c, ok := ctx.Value(contextKey{}).(Caller)
+ return c, ok
+}
+
+// FromRequest is From, for handlers that hold a request rather than a context.
+func FromRequest(r *http.Request) (Caller, bool) {
+ if r == nil {
+ return Caller{}, false
+ }
+ return From(r.Context())
+}
diff --git a/go/internal/apiauth/contract_gen.go b/go/internal/apiauth/contract_gen.go
new file mode 100644
index 00000000..b95645f1
--- /dev/null
+++ b/go/internal/apiauth/contract_gen.go
@@ -0,0 +1,33 @@
+// Code generated by gencontract from contract/registry.yaml. DO NOT EDIT.
+
+package apiauth
+
+// Roles an enrolment can hold. Hand-writing one of these strings is
+// what the registry exists to stop, and a role is the one name here
+// that decides whether a phone may change anything.
+const (
+ // RoleOwner — Owner.
+ RoleOwner = "owner"
+ // RoleViewer — Viewer.
+ RoleViewer = "viewer"
+)
+
+// RoleScopes is what each role carries, with '*' already expanded.
+// A role is a projection over scopes and never a replacement for them,
+// so this is the only place the projection is written down.
+var RoleScopes = map[string][]string{
+ RoleOwner: {
+ "ftw.live.read",
+ "ftw.history.read",
+ "ftw.plan.read",
+ "ftw.health.read",
+ "ftw.assets.read",
+ "ftw.dispatch.write",
+ "ftw.mode.write",
+ "ftw.members.read",
+ "ftw.members.write",
+ },
+ RoleViewer: {
+ "ftw.live.read",
+ },
+}
diff --git a/go/internal/apiauth/contract_test.go b/go/internal/apiauth/contract_test.go
new file mode 100644
index 00000000..1d532446
--- /dev/null
+++ b/go/internal/apiauth/contract_test.go
@@ -0,0 +1,60 @@
+package apiauth
+
+import (
+ "os"
+ "testing"
+
+ "github.com/srcfl/ftw/go/internal/appproto/gencontract"
+)
+
+// The role table must be what the registry currently says. A snapshot updated
+// without rerunning the generator is exactly the drift the registry exists to
+// prevent — and a role is the one name here that decides whether a phone may
+// change anything.
+func TestRoleTableIsCurrent(t *testing.T) {
+ raw, err := os.ReadFile("../../../contract/registry.yaml")
+ if err != nil {
+ t.Fatalf("read registry: %v", err)
+ }
+ want, err := gencontract.GenerateRoles(raw)
+ if err != nil {
+ t.Fatalf("generate: %v", err)
+ }
+ got, err := os.ReadFile("contract_gen.go")
+ if err != nil {
+ t.Fatalf("read generated file: %v", err)
+ }
+ if string(got) != string(want) {
+ t.Fatal("apiauth/contract_gen.go is stale; run: go generate ./internal/...")
+ }
+}
+
+// Every role must carry at least one scope. A role that carries none is one
+// nobody can use and one no refusal can explain.
+func TestEveryRoleCarriesSomething(t *testing.T) {
+ if len(RoleScopes) == 0 {
+ t.Fatal("the registry defines no roles")
+ }
+ for role, scopes := range RoleScopes {
+ if len(scopes) == 0 {
+ t.Fatalf("role %q carries no scopes", role)
+ }
+ }
+ if _, ok := RoleScopes[RoleOwner]; !ok {
+ t.Fatal("there is no owner role; a box with no owner cannot be administered")
+ }
+}
+
+// The zero Caller can do nothing. Anything else would make a Caller nobody
+// filled in more powerful than a viewer.
+func TestTheZeroCallerCarriesNoAuthority(t *testing.T) {
+ var nobody Caller
+ for _, scope := range RoleScopes[RoleOwner] {
+ if nobody.Scopes.Has(scope) {
+ t.Fatalf("an empty caller holds %q", scope)
+ }
+ }
+ if nobody.Role != "" || nobody.StepUp {
+ t.Fatalf("the zero caller is %+v", nobody)
+ }
+}
diff --git a/go/internal/apiauth/generate.go b/go/internal/apiauth/generate.go
new file mode 100644
index 00000000..d5a3223c
--- /dev/null
+++ b/go/internal/apiauth/generate.go
@@ -0,0 +1,8 @@
+package apiauth
+
+// The role table is generated from the same registry as everything else the
+// box shares with the app. It lives here rather than beside the wire
+// constants because the enrolment record, the HTTP layer and the app protocol
+// all need it, and this is the one package all three can import.
+
+//go:generate go run ../appproto/gencontract/cmd roles ../../../contract/registry.yaml contract_gen.go
diff --git a/go/internal/appenroll/boxcode.go b/go/internal/appenroll/boxcode.go
new file mode 100644
index 00000000..026a04a2
--- /dev/null
+++ b/go/internal/appenroll/boxcode.go
@@ -0,0 +1,129 @@
+package appenroll
+
+// The box code: the way back in without a camera.
+//
+// A phone with no printed QR and no other device already paired still has to
+// be able to get back into its home. The floor is somebody standing at the
+// box, reading eight characters down the phone. Everything here exists to make
+// those eight characters safe to say out loud.
+//
+// It is redeemed exactly where the QR code's payload is redeemed — inside
+// Noise handshake message 1 — so there is no new endpoint, no new carrier and
+// no HTTP path. The app decodes the characters back to five bytes and sends
+// those. The wire does not change at all.
+//
+// What it is not, and the box's own page says so on the screen: it is not a
+// way in for a phone that has never seen this box. These characters are the
+// pairing code and nothing else. The box's static key and its rendezvous
+// secret travel only in the QR payload, so a phone with no record of this box
+// can neither find it nor be sure the box answering is the right one. This
+// re-admits a phone that already holds those.
+//
+// What it proves is not that the phone is trusted. It is that somebody was
+// standing at the box: the code is minted only through the LAN-only pairing
+// route and shown only on the box's own screen.
+
+import (
+ "errors"
+ "strings"
+)
+
+const (
+ // SpokenCodeBytes is forty bits of entropy.
+ //
+ // Not thirty: the failure counter in Authorise already holds a guesser to
+ // five tries per minting, so thirty would very likely do. Forty costs one
+ // extra breath to say and removes the argument entirely. Not sixteen
+ // bytes like the QR code's — nobody reads sixteen bytes aloud.
+ SpokenCodeBytes = 5
+
+ // SpokenCodeChars is what those bytes become. Forty bits divides by five
+ // exactly, so there is no padding and no remainder to explain.
+ SpokenCodeChars = 8
+
+ // spokenAlphabet is Crockford base32.
+ //
+ // Chosen because "read it aloud, down a phone, once" is the whole
+ // requirement. It leaves out I, L and O — the characters people mishear
+ // as 1 and 0 — and decoding folds those back rather than refusing, so a
+ // listener who wrote down IO still gets in. U is left out too, so a
+ // random draw cannot spell something a household would rather not read to
+ // their neighbour.
+ spokenAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+)
+
+// ErrBadSpokenCode is a typed code that is not eight characters of the
+// alphabet above, once the folding has been applied.
+var ErrBadSpokenCode = errors.New("appenroll: that is not a box code")
+
+// encodeSpoken turns five bytes into eight characters, most significant
+// first.
+func encodeSpoken(raw []byte) string {
+ var n uint64
+ for _, b := range raw {
+ n = n<<8 | uint64(b)
+ }
+ out := make([]byte, SpokenCodeChars)
+ for i := SpokenCodeChars - 1; i >= 0; i-- {
+ out[i] = spokenAlphabet[n&0x1f]
+ n >>= 5
+ }
+ return string(out)
+}
+
+// GroupSpokenCode is how a code is shown and read: XXXX-XXXX.
+//
+// The hyphen is for the reader, not for the code. DecodeSpokenCode throws it
+// away again, along with spaces, so somebody who types what they hear without
+// it is not punished for it.
+func GroupSpokenCode(code string) string {
+ if len(code) != SpokenCodeChars {
+ return code
+ }
+ return code[:4] + "-" + code[4:]
+}
+
+// DecodeSpokenCode turns what somebody typed back into five bytes.
+//
+// This is the box's own copy of the rule the app applies before it sends the
+// bytes. It lives here because the folding is the part that has to match
+// exactly on both sides, and a test that encodes and decodes is what stops the
+// two drifting.
+func DecodeSpokenCode(typed string) ([]byte, error) {
+ var cleaned strings.Builder
+ for _, r := range typed {
+ switch r {
+ case ' ', '-', '\t':
+ continue // written for the reader, meaningless to the code
+ }
+ if r >= 'a' && r <= 'z' {
+ r -= 'a' - 'A'
+ }
+ switch r {
+ case 'I', 'L':
+ r = '1'
+ case 'O':
+ r = '0'
+ }
+ if strings.IndexRune(spokenAlphabet, r) < 0 {
+ return nil, ErrBadSpokenCode
+ }
+ cleaned.WriteRune(r)
+ }
+
+ chars := cleaned.String()
+ if len(chars) != SpokenCodeChars {
+ return nil, ErrBadSpokenCode
+ }
+
+ var n uint64
+ for i := 0; i < len(chars); i++ {
+ n = n<<5 | uint64(strings.IndexByte(spokenAlphabet, chars[i]))
+ }
+ out := make([]byte, SpokenCodeBytes)
+ for i := SpokenCodeBytes - 1; i >= 0; i-- {
+ out[i] = byte(n & 0xff)
+ n >>= 8
+ }
+ return out, nil
+}
diff --git a/go/internal/appenroll/enroll.go b/go/internal/appenroll/enroll.go
index dfbb999e..f81e0363 100644
--- a/go/internal/appenroll/enroll.go
+++ b/go/internal/appenroll/enroll.go
@@ -30,6 +30,7 @@ import (
"sync"
"time"
+ "github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/appwire"
)
@@ -50,6 +51,31 @@ const (
// this only bounds the window before anyone uses it at all.
PairingTTL = 10 * time.Minute
+ // InviteTTL is how long a shared, view-only code stays usable.
+ //
+ // The same ten minutes as an owner's, and deliberately not dressed up as
+ // less. An invite is not a weaker kind of code — it is the same code
+ // carrying a different role, and shortening its life would suggest the
+ // household is protected by the clock when what protects them is the
+ // role the box stamps at redemption.
+ InviteTTL = PairingTTL
+
+ // SpokenTTL is how long a box code stays usable.
+ //
+ // Half an owner code's life, and this one is a real difference: forty
+ // bits read down a phone is less entropy than sixteen random bytes
+ // scanned off a screen, and it is read out while both people are on the
+ // line. Five minutes is the length of that conversation.
+ SpokenTTL = 5 * time.Minute
+
+ // MaxPairingAttempts is how many wrong guesses a code survives.
+ //
+ // This is the number that makes a spoken code sound out loud: forty bits
+ // with unlimited tries is a lock somebody can pick given a weekend, and
+ // forty bits with five tries is one nobody can pick at all. See the
+ // counter in Authorise for why it is on the code and not on the caller.
+ MaxPairingAttempts = 5
+
// FileName is where the material lives, beside nova.key.
FileName = "applink.json"
)
@@ -58,8 +84,16 @@ var (
// ErrNoPairing is a handshake that offered nothing and came from a key
// this box has never authorised.
ErrNoPairing = errors.New("appenroll: no pairing code and an unknown app key")
- // ErrBadPairing is a code that is wrong, expired or already spent.
+ // ErrBadPairing is a code that is wrong, expired, already spent or has
+ // been guessed at too many times. One error for all four on purpose: a
+ // guesser who could tell "wrong" from "expired" would learn when to stop
+ // wasting attempts, and none of the four is a different sentence to the
+ // household — they all mean ask for a new code.
ErrBadPairing = errors.New("appenroll: pairing code is not valid")
+ // ErrUnknownRole is a role that is not in contract/registry.yaml.
+ ErrUnknownRole = errors.New("appenroll: no such role")
+ // ErrLastOwnerProtected is an attempt to remove or demote the only owner.
+ ErrLastOwnerProtected = errors.New("appenroll: that is the only owner")
)
// stored is the on-disk shape. Base64 rather than hex only because the app's
@@ -82,6 +116,16 @@ type storedApp struct {
Key string `json:"key"`
AddedAtMs int64 `json:"addedAtMs,omitempty"`
LastSeenMs int64 `json:"lastSeenMs,omitempty"`
+ // Role is what this phone may do. Absent on a row written before roles
+ // existed, and load() reads that as owner: a box updating from a version
+ // with no roles must not silently demote every paired phone to viewer.
+ Role string `json:"role,omitempty"`
+ // Epoch counts how many times this grant has been given a different
+ // role. It is what a log line names to say which version of a grant a
+ // request ran under; it is not what makes a change bite. That is
+ // GrantFor, which every privileged request calls. Absent, or zero, reads
+ // as the first epoch.
+ Epoch uint64 `json:"epoch,omitempty"`
}
// UnmarshalJSON accepts the legacy bare-string form beside the current one.
@@ -108,6 +152,13 @@ type DeviceInfo struct {
ID string
AddedAtMs int64
LastSeenMs int64
+ // Role is what this phone may do. It belongs in the list because sharing
+ // and locking out are the same screen: a household that cannot see which
+ // phone is a guest cannot decide which one to remove.
+ Role string
+ // LastOwner marks the row that cannot be removed or demoted, so the
+ // screen can say why before somebody presses the button rather than after.
+ LastOwner bool
}
// deviceID is the row name: the first eight characters of the base64 key.
@@ -144,13 +195,48 @@ type Identity struct {
type appMeta struct {
addedAtMs int64
lastSeenMs int64
+ role string
+ epoch uint64
+}
+
+// firstEpoch is where a fresh enrolment starts.
+//
+// One and not zero, so a row loaded from a file written before epochs existed
+// is the first epoch rather than an unwritten field.
+const firstEpoch uint64 = 1
+
+// Grant is what a finished handshake earned: which device, what it may do,
+// and which version of that answer it was told.
+type Grant struct {
+ DeviceID string
+ Role string
+ Epoch uint64
}
type pairingCode struct {
code []byte
expiresAt time.Time
+ // role is what the code lets in. Held by the box and stamped when the
+ // code is spent, never carried in the QR payload: a role in the fragment
+ // is a claim its holder can edit.
+ role string
+ // kind is "qr" or "spoken", and it exists to be logged and shown rather
+ // than to be checked. Both kinds are redeemed identically — the same
+ // field of the same handshake message — and the only thing that differs
+ // is how many bytes were drawn and how long they last.
+ kind string
+ // attempts counts wrong guesses against this code. See Authorise.
+ attempts int
}
+// Kinds of pairing code.
+const (
+ // PairingKindQR is sixteen bytes, scanned off a screen.
+ PairingKindQR = "qr"
+ // PairingKindSpoken is five bytes, read aloud. See boxcode.go.
+ PairingKindSpoken = "spoken"
+)
+
// LoadOrCreate reads the material beside keyPath, minting whatever is missing.
//
// Minting is idempotent in the sense that matters: an existing static key is
@@ -211,7 +297,23 @@ func (i *Identity) load(raw []byte) error {
i.staticSecret = secret
i.rendezvous = rendezvous
for _, app := range s.AuthorisedApps {
- i.authorised[app.Key] = &appMeta{addedAtMs: app.AddedAtMs, lastSeenMs: app.LastSeenMs}
+ role := app.Role
+ if role == "" {
+ // A row from before roles existed. Owner, because these are the
+ // phones the household already paired and trusts; reading them
+ // as viewers would lock a house out of its own box on an update.
+ role = apiauth.RoleOwner
+ }
+ epoch := app.Epoch
+ if epoch == 0 {
+ epoch = firstEpoch
+ }
+ i.authorised[app.Key] = &appMeta{
+ addedAtMs: app.AddedAtMs,
+ lastSeenMs: app.LastSeenMs,
+ role: role,
+ epoch: epoch,
+ }
}
return nil
}
@@ -252,6 +354,7 @@ func (i *Identity) save() error {
for key, meta := range i.authorised {
s.AuthorisedApps = append(s.AuthorisedApps, storedApp{
Key: key, AddedAtMs: meta.addedAtMs, LastSeenMs: meta.lastSeenMs,
+ Role: meta.role, Epoch: meta.epoch,
})
}
// Deterministic on disk, so two saves of the same state are the same
@@ -317,22 +420,83 @@ func (i *Identity) RotateRendezvousSecret() ([]byte, error) {
//
// Forgetting matters: two live codes means two strangers can pair, and the
// second one was minted by whoever pressed the button last. One code at a time
-// is the whole safety property.
-func (i *Identity) MintPairingCode() ([]byte, time.Time, error) {
+// is the whole safety property, and it holds across kinds — minting an invite
+// cancels a pending owner code and the reverse. The screen already copes with
+// a code expiring, so it copes with this.
+//
+// The role is remembered here rather than carried in the QR payload, because a
+// role the holder can edit is not a role. Nothing about the payload changes
+// between an owner's code and a viewer's: the box remembers which it minted
+// and stamps it when the code is spent.
+//
+// What stops the code being replayed, in the order it stops it:
+// - it is spent at the first success, so the second presentation of a
+// photographed code finds no live code at all;
+// - it lives for ttl and no longer;
+// - it survives MaxPairingAttempts wrong guesses and is then burned;
+// - it rides inside Noise handshake message 1, encrypted to this box's
+// static key, so the relay carrying it never sees it — and a replay of
+// that whole message earns nothing, because the replayer holds neither
+// the initiator's ephemeral nor its static private key and so cannot
+// derive the session keys the box answers with.
+func (i *Identity) MintPairingCode(role string, ttl time.Duration) ([]byte, time.Time, error) {
+ if err := knownRole(role); err != nil {
+ return nil, time.Time{}, err
+ }
code := make([]byte, PairingCodeBytes)
if _, err := i.randRead(code); err != nil {
return nil, time.Time{}, fmt.Errorf("appenroll: minting a pairing code: %w", err)
}
- expires := i.now().Add(PairingTTL)
+ expires := i.now().Add(ttl)
i.mu.Lock()
- i.pairing = &pairingCode{code: code, expiresAt: expires}
+ i.pairing = &pairingCode{code: code, expiresAt: expires, role: role, kind: PairingKindQR}
i.mu.Unlock()
return append([]byte(nil), code...), expires, nil
}
-// Authorise decides whether a finished handshake may become a session.
+// MintSpokenCode issues a box code: eight characters somebody can read aloud.
+//
+// It replaces any live code, exactly as MintPairingCode does, and it is spent,
+// timed and counted by the same machinery. The only differences are that it
+// carries forty bits rather than a hundred and twenty-eight, and that it lives
+// for five minutes rather than ten.
+//
+// Returned grouped as XXXX-XXXX, ready to be shown and read. The bytes are
+// what travels; the characters are for the person.
+func (i *Identity) MintSpokenCode(role string) (string, time.Time, error) {
+ if err := knownRole(role); err != nil {
+ return "", time.Time{}, err
+ }
+ code := make([]byte, SpokenCodeBytes)
+ if _, err := i.randRead(code); err != nil {
+ return "", time.Time{}, fmt.Errorf("appenroll: minting a box code: %w", err)
+ }
+ expires := i.now().Add(SpokenTTL)
+
+ i.mu.Lock()
+ i.pairing = &pairingCode{code: code, expiresAt: expires, role: role, kind: PairingKindSpoken}
+ i.mu.Unlock()
+
+ return GroupSpokenCode(encodeSpoken(code)), expires, nil
+}
+
+// knownRole refuses a role the registry does not define.
+//
+// Checked at every door into the stored state rather than trusted from the
+// caller, because a role nobody recognises expands to no scopes at all — so a
+// typo would not fail loudly, it would quietly make a phone that can do
+// nothing and give nobody a reason why.
+func knownRole(role string) error {
+ if _, ok := apiauth.RoleScopes[role]; !ok {
+ return fmt.Errorf("%w: %q", ErrUnknownRole, role)
+ }
+ return nil
+}
+
+// Authorise decides whether a finished handshake may become a session, and
+// says what the session may do.
//
// appStatic is the app's static key as the handshake authenticated it, never
// as it was claimed; payload is handshake message 1's plaintext.
@@ -341,7 +505,11 @@ func (i *Identity) MintPairingCode() ([]byte, time.Time, error) {
// reconnect after every dropped socket without the box handing out a second
// pairing code. Anything else needs a live code, and spending one records the
// key so the next reconnect takes the first branch.
-func (i *Identity) Authorise(appStatic, payload []byte) error {
+//
+// The Grant is the seam. This function has always known which device it just
+// authenticated; until now it threw that away and answered only yes or no,
+// which is why nothing downstream could tell two phones apart.
+func (i *Identity) Authorise(appStatic, payload []byte) (Grant, error) {
key := base64.RawURLEncoding.EncodeToString(appStatic)
i.mu.Lock()
@@ -350,46 +518,186 @@ func (i *Identity) Authorise(appStatic, payload []byte) error {
// phone in daily use from a key that paired once and vanished —
// which is exactly the row someone wants to revoke.
meta.lastSeenMs = i.now().UnixMilli()
+ grant := Grant{DeviceID: deviceID(key), Role: meta.role, Epoch: meta.epoch}
i.mu.Unlock()
- if err := i.save(); err != nil {
- // A stamp that could not be persisted must not block a session.
- return nil
- }
- return nil
+ // A stamp that could not be persisted must not block a session.
+ _ = i.save()
+ return grant, nil
}
- pairing := i.pairing
- i.mu.Unlock()
-
+ // The rest happens under the lock, because counting a wrong guess is a
+ // read and a write of the same code and two handshakes racing must not
+ // each see four attempts and let a fifth and a sixth through.
if len(payload) == 0 {
- return ErrNoPairing
+ i.mu.Unlock()
+ return Grant{}, ErrNoPairing
}
+ pairing := i.pairing
if pairing == nil || !i.now().Before(pairing.expiresAt) {
- return ErrBadPairing
+ i.mu.Unlock()
+ return Grant{}, ErrBadPairing
}
// Constant time, because a byte-at-a-time comparison against a live code
// is exactly the oracle an attacker with a relay socket would want.
if subtle.ConstantTimeCompare(payload, pairing.code) != 1 {
- return ErrBadPairing
+ // A failure counter on the code itself, and this is the layer that
+ // makes forty bits safe to say out loud: a guesser gets five tries
+ // per minting, and every minting needs a person standing at the box.
+ //
+ // Deliberately not keyed on the caller's address. docs/architecture.md
+ // records that the relay's own limiter keys on the socket address,
+ // which behind the documented TLS terminator is a single address for
+ // the whole fleet — an address-keyed counter here would inherit that
+ // bug and count the world as one guesser.
+ //
+ // The cost is real and accepted: anyone who can reach this box can
+ // burn a live code by guessing at it, and the household has to ask
+ // for another. Denying somebody a code they can re-mint in a second
+ // is a far smaller harm than a code that can be ground down at
+ // leisure.
+ pairing.attempts++
+ if pairing.attempts >= MaxPairingAttempts {
+ i.pairing = nil
+ }
+ i.mu.Unlock()
+ return Grant{}, ErrBadPairing
}
- i.mu.Lock()
now := i.now().UnixMilli()
- i.authorised[key] = &appMeta{addedAtMs: now, lastSeenMs: now}
+ // The role travels with the code rather than with the handshake, so a
+ // code minted for a viewer stamps a viewer here — and nothing the app
+ // sends can change it.
+ role := pairing.role
+ if role == "" {
+ role = apiauth.RoleOwner
+ }
+ if role != apiauth.RoleOwner && !i.hasOwnerLocked() {
+ // Whoever creates a home owns it. The first enrolment is an owner
+ // whatever its code said, because a box with no owner cannot be
+ // administered by anybody, from anywhere, ever again — and a
+ // household that handed out a view-only code before pairing its own
+ // phone would have built exactly that.
+ role = apiauth.RoleOwner
+ }
+ i.authorised[key] = &appMeta{addedAtMs: now, lastSeenMs: now, role: role, epoch: firstEpoch}
// Spent. A second phone offering the same photographed code now meets
// ErrBadPairing rather than a pairing.
i.pairing = nil
i.mu.Unlock()
+ return Grant{DeviceID: deviceID(key), Role: role, Epoch: firstEpoch}, i.save()
+}
+
+// hasOwnerLocked reports whether any enrolment can still administer this box.
+// The caller holds the mutex.
+func (i *Identity) hasOwnerLocked() bool {
+ for _, meta := range i.authorised {
+ if meta.role == apiauth.RoleOwner {
+ return true
+ }
+ }
+ return false
+}
+
+// ownerCountLocked is how many enrolments can administer this box. The caller
+// holds the mutex.
+func (i *Identity) ownerCountLocked() int {
+ n := 0
+ for _, meta := range i.authorised {
+ if meta.role == apiauth.RoleOwner {
+ n++
+ }
+ }
+ return n
+}
+
+// findLocked returns the enrolment behind a device id. The caller holds the
+// mutex.
+func (i *Identity) findLocked(id string) (string, *appMeta) {
+ for key, meta := range i.authorised {
+ if deviceID(key) == id {
+ return key, meta
+ }
+ }
+ return "", nil
+}
+
+// GrantFor is one enrolment as it stands right now.
+//
+// Read on every privileged request rather than at the handshake, because a
+// socket outlives both a revoke and a role change. Three layers stop a revoked
+// phone and none of them is enough alone: the session is torn down, the next
+// handshake fails, and this — the one that closes the window where a socket
+// outlives the revoke. It is also the only thing that makes a demotion bite
+// on a session that is already open and working.
+//
+// The second result is false when the enrolment is gone entirely, which is a
+// revoke. A grant that merely changed comes back with its new role, and the
+// caller is expected to start using it rather than to end the session: a
+// demoted owner is still a viewer, and telling them their access was withdrawn
+// would be a sentence the code does not mean.
+//
+// The honest limit belongs beside it: revocation is immediate at the box.
+// Nothing can un-send bytes already in a phone's cache.
+func (i *Identity) GrantFor(id string) (Grant, bool) {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ key, meta := i.findLocked(id)
+ if meta == nil {
+ return Grant{}, false
+ }
+ return Grant{DeviceID: deviceID(key), Role: meta.role, Epoch: meta.epoch}, true
+}
+
+// SetRole changes what one paired phone may do.
+//
+// It takes effect at once on a session that is already open, and the mechanism
+// is GrantFor rather than anything here: both doors re-read the grant on every
+// privileged request, so a phone demoted while it is connected loses its writes
+// at the very next one rather than at its next reconnect. The epoch moves
+// alongside, as the count of how many times this grant has changed.
+//
+// Refused for the only owner, here rather than in the API layer — otherwise
+// the box's own web UI could do what the app cannot, and the protection would
+// be a property of the screen instead of a property of the box.
+func (i *Identity) SetRole(id, role string) error {
+ if err := knownRole(role); err != nil {
+ return err
+ }
+
+ i.mu.Lock()
+ _, meta := i.findLocked(id)
+ if meta == nil {
+ i.mu.Unlock()
+ return ErrUnknownDevice
+ }
+ if meta.role == role {
+ // Nothing changed, so nothing is bumped. An epoch that moved for a
+ // write that changed nothing would make every live session re-read a
+ // grant that is the same as the one it holds.
+ i.mu.Unlock()
+ return nil
+ }
+ if meta.role == apiauth.RoleOwner && i.ownerCountLocked() == 1 {
+ i.mu.Unlock()
+ return ErrLastOwnerProtected
+ }
+ meta.role = role
+ meta.epoch++
+ i.mu.Unlock()
+
return i.save()
}
// Devices lists every paired phone, most recently seen first.
func (i *Identity) Devices() []DeviceInfo {
i.mu.Lock()
+ lastOwner := i.ownerCountLocked() == 1
out := make([]DeviceInfo, 0, len(i.authorised))
for key, meta := range i.authorised {
out = append(out, DeviceInfo{
ID: deviceID(key), AddedAtMs: meta.addedAtMs, LastSeenMs: meta.lastSeenMs,
+ Role: meta.role,
+ LastOwner: lastOwner && meta.role == apiauth.RoleOwner,
})
}
i.mu.Unlock()
@@ -409,19 +717,25 @@ var ErrUnknownDevice = errors.New("appenroll: no such device")
// Revoke forgets a phone by its device id and returns the full key, so the
// caller can also tear down any session that key is running right now. The
// next handshake from it meets ErrNoPairing like any stranger's.
+//
+// Removing a guest and locking out a phone are the same action, deliberately.
+// Sharing does not get its own gesture with its own bugs: a shared phone is a
+// row in the same list, removed by the same button.
+//
+// Refused for the only owner. A household can always pair a new owner at the
+// box and then remove the old one; what it cannot do is reduce itself to a set
+// of phones that can only look.
func (i *Identity) Revoke(id string) ([]byte, error) {
i.mu.Lock()
- var fullKey string
- for key := range i.authorised {
- if deviceID(key) == id {
- fullKey = key
- break
- }
- }
- if fullKey == "" {
+ fullKey, meta := i.findLocked(id)
+ if meta == nil {
i.mu.Unlock()
return nil, ErrUnknownDevice
}
+ if meta.role == apiauth.RoleOwner && i.ownerCountLocked() == 1 {
+ i.mu.Unlock()
+ return nil, ErrLastOwnerProtected
+ }
delete(i.authorised, fullKey)
i.mu.Unlock()
diff --git a/go/internal/appenroll/enroll_test.go b/go/internal/appenroll/enroll_test.go
index ac49992a..c525ab66 100644
--- a/go/internal/appenroll/enroll_test.go
+++ b/go/internal/appenroll/enroll_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"crypto/rand"
"encoding/base64"
+ "encoding/json"
"errors"
"os"
"path/filepath"
@@ -11,6 +12,7 @@ import (
"testing"
"time"
+ "github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/appwire"
)
@@ -59,14 +61,14 @@ func TestAKnownKeyNeedsNoPairingCode(t *testing.T) {
id, _ := newIdentity(t)
app := appKey(t)
- code, _, err := id.MintPairingCode()
+ code, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL)
if err != nil {
t.Fatalf("MintPairingCode: %v", err)
}
- if err := id.Authorise(app, code); err != nil {
+ if _, err := id.Authorise(app, code); err != nil {
t.Fatalf("first pairing: %v", err)
}
- if err := id.Authorise(app, nil); err != nil {
+ if _, err := id.Authorise(app, nil); err != nil {
t.Fatalf("reconnect with no code: %v", err)
}
}
@@ -74,7 +76,7 @@ func TestAKnownKeyNeedsNoPairingCode(t *testing.T) {
func TestAnUnknownKeyWithNoCodeIsRefused(t *testing.T) {
id, _ := newIdentity(t)
- if err := id.Authorise(appKey(t), nil); !errors.Is(err, ErrNoPairing) {
+ if _, err := id.Authorise(appKey(t), nil); !errors.Is(err, ErrNoPairing) {
t.Fatalf("err = %v, want ErrNoPairing", err)
}
}
@@ -84,16 +86,16 @@ func TestAnUnknownKeyWithNoCodeIsRefused(t *testing.T) {
func TestAPairingCodeIsSpentOnFirstUse(t *testing.T) {
id, _ := newIdentity(t)
- code, _, err := id.MintPairingCode()
+ code, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL)
if err != nil {
t.Fatalf("MintPairingCode: %v", err)
}
- if err := id.Authorise(appKey(t), code); err != nil {
+ if _, err := id.Authorise(appKey(t), code); err != nil {
t.Fatalf("first use: %v", err)
}
second := appKey(t)
- if err := id.Authorise(second, code); !errors.Is(err, ErrBadPairing) {
+ if _, err := id.Authorise(second, code); !errors.Is(err, ErrBadPairing) {
t.Fatalf("second use: err = %v, want ErrBadPairing", err)
}
}
@@ -103,7 +105,7 @@ func TestAnExpiredCodeIsRefused(t *testing.T) {
now := time.Now()
id.now = func() time.Time { return now }
- code, expires, err := id.MintPairingCode()
+ code, expires, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL)
if err != nil {
t.Fatalf("MintPairingCode: %v", err)
}
@@ -112,7 +114,7 @@ func TestAnExpiredCodeIsRefused(t *testing.T) {
}
id.now = func() time.Time { return now.Add(PairingTTL + time.Second) }
- if err := id.Authorise(appKey(t), code); !errors.Is(err, ErrBadPairing) {
+ if _, err := id.Authorise(appKey(t), code); !errors.Is(err, ErrBadPairing) {
t.Fatalf("err = %v, want ErrBadPairing", err)
}
}
@@ -120,11 +122,11 @@ func TestAnExpiredCodeIsRefused(t *testing.T) {
func TestAWrongCodeIsRefused(t *testing.T) {
id, _ := newIdentity(t)
- if _, _, err := id.MintPairingCode(); err != nil {
+ if _, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL); err != nil {
t.Fatalf("MintPairingCode: %v", err)
}
wrong := bytes.Repeat([]byte{0x5a}, PairingCodeBytes)
- if err := id.Authorise(appKey(t), wrong); !errors.Is(err, ErrBadPairing) {
+ if _, err := id.Authorise(appKey(t), wrong); !errors.Is(err, ErrBadPairing) {
t.Fatalf("err = %v, want ErrBadPairing", err)
}
}
@@ -134,15 +136,15 @@ func TestAWrongCodeIsRefused(t *testing.T) {
func TestMintingRetiresThePreviousCode(t *testing.T) {
id, _ := newIdentity(t)
- first, _, err := id.MintPairingCode()
+ first, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL)
if err != nil {
t.Fatalf("MintPairingCode: %v", err)
}
- if _, _, err := id.MintPairingCode(); err != nil {
+ if _, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL); err != nil {
t.Fatalf("second MintPairingCode: %v", err)
}
- if err := id.Authorise(appKey(t), first); !errors.Is(err, ErrBadPairing) {
+ if _, err := id.Authorise(appKey(t), first); !errors.Is(err, ErrBadPairing) {
t.Fatalf("err = %v, want ErrBadPairing", err)
}
}
@@ -151,11 +153,11 @@ func TestAuthorisationSurvivesARestart(t *testing.T) {
id, keyPath := newIdentity(t)
app := appKey(t)
- code, _, err := id.MintPairingCode()
+ code, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL)
if err != nil {
t.Fatalf("MintPairingCode: %v", err)
}
- if err := id.Authorise(app, code); err != nil {
+ if _, err := id.Authorise(app, code); err != nil {
t.Fatalf("pairing: %v", err)
}
@@ -166,7 +168,7 @@ func TestAuthorisationSurvivesARestart(t *testing.T) {
if again.AuthorisedCount() != 1 {
t.Fatalf("authorised = %d, want 1", again.AuthorisedCount())
}
- if err := again.Authorise(app, nil); err != nil {
+ if _, err := again.Authorise(app, nil); err != nil {
t.Fatalf("after restart: %v", err)
}
}
@@ -196,7 +198,7 @@ func TestRotatingTheRendezvousSecretPersists(t *testing.T) {
// parser in srcfl/ftw-webapp rather than against this file's own opinion.
func TestEnrollmentURLMatchesTheAppsParser(t *testing.T) {
id, _ := newIdentity(t)
- code, _, err := id.MintPairingCode()
+ code, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL)
if err != nil {
t.Fatalf("MintPairingCode: %v", err)
}
@@ -254,7 +256,7 @@ func TestEnrollmentURLMatchesTheAppsParser(t *testing.T) {
func TestEnrollmentURLRefusesAHintTheAppWouldReject(t *testing.T) {
id, _ := newIdentity(t)
- code, _, err := id.MintPairingCode()
+ code, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL)
if err != nil {
t.Fatalf("MintPairingCode: %v", err)
}
@@ -269,7 +271,7 @@ func TestEnrollmentURLRefusesAHintTheAppWouldReject(t *testing.T) {
func TestAnEmptyLANHintIsAllowed(t *testing.T) {
id, _ := newIdentity(t)
- code, _, err := id.MintPairingCode()
+ code, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL)
if err != nil {
t.Fatalf("MintPairingCode: %v", err)
}
@@ -331,7 +333,7 @@ func TestDeviceListAndRevoke(t *testing.T) {
id.now = func() time.Time { return clock }
pairPhone := func() []byte {
- code, _, err := id.MintPairingCode()
+ code, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL)
if err != nil {
t.Fatal(err)
}
@@ -339,7 +341,7 @@ func TestDeviceListAndRevoke(t *testing.T) {
if _, err := rand.Read(pub); err != nil {
t.Fatal(err)
}
- if err := id.Authorise(pub, code); err != nil {
+ if _, err := id.Authorise(pub, code); err != nil {
t.Fatal(err)
}
return pub
@@ -362,7 +364,7 @@ func TestDeviceListAndRevoke(t *testing.T) {
// A reconnect stamps lastSeen — that is what tells a live phone from a
// key that paired once and vanished.
clock = base.Add(2 * time.Hour)
- if err := id.Authorise(first, nil); err != nil {
+ if _, err := id.Authorise(first, nil); err != nil {
t.Fatalf("reconnect refused: %v", err)
}
devices = id.Devices()
@@ -379,7 +381,7 @@ func TestDeviceListAndRevoke(t *testing.T) {
if string(key) != string(second) {
t.Fatal("revoke returned a different key than it removed")
}
- if err := id.Authorise(second, nil); !errors.Is(err, ErrNoPairing) {
+ if _, err := id.Authorise(second, nil); !errors.Is(err, ErrNoPairing) {
t.Fatalf("a revoked key reconnected: %v", err)
}
if _, err := id.Revoke("nosuchid"); !errors.Is(err, ErrUnknownDevice) {
@@ -420,3 +422,101 @@ func TestLegacyBareKeyListStillReads(t *testing.T) {
t.Fatalf("legacy row should carry unknown stamps: %+v", devices)
}
}
+
+// --------------------------------------------------------------------------
+// What a grant says
+// --------------------------------------------------------------------------
+
+// The first phone a box pairs is its household's, so it is an owner. A box
+// with no owner cannot be administered.
+func TestTheFirstPairingIsAnOwner(t *testing.T) {
+ id, _ := newIdentity(t)
+ code, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL)
+ if err != nil {
+ t.Fatalf("MintPairingCode: %v", err)
+ }
+
+ app := appKey(t)
+ grant, err := id.Authorise(app, code)
+ if err != nil {
+ t.Fatalf("pairing: %v", err)
+ }
+ if grant.Role != apiauth.RoleOwner {
+ t.Fatalf("role = %q, want owner", grant.Role)
+ }
+ if grant.Epoch == 0 {
+ t.Fatal("the grant carries no epoch, so a revoke has nothing to move")
+ }
+ if want := base64.RawURLEncoding.EncodeToString(app)[:8]; grant.DeviceID != want {
+ t.Fatalf("device id = %q, want %q", grant.DeviceID, want)
+ }
+
+ // The reconnect takes the other branch and must answer the same.
+ again, err := id.Authorise(app, nil)
+ if err != nil {
+ t.Fatalf("reconnect: %v", err)
+ }
+ if again != grant {
+ t.Fatalf("reconnect granted %+v, want %+v", again, grant)
+ }
+}
+
+// A box updating from a version with no roles must not silently demote every
+// paired phone in the house to viewer.
+func TestALegacyEnrolmentLoadsAsAnOwner(t *testing.T) {
+ id, keyPath := newIdentity(t)
+ app := appKey(t)
+ key := base64.RawURLEncoding.EncodeToString(app)
+
+ // applink.json as a box that has never heard of roles wrote it.
+ raw, err := os.ReadFile(filepath.Join(filepath.Dir(keyPath), FileName))
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ var file map[string]any
+ if err := json.Unmarshal(raw, &file); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ file["authorisedApps"] = []any{map[string]any{"key": key, "addedAtMs": 1}}
+ patched, _ := json.Marshal(file)
+ if err := os.WriteFile(filepath.Join(filepath.Dir(keyPath), FileName), patched, 0o600); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ _ = id
+
+ again, err := LoadOrCreate(keyPath)
+ if err != nil {
+ t.Fatalf("LoadOrCreate: %v", err)
+ }
+ grant, err := again.Authorise(app, nil)
+ if err != nil {
+ t.Fatalf("reconnect after the update: %v", err)
+ }
+ if grant.Role != apiauth.RoleOwner {
+ t.Fatalf("role = %q; the update demoted a paired phone", grant.Role)
+ }
+ if grant.Epoch == 0 {
+ t.Fatal("a legacy row came back with no epoch, which no session can match")
+ }
+}
+
+// Revocation's second layer: the row is gone, so a session still holding an
+// epoch has nothing to match against and every privileged request it makes is
+// refused before a handler runs.
+func TestARevokedDeviceHasNoLiveGrant(t *testing.T) {
+ id, _ := newIdentity(t)
+ // An owner first, because the only owner cannot be revoked — and a
+ // household revoking a phone almost always has another.
+ pair(t, id, apiauth.RoleOwner)
+ guest := pair(t, id, apiauth.RoleViewer)
+
+ if live, ok := id.GrantFor(guest.DeviceID); !ok || live.Epoch != guest.Epoch {
+ t.Fatalf("grant before the revoke = %+v/%v, want %+v/live", live, ok, guest)
+ }
+ if _, err := id.Revoke(guest.DeviceID); err != nil {
+ t.Fatalf("Revoke: %v", err)
+ }
+ if live, ok := id.GrantFor(guest.DeviceID); ok {
+ t.Fatalf("a revoked device still has grant %+v", live)
+ }
+}
diff --git a/go/internal/appenroll/roles_test.go b/go/internal/appenroll/roles_test.go
new file mode 100644
index 00000000..f10cee04
--- /dev/null
+++ b/go/internal/appenroll/roles_test.go
@@ -0,0 +1,599 @@
+package appenroll
+
+// Roles, sharing and the box code, at the layer that decides them.
+//
+// Everything here asserts on the stored grant rather than on a returned error,
+// because an error message is the one thing that stays right when the check
+// behind it is deleted.
+
+import (
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/apiauth"
+)
+
+// pair walks one phone through a freshly minted code of the given role and
+// returns the grant the box stamped on it.
+func pair(t *testing.T, id *Identity, role string) Grant {
+ t.Helper()
+ code, _, err := id.MintPairingCode(role, PairingTTL)
+ if err != nil {
+ t.Fatalf("MintPairingCode(%q): %v", role, err)
+ }
+ grant, err := id.Authorise(appKey(t), code)
+ if err != nil {
+ t.Fatalf("pairing a %s: %v", role, err)
+ }
+ return grant
+}
+
+// roleOf reads one row's role back out of the device list.
+func roleOf(t *testing.T, id *Identity, deviceID string) string {
+ t.Helper()
+ for _, d := range id.Devices() {
+ if d.ID == deviceID {
+ return d.Role
+ }
+ }
+ t.Fatalf("no device %q in the list", deviceID)
+ return ""
+}
+
+// --------------------------------------------------------------------------
+// An invite is a capability to become an enrolment
+// --------------------------------------------------------------------------
+
+// The whole of sharing, in one test: a code minted for a viewer makes a
+// viewer, works exactly once, and the second holder of the same code gets
+// nothing.
+func TestAnInviteIsRedeemedOnceAndRefusedTwice(t *testing.T) {
+ id, _ := newIdentity(t)
+ owner := pair(t, id, apiauth.RoleOwner)
+
+ code, _, err := id.MintPairingCode(apiauth.RoleViewer, InviteTTL)
+ if err != nil {
+ t.Fatalf("MintPairingCode: %v", err)
+ }
+
+ guest, err := id.Authorise(appKey(t), code)
+ if err != nil {
+ t.Fatalf("redeeming the invite: %v", err)
+ }
+ if guest.Role != apiauth.RoleViewer {
+ t.Fatalf("the invite made a %q, want a viewer", guest.Role)
+ }
+ if guest.DeviceID == owner.DeviceID {
+ t.Fatal("the guest landed on the owner's row")
+ }
+
+ // The same code again, from a different phone. This is the photographed
+ // screen, the forwarded screenshot, the second person in the room.
+ second := appKey(t)
+ if _, err := id.Authorise(second, code); !errors.Is(err, ErrBadPairing) {
+ t.Fatalf("a spent invite was redeemed again: %v", err)
+ }
+ // And the box did not quietly enrol them anyway, which is the assertion
+ // that survives someone deleting the check and keeping the error.
+ if n := id.AuthorisedCount(); n != 2 {
+ t.Fatalf("%d enrolments after a replayed invite, want 2", n)
+ }
+}
+
+func TestAnExpiredInviteIsRefused(t *testing.T) {
+ id, _ := newIdentity(t)
+ pair(t, id, apiauth.RoleOwner)
+
+ now := time.UnixMilli(1_760_000_000_000)
+ id.now = func() time.Time { return now }
+
+ code, expires, err := id.MintPairingCode(apiauth.RoleViewer, InviteTTL)
+ if err != nil {
+ t.Fatalf("MintPairingCode: %v", err)
+ }
+
+ // One millisecond past the stamp the screen showed. The boundary is the
+ // interesting part: a check written with >= would let this through.
+ now = expires.Add(time.Millisecond)
+ if _, err := id.Authorise(appKey(t), code); !errors.Is(err, ErrBadPairing) {
+ t.Fatalf("an expired invite was redeemed: %v", err)
+ }
+ if n := id.AuthorisedCount(); n != 1 {
+ t.Fatalf("%d enrolments after an expired invite, want 1", n)
+ }
+}
+
+// One live code at a time, across kinds. Minting an invite cancels a pending
+// owner code, or a household that pressed both buttons has handed out two.
+func TestMintingAnInviteCancelsAPendingOwnerCode(t *testing.T) {
+ id, _ := newIdentity(t)
+ pair(t, id, apiauth.RoleOwner)
+
+ ownerCode, _, err := id.MintPairingCode(apiauth.RoleOwner, PairingTTL)
+ if err != nil {
+ t.Fatalf("MintPairingCode: %v", err)
+ }
+ if _, _, err := id.MintSpokenCode(apiauth.RoleViewer); err != nil {
+ t.Fatalf("MintSpokenCode: %v", err)
+ }
+
+ if _, err := id.Authorise(appKey(t), ownerCode); !errors.Is(err, ErrBadPairing) {
+ t.Fatalf("a superseded owner code still worked: %v", err)
+ }
+ if n := id.AuthorisedCount(); n != 1 {
+ t.Fatalf("%d enrolments, want 1 — the superseded code enrolled somebody", n)
+ }
+}
+
+// A role the registry does not define is refused at the door rather than
+// stored. Stored, it would expand to no scopes and give nobody a reason why.
+func TestAnUnknownRoleIsRefusedEverywhere(t *testing.T) {
+ id, _ := newIdentity(t)
+ owner := pair(t, id, apiauth.RoleOwner)
+ pair(t, id, apiauth.RoleOwner)
+
+ if _, _, err := id.MintPairingCode("administrator", PairingTTL); !errors.Is(err, ErrUnknownRole) {
+ t.Fatalf("minting for an unknown role: %v", err)
+ }
+ if _, _, err := id.MintSpokenCode("administrator"); !errors.Is(err, ErrUnknownRole) {
+ t.Fatalf("minting a box code for an unknown role: %v", err)
+ }
+ if err := id.SetRole(owner.DeviceID, "administrator"); !errors.Is(err, ErrUnknownRole) {
+ t.Fatalf("setting an unknown role: %v", err)
+ }
+ if got := roleOf(t, id, owner.DeviceID); got != apiauth.RoleOwner {
+ t.Fatalf("the row is now %q; an unknown role was written", got)
+ }
+}
+
+// --------------------------------------------------------------------------
+// Whoever creates a home owns it
+// --------------------------------------------------------------------------
+
+// A box with no owner cannot be administered by anybody ever again. The first
+// enrolment is an owner whatever its code said.
+func TestTheFirstEnrolmentIsAnOwnerEvenFromAViewerCode(t *testing.T) {
+ id, _ := newIdentity(t)
+
+ code, _, err := id.MintPairingCode(apiauth.RoleViewer, InviteTTL)
+ if err != nil {
+ t.Fatalf("MintPairingCode: %v", err)
+ }
+ grant, err := id.Authorise(appKey(t), code)
+ if err != nil {
+ t.Fatalf("pairing: %v", err)
+ }
+ if grant.Role != apiauth.RoleOwner {
+ t.Fatalf("the first phone on the box is a %q; the home has no owner", grant.Role)
+ }
+
+ // And only the first. The second viewer code makes a viewer, or the rule
+ // above would quietly promote every guest.
+ next, _, err := id.MintPairingCode(apiauth.RoleViewer, InviteTTL)
+ if err != nil {
+ t.Fatalf("MintPairingCode: %v", err)
+ }
+ guest, err := id.Authorise(appKey(t), next)
+ if err != nil {
+ t.Fatalf("pairing a guest: %v", err)
+ }
+ if guest.Role != apiauth.RoleViewer {
+ t.Fatalf("the second phone is a %q, want a viewer", guest.Role)
+ }
+}
+
+// --------------------------------------------------------------------------
+// The last owner
+// --------------------------------------------------------------------------
+
+// Nobody can leave the household with no phone that can change anything —
+// not through the app, and not from the box's own page either, because the
+// check is here and not in the API layer.
+func TestTheLastOwnerCannotBeRemovedOrDemoted(t *testing.T) {
+ id, _ := newIdentity(t)
+ owner := pair(t, id, apiauth.RoleOwner)
+ guest := pair(t, id, apiauth.RoleViewer)
+
+ if err := id.SetRole(owner.DeviceID, apiauth.RoleViewer); !errors.Is(err, ErrLastOwnerProtected) {
+ t.Fatalf("demoting the only owner: %v", err)
+ }
+ if _, err := id.Revoke(owner.DeviceID); !errors.Is(err, ErrLastOwnerProtected) {
+ t.Fatalf("revoking the only owner: %v", err)
+ }
+ // The box, not the error. A check that returns the right error and writes
+ // anyway is the failure this project keeps rediscovering.
+ if got := roleOf(t, id, owner.DeviceID); got != apiauth.RoleOwner {
+ t.Fatalf("the last owner is now a %q", got)
+ }
+ if id.Devices()[0].LastOwner != true && id.Devices()[1].LastOwner != true {
+ t.Fatal("no row is marked as the last owner, so no screen can explain the refusal")
+ }
+
+ // Promote the guest, and the first owner is free to go.
+ if err := id.SetRole(guest.DeviceID, apiauth.RoleOwner); err != nil {
+ t.Fatalf("promoting the guest: %v", err)
+ }
+ if _, err := id.Revoke(owner.DeviceID); err != nil {
+ t.Fatalf("revoking an owner once there are two: %v", err)
+ }
+ if n := id.AuthorisedCount(); n != 1 {
+ t.Fatalf("%d enrolments, want 1", n)
+ }
+ if got := roleOf(t, id, guest.DeviceID); got != apiauth.RoleOwner {
+ t.Fatalf("the surviving phone is a %q, want the owner it was promoted to", got)
+ }
+}
+
+// --------------------------------------------------------------------------
+// A role change moves the epoch, which is what makes it bite mid-session
+// --------------------------------------------------------------------------
+
+func TestChangingARoleMovesTheEpoch(t *testing.T) {
+ id, _ := newIdentity(t)
+ pair(t, id, apiauth.RoleOwner)
+ guest := pair(t, id, apiauth.RoleViewer)
+
+ before, ok := id.GrantFor(guest.DeviceID)
+ if !ok {
+ t.Fatal("the guest has no grant")
+ }
+ if err := id.SetRole(guest.DeviceID, apiauth.RoleOwner); err != nil {
+ t.Fatalf("SetRole: %v", err)
+ }
+
+ after, ok := id.GrantFor(guest.DeviceID)
+ if !ok {
+ t.Fatal("the guest lost their grant to a promotion")
+ }
+ if after.Role != apiauth.RoleOwner {
+ t.Fatalf("role is %q, want owner", after.Role)
+ }
+ if after.Epoch <= before.Epoch {
+ t.Fatalf("epoch went %d → %d; a live session would never notice",
+ before.Epoch, after.Epoch)
+ }
+
+ // Setting the same role again changes nothing, so nothing moves. An epoch
+ // that jumped for a write that changed nothing would make every open
+ // session re-read a grant identical to the one it holds.
+ if err := id.SetRole(guest.DeviceID, apiauth.RoleOwner); err != nil {
+ t.Fatalf("SetRole, unchanged: %v", err)
+ }
+ same, _ := id.GrantFor(guest.DeviceID)
+ if same.Epoch != after.Epoch {
+ t.Fatalf("epoch moved %d → %d for a change that was not one", after.Epoch, same.Epoch)
+ }
+}
+
+// A role survives a restart. Without this, every box update would re-read its
+// guests as whatever the loader defaults to.
+func TestRolesSurviveARestart(t *testing.T) {
+ id, keyPath := newIdentity(t)
+ pair(t, id, apiauth.RoleOwner)
+ guest := pair(t, id, apiauth.RoleViewer)
+
+ again, err := LoadOrCreate(keyPath)
+ if err != nil {
+ t.Fatalf("LoadOrCreate: %v", err)
+ }
+ reloaded, ok := again.GrantFor(guest.DeviceID)
+ if !ok {
+ t.Fatal("the guest is gone after a restart")
+ }
+ if reloaded.Role != apiauth.RoleViewer {
+ t.Fatalf("the guest came back as a %q, want a viewer", reloaded.Role)
+ }
+ if reloaded.Epoch != guest.Epoch {
+ t.Fatalf("epoch %d after a restart, want %d — every session would be refused",
+ reloaded.Epoch, guest.Epoch)
+ }
+}
+
+// --------------------------------------------------------------------------
+// The box code
+// --------------------------------------------------------------------------
+
+// The round trip, including everything a listener is likely to mishear. A
+// decode that did not fold these would send five wrong bytes and burn one of
+// the five attempts on a person who heard correctly.
+func TestABoxCodeSurvivesBeingReadAloud(t *testing.T) {
+ id, _ := newIdentity(t)
+ pair(t, id, apiauth.RoleOwner)
+
+ shown, _, err := id.MintSpokenCode(apiauth.RoleViewer)
+ if err != nil {
+ t.Fatalf("MintSpokenCode: %v", err)
+ }
+ if len(shown) != SpokenCodeChars+1 || shown[4] != '-' {
+ t.Fatalf("code %q is not grouped as XXXX-XXXX", shown)
+ }
+
+ want, err := DecodeSpokenCode(shown)
+ if err != nil {
+ t.Fatalf("decoding what the box showed: %v", err)
+ }
+ if len(want) != SpokenCodeBytes {
+ t.Fatalf("decoded to %d bytes, want %d", len(want), SpokenCodeBytes)
+ }
+
+ // Written down by ear: lower case, no hyphen, spaces. These hold for any
+ // code; the folding of misheard characters is pinned separately below,
+ // against a code known to contain the characters in question.
+ bare := strings.ReplaceAll(shown, "-", "")
+ for _, typed := range []string{
+ strings.ToLower(shown),
+ bare,
+ bare[:4] + " " + bare[4:],
+ } {
+ got, err := DecodeSpokenCode(typed)
+ if err != nil {
+ t.Fatalf("decoding %q: %v", typed, err)
+ }
+ if string(got) != string(want) {
+ t.Fatalf("%q decoded to different bytes than %q", typed, shown)
+ }
+ }
+
+ // And it opens the door, which is the only thing that matters.
+ grant, err := id.Authorise(appKey(t), want)
+ if err != nil {
+ t.Fatalf("redeeming the box code: %v", err)
+ }
+ if grant.Role != apiauth.RoleViewer {
+ t.Fatalf("the box code let in a %q, want the viewer it was minted for", grant.Role)
+ }
+}
+
+// The folding, against a code chosen to contain the characters it folds.
+//
+// Built from fixed bytes rather than from a minted code on purpose: a random
+// forty bits very often contains no 0 and no 1 at all, and a test that
+// substitutes characters a code does not have asserts nothing while appearing
+// to assert everything. That is the shape of test this project has lost
+// several rounds to, so it is written out longhand here.
+func TestABoxCodeFoldsTheCharactersPeopleMishear(t *testing.T) {
+ // Forty bits of 0x0000000001, which encodes to seven noughts and a one:
+ // every character this alphabet folds, in one code.
+ canonical := encodeSpoken([]byte{0, 0, 0, 0, 1})
+ if canonical != "00000001" {
+ t.Fatalf("encodeSpoken = %q, want 00000001", canonical)
+ }
+
+ want, err := DecodeSpokenCode(canonical)
+ if err != nil {
+ t.Fatalf("decoding the canonical form: %v", err)
+ }
+
+ for _, c := range []struct{ name, typed string }{
+ {"O heard as nought", "OOOOOOO1"},
+ {"o heard as nought", "ooooooo1"},
+ {"I heard as one", "0000000I"},
+ {"l heard as one", "0000000l"},
+ {"L heard as one", "0000000L"},
+ {"all of them at once", "OOOOOOOI"},
+ {"grouped and misheard", "OOOO-OOOL"},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ // The variant must really be a variant, or the case below is a
+ // test of nothing.
+ if strings.ReplaceAll(c.typed, "-", "") == canonical {
+ t.Fatalf("%q is the canonical form; this case folds nothing", c.typed)
+ }
+ got, err := DecodeSpokenCode(c.typed)
+ if err != nil {
+ t.Fatalf("decoding %q: %v", c.typed, err)
+ }
+ if string(got) != string(want) {
+ t.Fatalf("%q decoded to %x, want %x", c.typed, got, want)
+ }
+ })
+ }
+}
+
+// Encoding and decoding agree across the whole range, one byte at a time.
+// The app implements the same folding on its side, and this is the table it
+// has to match.
+func TestSpokenCodesRoundTrip(t *testing.T) {
+ for _, raw := range [][]byte{
+ {0, 0, 0, 0, 0},
+ {0, 0, 0, 0, 1},
+ {0xff, 0xff, 0xff, 0xff, 0xff},
+ {0x80, 0, 0, 0, 0},
+ {0x01, 0x23, 0x45, 0x67, 0x89},
+ {0xde, 0xad, 0xbe, 0xef, 0x42},
+ } {
+ encoded := encodeSpoken(raw)
+ if len(encoded) != SpokenCodeChars {
+ t.Fatalf("%x encoded to %q, %d characters", raw, encoded, len(encoded))
+ }
+ got, err := DecodeSpokenCode(GroupSpokenCode(encoded))
+ if err != nil {
+ t.Fatalf("decoding %q: %v", encoded, err)
+ }
+ if string(got) != string(raw) {
+ t.Fatalf("%x → %q → %x", raw, encoded, got)
+ }
+ }
+
+ // Every one of the 32 characters is reachable and decodes back to the
+ // value it stands for. The last character carries the low five bits, so
+ // counting through 0..31 walks the whole alphabet.
+ for i := 0; i < 32; i++ {
+ encoded := encodeSpoken([]byte{0, 0, 0, 0, byte(i)})
+ last := rune(encoded[SpokenCodeChars-1])
+ if !strings.ContainsRune(spokenAlphabet, last) {
+ t.Fatalf("value %d encoded to %q, which is not in the alphabet", i, last)
+ }
+ got, err := DecodeSpokenCode(encoded)
+ if err != nil {
+ t.Fatalf("value %d encoded to %q, which will not decode: %v", i, encoded, err)
+ }
+ if got[SpokenCodeBytes-1] != byte(i) {
+ t.Fatalf("value %d round-tripped to %d", i, got[SpokenCodeBytes-1])
+ }
+ }
+
+ // And the four characters Crockford leaves out are not in it, or the
+ // folding above would be ambiguous.
+ for _, excluded := range "ILOU" {
+ if strings.ContainsRune(spokenAlphabet, excluded) {
+ t.Fatalf("%q is in the alphabet; folding it would be ambiguous", excluded)
+ }
+ }
+}
+
+func TestRubbishIsNotABoxCode(t *testing.T) {
+ for _, typed := range []string{
+ "",
+ "ABCD-EFG", // seven characters
+ "ABCD-EFGHJ", // nine
+ "ABCD-EFGU", // U is not in the alphabet
+ "ABCD-EFG!", // punctuation
+ "ABCD-EFGé", // not ASCII
+ } {
+ if _, err := DecodeSpokenCode(typed); !errors.Is(err, ErrBadSpokenCode) {
+ t.Fatalf("DecodeSpokenCode(%q) = %v, want a refusal", typed, err)
+ }
+ }
+}
+
+// The property the whole box code rests on: a guesser gets MaxPairingAttempts
+// tries per minting, and every minting needs somebody standing at the box.
+//
+// Forty bits with unlimited tries is a lock that opens given a weekend. Forty
+// bits with five is one that never opens at all — a guesser expecting to
+// succeed needs about 2^40/5 mintings, each of which is a person walking to
+// the box, so the code expires unguessed every time.
+func TestABoxCodeCannotBeGuessedFasterThanItExpires(t *testing.T) {
+ id, _ := newIdentity(t)
+ pair(t, id, apiauth.RoleOwner)
+
+ shown, _, err := id.MintSpokenCode(apiauth.RoleViewer)
+ if err != nil {
+ t.Fatalf("MintSpokenCode: %v", err)
+ }
+ real, err := DecodeSpokenCode(shown)
+ if err != nil {
+ t.Fatalf("DecodeSpokenCode: %v", err)
+ }
+
+ // Wrong guesses, all well within the five minutes and all correctly
+ // shaped — this is somebody working through the space, not fumbling.
+ for i := 0; i < MaxPairingAttempts; i++ {
+ guess := append([]byte(nil), real...)
+ guess[0] ^= byte(i + 1)
+ if _, err := id.Authorise(appKey(t), guess); !errors.Is(err, ErrBadPairing) {
+ t.Fatalf("guess %d: %v", i, err)
+ }
+ }
+
+ // The code is burned, so even the right answer buys nothing. Somebody has
+ // to walk back to the box.
+ if _, err := id.Authorise(appKey(t), real); !errors.Is(err, ErrBadPairing) {
+ t.Fatalf("the real code still worked after %d wrong guesses: %v", MaxPairingAttempts, err)
+ }
+ if n := id.AuthorisedCount(); n != 1 {
+ t.Fatalf("%d enrolments, want 1 — a guesser got in", n)
+ }
+
+ // A fresh minting starts the counter again, or a household would be
+ // locked out for good by anyone who could reach the box.
+ next, _, err := id.MintSpokenCode(apiauth.RoleViewer)
+ if err != nil {
+ t.Fatalf("MintSpokenCode: %v", err)
+ }
+ fresh, err := DecodeSpokenCode(next)
+ if err != nil {
+ t.Fatalf("DecodeSpokenCode: %v", err)
+ }
+ if _, err := id.Authorise(appKey(t), fresh); err != nil {
+ t.Fatalf("a freshly minted code was refused: %v", err)
+ }
+}
+
+// The counter is on the code, not on the caller. Five phones guessing once
+// each must burn the code exactly as one phone guessing five times does — the
+// relay's own limiter already keys on an address that is one address for the
+// whole fleet, and an address-keyed counter here would inherit that.
+func TestTheAttemptCounterFollowsTheCodeNotTheCaller(t *testing.T) {
+ id, _ := newIdentity(t)
+ pair(t, id, apiauth.RoleOwner)
+
+ shown, _, err := id.MintSpokenCode(apiauth.RoleViewer)
+ if err != nil {
+ t.Fatalf("MintSpokenCode: %v", err)
+ }
+ real, err := DecodeSpokenCode(shown)
+ if err != nil {
+ t.Fatalf("DecodeSpokenCode: %v", err)
+ }
+
+ for i := 0; i < MaxPairingAttempts; i++ {
+ guess := append([]byte(nil), real...)
+ guess[4] ^= byte(i + 1)
+ // A different key every time: a different phone, or the same phone
+ // pretending to be one.
+ if _, err := id.Authorise(appKey(t), guess); !errors.Is(err, ErrBadPairing) {
+ t.Fatalf("guess %d from a fresh key: %v", i, err)
+ }
+ }
+ if _, err := id.Authorise(appKey(t), real); !errors.Is(err, ErrBadPairing) {
+ t.Fatalf("spreading the guesses across keys kept the code alive: %v", err)
+ }
+}
+
+// The QR code is counted too. It has enough entropy not to need it, but one
+// rule for both kinds is one rule to get wrong.
+func TestAQRCodeIsBurnedByGuessingToo(t *testing.T) {
+ id, _ := newIdentity(t)
+ pair(t, id, apiauth.RoleOwner)
+
+ code, _, err := id.MintPairingCode(apiauth.RoleViewer, InviteTTL)
+ if err != nil {
+ t.Fatalf("MintPairingCode: %v", err)
+ }
+ for i := 0; i < MaxPairingAttempts; i++ {
+ guess := append([]byte(nil), code...)
+ guess[0] ^= byte(i + 1)
+ if _, err := id.Authorise(appKey(t), guess); !errors.Is(err, ErrBadPairing) {
+ t.Fatalf("guess %d: %v", i, err)
+ }
+ }
+ if _, err := id.Authorise(appKey(t), code); !errors.Is(err, ErrBadPairing) {
+ t.Fatalf("a guessed-at QR code survived: %v", err)
+ }
+}
+
+// A box code lives half as long as a scanned one, and the difference is real
+// rather than decorative: fewer bits, read out while two people are talking.
+func TestABoxCodeExpiresSoonerThanAScannedOne(t *testing.T) {
+ if SpokenTTL >= PairingTTL {
+ t.Fatalf("SpokenTTL is %s and PairingTTL is %s; the comment above the "+
+ "constant claims a shorter life", SpokenTTL, PairingTTL)
+ }
+
+ id, _ := newIdentity(t)
+ pair(t, id, apiauth.RoleOwner)
+
+ now := time.UnixMilli(1_760_000_000_000)
+ id.now = func() time.Time { return now }
+
+ shown, expires, err := id.MintSpokenCode(apiauth.RoleOwner)
+ if err != nil {
+ t.Fatalf("MintSpokenCode: %v", err)
+ }
+ if got := expires.Sub(now); got != SpokenTTL {
+ t.Fatalf("a box code lives %s, want %s", got, SpokenTTL)
+ }
+ real, err := DecodeSpokenCode(shown)
+ if err != nil {
+ t.Fatalf("DecodeSpokenCode: %v", err)
+ }
+
+ now = expires.Add(time.Millisecond)
+ if _, err := id.Authorise(appKey(t), real); !errors.Is(err, ErrBadPairing) {
+ t.Fatalf("an expired box code was redeemed: %v", err)
+ }
+}
diff --git a/go/internal/appproto/command_test.go b/go/internal/appproto/command_test.go
index 3ba5cabc..62a1af0d 100644
--- a/go/internal/appproto/command_test.go
+++ b/go/internal/appproto/command_test.go
@@ -4,6 +4,7 @@ import (
"errors"
"testing"
+ "github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/control"
)
@@ -364,3 +365,122 @@ func TestAStaleMeterDoesNotBlockAModeChange(t *testing.T) {
t.Fatalf("state = %q, want applied", res.State)
}
}
+
+// A viewer's command is refused, and the mode controller is never called.
+//
+// The scope every operation declares had been declared since defaultOps was
+// written and read by nothing, so a guest's command was refused by nothing at
+// all. This is that check, pinned in the package the check lives in.
+//
+// It asserts on the box and not on the error. A test that only read the
+// refusal would pass just as happily with the check deleted and the error left
+// in place, which is the shape of test this project has already lost rounds
+// to.
+func TestAViewersCommandNeverReachesTheModeController(t *testing.T) {
+ h, box, rec, _ := newRig(t)
+ h.cfg.Caller = viewerCaller()
+ h.cfg.Grants = newViewerGrants()
+ subscribe(t, h, rec)
+
+ before := box.mode
+ deliver(t, h, MsgCmd, nil, cmdSetMode(string(control.ModeCharge), 7, 200_000, nil))
+
+ res := body[CmdResult](t, rec.only(t, MsgCmdResult))
+ if res.State != CmdRejected {
+ t.Fatalf("state = %q, want rejected", res.State)
+ }
+ if res.Error == nil || res.Error.Code != ErrScopeDenied {
+ t.Fatalf("refusal = %+v, want %s", res.Error, ErrScopeDenied)
+ }
+ if res.Error.Args["needScope"] != ScopeModeWrite {
+ t.Fatalf("refusal args = %v, want the scope it needs", res.Error.Args)
+ }
+
+ if box.setModeCalls != 0 {
+ t.Fatalf("the mode controller was called %d times by a viewer", box.setModeCalls)
+ }
+ if box.mode != before {
+ t.Fatalf("a viewer moved the box to %q", box.mode)
+ }
+ if rec.has(MsgCmdAck) {
+ t.Fatal("a refused command was acked, so the app was told a lease exists")
+ }
+}
+
+// The same command from the phone that paired the box, so the test above
+// cannot pass on a handler that refuses everything.
+func TestAnOwnersCommandStillReachesTheModeController(t *testing.T) {
+ h, box, rec, _ := newRig(t)
+ subscribe(t, h, rec)
+
+ deliver(t, h, MsgCmd, nil, cmdSetMode(string(control.ModeCharge), 7, 200_000, nil))
+
+ if res := body[CmdResult](t, rec.only(t, MsgCmdResult)); res.State != CmdApplied {
+ t.Fatalf("state = %q (%+v), want applied", res.State, res.Error)
+ }
+ if box.setModeCalls != 1 {
+ t.Fatalf("the mode controller was called %d times, want once", box.setModeCalls)
+ }
+}
+
+// A grant that changed while the phone was connected is obeyed as it now
+// reads, on the command lane as well as the HTTP one.
+//
+// The handshake said owner and the app still believes it — the buttons it drew
+// from hello_ok are wrong until it reconnects, and that is the right way
+// round: the app drawing a button is presentation and the box refusing it is
+// the enforcement. Without this, onCmd could go back to trusting the caller
+// its handshake built and no test would notice.
+func TestADemotedOwnersCommandNeverReachesTheModeController(t *testing.T) {
+ h, box, rec, _ := newRig(t)
+ grants := h.cfg.Grants.(*fakeGrants)
+ subscribe(t, h, rec)
+
+ grants.setRole(apiauth.RoleViewer)
+ deliver(t, h, MsgCmd, nil, cmdSetMode(string(control.ModeCharge), 7, 200_000, nil))
+
+ res := body[CmdResult](t, rec.only(t, MsgCmdResult))
+ if res.State != CmdRejected {
+ t.Fatalf("state = %q, want rejected", res.State)
+ }
+ if res.Error == nil || res.Error.Code != ErrScopeDenied {
+ t.Fatalf("refusal = %+v, want %s", res.Error, ErrScopeDenied)
+ }
+ if res.Error.Args["role"] != apiauth.RoleViewer {
+ t.Fatalf("refusal args = %v, want the role on file now", res.Error.Args)
+ }
+ if box.setModeCalls != 0 {
+ t.Fatalf("a demoted owner called the mode controller %d times", box.setModeCalls)
+ }
+ // A demotion is not a revoke. Ending the session here would tell its
+ // holder their access was withdrawn, which is not what happened.
+ if rec.has(MsgSessionTerminate) {
+ t.Fatal("a demotion ended the session")
+ }
+}
+
+// A phone locked out mid-session gets no last command through on the strength
+// of what its handshake said.
+func TestARevokedDevicesCommandIsRefusedAndTheSessionEnds(t *testing.T) {
+ h, box, rec, _ := newRig(t)
+ grants := h.cfg.Grants.(*fakeGrants)
+ subscribe(t, h, rec)
+
+ grants.revoke()
+ deliver(t, h, MsgCmd, nil, cmdSetMode(string(control.ModeCharge), 7, 200_000, nil))
+
+ res := body[CmdResult](t, rec.only(t, MsgCmdResult))
+ if res.State != CmdRejected {
+ t.Fatalf("state = %q, want rejected", res.State)
+ }
+ if res.Error == nil || res.Error.Code != ErrGrantRevoked {
+ t.Fatalf("refusal = %+v, want %s", res.Error, ErrGrantRevoked)
+ }
+ if box.setModeCalls != 0 {
+ t.Fatalf("a revoked phone called the mode controller %d times", box.setModeCalls)
+ }
+ term := body[SessionTerminate](t, rec.only(t, MsgSessionTerminate))
+ if term.Reason != TerminateRevoked {
+ t.Fatalf("termination reason %q, want %q", term.Reason, TerminateRevoked)
+ }
+}
diff --git a/go/internal/appproto/contract_gen.go b/go/internal/appproto/contract_gen.go
index 2b99949a..15d0913a 100644
--- a/go/internal/appproto/contract_gen.go
+++ b/go/internal/appproto/contract_gen.go
@@ -78,6 +78,7 @@ const (
CapPlanDispatch = "plan.dispatch"
CapNetWebrtc = "net.webrtc"
CapPriceSpot = "price.spot"
+ CapApiPassthrough = "api.passthrough"
)
// AllCapabilities is every capability name the registry defines.
@@ -97,6 +98,7 @@ var AllCapabilities = []string{
CapPlanDispatch,
CapNetWebrtc,
CapPriceSpot,
+ CapApiPassthrough,
}
// Scopes are one object axis and two verb axes. Roles are a projection over
@@ -122,6 +124,13 @@ const (
ScopeMembersWrite = "ftw.members.write"
)
+// WriteScopes is every scope that changes something.
+var WriteScopes = []string{
+ ScopeDispatchWrite,
+ ScopeModeWrite,
+ ScopeMembersWrite,
+}
+
// Error codes. The box sends the code and machine-readable args; the app
// owns every word of prose, in every language it ships.
const (
@@ -145,6 +154,16 @@ const (
ErrRangeTooLarge = "E_RANGE_TOO_LARGE"
// ErrUnavailable — Source or subsystem is down.
ErrUnavailable = "E_UNAVAILABLE"
+ // ErrNeedsStepUp — Request needs a fresh passkey ceremony.
+ ErrNeedsStepUp = "E_NEEDS_STEP_UP"
+ // ErrUseCmd — Route moves energy and belongs on cmd.
+ ErrUseCmd = "E_USE_CMD"
+ // ErrUnsupportedMedia — Answer is not a kind the session carries.
+ ErrUnsupportedMedia = "E_UNSUPPORTED_MEDIA"
+ // ErrWholeDocument — Route replaces a whole document rather than part of one.
+ ErrWholeDocument = "E_WHOLE_DOCUMENT"
+ // ErrLocalOnly — Route is served only on the box's own page.
+ ErrLocalOnly = "E_LOCAL_ONLY"
)
// ErrorRetryable reports whether the app should offer a retry for a code.
@@ -159,6 +178,11 @@ var ErrorRetryable = map[string]bool{
ErrLastOwnerProtected: false,
ErrRangeTooLarge: false,
ErrUnavailable: true,
+ ErrNeedsStepUp: true,
+ ErrUseCmd: false,
+ ErrUnsupportedMedia: false,
+ ErrWholeDocument: false,
+ ErrLocalOnly: false,
}
// SourceState says whether a device is answering. It is orthogonal to the
diff --git a/go/internal/appproto/contract_test.go b/go/internal/appproto/contract_test.go
index fc4ddb8c..9308810f 100644
--- a/go/internal/appproto/contract_test.go
+++ b/go/internal/appproto/contract_test.go
@@ -28,7 +28,7 @@ func TestContractGenIsCurrent(t *testing.T) {
t.Fatalf("read generated file: %v", err)
}
if string(got) != string(want) {
- t.Fatalf("contract_gen.go is stale; run: go generate ./internal/appproto/...\n"+
+ t.Fatalf("contract_gen.go is stale; run: go generate ./internal/...\n"+
"(registry: %s)", filepath.Clean(registryPath))
}
}
diff --git a/go/internal/appproto/gencontract/cmd/main.go b/go/internal/appproto/gencontract/cmd/main.go
index 8bee8dcd..95c4c8fc 100644
--- a/go/internal/appproto/gencontract/cmd/main.go
+++ b/go/internal/appproto/gencontract/cmd/main.go
@@ -9,24 +9,33 @@ import (
)
func main() {
- if len(os.Args) != 3 {
- fmt.Fprintln(os.Stderr, "usage: gencontract ")
+ if len(os.Args) != 4 {
+ fmt.Fprintln(os.Stderr, "usage: gencontract ")
os.Exit(2)
}
- raw, err := os.ReadFile(os.Args[1])
+ raw, err := os.ReadFile(os.Args[2])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
- out, err := gencontract.Generate(raw)
+ var out []byte
+ switch os.Args[1] {
+ case "proto":
+ out, err = gencontract.Generate(raw)
+ case "roles":
+ out, err = gencontract.GenerateRoles(raw)
+ default:
+ fmt.Fprintf(os.Stderr, "unknown target %q\n", os.Args[1])
+ os.Exit(2)
+ }
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
- if err := os.WriteFile(os.Args[2], out, 0o644); err != nil {
+ if err := os.WriteFile(os.Args[3], out, 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
diff --git a/go/internal/appproto/gencontract/gencontract.go b/go/internal/appproto/gencontract/gencontract.go
index e7b0efb6..a2b32387 100644
--- a/go/internal/appproto/gencontract/gencontract.go
+++ b/go/internal/appproto/gencontract/gencontract.go
@@ -18,8 +18,8 @@ import (
)
// Registry is the subset of the YAML the box needs. Fields the app owns alone
-// (roles, carrier states) are deliberately absent: generating a constant the
-// box never uses only invites someone to use it.
+// (carrier states) are deliberately absent: generating a constant the box
+// never uses only invites someone to use it.
type Registry struct {
Version int `yaml:"version"`
FrozenFields map[int]struct {
@@ -32,6 +32,10 @@ type Registry struct {
Name string `yaml:"name"`
Desc string `yaml:"desc"`
} `yaml:"scopes"`
+ Roles map[string]struct {
+ Label string `yaml:"label"`
+ Scopes []string `yaml:"scopes"`
+ } `yaml:"roles"`
Modes []struct {
Key string `yaml:"key"`
Tier string `yaml:"tier"`
@@ -75,6 +79,26 @@ func Generate(raw []byte) ([]byte, error) {
return format.Source(b.Bytes())
}
+// GenerateRoles renders the role table into package apiauth.
+//
+// Roles live there and not beside the other constants because three packages
+// need the word "owner" — the enrolment record, the HTTP layer's local caller
+// and the app protocol's gate — and apiauth is the only one all three can
+// import without dragging the box in behind it.
+func GenerateRoles(raw []byte) ([]byte, error) {
+ var reg Registry
+ if err := yaml.Unmarshal(raw, ®); err != nil {
+ return nil, fmt.Errorf("parse registry: %w", err)
+ }
+
+ var b bytes.Buffer
+ b.WriteString("// Code generated by gencontract from contract/registry.yaml. DO NOT EDIT.\n\n")
+ b.WriteString("package apiauth\n\n")
+ writeRoles(&b, reg)
+
+ return format.Source(b.Bytes())
+}
+
func writeFields(b *bytes.Buffer, reg Registry) {
ids := make([]int, 0, len(reg.FrozenFields))
for id := range reg.FrozenFields {
@@ -138,6 +162,65 @@ func writeScopes(b *bytes.Buffer, reg Registry) {
fmt.Fprintf(b, "\tScope%s = %q\n", goName(strings.TrimPrefix(s.Name, "ftw.")), s.Name)
}
b.WriteString(")\n\n")
+
+ // The registry's own shape: one object axis and two verb axes. The suffix
+ // is the contract, so the write set is derived rather than listed — a
+ // hand-kept list is one a new scope gets left out of.
+ b.WriteString("// WriteScopes is every scope that changes something.\n")
+ b.WriteString("var WriteScopes = []string{\n")
+ for _, s := range reg.Scopes {
+ if !strings.HasSuffix(s.Name, ".write") {
+ continue
+ }
+ fmt.Fprintf(b, "\tScope%s,\n", goName(strings.TrimPrefix(s.Name, "ftw.")))
+ }
+ b.WriteString("}\n\n")
+}
+
+// writeRoles emits the role table with '*' expanded.
+//
+// Expanded here and not at the call site, because a star that has to be
+// understood by every reader is a star somebody eventually forgets. The
+// registry says roles are a projection over scopes; this is the projection,
+// written once.
+func writeRoles(b *bytes.Buffer, reg Registry) {
+ every := make([]string, 0, len(reg.Scopes))
+ for _, s := range reg.Scopes {
+ every = append(every, s.Name)
+ }
+
+ names := make([]string, 0, len(reg.Roles))
+ for name := range reg.Roles {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ b.WriteString("// Roles an enrolment can hold. Hand-writing one of these strings is\n")
+ b.WriteString("// what the registry exists to stop, and a role is the one name here\n")
+ b.WriteString("// that decides whether a phone may change anything.\n")
+ b.WriteString("const (\n")
+ for _, name := range names {
+ fmt.Fprintf(b, "\t// Role%s — %s.\n", goName(name), reg.Roles[name].Label)
+ fmt.Fprintf(b, "\tRole%s = %q\n", goName(name), name)
+ }
+ b.WriteString(")\n\n")
+
+ b.WriteString("// RoleScopes is what each role carries, with '*' already expanded.\n")
+ b.WriteString("// A role is a projection over scopes and never a replacement for them,\n")
+ b.WriteString("// so this is the only place the projection is written down.\n")
+ b.WriteString("var RoleScopes = map[string][]string{\n")
+ for _, name := range names {
+ scopes := reg.Roles[name].Scopes
+ if len(scopes) == 1 && scopes[0] == "*" {
+ scopes = every
+ }
+ fmt.Fprintf(b, "\tRole%s: {\n", goName(name))
+ for _, s := range scopes {
+ fmt.Fprintf(b, "\t\t%q,\n", s)
+ }
+ b.WriteString("\t},\n")
+ }
+ b.WriteString("}\n\n")
}
func writeErrors(b *bytes.Buffer, reg Registry) {
diff --git a/go/internal/appproto/generate.go b/go/internal/appproto/generate.go
index 758e455b..71380dfb 100644
--- a/go/internal/appproto/generate.go
+++ b/go/internal/appproto/generate.go
@@ -1,3 +1,3 @@
package appproto
-//go:generate go run ./gencontract/cmd ../../../contract/registry.yaml contract_gen.go
+//go:generate go run ./gencontract/cmd proto ../../../contract/registry.yaml contract_gen.go
diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go
index ec555cb0..0cb2020a 100644
--- a/go/internal/appproto/handler.go
+++ b/go/internal/appproto/handler.go
@@ -10,8 +10,10 @@ import (
"sort"
"strconv"
"sync"
+ "sync/atomic"
"github.com/google/uuid"
+ "github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/control"
)
@@ -35,6 +37,20 @@ type Config struct {
// service or no zone to fetch one for. Optional for the same reason.
Prices PriceReader
+ // API is the box's own HTTP API, served in process. Nil on a box that
+ // does not offer it; the capability then stays unsaid and the app hides
+ // every view that needs it, exactly like history.
+ API APIGateway
+
+ // Caller is whose session this is. Required: a session always belongs to
+ // one enrolled device, and a handler that does not know which one cannot
+ // refuse anything.
+ Caller apiauth.Caller
+
+ // Grants is that enrolment as it stands right now, re-read on every
+ // privileged request. Required, because revocation is not optional.
+ Grants GrantReader
+
// Caps is what this box advertises. Names must come from the generated
// contract constants; a typo is refused at construction rather than
// silently hiding a feature in the app.
@@ -67,6 +83,14 @@ type Handler struct {
// ops is this handler's own copy of the operation table.
ops map[string]opSpec
+ // ctx is the session's lifetime and cancel ends it. A passthrough
+ // request derives from this, so Close stops a call already in flight
+ // rather than only the next one.
+ ctx context.Context
+ cancel context.CancelFunc
+ // apiBusy is the passthrough queue, and its depth is one.
+ apiBusy atomic.Bool
+
mu sync.Mutex
proto int
subscribed bool
@@ -117,6 +141,15 @@ func New(cfg Config) (*Handler, error) {
return nil, errors.New("appproto: Codec is required")
case cfg.Sender == nil:
return nil, errors.New("appproto: Sender is required")
+ case cfg.Grants == nil:
+ return nil, errors.New("appproto: Grants is required")
+ }
+
+ // A role nobody recognises carries nothing, and a handler that would
+ // refuse everything is a bug worth failing at construction rather than
+ // one session at a time.
+ if _, known := apiauth.RoleScopes[cfg.Caller.Role]; !known {
+ return nil, fmt.Errorf("appproto: %q is not a role in contract/registry.yaml", cfg.Caller.Role)
}
if cfg.Caps == nil {
@@ -139,6 +172,7 @@ func New(cfg Config) (*Handler, error) {
cfg.Logger = slog.Default()
}
+ ctx, cancel := context.WithCancel(context.Background())
return &Handler{
cfg: cfg,
log: cfg.Logger.With("component", "appproto"),
@@ -146,12 +180,60 @@ func New(cfg Config) (*Handler, error) {
dict: fieldDict(cfg.SrcGrid, cfg.SrcPV, cfg.SrcBattery),
cmds: newCmdLog(),
ops: defaultOps(),
+ ctx: ctx,
+ cancel: cancel,
proto: ProtoMax,
bucket: 512,
lastSent: map[string]int64{},
}, nil
}
+// Close ends the handler's own work when the session goes.
+//
+// The one thing it must do is stop a passthrough request that is running now:
+// a device locked out mid-call must not finish the call it is making. Safe to
+// call more than once, and safe to call on a session that never used the
+// passthrough.
+func (h *Handler) Close() {
+ h.cancel()
+}
+
+// ScopesForRole expands a registry role into the scopes it carries.
+//
+// The expansion lives here, once, because a role is a projection over scopes
+// and a second projection somewhere else is how a codebase ends up with three
+// authorisation namespaces. A role the registry does not define expands to
+// nothing, so an unrecognised name can do nothing rather than everything.
+func ScopesForRole(role string) apiauth.ScopeSet {
+ return apiauth.NewScopeSet(apiauth.RoleScopes[role]...)
+}
+
+// liveCaller is whose session this is, as the box's records stand this second.
+//
+// Not the snapshot the handshake took. A socket outlives a revoke, and it
+// outlives a role change too: an owner demoted to a viewer while their phone
+// is in their hand must lose their writes at the next request, not at the next
+// reconnect. Both doors — cmd and the passthrough — ask this, and neither
+// trusts Config.Caller for anything that can change underneath it.
+//
+// The second result is false only when the enrolment is gone, which is a
+// revoke and ends the session. A role that merely changed is not a revoke and
+// must not be reported as one: the session carries on under the new role. The
+// buttons the app drew from hello_ok are then wrong until it reconnects, and
+// that is the right way round — the app drawing a button is presentation, and
+// the box refusing it is the enforcement.
+func (h *Handler) liveCaller() (apiauth.Caller, bool) {
+ role, epoch, ok := h.cfg.Grants.Grant()
+ if !ok {
+ return apiauth.Caller{}, false
+ }
+ caller := h.cfg.Caller
+ caller.Role = role
+ caller.Epoch = epoch
+ caller.Scopes = ScopesForRole(role)
+ return caller, true
+}
+
// Subscribed reports whether the client asked for the telemetry stream.
func (h *Handler) Subscribed() bool {
h.mu.Lock()
@@ -194,6 +276,8 @@ func (h *Handler) dispatch(ctx context.Context, env Envelope) error {
return h.onPriceGet(ctx, env)
case MsgHistQuery:
return h.onHistQuery(ctx, env)
+ case MsgAPIReq:
+ return h.onAPIReq(env)
default:
// Unknown types are ignored, never fatal. That rule in both
// directions is what lets a newer app talk to an older box and a
@@ -237,6 +321,8 @@ func (h *Handler) onHello(env Envelope) error {
mode = BoxModeBooting
case proto == ProtoFloor:
mode = BoxModeFloor
+ case !h.canWrite():
+ mode = BoxModeReadonly
}
caps := h.cfg.Caps
@@ -265,6 +351,8 @@ func (h *Handler) onHello(env Envelope) error {
Modes: h.modes,
Boot: boot,
Hint: hint,
+ Role: h.cfg.Caller.Role,
+ Scopes: h.cfg.Caller.Scopes.Names(),
}
h.mu.Lock()
@@ -277,6 +365,20 @@ func (h *Handler) onHello(env Envelope) error {
return h.sendBulk(MsgHelloOK, nil, body)
}
+// canWrite reports whether this grant carries any scope that changes
+// something. A grant with none is a session the app should draw without its
+// buttons — which is presentation, not the enforcement. The enforcement is at
+// every door: onCmd checks the operation's scope and the passthrough checks
+// the role.
+func (h *Handler) canWrite() bool {
+ for _, scope := range WriteScopes {
+ if h.cfg.Caller.Scopes.Has(scope) {
+ return true
+ }
+ }
+ return false
+}
+
// capsHash lets the app cache a capability set across sessions and notice when
// it changed without diffing the list. Sorted first, because the same set in a
// different order is the same set.
@@ -591,6 +693,47 @@ func (h *Handler) onCmd(ctx context.Context, env Envelope) error {
})
}
+ // Who is asking, as the box's records stand right now — the same question
+ // the passthrough asks, answered the same way. A phone revoked or demoted
+ // mid-session must not get one last command through on the strength of
+ // what its handshake said.
+ caller, enrolled := h.liveCaller()
+ if !enrolled {
+ if err := h.sendCmdResult(CmdResult{
+ CmdID: cmd.CmdID,
+ State: CmdRejected,
+ Error: &ErrorBody{
+ Code: ErrGrantRevoked,
+ Retryable: ErrorRetryable[ErrGrantRevoked],
+ },
+ }); err != nil {
+ return err
+ }
+ return h.Terminate(TerminateRevoked)
+ }
+
+ // The scope the operation declares, checked against the scope this
+ // session holds. defaultOps has declared a scope for every op since the
+ // day it was written and nothing ever read it, which meant a viewer's
+ // command was refused by nothing at all. This is the line that makes a
+ // role real on the command lane; the passthrough's role gate is the same
+ // property on the other one.
+ if !caller.Scopes.Has(spec.scope) {
+ return h.sendCmdResult(CmdResult{
+ CmdID: cmd.CmdID,
+ State: CmdRejected,
+ Error: &ErrorBody{
+ Code: ErrScopeDenied,
+ Retryable: ErrorRetryable[ErrScopeDenied],
+ Args: map[string]any{
+ "op": cmd.Op,
+ "needScope": spec.scope,
+ "role": caller.Role,
+ },
+ },
+ })
+ }
+
// Fresh state, read here and not before: this is the dispatch boundary,
// and a guard checked against the state the user was looking at is not a
// guard.
diff --git a/go/internal/appproto/handshake_test.go b/go/internal/appproto/handshake_test.go
index 50b325db..1356cce8 100644
--- a/go/internal/appproto/handshake_test.go
+++ b/go/internal/appproto/handshake_test.go
@@ -149,6 +149,7 @@ func TestNewRefusesACapabilityNotInTheRegistry(t *testing.T) {
_, err := New(Config{
Clock: clock, Site: box, Info: box, Modes: box, Plans: box,
Codec: testCodec{}, Sender: rec,
+ Caller: ownerCaller(), Grants: newGrants(),
Caps: []string{"status.core", "status.kore"},
})
if err == nil {
diff --git a/go/internal/appproto/harness_test.go b/go/internal/appproto/harness_test.go
index 2a5c63ff..acdaf58a 100644
--- a/go/internal/appproto/harness_test.go
+++ b/go/internal/appproto/harness_test.go
@@ -4,9 +4,11 @@ import (
"context"
"encoding/binary"
"fmt"
+ "sync"
"testing"
"time"
+ "github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/mpc"
)
@@ -68,12 +70,17 @@ type sent struct {
env Envelope
}
+// recorder is guarded because a handler sends from two goroutines: the tick,
+// and the passthrough answering a request.
type recorder struct {
+ mu sync.Mutex
frames []sent
fail error
}
func (r *recorder) Send(frame []byte) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
if r.fail != nil {
return r.fail
}
@@ -85,13 +92,24 @@ func (r *recorder) Send(frame []byte) error {
return nil
}
-func (r *recorder) reset() { r.frames = nil }
+func (r *recorder) reset() {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.frames = nil
+}
+
+// snapshot is how an asynchronous test reads what has been sent so far.
+func (r *recorder) snapshot() []sent {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return append([]sent(nil), r.frames...)
+}
// only returns the single frame of a type, failing if there is not exactly one.
func (r *recorder) only(t *testing.T, msgType string) sent {
t.Helper()
var found []sent
- for _, f := range r.frames {
+ for _, f := range r.snapshot() {
if f.env.T == msgType {
found = append(found, f)
}
@@ -104,9 +122,10 @@ func (r *recorder) only(t *testing.T, msgType string) sent {
func (r *recorder) last(t *testing.T, msgType string) sent {
t.Helper()
- for i := len(r.frames) - 1; i >= 0; i-- {
- if r.frames[i].env.T == msgType {
- return r.frames[i]
+ frames := r.snapshot()
+ for i := len(frames) - 1; i >= 0; i-- {
+ if frames[i].env.T == msgType {
+ return frames[i]
}
}
t.Fatalf("no %s frame; got %s", msgType, r.types())
@@ -114,7 +133,7 @@ func (r *recorder) last(t *testing.T, msgType string) sent {
}
func (r *recorder) has(msgType string) bool {
- for _, f := range r.frames {
+ for _, f := range r.snapshot() {
if f.env.T == msgType {
return true
}
@@ -124,7 +143,7 @@ func (r *recorder) has(msgType string) bool {
func (r *recorder) types() string {
var out []string
- for _, f := range r.frames {
+ for _, f := range r.snapshot() {
out = append(out, f.env.T)
}
return fmt.Sprint(out)
@@ -196,6 +215,65 @@ func (b *fakeBox) SetMode(_ context.Context, m control.Mode) error {
func (b *fakeBox) ObservedMode() (control.Mode, bool) { return b.observedMode, b.observedOK }
+// ownerCaller is the grant every test here used to carry implicitly: the
+// household's own phone, paired from the code on the box's lid.
+func ownerCaller() apiauth.Caller {
+ return apiauth.Caller{
+ Subject: apiauth.KindApp + ":aBcD1234",
+ Kind: apiauth.KindApp,
+ Role: apiauth.RoleOwner,
+ Scopes: ScopesForRole(apiauth.RoleOwner),
+ Epoch: 1,
+ }
+}
+
+func viewerCaller() apiauth.Caller {
+ return apiauth.Caller{
+ Subject: apiauth.KindApp + ":wXyZ9876",
+ Kind: apiauth.KindApp,
+ Role: apiauth.RoleViewer,
+ Scopes: ScopesForRole(apiauth.RoleViewer),
+ Epoch: 1,
+ }
+}
+
+// fakeGrants is the enrolment record as the box holds it right now. Tests
+// move it under a running session, which is the whole point of re-reading it
+// per request rather than trusting the handshake.
+type fakeGrants struct {
+ mu sync.Mutex
+ role string
+ epoch uint64
+ gone bool
+}
+
+func newGrants() *fakeGrants { return &fakeGrants{role: apiauth.RoleOwner, epoch: 1} }
+
+func newViewerGrants() *fakeGrants { return &fakeGrants{role: apiauth.RoleViewer, epoch: 1} }
+
+func (g *fakeGrants) Grant() (string, uint64, bool) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return g.role, g.epoch, !g.gone
+}
+
+// revoke is what appenroll.Revoke does to a row: it stops existing.
+func (g *fakeGrants) revoke() {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.gone = true
+}
+
+// setRole is what appenroll.SetRole does to a row: the role changes, the epoch
+// moves with it, and the row goes on existing. That last part is the whole
+// difference between a demotion and a revoke.
+func (g *fakeGrants) setRole(role string) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.role = role
+ g.epoch++
+}
+
// newRig builds a handler over a box with a meter, an inverter and a battery,
// all live.
func newRig(t *testing.T) (*Handler, *fakeBox, *recorder, *fakeClock) {
@@ -238,6 +316,8 @@ func newRig(t *testing.T) (*Handler, *fakeBox, *recorder, *fakeClock) {
Plans: box,
Codec: testCodec{},
Sender: rec,
+ Caller: ownerCaller(),
+ Grants: newGrants(),
SrcGrid: "meter.p1",
SrcPV: "inverter.sungrow",
SrcBattery: "battery.sungrow",
diff --git a/go/internal/appproto/history_test.go b/go/internal/appproto/history_test.go
index f41d0aea..94fbc598 100644
--- a/go/internal/appproto/history_test.go
+++ b/go/internal/appproto/history_test.go
@@ -123,6 +123,7 @@ func newHistoryRig(t *testing.T, ledger HistoryProvider) (*Handler, *recorder, *
Clock: clock, Site: box, Info: box, Modes: box, Plans: box,
History: ledger,
Codec: testCodec{}, Sender: rec,
+ Caller: ownerCaller(), Grants: newGrants(),
SrcGrid: "meter.p1", SrcPV: "meter.p1", SrcBattery: "meter.p1",
NewLeaseID: func() string { return "lease-test" },
})
diff --git a/go/internal/appproto/messages.go b/go/internal/appproto/messages.go
index dcddf16f..e6d0cde6 100644
--- a/go/internal/appproto/messages.go
+++ b/go/internal/appproto/messages.go
@@ -23,6 +23,10 @@ const (
MsgHistQuery = "hist.query"
MsgHistChunk = "hist.chunk"
MsgHistEnd = "hist.end"
+ MsgAPIReq = "api.req"
+ MsgAPIHead = "api.head"
+ MsgAPIChunk = "api.chunk"
+ MsgAPIEnd = "api.end"
)
// Operations a client may ask for. Each maps to a scope.
@@ -96,6 +100,15 @@ type HelloOK struct {
// take minutes; saying so beats a spinner that looks hung.
Boot *BootProgress `cbor:"boot,omitempty"`
Hint string `cbor:"hint,omitempty"`
+ // Role and Scopes are what this enrolment holds. The app uses them for
+ // one thing: deciding what to draw. The app hiding a button is
+ // presentation — if the app is wrong and shows it, the box refuses it.
+ //
+ // Both are sent, and the redundancy is deliberate. Role alone would make
+ // the app read a role table it has no reason to hold; the expanded list
+ // alone would leave the app unable to say "viewer" in a sentence.
+ Role string `cbor:"role"`
+ Scopes []string `cbor:"scopes"`
}
// HintAppUpdate tells the app it is behind. It is a hint, not an error: the
@@ -467,6 +480,89 @@ const (
GapBoxDown = "box_down"
)
+// --------------------------------------------------------------------------
+// The box's own HTTP API, over the session
+// --------------------------------------------------------------------------
+
+// APIReq asks the box to run one request against its own HTTP API.
+//
+// All four api.* messages ride the bulk lane and carry the request id. Never
+// lane 0: every field here varies in length with what was asked and answered,
+// and lane 0's constant size is a privacy control rather than a budget.
+type APIReq struct {
+ // Method is one of the six the passthrough accepts. Anything else is
+ // refused before a handler runs.
+ Method string `cbor:"method"`
+ // Path must start "/api/". The box's static handler is unreachable on
+ // purpose: serving HTML through the session would be a second origin
+ // under another name, which docs/architecture.md rejected.
+ Path string `cbor:"path"`
+ // Query is parsed, not a raw string. Two reasons: the tier gate keys on
+ // the path, and a tier decided over a string that can carry "?" is a
+ // parser bug that becomes a privilege bug — and this way the app never
+ // handles encoding, because the box rebuilds the URL itself.
+ Query map[string]string `cbor:"query,omitempty"`
+ // Body is opaque. The box sets Content-Type: application/json when one
+ // is present, and sets nothing else.
+ Body []byte `cbor:"body,omitempty"`
+ // MaxBytes is the app's own ceiling, clamped by the box's APIMaxBytes.
+ // The app learns the real figure from APIEnd.
+ MaxBytes int64 `cbor:"maxBytes,omitempty"`
+ // StepUp says the app ran a passkey ceremony for this request. Read the
+ // note above the check in passthrough.go before believing it means more
+ // than it does.
+ StepUp bool `cbor:"stepUp,omitempty"`
+
+ // There is deliberately no headers field. There is no path by which a
+ // client byte becomes a caller claim: identity rides on the request
+ // context inside the box process, and nothing about the caller is on the
+ // wire. Adding headers later would open exactly that hole.
+}
+
+// Methods the passthrough carries.
+const (
+ APIGet = "GET"
+ APIHead = "HEAD"
+ APIPost = "POST"
+ APIPut = "PUT"
+ APIPatch = "PATCH"
+ APIDelete = "DELETE"
+)
+
+// APIHeadMsg is the answer's status line.
+//
+// Its arrival is what tells the two error kinds apart, and it needs no body
+// inspection: if api.head arrived, the box's HTTP layer answered and Status
+// is the answer — a 403 or a 500 from a box handler is a status, never an
+// E_ code. If an error arrived on that id instead, the passthrough refused
+// and no handler ran.
+type APIHeadMsg struct {
+ Status int `cbor:"status"`
+ // Headers is a short allowlist, not the handler's full set. Everything
+ // the app needs to read an answer and nothing that describes the box's
+ // LAN.
+ Headers map[string]string `cbor:"headers"`
+ // Len is the declared length, absent when the handler did not declare
+ // one — which is the usual case in process. A guessed number here would
+ // be a progress bar that lies.
+ Len *int64 `cbor:"len"`
+}
+
+// APIChunk is one piece of the answer, in order.
+type APIChunk struct {
+ Seq uint32 `cbor:"seq"`
+ Data []byte `cbor:"data"`
+}
+
+// APIEnd closes an answer.
+type APIEnd struct {
+ Bytes int64 `cbor:"bytes"`
+ // Truncated means the answer ran past the ceiling and stops here. The
+ // app treats it as a failure rather than showing a partial answer as a
+ // whole one.
+ Truncated bool `cbor:"truncated"`
+}
+
// --------------------------------------------------------------------------
// Errors, events, teardown
// --------------------------------------------------------------------------
diff --git a/go/internal/appproto/passthrough.go b/go/internal/appproto/passthrough.go
new file mode 100644
index 00000000..f238646f
--- /dev/null
+++ b/go/internal/appproto/passthrough.go
@@ -0,0 +1,603 @@
+package appproto
+
+// The app's window onto the box's own HTTP API.
+//
+// Why this exists: the app could ask the box six things, and the box's own web
+// UI could ask it 124. Every new view in the app meant a box release. This
+// carries an ordinary HTTP request in process — no socket, no port, no TLS —
+// and hands the answer back as a byte stream.
+//
+// It is a security improvement, not a relaxation. That same API is already
+// served on the home LAN with no authentication at all; reaching it through a
+// Noise session pinned to an enrolled device, gated by role and tier, is
+// strictly stronger than what a household runs today.
+//
+// Two doors, and this is only one of them. Reads and configuration come
+// through here. Anything that moves energy stays on cmd, because a command
+// carries an expiry and preconditions and the box revalidates against fresh
+// state before acting — and an HTTP request carries none of that.
+//
+// What a route costs is decided by what its handler DOES, declared beside the
+// handler in api.routes and never inferred from the method. See apiauth.Tier
+// for the four tiers and the two routes whose verbs lied about them.
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "mime"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/apiauth"
+)
+
+const (
+ // APIChunkBytes is one chunk of an answer.
+ //
+ // Fixed, not sized by trial: the largest bulk bucket is 16384 with six
+ // bytes of frame header plus the CBOR envelope, so 12 KiB always lands
+ // inside it and the box never discovers an overrun on a live session.
+ APIChunkBytes = 12288
+
+ // APIMaxBytes is the most of an answer the box will send. A 4 MB answer
+ // is some 340 chunks, which is fine over a relay and is why the app's
+ // timeout for this matches its history timeout.
+ APIMaxBytes = 8 << 20
+
+ // apiHandlerTimeout bounds one in-process request.
+ apiHandlerTimeout = 15 * time.Second
+)
+
+// APIGateway is the box's HTTP API as this package needs it.
+//
+// An interface, so the message layer never imports the API package: this
+// package's job is what a message means, and it stays reviewable on its own.
+type APIGateway interface {
+ // ServeHTTP runs the request. The implementation is the same handler the
+ // LAN listener serves, trust boundary included — going round it to the
+ // bare mux would be a second, weaker door onto the same handlers.
+ http.Handler
+
+ // Route says what a route costs before it runs.
+ Route(*http.Request) apiauth.RouteFacts
+}
+
+// GrantReader is this session's enrolment, as it stands right now.
+//
+// Read on every privileged request rather than at the handshake, because a
+// socket outlives a revoke and it outlives a role change too. One string and
+// one integer behind a mutex that is already there.
+type GrantReader interface {
+ // Grant is what the enrolment currently carries: the role it holds and
+ // the epoch that answer was written under. ok is false when the device is
+ // no longer enrolled at all — a revoke is an absence, not a stale number.
+ Grant() (role string, epoch uint64, ok bool)
+}
+
+// --------------------------------------------------------------------------
+// Taking the request
+// --------------------------------------------------------------------------
+
+func (h *Handler) onAPIReq(env Envelope) error {
+ if env.ID == nil {
+ // A response nobody can route is a response nobody asked for.
+ return nil
+ }
+ if h.cfg.API == nil {
+ return h.sendError(env.ID, ErrorBody{
+ Code: ErrUnavailable,
+ Retryable: ErrorRetryable[ErrUnavailable],
+ Args: map[string]any{"subsystem": "api"},
+ })
+ }
+
+ var req APIReq
+ if err := Unmarshal(env.B, &req); err != nil {
+ h.log.Warn("undecodable api.req dropped", "err", err)
+ return nil
+ }
+
+ if field := badAPIRequest(req); field != "" {
+ return h.sendError(env.ID, ErrorBody{
+ Code: ErrUnknownOp,
+ Retryable: false,
+ Args: map[string]any{"t": MsgAPIReq, "field": field},
+ })
+ }
+
+ // One at a time. Not for want of memory: sessions.route hands frames to
+ // Handle on the reader goroutine, so a fifteen-second call that ran there
+ // would stall inbound frames for every phone on this connection —
+ // including their lane 0 stream.
+ if !h.apiBusy.CompareAndSwap(false, true) {
+ return h.sendError(env.ID, ErrorBody{
+ Code: ErrUnavailable,
+ Retryable: true,
+ Args: map[string]any{"reason": "busy"},
+ })
+ }
+
+ id := *env.ID
+ go func() {
+ defer h.apiBusy.Store(false)
+ if err := h.serveAPI(id, req); err != nil {
+ h.log.Warn("api passthrough failed", "err", err)
+ }
+ }()
+ return nil
+}
+
+// badAPIRequest names the field that makes a request unserveable, or "".
+//
+// The path rules are narrow on purpose. Only /api/: the box's static handler
+// stays unreachable, because serving the box's own HTML through the session
+// would be a second origin under another name. No "?" or "#": the query is a
+// parsed map, and a path that can smuggle a query is a tier decided over a
+// string the router will read differently.
+func badAPIRequest(req APIReq) string {
+ switch req.Method {
+ case APIGet, APIHead, APIPost, APIPut, APIPatch, APIDelete:
+ default:
+ return "method"
+ }
+ switch {
+ case !strings.HasPrefix(req.Path, "/api/"),
+ len(req.Path) > 1024,
+ strings.Contains(req.Path, ".."),
+ strings.Contains(req.Path, "//"),
+ strings.ContainsAny(req.Path, "?#"),
+ strings.IndexFunc(req.Path, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0:
+ return "path"
+ }
+ for key := range req.Query {
+ if key == "" || strings.ContainsAny(key, "\x00\n\r") {
+ return "query"
+ }
+ }
+ return ""
+}
+
+// --------------------------------------------------------------------------
+// Serving it
+// --------------------------------------------------------------------------
+
+func (h *Handler) serveAPI(id uint32, req APIReq) error {
+ ctx, cancel := context.WithTimeout(h.ctx, apiHandlerTimeout)
+ defer cancel()
+
+ // Revocation, checked here and on every request. The session dying and
+ // the next handshake failing are the other two layers; this is the one
+ // that closes the window where a socket outlives the revoke.
+ caller, enrolled := h.liveCaller()
+ if !enrolled {
+ if err := h.sendError(&id, ErrorBody{
+ Code: ErrGrantRevoked,
+ Retryable: false,
+ }); err != nil {
+ return err
+ }
+ return h.Terminate(TerminateRevoked)
+ }
+
+ request := buildAPIRequest(ctx, caller, req)
+
+ facts := h.cfg.API.Route(request)
+ if refusal := h.gateAPI(caller, facts, req); refusal != nil {
+ h.log.Info("api passthrough refused",
+ "subject", caller.Subject, "method", req.Method, "path", req.Path,
+ "tier", facts.Tier, "code", refusal.Code)
+ return h.sendError(&id, *refusal)
+ }
+
+ if facts.Tier != apiauth.TierRead {
+ // The box's own contribution to step-up is the part only the box can
+ // do: record it. Nothing here is a claim that a passkey was checked.
+ h.log.Info("api passthrough write",
+ "subject", caller.Subject, "role", caller.Role,
+ "method", req.Method, "path", req.Path, "stepUp", req.StepUp)
+ }
+
+ max := int64(APIMaxBytes)
+ if req.MaxBytes > 0 && req.MaxBytes < max {
+ max = req.MaxBytes
+ }
+ writer := &apiWriter{h: h, id: id, ctx: ctx, max: max, header: http.Header{}}
+ h.serveHandler(writer, request)
+ return writer.finish()
+}
+
+// serveHandler runs the box's handler and survives it panicking.
+//
+// net/http recovers a panicking handler and loses one connection. In process
+// there is no connection to lose: an unrecovered panic here would end the box,
+// and a phone that could end the box by opening the wrong screen would stop a
+// house's dispatch. The request is reported as failed and the session lives.
+func (h *Handler) serveHandler(w *apiWriter, r *http.Request) {
+ defer func() {
+ if p := recover(); p != nil {
+ h.log.Error("api handler panicked", "path", r.URL.Path, "err", p)
+ w.panicked = true
+ }
+ }()
+ h.cfg.API.ServeHTTP(w, r)
+}
+
+// gateAPI is the whole of what the passthrough refuses.
+//
+// Every request passes through the switch below and every tier has a branch,
+// including read. That shape is the fix for how a credential got out: the gate
+// used to be a chain of cases with no read branch at all, so a route the
+// router happened to call a read met no check to fail. A switch with a closed
+// default cannot be skipped by a tier nobody thought about.
+func (h *Handler) gateAPI(caller apiauth.Caller, facts apiauth.RouteFacts, req APIReq) *ErrorBody {
+ if facts.Static {
+ // A path under /api/ that no handler claims falls through to the box's
+ // own file server. The path rules above cannot see that — only the
+ // router can — and the same refusal is the honest one: the session
+ // does not carry this path.
+ return &ErrorBody{
+ Code: ErrUnknownOp,
+ Retryable: false,
+ Args: map[string]any{"t": MsgAPIReq, "field": "path"},
+ }
+ }
+
+ switch facts.Tier {
+ case apiauth.TierRead:
+ // Nothing to check, and that is a claim about the tier rather than an
+ // absence. A route is Read only if its answer changes nothing and
+ // carries nothing back that could be replayed as authority — the box
+ // decides that per route, beside the handler, in api.routes. Anything
+ // that hands out a credential is Local and never arrives here.
+ return nil
+
+ case apiauth.TierConfigure:
+ if facts.ReplacesAll {
+ // A body that replaces a whole document drops every field the
+ // sender did not know about. On the LAN the browser had just
+ // loaded that document from this box; a phone on a relay, possibly
+ // a year behind the box, has no such guarantee. The app's sentence
+ // for this is that the setting lives on the box's own page.
+ return &ErrorBody{
+ Code: ErrWholeDocument,
+ Retryable: false,
+ Args: map[string]any{"path": req.Path},
+ }
+ }
+ if caller.Role != apiauth.RoleOwner {
+ // The role governs the passthrough; scopes govern named
+ // operations. A path-to-scope map across 132 endpoints is
+ // precisely the list that rots, so it is not built.
+ //
+ // The role here is the one on file this second, not the one the
+ // handshake saw. An owner demoted while their phone is connected
+ // is refused at their very next write.
+ return &ErrorBody{
+ Code: ErrScopeDenied,
+ Retryable: false,
+ Args: map[string]any{"needRole": apiauth.RoleOwner, "role": caller.Role},
+ }
+ }
+ if !req.StepUp {
+ // What step-up buys: a phone left unlocked on a table cannot be
+ // picked up and used to reconfigure the site, because the app will
+ // not send stepUp without a ceremony.
+ //
+ // What it does not buy: anything against a modified client. The
+ // box CANNOT verify that a passkey ceremony happened — it has no
+ // relationship with the authenticator, and being a WebAuthn
+ // relying party would need an origin, which the box is
+ // deliberately never. A modified client on an enrolled device can
+ // already send a cmd today. Do not let this comment, or any other,
+ // grow into a claim that the box checked a signature.
+ return &ErrorBody{
+ Code: ErrNeedsStepUp,
+ Retryable: true,
+ Args: map[string]any{"tier": string(facts.Tier)},
+ }
+ }
+ return nil
+
+ case apiauth.TierActuate:
+ // The second door, and the sharper call than "actuation costs an
+ // extra step". A command carries an expiry and the box revalidates
+ // against fresh state before acting; an HTTP request has no expiry,
+ // and a request with no expiry must never move energy. So actuation
+ // has exactly one door and it is the one with the lease.
+ //
+ // No role check and no step-up check above it: the tier is refused for
+ // everybody, so a stronger caller would only reach the same answer by
+ // a longer route.
+ args := map[string]any{"path": req.Path}
+ if facts.CmdOp != "" {
+ args["op"] = facts.CmdOp
+ }
+ return &ErrorBody{Code: ErrUseCmd, Retryable: false, Args: args}
+
+ case apiauth.TierLocal:
+ // Its answer holds a credential or a whole file, or doing it needs
+ // somebody standing at the box. Not a permission the owner is missing,
+ // so neither the role nor the ceremony is consulted — asking again as
+ // somebody else gets the same answer. The app's sentence is that this
+ // one lives on the box's own page, from home.
+ return &ErrorBody{
+ Code: ErrLocalOnly,
+ Retryable: false,
+ Args: map[string]any{"path": req.Path},
+ }
+
+ default:
+ // A tier this gate has no branch for. It can only be a route nobody
+ // priced, so it is refused exactly as Local is and logged loudly
+ // enough to be fixed. Wrong-safe: the cost is a view the app cannot
+ // draw, not a control a stranger can reach.
+ h.log.Error("api route has no tier the gate knows",
+ "path", req.Path, "method", req.Method, "tier", facts.Tier)
+ return &ErrorBody{
+ Code: ErrLocalOnly,
+ Retryable: false,
+ Args: map[string]any{"path": req.Path},
+ }
+ }
+}
+
+// buildAPIRequest makes the in-process request.
+//
+// Host localhost, RemoteAddr loopback, no Origin, no Sec-Fetch-Site and no
+// forwarding header: the API's own trust boundary then sees a local client and
+// does not reach for the LAN bearer token. Correct, because this session's
+// Noise authentication is stronger than that token and it travels in the
+// request context, where nothing on the wire can reach it.
+func buildAPIRequest(ctx context.Context, caller apiauth.Caller, req APIReq) *http.Request {
+ values := url.Values{}
+ for k, v := range req.Query {
+ values.Set(k, v)
+ }
+ target := &url.URL{Path: req.Path, RawQuery: values.Encode()}
+
+ var body io.ReadCloser = http.NoBody
+ var length int64
+ if len(req.Body) > 0 {
+ body = io.NopCloser(bytes.NewReader(req.Body))
+ length = int64(len(req.Body))
+ }
+
+ request := &http.Request{
+ Method: req.Method,
+ URL: target,
+ Proto: "HTTP/1.1",
+ ProtoMajor: 1,
+ ProtoMinor: 1,
+ Header: http.Header{},
+ Body: body,
+ ContentLength: length,
+ Host: "localhost",
+ RemoteAddr: "127.0.0.1:0",
+ RequestURI: target.RequestURI(),
+ }
+ if length > 0 {
+ request.Header.Set("Content-Type", "application/json")
+ }
+
+ caller.StepUp = req.StepUp
+ return request.WithContext(apiauth.WithCaller(ctx, caller))
+}
+
+// --------------------------------------------------------------------------
+// Writing the answer back
+// --------------------------------------------------------------------------
+
+// apiWriter turns an http.ResponseWriter into api.head, api.chunk and api.end.
+type apiWriter struct {
+ h *Handler
+ id uint32
+ ctx context.Context
+ max int64
+
+ header http.Header
+ buf []byte
+ seq uint32
+ written int64
+ wroteHead bool
+ refused bool
+ truncated bool
+ panicked bool
+ sendErr error
+}
+
+func (w *apiWriter) Header() http.Header { return w.header }
+
+func (w *apiWriter) WriteHeader(status int) {
+ if w.wroteHead || w.refused {
+ return
+ }
+ if w.ctx.Err() != nil {
+ // Out of time, or the session is gone. Either way the status must not
+ // go out: once one has, the app is committed to it, and finish is
+ // what says so instead.
+ return
+ }
+
+ // Refused by class, never by a path list. /api/support/dump streams a
+ // multi-megabyte ZIP, and a PWA inside a Noise session has nothing useful
+ // to do with one; carrying it would be promising more than this delivers.
+ // The app's sentence: that one is only available on your box's own page,
+ // from home.
+ contentType := w.header.Get("Content-Type")
+ if contentType != "" && !carriedMedia(contentType) {
+ w.refused = true
+ w.sendErr = w.h.sendError(&w.id, ErrorBody{
+ Code: ErrUnsupportedMedia,
+ Retryable: false,
+ Args: map[string]any{"contentType": contentType},
+ })
+ return
+ }
+
+ w.wroteHead = true
+ w.sendErr = w.h.sendBulk(MsgAPIHead, &w.id, APIHeadMsg{
+ Status: status,
+ Headers: carriedHeaders(w.header),
+ Len: declaredLength(w.header),
+ })
+}
+
+func (w *apiWriter) Write(p []byte) (int, error) {
+ if !w.wroteHead && !w.refused {
+ w.WriteHeader(http.StatusOK)
+ }
+ if w.refused {
+ return 0, errAPIStopped
+ }
+ if w.sendErr != nil {
+ return 0, w.sendErr
+ }
+ if w.ctx.Err() != nil {
+ // Either the fifteen seconds are up or the session is gone. Either
+ // way this answer is not going anywhere; say so to the handler rather
+ // than let it keep building one.
+ return 0, errAPIStopped
+ }
+
+ room := w.max - w.written
+ if room <= 0 {
+ w.truncated = true
+ return 0, errAPIStopped
+ }
+ take := p
+ if int64(len(take)) > room {
+ take = take[:room]
+ w.truncated = true
+ }
+
+ w.buf = append(w.buf, take...)
+ w.written += int64(len(take))
+ for len(w.buf) >= APIChunkBytes {
+ if err := w.flush(APIChunkBytes); err != nil {
+ w.sendErr = err
+ return 0, err
+ }
+ }
+ if w.truncated {
+ return len(take), errAPIStopped
+ }
+ return len(p), nil
+}
+
+func (w *apiWriter) flush(n int) error {
+ chunk := APIChunk{Seq: w.seq, Data: w.buf[:n]}
+ w.seq++
+ if err := w.h.sendBulk(MsgAPIChunk, &w.id, chunk); err != nil {
+ return err
+ }
+ w.buf = append(w.buf[:0], w.buf[n:]...)
+ return nil
+}
+
+// finish closes the answer.
+func (w *apiWriter) finish() error {
+ if w.h.ctx.Err() != nil {
+ // The session was torn down while the handler ran — revoked, or the
+ // socket died. Nothing goes out: the transport is closed, so anything
+ // written would go nowhere anyway. This is what makes a revoke stop a
+ // call already in flight rather than merely the next one.
+ return nil
+ }
+ if w.refused {
+ return w.sendErr
+ }
+ if w.sendErr != nil {
+ return w.sendErr
+ }
+
+ if !w.wroteHead {
+ if w.ctx.Err() != nil || w.panicked {
+ // Nothing was reported yet, so the honest answer is that the box
+ // could not serve it. After a status has gone out the app is
+ // committed to that status, which is why the same timeout ends
+ // differently below.
+ reason := "timeout"
+ if w.panicked {
+ reason = "handler"
+ }
+ return w.h.sendError(&w.id, ErrorBody{
+ Code: ErrUnavailable,
+ Retryable: ErrorRetryable[ErrUnavailable],
+ Args: map[string]any{"subsystem": "api", "reason": reason},
+ })
+ }
+ // A handler that returned without writing anything. net/http would
+ // have called this 200 with an empty body, and so does this.
+ w.WriteHeader(http.StatusOK)
+ if w.sendErr != nil {
+ return w.sendErr
+ }
+ }
+
+ if len(w.buf) > 0 {
+ if err := w.flush(len(w.buf)); err != nil {
+ return err
+ }
+ }
+ // A panic after the status went out leaves an answer that stops in the
+ // middle. The app is committed to the status by then, so the only honest
+ // thing left to say is that this is not the whole of it.
+ truncated := w.truncated || w.ctx.Err() != nil || w.panicked
+ return w.h.sendBulk(MsgAPIEnd, &w.id, APIEnd{Bytes: w.written, Truncated: truncated})
+}
+
+// errAPIStopped tells a handler its answer is no longer wanted. A handler that
+// ignores write errors simply finishes into a discarded buffer, which costs
+// the box some work and the session nothing.
+var errAPIStopped = errors.New("appproto: the passthrough stopped reading")
+
+// carriedMedia is the class test. application/json, anything +json, and
+// text/*. Everything else meets a refusal before a byte streams.
+func carriedMedia(contentType string) bool {
+ mediaType, _, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return false
+ }
+ mediaType = strings.ToLower(mediaType)
+ switch {
+ case mediaType == "application/json",
+ strings.HasPrefix(mediaType, "application/") && strings.HasSuffix(mediaType, "+json"),
+ strings.HasPrefix(mediaType, "text/"):
+ return true
+ }
+ return false
+}
+
+// apiCarriedHeaders is everything the app is told about an answer. Short on
+// purpose: a header the app has no use for is a fact about the box's LAN with
+// nowhere useful to go.
+var apiCarriedHeaders = []string{"Content-Type", "ETag", "Last-Modified"}
+
+func carriedHeaders(header http.Header) map[string]string {
+ out := map[string]string{}
+ for _, name := range apiCarriedHeaders {
+ if v := header.Get(name); v != "" {
+ out[name] = v
+ }
+ }
+ return out
+}
+
+// declaredLength is the handler's own Content-Length, when it set one. Most do
+// not in process, and a guessed figure would be a progress bar that lies.
+func declaredLength(header http.Header) *int64 {
+ raw := header.Get("Content-Length")
+ if raw == "" {
+ return nil
+ }
+ n, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil || n < 0 {
+ return nil
+ }
+ return &n
+}
diff --git a/go/internal/appproto/passthrough_test.go b/go/internal/appproto/passthrough_test.go
new file mode 100644
index 00000000..b7816a02
--- /dev/null
+++ b/go/internal/appproto/passthrough_test.go
@@ -0,0 +1,625 @@
+package appproto
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/apiauth"
+)
+
+// stubAPI stands in for the box's HTTP layer where the test is about the
+// carriage rather than about who may ask. The refusals that matter — a viewer
+// writing, an actuating route, a whole-document write — are tested against the
+// real handler in internal/api, because a fake that agrees with the gate
+// proves nothing about the box.
+type stubAPI struct {
+ facts apiauth.RouteFacts
+ serve func(http.ResponseWriter, *http.Request)
+
+ mu sync.Mutex
+ seen *http.Request
+}
+
+func (s *stubAPI) Route(*http.Request) apiauth.RouteFacts { return s.facts }
+
+func (s *stubAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ s.mu.Lock()
+ s.seen = r
+ s.mu.Unlock()
+ s.serve(w, r)
+}
+
+func (s *stubAPI) request() *http.Request {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.seen
+}
+
+func text(body string) func(http.ResponseWriter, *http.Request) {
+ return func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, body)
+ }
+}
+
+// newAPIRig is a subscribed owner session over a stub gateway.
+func newAPIRig(t *testing.T, api *stubAPI) (*Handler, *fakeBox, *recorder, *fakeGrants) {
+ t.Helper()
+ h, box, rec, _ := newRig(t)
+ grants := newGrants()
+ h.cfg.API = api
+ h.cfg.Grants = grants
+ t.Cleanup(h.Close)
+ subscribe(t, h, rec)
+ return h, box, rec, grants
+}
+
+// waitFor blocks until a frame of this type has been sent, because the
+// passthrough answers on its own goroutine and Handle returns before it.
+func waitFor(t *testing.T, rec *recorder, msgType string) sent {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ for _, f := range rec.snapshot() {
+ if f.env.T == msgType {
+ return f
+ }
+ }
+ time.Sleep(time.Millisecond)
+ }
+ t.Fatalf("no %s frame; got %s", msgType, rec.types())
+ return sent{}
+}
+
+func apiChunks(rec *recorder) []byte {
+ var out []byte
+ for _, f := range rec.snapshot() {
+ if f.env.T != MsgAPIChunk {
+ continue
+ }
+ var c APIChunk
+ if err := Unmarshal(f.env.B, &c); err == nil {
+ out = append(out, c.Data...)
+ }
+ }
+ return out
+}
+
+// --------------------------------------------------------------------------
+// What may be asked
+// --------------------------------------------------------------------------
+
+// The path rules are the whole of what stops the session becoming a second
+// origin onto the box's own web UI.
+func TestAPathTheSessionDoesNotCarryIsRefused(t *testing.T) {
+ cases := map[string]APIReq{
+ "the static handler": {Method: APIGet, Path: "/index.html"},
+ "the root": {Method: APIGet, Path: "/"},
+ "a traversal": {Method: APIGet, Path: "/api/../secrets"},
+ "a doubled separator": {Method: APIGet, Path: "/api//status"},
+ "a smuggled query": {Method: APIGet, Path: "/api/status?force=1"},
+ "a fragment": {Method: APIGet, Path: "/api/status#x"},
+ "a control character": {Method: APIGet, Path: "/api/sta\ntus"},
+ }
+ for name, req := range cases {
+ t.Run(name, func(t *testing.T) {
+ h, _, rec, _ := newAPIRig(t, &stubAPI{serve: text(`{}`)})
+ deliver(t, h, MsgAPIReq, ptrU32(4), req)
+
+ body := body[ErrorBody](t, rec.only(t, MsgError))
+ if body.Code != ErrUnknownOp || body.Args["field"] != "path" {
+ t.Fatalf("refusal was %+v, want E_UNKNOWN_OP on the path", body)
+ }
+ if rec.has(MsgAPIHead) {
+ t.Fatal("a handler ran for a path the passthrough does not carry")
+ }
+ })
+ }
+}
+
+func TestAMethodTheSessionDoesNotCarryIsRefused(t *testing.T) {
+ h, _, rec, _ := newAPIRig(t, &stubAPI{serve: text(`{}`)})
+ deliver(t, h, MsgAPIReq, ptrU32(4), APIReq{Method: "TRACE", Path: "/api/status"})
+
+ body := body[ErrorBody](t, rec.only(t, MsgError))
+ if body.Code != ErrUnknownOp || body.Args["field"] != "method" {
+ t.Fatalf("refusal was %+v, want E_UNKNOWN_OP on the method", body)
+ }
+}
+
+// A tier the gate has no branch for is refused, not served.
+//
+// The gate is one switch and every tier passes through it, so the only way
+// past it is a value nobody wrote a branch for — a fifth tier added upstairs,
+// or a route registered without one. Either way the handler must not run. The
+// gate this replaced was a chain of cases with no read branch at all, and a
+// route the router happened to call a read met nothing to fail: that is how a
+// CalDAV password reached a viewer's phone.
+func TestATierTheGateDoesNotKnowIsRefused(t *testing.T) {
+ for _, tier := range []apiauth.Tier{"", "somebody's new idea"} {
+ t.Run(string(tier), func(t *testing.T) {
+ api := &stubAPI{
+ facts: apiauth.RouteFacts{Tier: tier},
+ serve: text(`{"password":"hunter2"}`),
+ }
+ h, _, rec, _ := newAPIRig(t, api)
+ deliver(t, h, MsgAPIReq, ptrU32(4), APIReq{
+ Method: APIGet, Path: "/api/whatever", StepUp: true,
+ })
+
+ refusal := body[ErrorBody](t, waitFor(t, rec, MsgError))
+ if refusal.Code != ErrLocalOnly {
+ t.Fatalf("refusal was %+v, want E_LOCAL_ONLY", refusal)
+ }
+ if rec.has(MsgAPIHead) || rec.has(MsgAPIChunk) {
+ t.Fatal("a route with no tier the gate knows was served anyway")
+ }
+ if api.request() != nil {
+ t.Fatal("a route with no tier the gate knows reached the handler")
+ }
+ })
+ }
+}
+
+// A route the box does price as local is refused for that reason, with no role
+// and no ceremony consulted. It is not a permission an owner is missing.
+func TestALocalRouteIsRefusedForEveryCaller(t *testing.T) {
+ api := &stubAPI{
+ facts: apiauth.RouteFacts{Tier: apiauth.TierLocal},
+ serve: text(`{"password":"hunter2"}`),
+ }
+ h, _, rec, _ := newAPIRig(t, api)
+ deliver(t, h, MsgAPIReq, ptrU32(4), APIReq{
+ Method: APIGet, Path: "/api/caldav/credentials", StepUp: true,
+ })
+
+ refusal := body[ErrorBody](t, waitFor(t, rec, MsgError))
+ if refusal.Code != ErrLocalOnly {
+ t.Fatalf("refusal was %+v, want E_LOCAL_ONLY", refusal)
+ }
+ if refusal.Args["path"] != "/api/caldav/credentials" {
+ t.Fatalf("refusal args = %v, want the path it is about", refusal.Args)
+ }
+ if api.request() != nil {
+ t.Fatal("a local route reached its handler through the session")
+ }
+ if strings.Contains(string(apiChunks(rec)), "hunter2") {
+ t.Fatal("a local route's answer crossed the session")
+ }
+}
+
+// The request the box builds is the whole of what the handler sees. Nothing
+// the app sent describes the caller, and nothing about the caller is on the
+// wire — the identity is put on the context here and nowhere else.
+func TestTheRequestCarriesTheCallerAndNothingElseClaimsTo(t *testing.T) {
+ api := &stubAPI{facts: apiauth.RouteFacts{Tier: apiauth.TierRead}, serve: text(`{}`)}
+ h, _, rec, _ := newAPIRig(t, api)
+
+ deliver(t, h, MsgAPIReq, ptrU32(7), APIReq{
+ Method: APIGet,
+ Path: "/api/energy/history",
+ Query: map[string]string{"to": "2", "from": "1"},
+ })
+ waitFor(t, rec, MsgAPIEnd)
+
+ got := api.request()
+ if got == nil {
+ t.Fatal("the handler never ran")
+ }
+ caller, ok := apiauth.FromRequest(got)
+ if !ok {
+ t.Fatal("the request reached the handler with no caller on it")
+ }
+ if caller.Kind != apiauth.KindApp || caller.Role != apiauth.RoleOwner {
+ t.Fatalf("caller = %+v, want the session's enrolled owner", caller)
+ }
+ // Sorted, because url.Values.Encode sorts and both ends must agree on
+ // the bytes the router sees.
+ if got.URL.RawQuery != "from=1&to=2" {
+ t.Fatalf("query = %q, want from=1&to=2", got.URL.RawQuery)
+ }
+ if got.Host != "localhost" || got.RemoteAddr != "127.0.0.1:0" {
+ t.Fatalf("host %q from %q, want a local request", got.Host, got.RemoteAddr)
+ }
+ if got.Header.Get("Origin") != "" || got.Header.Get("Sec-Fetch-Site") != "" {
+ t.Fatal("the synthetic request carries browser fetch metadata it never had")
+ }
+ if got.Header.Get("Authorization") != "" {
+ t.Fatal("the synthetic request carries a bearer token")
+ }
+}
+
+func TestABodyIsSentAsJSONAndNothingElse(t *testing.T) {
+ api := &stubAPI{facts: apiauth.RouteFacts{Tier: apiauth.TierConfigure}, serve: text(`{}`)}
+ h, _, rec, _ := newAPIRig(t, api)
+
+ deliver(t, h, MsgAPIReq, ptrU32(8), APIReq{
+ Method: APIPost,
+ Path: "/api/battery_covers_ev",
+ Body: []byte(`{"enabled":true}`),
+ StepUp: true,
+ })
+ waitFor(t, rec, MsgAPIEnd)
+
+ got := api.request()
+ if ct := got.Header.Get("Content-Type"); ct != "application/json" {
+ t.Fatalf("Content-Type = %q, want application/json", ct)
+ }
+ read, _ := io.ReadAll(got.Body)
+ if string(read) != `{"enabled":true}` {
+ t.Fatalf("body reached the handler as %q", read)
+ }
+}
+
+// --------------------------------------------------------------------------
+// Carrying the answer
+// --------------------------------------------------------------------------
+
+func TestAnAnswerArrivesAsWholeChunksInOrder(t *testing.T) {
+ want := strings.Repeat("x", APIChunkBytes*2+17)
+ h, _, rec, _ := newAPIRig(t, &stubAPI{
+ facts: apiauth.RouteFacts{Tier: apiauth.TierRead},
+ serve: text(want),
+ })
+
+ deliver(t, h, MsgAPIReq, ptrU32(9), APIReq{Method: APIGet, Path: "/api/logs"})
+ end := body[APIEnd](t, waitFor(t, rec, MsgAPIEnd))
+
+ head := body[APIHeadMsg](t, rec.only(t, MsgAPIHead))
+ if head.Status != http.StatusOK {
+ t.Fatalf("status = %d, want 200", head.Status)
+ }
+ if got := string(apiChunks(rec)); got != want {
+ t.Fatalf("reassembled %d bytes, want %d", len(got), len(want))
+ }
+ if end.Truncated || end.Bytes != int64(len(want)) {
+ t.Fatalf("end = %+v, want the whole answer", end)
+ }
+
+ var seqs []uint32
+ var sizes []int
+ for _, f := range rec.snapshot() {
+ if f.env.T != MsgAPIChunk {
+ continue
+ }
+ c := body[APIChunk](t, f)
+ seqs = append(seqs, c.Seq)
+ sizes = append(sizes, len(c.Data))
+ if f.lane != LaneBulk {
+ t.Fatal("a chunk went out on lane 0, whose size must never vary")
+ }
+ }
+ if len(seqs) != 3 || seqs[0] != 0 || seqs[1] != 1 || seqs[2] != 2 {
+ t.Fatalf("chunk sequence %v, want 0,1,2", seqs)
+ }
+ if sizes[0] != APIChunkBytes || sizes[1] != APIChunkBytes || sizes[2] != 17 {
+ t.Fatalf("chunk sizes %v, want two full chunks and a remainder", sizes)
+ }
+}
+
+func TestAnAnswerPastTheCeilingStopsAndSaysSo(t *testing.T) {
+ h, _, rec, _ := newAPIRig(t, &stubAPI{
+ facts: apiauth.RouteFacts{Tier: apiauth.TierRead},
+ serve: text(strings.Repeat("y", 40_000)),
+ })
+
+ deliver(t, h, MsgAPIReq, ptrU32(10), APIReq{
+ Method: APIGet, Path: "/api/energy/history.csv", MaxBytes: 20_000,
+ })
+ end := body[APIEnd](t, waitFor(t, rec, MsgAPIEnd))
+
+ if !end.Truncated {
+ t.Fatal("an answer that ran past the ceiling was reported whole")
+ }
+ if end.Bytes != 20_000 {
+ t.Fatalf("sent %d bytes, want the ceiling of 20000", end.Bytes)
+ }
+ if got := len(apiChunks(rec)); got != 20_000 {
+ t.Fatalf("chunks carried %d bytes, want 20000", got)
+ }
+}
+
+// Refused by class, before a byte streams. A PWA inside a Noise session has
+// nothing useful to do with a ZIP.
+func TestAnAnswerTheSessionCannotCarryIsRefusedAtTheHead(t *testing.T) {
+ h, _, rec, _ := newAPIRig(t, &stubAPI{
+ facts: apiauth.RouteFacts{Tier: apiauth.TierRead},
+ serve: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/zip")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(make([]byte, 4096))
+ },
+ })
+
+ deliver(t, h, MsgAPIReq, ptrU32(11), APIReq{Method: APIGet, Path: "/api/support/dump"})
+ err := body[ErrorBody](t, waitFor(t, rec, MsgError))
+
+ if err.Code != ErrUnsupportedMedia || err.Args["contentType"] != "application/zip" {
+ t.Fatalf("refusal was %+v, want E_UNSUPPORTED_MEDIA naming the type", err)
+ }
+ if rec.has(MsgAPIHead) || rec.has(MsgAPIChunk) {
+ t.Fatal("a refused answer still put bytes on the wire")
+ }
+}
+
+// --------------------------------------------------------------------------
+// One at a time, and never on the reader goroutine
+// --------------------------------------------------------------------------
+
+// blockingAPI holds a request until the test lets it go, or until its context
+// ends. It then writes its answer regardless, which is what a handler that
+// pays no attention to cancellation does — and the case the writer has to
+// swallow rather than put on the wire.
+func blockingAPI(started chan<- struct{}, release <-chan struct{}) (*stubAPI, <-chan struct{}) {
+ returned := make(chan struct{})
+ return &stubAPI{
+ facts: apiauth.RouteFacts{Tier: apiauth.TierRead},
+ serve: func(w http.ResponseWriter, r *http.Request) {
+ defer close(returned)
+ close(started)
+ select {
+ case <-release:
+ case <-r.Context().Done():
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, `{"late":true}`)
+ },
+ }, returned
+}
+
+// A call that ran on the reader goroutine would stall inbound frames for
+// every phone on the connection, including their lane 0 stream.
+func TestARequestDoesNotStallTheReader(t *testing.T) {
+ started := make(chan struct{})
+ release := make(chan struct{})
+ defer close(release)
+ api, _ := blockingAPI(started, release)
+ h, _, rec, _ := newAPIRig(t, api)
+
+ done := make(chan struct{})
+ go func() {
+ deliver(t, h, MsgAPIReq, ptrU32(12), APIReq{Method: APIGet, Path: "/api/status"})
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("Handle blocked until the request finished")
+ }
+
+ <-started
+ // The stream keeps running underneath it.
+ if err := h.Tick(); err != nil {
+ t.Fatalf("tick during a passthrough call: %v", err)
+ }
+ if !rec.has(MsgTick) && !rec.has(MsgDelta) {
+ t.Fatalf("no lane 0 frame while a call was in flight; got %s", rec.types())
+ }
+}
+
+func TestASecondRequestWhileOneIsInFlightIsRefused(t *testing.T) {
+ started := make(chan struct{})
+ release := make(chan struct{})
+ defer close(release)
+ api, _ := blockingAPI(started, release)
+ h, _, rec, _ := newAPIRig(t, api)
+
+ deliver(t, h, MsgAPIReq, ptrU32(13), APIReq{Method: APIGet, Path: "/api/status"})
+ <-started
+ deliver(t, h, MsgAPIReq, ptrU32(14), APIReq{Method: APIGet, Path: "/api/status"})
+
+ err := body[ErrorBody](t, waitFor(t, rec, MsgError))
+ if err.Code != ErrUnavailable || err.Args["reason"] != "busy" {
+ t.Fatalf("second request met %+v, want E_UNAVAILABLE busy", err)
+ }
+ if !err.Retryable {
+ t.Fatal("busy was reported as permanent")
+ }
+}
+
+// --------------------------------------------------------------------------
+// Revocation
+// --------------------------------------------------------------------------
+
+func TestARevokedGrantIsRefusedAndTheSessionEnds(t *testing.T) {
+ h, _, rec, grants := newAPIRig(t, &stubAPI{
+ facts: apiauth.RouteFacts{Tier: apiauth.TierRead},
+ serve: text(`{"secret":true}`),
+ })
+
+ grants.revoke()
+ deliver(t, h, MsgAPIReq, ptrU32(15), APIReq{Method: APIGet, Path: "/api/status"})
+
+ err := body[ErrorBody](t, waitFor(t, rec, MsgError))
+ if err.Code != ErrGrantRevoked {
+ t.Fatalf("refusal was %+v, want E_GRANT_REVOKED", err)
+ }
+ term := body[SessionTerminate](t, waitFor(t, rec, MsgSessionTerminate))
+ if term.Reason != TerminateRevoked {
+ t.Fatalf("termination reason %q, want %q", term.Reason, TerminateRevoked)
+ }
+ if rec.has(MsgAPIHead) {
+ t.Fatal("a revoked device reached a handler")
+ }
+}
+
+// A demotion bumps the epoch without deleting the row, and the session the
+// phone is holding must stop trusting the role it was admitted with — at the
+// very next request, not at its next reconnect.
+//
+// It must also stop at exactly that. A demotion is not a revoke: the phone is
+// still enrolled and a viewer may still read. Ending the session here would
+// tell its holder their access was withdrawn, which is not what happened.
+func TestADemotedOwnerLosesWritesAndKeepsReads(t *testing.T) {
+ h, _, rec, grants := newAPIRig(t, &stubAPI{
+ facts: apiauth.RouteFacts{Tier: apiauth.TierConfigure},
+ serve: text(`{}`),
+ })
+
+ grants.setRole(apiauth.RoleViewer)
+
+ // The write, refused for want of the role the grant no longer carries.
+ deliver(t, h, MsgAPIReq, ptrU32(16), APIReq{
+ Method: APIPost, Path: "/api/battery_covers_ev", StepUp: true,
+ })
+ err := body[ErrorBody](t, waitFor(t, rec, MsgError))
+ if err.Code != ErrScopeDenied {
+ t.Fatalf("refusal was %+v, want E_SCOPE_DENIED", err)
+ }
+ if err.Args["role"] != apiauth.RoleViewer {
+ t.Fatalf("refusal args %v, want the role on file now", err.Args)
+ }
+ if rec.has(MsgAPIHead) {
+ t.Fatal("a demoted owner's write reached a handler")
+ }
+ if rec.has(MsgSessionTerminate) {
+ t.Fatal("a demotion ended the session, which tells its holder they were revoked")
+ }
+
+ // The read, which a viewer is entitled to and which must still work.
+ h.cfg.API = &stubAPI{
+ facts: apiauth.RouteFacts{Tier: apiauth.TierRead},
+ serve: text(`{"ok":true}`),
+ }
+ deliver(t, h, MsgAPIReq, ptrU32(17), APIReq{Method: APIGet, Path: "/api/status"})
+ head := body[APIHeadMsg](t, waitFor(t, rec, MsgAPIHead))
+ if head.Status != http.StatusOK {
+ t.Fatalf("a demoted owner's read answered %d, want 200", head.Status)
+ }
+}
+
+// The other direction, and the one a comment is most likely to be wrong about:
+// a guest promoted to owner may write without reconnecting first.
+func TestAPromotedViewerCanWriteAtOnce(t *testing.T) {
+ h, _, rec, grants := newAPIRig(t, &stubAPI{
+ facts: apiauth.RouteFacts{Tier: apiauth.TierConfigure},
+ serve: text(`{}`),
+ })
+ h.cfg.Caller = viewerCaller()
+ grants.setRole(apiauth.RoleViewer)
+
+ deliver(t, h, MsgAPIReq, ptrU32(18), APIReq{
+ Method: APIPost, Path: "/api/battery_covers_ev", StepUp: true,
+ })
+ if err := body[ErrorBody](t, waitFor(t, rec, MsgError)); err.Code != ErrScopeDenied {
+ t.Fatalf("a viewer's write was refused with %+v, want E_SCOPE_DENIED", err)
+ }
+
+ grants.setRole(apiauth.RoleOwner)
+ deliver(t, h, MsgAPIReq, ptrU32(19), APIReq{
+ Method: APIPost, Path: "/api/battery_covers_ev", StepUp: true,
+ })
+ head := body[APIHeadMsg](t, waitFor(t, rec, MsgAPIHead))
+ if head.Status != http.StatusOK {
+ t.Fatalf("a promoted viewer's write answered %d, want 200", head.Status)
+ }
+}
+
+// The one revocation case a per-request check cannot catch on its own: the
+// call was already running when the owner pressed revoke.
+func TestARevokeStopsACallAlreadyInFlight(t *testing.T) {
+ started := make(chan struct{})
+ release := make(chan struct{})
+ defer close(release)
+ api, returned := blockingAPI(started, release)
+ h, _, rec, _ := newAPIRig(t, api)
+
+ deliver(t, h, MsgAPIReq, ptrU32(17), APIReq{Method: APIGet, Path: "/api/logs"})
+ <-started
+
+ // What sessions.dropByAppKey does to a session whose key was revoked.
+ // Nothing releases the handler except the end of the session.
+ h.Close()
+
+ select {
+ case <-returned:
+ case <-time.After(2 * time.Second):
+ t.Fatal("the handler kept running after the session was torn down")
+ }
+
+ // It wrote its answer on the way out, as a handler that ignores its
+ // context does. None of it may reach the app.
+ deadline := time.Now().Add(250 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ if rec.has(MsgAPIHead) || rec.has(MsgAPIChunk) || rec.has(MsgAPIEnd) {
+ t.Fatalf("a revoked session finished its call: %s", rec.types())
+ }
+ time.Sleep(2 * time.Millisecond)
+ }
+}
+
+// --------------------------------------------------------------------------
+// Construction
+// --------------------------------------------------------------------------
+
+func TestNewRefusesASessionWithNoIdentity(t *testing.T) {
+ _, box, rec, clock := newRig(t)
+ base := Config{
+ Clock: clock, Site: box, Info: box, Modes: box, Plans: box,
+ Codec: testCodec{}, Sender: rec,
+ }
+
+ withRole := base
+ withRole.Grants = newGrants()
+ withRole.Caller = apiauth.Caller{Role: "administrator"}
+ if _, err := New(withRole); err == nil {
+ t.Fatal("a role that is not in the registry was accepted")
+ }
+
+ withoutGrants := base
+ withoutGrants.Caller = ownerCaller()
+ if _, err := New(withoutGrants); err == nil {
+ t.Fatal("a session with no way to see a revoke was accepted")
+ }
+}
+
+func TestAViewerSessionIsAnnouncedAsReadOnly(t *testing.T) {
+ h, _, rec, _ := newRig(t)
+ h.cfg.Caller = viewerCaller()
+
+ deliver(t, h, MsgHello, nil, Hello{
+ Proto: ProtoRange{Min: ProtoMin, Max: ProtoMax},
+ App: AppInfo{Build: "test", UA: "pwa"},
+ })
+
+ ok := body[HelloOK](t, rec.only(t, MsgHelloOK))
+ if ok.Mode != BoxModeReadonly {
+ t.Fatalf("box mode = %q, want %q", ok.Mode, BoxModeReadonly)
+ }
+ if ok.Role != apiauth.RoleViewer {
+ t.Fatalf("role = %q, want %q", ok.Role, apiauth.RoleViewer)
+ }
+ if len(ok.Scopes) != 1 || ok.Scopes[0] != ScopeLiveRead {
+ t.Fatalf("scopes = %v, want only the live read", ok.Scopes)
+ }
+}
+
+// A handler that panics must not take the box with it. net/http would lose a
+// connection; in process there is no connection to lose, and a phone able to
+// end the box by opening the wrong screen would stop a house's dispatch.
+func TestAPanickingHandlerDoesNotEndTheBox(t *testing.T) {
+ h, _, rec, _ := newAPIRig(t, &stubAPI{
+ facts: apiauth.RouteFacts{Tier: apiauth.TierRead},
+ serve: func(http.ResponseWriter, *http.Request) {
+ panic("a driver map was nil")
+ },
+ })
+
+ deliver(t, h, MsgAPIReq, ptrU32(18), APIReq{Method: APIGet, Path: "/api/drivers"})
+ err := body[ErrorBody](t, waitFor(t, rec, MsgError))
+
+ if err.Code != ErrUnavailable || err.Args["reason"] != "handler" {
+ t.Fatalf("refusal was %+v, want E_UNAVAILABLE from the handler", err)
+ }
+
+ // And the session still works.
+ if err := h.Tick(); err != nil {
+ t.Fatalf("tick after a panicking handler: %v", err)
+ }
+}
diff --git a/go/internal/appproto/price_test.go b/go/internal/appproto/price_test.go
index 2961e44d..53ff19f0 100644
--- a/go/internal/appproto/price_test.go
+++ b/go/internal/appproto/price_test.go
@@ -91,6 +91,7 @@ func newPriceRig(t *testing.T, reader PriceReader) (*Handler, *recorder, *fakeCl
Clock: clock, Site: box, Info: box, Modes: box, Plans: box,
Prices: reader,
Codec: testCodec{}, Sender: rec,
+ Caller: ownerCaller(), Grants: newGrants(),
SrcGrid: "meter.p1", SrcPV: "meter.p1", SrcBattery: "meter.p1",
NewLeaseID: func() string { return "lease-test" },
})
diff --git a/go/internal/appuplink/client.go b/go/internal/appuplink/client.go
index 93e1c3b0..91940cd3 100644
--- a/go/internal/appuplink/client.go
+++ b/go/internal/appuplink/client.go
@@ -31,6 +31,7 @@ import (
"time"
"github.com/gorilla/websocket"
+ "github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/appenroll"
"github.com/srcfl/ftw/go/internal/appproto"
)
@@ -112,7 +113,7 @@ type Options struct {
// Handler builds one message-layer handler per app session. The caller
// supplies it so this package needs to know nothing about telemetry,
// control or the planner.
- Handler func(appproto.Sender) (*appproto.Handler, error)
+ Handler HandlerBuilder
Logger *slog.Logger
@@ -122,6 +123,16 @@ type Options struct {
Random func() float64
}
+// HandlerBuilder makes one message-layer handler for one app session.
+//
+// The caller is passed in rather than looked up later, because the handshake
+// has just authenticated the device and that is the only moment the answer is
+// certain. The grant reader is passed beside it because the caller is a
+// snapshot and a revoke can land a second afterwards: the handler re-reads
+// the enrolment on every privileged request, and refuses one whose epoch has
+// moved.
+type HandlerBuilder func(appproto.Sender, apiauth.Caller, appproto.GrantReader) (*appproto.Handler, error)
+
// Uplink maintains the connection and the sessions on it.
type Uplink struct {
opts Options
diff --git a/go/internal/appuplink/session.go b/go/internal/appuplink/session.go
index 8c3e575c..b8d15007 100644
--- a/go/internal/appuplink/session.go
+++ b/go/internal/appuplink/session.go
@@ -7,6 +7,7 @@ import (
"log/slog"
"sync"
+ "github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/appenroll"
"github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/appwire"
@@ -33,6 +34,18 @@ type session struct {
appStatic []byte
}
+// close ends one session for good.
+//
+// Both halves matter and neither is enough alone: closing the transport stops
+// anything further being encrypted onto the socket, and closing the handler
+// cancels work already running inside the box — a passthrough request in
+// flight, which must not be allowed to finish for a phone that has just been
+// locked out.
+func (s *session) close() {
+ s.handler.Close()
+ s.transport.Close()
+}
+
// sender encrypts one session's frames onto the shared socket.
type sender struct {
transport *appwire.Transport
@@ -58,7 +71,7 @@ func (s sender) Send(frame []byte) error {
type sessions struct {
mu sync.Mutex
live []*session
- build func(appproto.Sender) (*appproto.Handler, error)
+ build HandlerBuilder
enroll *appenroll.Identity
static appwire.KeyPair
@@ -147,12 +160,29 @@ func (s *sessions) open(message []byte) error {
// The payload is the pairing code from the QR. A device that has already
// paired presents nothing and is recognised by its static key, which is
// what makes the code single-use without breaking reconnects.
- if err := s.enroll.Authorise(noise.RemoteStatic, payload); err != nil {
+ grant, err := s.enroll.Authorise(noise.RemoteStatic, payload)
+ if err != nil {
transport.Close()
return err
}
- handler, err := s.build(sender{transport: transport, write: s.write})
+ // The grant becomes a caller here, and this is the only place it is
+ // built. Everything the box later decides about this phone — which
+ // commands it may send, which routes it may reach — is decided from this
+ // value, and nothing the phone sends can add to it.
+ caller := apiauth.Caller{
+ Subject: apiauth.KindApp + ":" + grant.DeviceID,
+ Kind: apiauth.KindApp,
+ Role: grant.Role,
+ Scopes: appproto.ScopesForRole(grant.Role),
+ Epoch: grant.Epoch,
+ }
+
+ handler, err := s.build(
+ sender{transport: transport, write: s.write},
+ caller,
+ liveGrant{enroll: s.enroll, deviceID: grant.DeviceID},
+ )
if err != nil {
transport.Close()
return err
@@ -183,7 +213,7 @@ func (s *sessions) admit(sess *session) {
s.live = append(s.live, sess)
for len(s.live) > MaxSessions {
- s.live[0].transport.Close()
+ s.live[0].close()
s.live = s.live[1:]
}
}
@@ -202,8 +232,8 @@ func (s *sessions) dropByAppKey(pub []byte) int {
if bytes.Equal(live.appStatic, pub) {
// The reason the app already knows how to say: its terminated
// screen reads "your access was withdrawn by its owner".
- _ = live.handler.Terminate("revoked")
- live.transport.Close()
+ _ = live.handler.Terminate(appproto.TerminateRevoked)
+ live.close()
dropped++
continue
}
@@ -219,7 +249,7 @@ func (s *sessions) drop(sess *session) {
for i, live := range s.live {
if live == sess {
- live.transport.Close()
+ live.close()
s.live = append(s.live[:i], s.live[i+1:]...)
return
}
@@ -236,8 +266,26 @@ func (s *sessions) closeAll() {
s.mu.Unlock()
for _, sess := range live {
- sess.transport.Close()
+ sess.close()
+ }
+}
+
+// liveGrant is one session's view of its own enrolment, as it stands now.
+//
+// The device id and not the key, because that is what the settings page
+// revokes by and what an audit line names — one identifier for the phone
+// across every place the box talks about it.
+type liveGrant struct {
+ enroll *appenroll.Identity
+ deviceID string
+}
+
+func (g liveGrant) Grant() (string, uint64, bool) {
+ grant, ok := g.enroll.GrantFor(g.deviceID)
+ if !ok {
+ return "", 0, false
}
+ return grant.Role, grant.Epoch, true
}
func (s *sessions) count() int {
diff --git a/go/internal/appuplink/uplink_test.go b/go/internal/appuplink/uplink_test.go
index 209f16db..381410a7 100644
--- a/go/internal/appuplink/uplink_test.go
+++ b/go/internal/appuplink/uplink_test.go
@@ -2,14 +2,19 @@ package appuplink
import (
"context"
+ "crypto/rand"
+ "encoding/base64"
"encoding/hex"
"log/slog"
+ "net/http"
"path/filepath"
"strconv"
+ "sync"
"testing"
"time"
"github.com/gorilla/websocket"
+ "github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/appenroll"
"github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/appwire"
@@ -106,6 +111,13 @@ type rig struct {
func newRig(t *testing.T, epoch int64) *rig {
t.Helper()
+ return newRigWith(t, epoch, newHandler)
+}
+
+// newRigWith is newRig for a test that needs its own handler — one wired to a
+// gateway it can hold open, so a revoke can land while a call is running.
+func newRigWith(t *testing.T, epoch int64, build HandlerBuilder) *rig {
+ t.Helper()
relay := newFakeRelay(epoch)
t.Cleanup(relay.close)
@@ -119,7 +131,7 @@ func newRig(t *testing.T, epoch int64) *rig {
uplink, err := New(Options{
Endpoint: relay.url(),
Enroll: enroll,
- Handler: func(s appproto.Sender) (*appproto.Handler, error) { return newHandler(s) },
+ Handler: build,
Logger: slog.New(slog.DiscardHandler),
Now: func() time.Time { return now },
Random: func() float64 { return 0 },
@@ -187,7 +199,7 @@ func waitForBinary(t *testing.T, conn *websocket.Conn) []byte {
func TestAPairedAppGetsASessionAndTelemetry(t *testing.T) {
r := newRig(t, 481234)
- code, _, err := r.enroll.MintPairingCode()
+ code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL)
if err != nil {
t.Fatalf("MintPairingCode: %v", err)
}
@@ -272,7 +284,7 @@ func TestAnUnpairedAppGetsNoReply(t *testing.T) {
func TestAWrongPairingCodeIsRefused(t *testing.T) {
r := newRig(t, 481234)
- if _, _, err := r.enroll.MintPairingCode(); err != nil {
+ if _, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL); err != nil {
t.Fatalf("MintPairingCode: %v", err)
}
@@ -374,7 +386,7 @@ func TestRotationIsNotFollowedInstantly(t *testing.T) {
u, err := New(Options{
Endpoint: relay.url(),
Enroll: enroll,
- Handler: func(s appproto.Sender) (*appproto.Handler, error) { return newHandler(s) },
+ Handler: newHandler,
Logger: slog.New(slog.DiscardHandler),
Now: func() time.Time { return now },
// The largest draw the jitter can make, so the assertion is about
@@ -416,7 +428,7 @@ func TestAnEpochCorrectionRetriesImmediately(t *testing.T) {
u, err := New(Options{
Endpoint: relay.url(),
Enroll: enroll,
- Handler: func(s appproto.Sender) (*appproto.Handler, error) { return newHandler(s) },
+ Handler: newHandler,
Logger: slog.New(slog.DiscardHandler),
Now: func() time.Time { return now },
Random: func() float64 { return 1 },
@@ -442,7 +454,7 @@ func TestARelayOriginWithAPathIsRefused(t *testing.T) {
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
- handler := func(s appproto.Sender) (*appproto.Handler, error) { return newHandler(s) }
+ handler := HandlerBuilder(newHandler)
for _, endpoint := range []string{
"wss://relay.ftw.energy/r/1/abc/box",
@@ -497,7 +509,9 @@ func (stubPlans) Latest() *mpc.Plan { return nil }
func (stubPlans) Rev() uint64 { return 0 }
func (stubPlans) CeilingW() *int64 { return nil }
-func newHandler(s appproto.Sender) (*appproto.Handler, error) {
+func newHandler(
+ s appproto.Sender, caller apiauth.Caller, grants appproto.GrantReader,
+) (*appproto.Handler, error) {
return appproto.New(appproto.Config{
Clock: appproto.SystemClock{StartedAt: time.Now(), Source: "ntp"},
Site: stubSite{},
@@ -506,6 +520,8 @@ func newHandler(s appproto.Sender) (*appproto.Handler, error) {
Plans: stubPlans{},
Codec: codec{},
Sender: s,
+ Caller: caller,
+ Grants: grants,
SrcGrid: "meter",
SrcPV: "meter",
SrcBattery: "meter",
@@ -515,15 +531,25 @@ func newHandler(s appproto.Sender) (*appproto.Handler, error) {
func send(t *testing.T, conn *websocket.Conn, app *appClient, msgType string, bucket int, body any) {
t.Helper()
+ sendOn(t, conn, app, appwire.LaneControl, msgType, bucket, nil, body)
+}
+
+// sendOn is send for a message that belongs on the bulk lane, where a request
+// carries an id and a frame's length is allowed to follow its content.
+func sendOn(
+ t *testing.T, conn *websocket.Conn, app *appClient,
+ lane uint8, msgType string, bucket int, id *uint32, body any,
+) {
+ t.Helper()
raw, err := appproto.Marshal(body)
if err != nil {
t.Fatalf("marshalling %s: %v", msgType, err)
}
frame, err := appwire.EncodeFrame(appwire.Frame{
- Lane: appwire.LaneControl,
+ Lane: lane,
Bucket: bucket,
- Envelope: appwire.Envelope{T: msgType, B: raw},
+ Envelope: appwire.Envelope{T: msgType, ID: id, B: raw},
})
if err != nil {
t.Fatalf("encoding %s: %v", msgType, err)
@@ -550,3 +576,136 @@ func receive(t *testing.T, conn *websocket.Conn, app *appClient) appwire.Frame {
}
return frame
}
+
+// --------------------------------------------------------------------------
+// Revocation, end to end
+// --------------------------------------------------------------------------
+
+// heldAPI is a gateway that answers nothing until its request is cancelled.
+type heldAPI struct {
+ started chan struct{}
+ cancelled chan struct{}
+ once sync.Once
+}
+
+func (h *heldAPI) Route(*http.Request) apiauth.RouteFacts {
+ return apiauth.RouteFacts{Tier: apiauth.TierRead}
+}
+
+func (h *heldAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ h.once.Do(func() { close(h.started) })
+ <-r.Context().Done()
+ close(h.cancelled)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"late":true}`))
+}
+
+// enrolOwner pairs a throwaway owner phone, so a test can then share the box
+// with a guest. The first enrolment on any box is an owner whatever its code
+// said, and the only owner cannot be revoked — both deliberate, and both in
+// the way of a test that wants a removable guest.
+func enrolOwner(t *testing.T, enroll *appenroll.Identity) {
+ t.Helper()
+ code, _, err := enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL)
+ if err != nil {
+ t.Fatalf("MintPairingCode: %v", err)
+ }
+ pub := make([]byte, 32)
+ if _, err := rand.Read(pub); err != nil {
+ t.Fatalf("rand: %v", err)
+ }
+ if _, err := enroll.Authorise(pub, code); err != nil {
+ t.Fatalf("enrolling the first owner: %v", err)
+ }
+}
+
+// Revoking a shared phone must stop the call it is making, not only the next
+// one — and it must be the same action as locking any other phone out, not a
+// second gesture with its own bugs.
+//
+// The three layers are the session dying, the enrolment epoch and the next
+// handshake failing; this is the first, over a real relay and a real Noise
+// session.
+func TestARevokedShareStopsACallAlreadyInFlight(t *testing.T) {
+ gateway := &heldAPI{started: make(chan struct{}), cancelled: make(chan struct{})}
+ r := newRigWith(t, 481234, func(
+ s appproto.Sender, caller apiauth.Caller, grants appproto.GrantReader,
+ ) (*appproto.Handler, error) {
+ return appproto.New(appproto.Config{
+ Clock: appproto.SystemClock{StartedAt: time.Now(), Source: "ntp"},
+ Site: stubSite{},
+ Info: stubInfo{},
+ Modes: stubModes{},
+ Plans: stubPlans{},
+ Codec: codec{},
+ Sender: s,
+ API: gateway,
+ Caller: caller,
+ Grants: grants,
+ Caps: append(appproto.DefaultCaps(), appproto.CapApiPassthrough),
+ SrcGrid: "meter",
+ SrcPV: "meter",
+ SrcBattery: "meter",
+ Logger: slog.New(slog.DiscardHandler),
+ })
+ })
+
+ // The household's own phone, so the guest below is removable.
+ enrolOwner(t, r.enroll)
+
+ code, _, err := r.enroll.MintPairingCode(apiauth.RoleViewer, appenroll.InviteTTL)
+ if err != nil {
+ t.Fatalf("MintPairingCode: %v", err)
+ }
+ conn := r.dialApp(t, 481234)
+ app, err := newAppClient()
+ if err != nil {
+ t.Fatalf("newAppClient: %v", err)
+ }
+ message1, err := app.message1(r.enroll.StaticKey().Public(), code)
+ if err != nil {
+ t.Fatalf("message1: %v", err)
+ }
+ if err := conn.WriteMessage(websocket.BinaryMessage, message1); err != nil {
+ t.Fatalf("sending message 1: %v", err)
+ }
+ if err := app.readMessage2(waitForBinary(t, conn)); err != nil {
+ t.Fatalf("readMessage2: %v", err)
+ }
+
+ // A read, which is all a guest may do and which must work until the
+ // moment it is taken away.
+ id := uint32(1)
+ sendOn(t, conn, app, appwire.LaneBulk, appproto.MsgAPIReq, 16384, &id, appproto.APIReq{
+ Method: appproto.APIGet, Path: "/api/logs",
+ })
+ select {
+ case <-gateway.started:
+ case <-time.After(5 * time.Second):
+ t.Fatal("the request never reached the box's API")
+ }
+
+ // What the settings page does: forget the key, then tear down whatever
+ // it is running right now.
+ key, err := r.enroll.Revoke(deviceIDOf(app.staticPublic()))
+ if err != nil {
+ t.Fatalf("Revoke: %v", err)
+ }
+ if dropped := r.uplink.DropSessionsByAppKey(key); dropped != 1 {
+ t.Fatalf("dropped %d sessions, want 1", dropped)
+ }
+
+ select {
+ case <-gateway.cancelled:
+ case <-time.After(5 * time.Second):
+ t.Fatal("the revoked phone's call was still running inside the box")
+ }
+}
+
+// deviceIDOf is how appenroll names a row: the first eight characters of the
+// base64 key. Spelled out here so the test revokes by the id a person would
+// click, not by a handle the test invented.
+func deviceIDOf(pub []byte) string {
+ return base64.RawURLEncoding.EncodeToString(pub)[:8]
+}
diff --git a/web/app-link-sharing.test.mjs b/web/app-link-sharing.test.mjs
new file mode 100644
index 00000000..695653e1
--- /dev/null
+++ b/web/app-link-sharing.test.mjs
@@ -0,0 +1,372 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { describe, it } from "node:test";
+import vm from "node:vm";
+
+// Sharing, on the box's own settings page.
+//
+// The device list is where a household decides who can reach their home, so
+// these drive the real refreshDevices() and the real buttons against a small
+// DOM and read what they built. Asserting on the markup string alone would
+// not catch the row that never gets a Remove button.
+
+const source = readFileSync(new URL("./settings/tabs/app.js", import.meta.url), "utf8");
+
+// A DOM small enough to read and real enough to answer the two questions
+// here: what did the page build, and what does it say.
+function element(tag) {
+ const el = {
+ tagName: tag,
+ className: "",
+ type: "",
+ hidden: false,
+ disabled: false,
+ children: [],
+ listeners: {},
+ appendChild(child) {
+ this.children.push(child);
+ return child;
+ },
+ addEventListener(name, fn) {
+ this.listeners[name] = fn;
+ },
+ click() {
+ if (this.listeners.click) this.listeners.click();
+ },
+ // Everything this element and its descendants say, so a test can assert
+ // on wording without walking the tree by hand.
+ words() {
+ return [this.textContent, ...this.children.map((c) => c.words())].join(" ");
+ },
+ buttons() {
+ const mine = this.tagName === "button" ? [this] : [];
+ return mine.concat(...this.children.map((c) => c.buttons()));
+ },
+ };
+
+ // Setting textContent replaces everything inside, children included. That
+ // is how the real DOM clears a slot, and it is what keeps the assertions
+ // below about what is on screen now rather than about everything that has
+ // ever been on it — a stale sentence left in the tree would let a test pass
+ // on the strength of a code that has already been replaced.
+ let text = "";
+ Object.defineProperty(el, "textContent", {
+ get() {
+ return text;
+ },
+ set(value) {
+ text = String(value);
+ el.children = [];
+ },
+ });
+ return el;
+}
+
+// The ids render() reaches for. Registered up front, because the tab wires
+// itself to them the moment it is rendered.
+const IDS = [
+ "app-link-devices",
+ "app-link-slot",
+ "app-link-status",
+ "app-link-pair",
+ "app-link-share",
+ "app-link-enabled",
+];
+
+// load runs app.js, renders the tab, and hands back the DOM it wired itself
+// into along with every request it made.
+function load({ devices = [], pairing = null, spoken = null, refuse = null } = {}) {
+ const byId = new Map();
+ for (const id of IDS) {
+ byId.set(id, element(id.startsWith("app-link-devices") || id.endsWith("slot") ? "div" : "button"));
+ }
+ byId.get("app-link-share").hidden = true;
+
+ const document = {
+ getElementById: (id) => byId.get(id) ?? null,
+ createElement: element,
+ };
+
+ const calls = [];
+ const answer = (body, ok = true) => Promise.resolve({ ok, json: () => Promise.resolve(body) });
+
+ const sandbox = {
+ window: { FTWSettings: { tabs: {} } },
+ setTimeout: (fn) => {
+ fn();
+ return 0;
+ },
+ document,
+ confirm: () => true,
+ fetch: (path, opts) => {
+ calls.push({ path, opts });
+ if (refuse && opts && opts.method === refuse.method) {
+ return answer({ error: refuse.error }, false);
+ }
+ if (path === "/api/app-link/devices") return answer({ devices });
+ if (path === "/api/app-link/pairing") {
+ // The box echoes back the role it minted for, so the stub does too.
+ // One that answered a fixed role whatever it was asked would hide the
+ // bug this file exists to catch: a code whose screen names a power it
+ // does not carry.
+ const asked = JSON.parse((opts && opts.body) || "{}");
+ const shape = asked.kind === "spoken" ? spoken : pairing;
+ return answer(shape && Object.assign({}, shape, { role: asked.role }));
+ }
+ if (path === "/api/app-link/status") {
+ return answer({ enabled: true, paired_devices: devices.length });
+ }
+ return answer({});
+ },
+ JSON,
+ Promise,
+ Date,
+ Math,
+ Array,
+ Object,
+ String,
+ console,
+ };
+ sandbox.globalThis = sandbox;
+ vm.createContext(sandbox);
+ vm.runInContext(source, sandbox);
+
+ const tab = sandbox.window.FTWSettings.tabs.app;
+ const html = tab.render({ config: { app_link: { enabled: true } } });
+ return { html, calls, el: (id) => byId.get(id) };
+}
+
+// Lets the promise chains inside the tab settle. Two turns, because a button
+// press fetches and then repaints from a second fetch.
+const settle = async () => {
+ for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
+};
+
+describe("the device list", () => {
+ it("says what each phone may do", async () => {
+ const { el } = load({
+ devices: [
+ { id: "aaaa1111", role: "owner", last_seen_ms: Date.now() },
+ { id: "bbbb2222", role: "viewer", last_seen_ms: Date.now() },
+ ],
+ });
+ await settle();
+
+ const list = el("app-link-devices");
+ assert.equal(list.children.length, 2, "both phones should be listed");
+ assert.match(list.children[0].words(), /Can change things/);
+ assert.match(list.children[1].words(), /Can look/);
+ });
+
+ it("removes a guest with the same button that locks a phone out", async () => {
+ const { el, calls } = load({
+ devices: [
+ { id: "aaaa1111", role: "owner" },
+ { id: "bbbb2222", role: "viewer" },
+ ],
+ });
+ await settle();
+
+ const list = el("app-link-devices");
+ for (const row of list.children) {
+ const labels = row.buttons().map((b) => b.textContent);
+ assert.ok(
+ labels.includes("Remove"),
+ `no Remove on "${row.words()}" — a guest must be removed where a phone is`,
+ );
+ }
+
+ // And it is the same request, not a sharing-specific one.
+ list.children[1].buttons().find((b) => b.textContent === "Remove").click();
+ await settle();
+ const removal = calls.find((c) => c.opts && c.opts.method === "DELETE");
+ assert.ok(removal, "removing a guest sent nothing");
+ assert.equal(removal.path, "/api/app-link/devices/bbbb2222");
+ });
+
+ it("explains the last owner instead of offering a button that fails", async () => {
+ const { el } = load({
+ devices: [{ id: "aaaa1111", role: "owner", last_owner: true }],
+ });
+ await settle();
+
+ const row = el("app-link-devices").children[0];
+ assert.equal(row.buttons().length, 0, "the last owner was offered a button the box refuses");
+ assert.match(row.words(), /only phone that can change things/);
+ assert.match(row.words(), /add another/i);
+ });
+
+ it("hides sharing until there is somebody to share from", async () => {
+ // The first phone on a box is its owner whatever code it used, so an
+ // invite offered before then would silently make an owner.
+ const empty = load({ devices: [] });
+ await settle();
+ assert.equal(empty.el("app-link-share").hidden, true);
+
+ const paired = load({ devices: [{ id: "aaaa1111", role: "owner" }] });
+ await settle();
+ assert.equal(paired.el("app-link-share").hidden, false);
+ });
+
+ it("changes a role from the list rather than a screen of its own", async () => {
+ const { el, calls } = load({
+ devices: [
+ { id: "aaaa1111", role: "owner" },
+ { id: "bbbb2222", role: "viewer" },
+ ],
+ });
+ await settle();
+
+ const row = el("app-link-devices").children[1];
+ const promote = row.buttons().find((b) => /change things/.test(b.textContent));
+ assert.ok(promote, "a guest cannot be promoted from the list");
+ promote.click();
+ await settle();
+
+ const patch = calls.find((c) => c.opts && c.opts.method === "PATCH");
+ assert.ok(patch, "no role change was sent");
+ assert.equal(patch.path, "/api/app-link/devices/bbbb2222");
+ assert.equal(JSON.parse(patch.opts.body).role, "owner");
+ });
+
+ it("says why when the box refuses", async () => {
+ // Without this the button simply does nothing and the household has no
+ // idea the box protected anything.
+ const { el } = load({
+ devices: [
+ { id: "aaaa1111", role: "owner" },
+ { id: "bbbb2222", role: "owner" },
+ ],
+ refuse: { method: "DELETE", error: "that is the only phone that can change anything here." },
+ });
+ await settle();
+
+ const row = el("app-link-devices").children[0];
+ row.buttons().find((b) => b.textContent === "Remove").click();
+ await settle();
+
+ assert.match(row.words(), /only phone that can change anything/);
+ });
+});
+
+describe("the code on screen", () => {
+ it("names what an invite lets in, above the code", async () => {
+ const { el } = load({
+ devices: [{ id: "aaaa1111", role: "owner" }],
+ pairing: {
+ url: "https://app.ftw.energy/p#v2.a.b.c.d",
+ role: "viewer",
+ expires_at_ms: Date.now() + 600000,
+ },
+ });
+ await settle();
+
+ el("app-link-share").click();
+ await settle();
+
+ const said = el("app-link-slot").words();
+ assert.match(said, /lets someone see this home/i);
+ assert.match(said, /cannot change anything/i);
+ });
+
+ it("asks for the role the button promises", async () => {
+ const { el, calls } = load({
+ devices: [{ id: "aaaa1111", role: "owner" }],
+ pairing: { url: "https://app.ftw.energy/p#x", role: "viewer", expires_at_ms: Date.now() + 600000 },
+ });
+ await settle();
+
+ el("app-link-share").click();
+ await settle();
+ const invite = calls.filter((c) => c.path === "/api/app-link/pairing").pop();
+ assert.equal(JSON.parse(invite.opts.body).role, "viewer");
+
+ el("app-link-pair").click();
+ await settle();
+ const owner = calls.filter((c) => c.path === "/api/app-link/pairing").pop();
+ assert.equal(JSON.parse(owner.opts.body).role, "owner");
+ });
+
+ it("shows a box code as readable text", async () => {
+ const { el, calls } = load({
+ devices: [{ id: "aaaa1111", role: "owner" }],
+ pairing: { url: "https://app.ftw.energy/p#x", expires_at_ms: Date.now() + 600000 },
+ spoken: { code: "ABCD-EFGH", expires_at_ms: Date.now() + 300000 },
+ });
+ await settle();
+
+ el("app-link-pair").click();
+ await settle();
+ el("app-link-slot").buttons()[0].click();
+ await settle();
+
+ const asked = calls.filter((c) => c.path === "/api/app-link/pairing").pop();
+ assert.equal(JSON.parse(asked.opts.body).kind, "spoken");
+
+ const said = el("app-link-slot").words();
+ assert.match(said, /ABCD-EFGH/, "the code was never shown");
+ assert.match(said, /Five wrong tries/, "nothing warns that guessing burns the code");
+ });
+
+ // The box code is the floor that always works, so it has to reach a guest
+ // too — a phone with no camera cannot be shared a home by scanning. It
+ // carries the role of the code already on screen, so the sentence the
+ // household just read is the one the code obeys.
+ it("reads out a code for whoever the code on screen was for", async () => {
+ const { el, calls } = load({
+ devices: [{ id: "aaaa1111", role: "owner" }],
+ pairing: { url: "https://app.ftw.energy/p#x", expires_at_ms: Date.now() + 600000 },
+ spoken: { code: "K7M2-9QRT", expires_at_ms: Date.now() + 300000 },
+ });
+ await settle();
+
+ // The guest's code first, then read it out rather than scan it.
+ el("app-link-share").click();
+ await settle();
+ const fallback = el("app-link-slot").buttons()[0];
+ assert.ok(fallback, "a code on screen offers no way to read it out");
+ assert.match(fallback.textContent, /read a code out/i);
+
+ fallback.click();
+ await settle();
+
+ const asked = JSON.parse(calls.filter((c) => c.path === "/api/app-link/pairing").pop().opts.body);
+ assert.deepEqual(
+ asked,
+ { role: "viewer", kind: "spoken" },
+ "the spoken code was minted for a different role than the screen promised",
+ );
+ assert.match(el("app-link-slot").words(), /K7M2-9QRT/);
+ assert.match(
+ el("app-link-slot").words(),
+ /cannot change anything/i,
+ "the spoken code never says it is view only",
+ );
+ });
+
+ // A typed code carries the code and nothing else. The box's own key and its
+ // rendezvous secret travel only in the square, so a phone that has never
+ // seen this box cannot be read its way in — and the page has to say so
+ // rather than offer a path that cannot work.
+ it("says a code read out will not do for a phone that has never been here", () => {
+ const { html } = load({ devices: [{ id: "aaaa1111", role: "owner" }] });
+ assert.match(html, /read out instead of scanned/i);
+ assert.match(html, /never seen this box has to scan/i);
+ });
+
+ it("never prints a scannable payload as text", async () => {
+ // The QR payload is a credential. The only path it should take is a
+ // camera, so it must not appear anywhere a person could copy it.
+ const payload = "https://app.ftw.energy/p#v2.secret.key.material.here";
+ const { el } = load({
+ devices: [{ id: "aaaa1111", role: "owner" }],
+ pairing: { url: payload, role: "owner", expires_at_ms: Date.now() + 600000 },
+ });
+ await settle();
+
+ el("app-link-pair").click();
+ await settle();
+
+ assert.doesNotMatch(el("app-link-slot").words(), /secret\.key\.material/);
+ });
+});
diff --git a/web/settings/tabs/app.js b/web/settings/tabs/app.js
index e1610625..33de3d55 100644
--- a/web/settings/tabs/app.js
+++ b/web/settings/tabs/app.js
@@ -56,11 +56,33 @@
return Math.round(d / 86400000) + " d ago";
}
- // The device list is what makes "remove" possible at all. Rows carry a
- // short key prefix and two timestamps — the phone in daily use shows a
- // fresh "last seen" and floats to the top; a key that paired once and
- // vanished (a test run, a mistake, a stranger) sinks and is the one to
- // remove. Removal is immediate: the box drops any live session too.
+ // What each role may do, in the words the row uses. The box is what
+ // enforces these; this only names them.
+ function roleText(role) {
+ return role === "viewer" ? "Can look" : "Can change things";
+ }
+
+ // Reports a refusal the box gave, rather than swallowing it. The one that
+ // matters is the last owner: without a sentence here, the Remove button
+ // simply does nothing and the household has no idea why.
+ function sayWhyNot(response, row) {
+ return response.json().then(function (body) {
+ var note = document.createElement("p");
+ note.className = "hint";
+ note.textContent = (body && body.error) || "That did not work.";
+ row.appendChild(note);
+ });
+ }
+
+ // The device list is what makes "remove" possible at all, and it is where
+ // sharing lives too. Rows carry a short key prefix, what the phone may do
+ // and two timestamps — the phone in daily use shows a fresh "last seen" and
+ // floats to the top; a key that paired once and vanished (a test run, a
+ // mistake, a stranger) sinks and is the one to remove.
+ //
+ // Removing a guest and locking a phone out are the same button, because
+ // they are the same thing: a guest's phone is a paired phone. Removal is
+ // immediate — the box drops any live session too.
function refreshDevices() {
var list = document.getElementById("app-link-devices");
if (!list) return;
@@ -69,6 +91,12 @@
.then(function (body) {
list.textContent = "";
var devices = (body && body.devices) || [];
+ // Sharing needs somebody to share from. The first phone on a box is
+ // its owner whatever code it used, so the button appears once there
+ // is one — otherwise it would offer a guest pass that silently makes
+ // an owner.
+ var shareButton = document.getElementById("app-link-share");
+ if (shareButton) shareButton.hidden = devices.length === 0;
if (devices.length === 0) return;
devices.forEach(function (d) {
@@ -80,22 +108,62 @@
name.textContent = "Phone " + d.id;
row.appendChild(name);
+ var what = document.createElement("span");
+ what.className = "hint";
+ what.textContent = roleText(d.role);
+ row.appendChild(what);
+
var seen = document.createElement("span");
seen.className = "hint";
seen.textContent = d.last_seen_ms ? "seen " + agoText(d.last_seen_ms) : "never connected";
row.appendChild(seen);
- var btn = document.createElement("button");
- btn.type = "button";
- btn.textContent = "Remove";
- btn.addEventListener("click", function () {
- if (!confirm("Remove this phone? It loses access immediately and must scan a new code to come back.")) return;
- btn.disabled = true;
- fetch("/api/app-link/devices/" + encodeURIComponent(d.id), { method: "DELETE" })
- .then(function () { refreshDevices(); refreshStatus(pairingCtx); })
- .catch(function () { btn.disabled = false; });
- });
- row.appendChild(btn);
+ // The last owner cannot be removed or stepped down, so say so on
+ // the row rather than letting somebody press a button that answers
+ // with a refusal.
+ if (d.last_owner) {
+ var only = document.createElement("span");
+ only.className = "hint";
+ only.textContent = "The only phone that can change things — add another before removing this one.";
+ row.appendChild(only);
+ } else {
+ var toRole = d.role === "viewer" ? "owner" : "viewer";
+ var change = document.createElement("button");
+ change.type = "button";
+ change.textContent = d.role === "viewer" ? "Let it change things" : "Make it view only";
+ change.addEventListener("click", function () {
+ change.disabled = true;
+ fetch("/api/app-link/devices/" + encodeURIComponent(d.id), {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ role: toRole }),
+ })
+ .then(function (r) {
+ if (!r.ok) return sayWhyNot(r, row);
+ refreshDevices();
+ })
+ .catch(function () {})
+ .then(function () { change.disabled = false; });
+ });
+ row.appendChild(change);
+
+ var btn = document.createElement("button");
+ btn.type = "button";
+ btn.textContent = "Remove";
+ btn.addEventListener("click", function () {
+ if (!confirm("Remove this phone? It loses access immediately and must scan a new code to come back.")) return;
+ btn.disabled = true;
+ fetch("/api/app-link/devices/" + encodeURIComponent(d.id), { method: "DELETE" })
+ .then(function (r) {
+ if (!r.ok) return sayWhyNot(r, row);
+ refreshDevices();
+ refreshStatus(pairingCtx);
+ })
+ .catch(function () {})
+ .then(function () { btn.disabled = false; });
+ });
+ row.appendChild(btn);
+ }
list.appendChild(row);
});
@@ -111,15 +179,24 @@
return r.ok ? r.json() : null;
})
.then(function (s) {
- var button = document.getElementById("app-link-pair");
+ // Both ways of letting a phone in, enabled together. Sharing is not a
+ // separate feature that could still work with the app link off: it
+ // mints the same code from the same endpoint.
+ var buttons = [
+ document.getElementById("app-link-pair"),
+ document.getElementById("app-link-share"),
+ ];
+ var setEnabled = function (on) {
+ buttons.forEach(function (b) { if (b) b.disabled = !on; });
+ };
if (!s) {
setStatus("Pairing is available on your local network only.");
- if (button) button.disabled = true;
+ setEnabled(false);
return;
}
var note = describe(saved, s.enabled);
setStatus(note === null ? pairedText(s.paired_devices) : note);
- if (button) button.disabled = !s.enabled;
+ setEnabled(s.enabled);
if (s.enabled) refreshDevices();
})
.catch(function () {
@@ -150,44 +227,109 @@
return canvas;
}
+ // What the code lets in, said in words above it, before anyone reads it
+ // out or holds a phone up to it. A code whose power is invisible is the one
+ // that gets given to the wrong person.
+ function sayWhatItLetsIn(slot, role) {
+ var says = document.createElement("p");
+ says.className = "hint";
+ says.textContent = role === "viewer"
+ ? "This lets someone see this home. They cannot change anything."
+ : "This adds a phone that can change things here.";
+ slot.appendChild(says);
+ }
+
+ function expiryText(pairing) {
+ var minutes = Math.max(1, Math.round((pairing.expires_at_ms - Date.now()) / 60000));
+ return "Works once, for about " + minutes + " more minutes.";
+ }
+
+ // The way in for a phone that cannot scan: no camera, a cracked lens, or
+ // somebody setting their own phone up again from another house.
+ //
+ // It carries the role of the code already on screen, so who this is for is
+ // chosen once, at the button above, and cannot drift between the sentence
+ // the household just read and the code they read out. It is built before
+ // the encoder is even asked for, because a QR that will not draw is exactly
+ // when somebody needs this most.
+ function spokenFallback(role) {
+ var label = "Cannot scan? Read a code out instead";
+ var btn = document.createElement("button");
+ btn.type = "button";
+ btn.textContent = label;
+ btn.addEventListener("click", function () {
+ requestCode({ role: role, kind: "spoken" }, btn, "Making a code…", label);
+ });
+ return btn;
+ }
+
function showCode(pairing) {
var slot = document.getElementById("app-link-slot");
if (!slot) return;
slot.textContent = "";
+ sayWhatItLetsIn(slot, pairing.role);
+
+ // A box code is meant to be read down a phone, so it is shown as text and
+ // nothing else. The QR payload never is: it is a credential, and the only
+ // path it should take is a camera.
+ if (pairing.code) {
+ var spoken = document.createElement("p");
+ spoken.className = "mono app-box-code";
+ spoken.textContent = pairing.code;
+ slot.appendChild(spoken);
+
+ var how = document.createElement("p");
+ how.className = "hint";
+ // What to do, not what to tap. The button that takes this code lives in
+ // the app, in another repository, and a label quoted here would go on
+ // being quoted long after the app renamed it.
+ how.textContent = "Read this out. On the other phone, choose to type a code " +
+ "rather than scan one. " + expiryText(pairing) + " Five wrong tries and it " +
+ "stops working, so ask for a new one rather than guessing.";
+ slot.appendChild(how);
+ return;
+ }
+
+ // Held so the drawing lands above the fallback button rather than after
+ // it, whenever the encoder finishes.
+ var picture = document.createElement("div");
+ slot.appendChild(picture);
+ slot.appendChild(spokenFallback(pairing.role));
// Loaded on demand: this tab is opened once per phone, and every other
// page would otherwise carry the encoder for nothing.
import("/vendor/qrcode.js")
.then(function (m) {
- slot.appendChild(drawQR(m.qrMatrix, pairing.url, 260));
+ picture.appendChild(drawQR(m.qrMatrix, pairing.url, 260));
var note = document.createElement("p");
note.className = "hint";
- var minutes = Math.max(1, Math.round((pairing.expires_at_ms - Date.now()) / 60000));
- note.textContent = "Works once, for about " + minutes + " more minutes.";
- slot.appendChild(note);
+ note.textContent = expiryText(pairing);
+ picture.appendChild(note);
})
.catch(function () {
var err = document.createElement("p");
err.className = "hint";
- err.textContent = "Could not draw the code. Reload the page and try again.";
- slot.appendChild(err);
+ err.textContent = "Could not draw the code here — read one out instead.";
+ picture.appendChild(err);
});
}
- function requestCode() {
- var button = document.getElementById("app-link-pair");
+ // One code at a time, whatever kind it is: asking for a guest pass cancels
+ // a pairing code still on screen, and the reverse. Every button here goes
+ // through this, so the rule is not something a caller has to remember.
+ function requestCode(opts, button, busyText, doneText) {
var slot = document.getElementById("app-link-slot");
if (!button || !slot) return;
button.disabled = true;
- button.textContent = "Making a code…";
+ button.textContent = busyText;
slot.textContent = "";
fetch("/api/app-link/pairing", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: "{}",
+ body: JSON.stringify(opts),
})
.then(function (r) {
return r.json().then(function (body) {
@@ -204,7 +346,7 @@
})
.then(function () {
button.disabled = false;
- button.textContent = "Show a new code";
+ button.textContent = doneText;
refreshStatus(pairingCtx);
});
}
@@ -217,8 +359,19 @@
// Wired after this string becomes the DOM.
setTimeout(function () {
- var button = document.getElementById("app-link-pair");
- if (button) button.addEventListener("click", requestCode);
+ var pairButton = document.getElementById("app-link-pair");
+ if (pairButton) {
+ pairButton.addEventListener("click", function () {
+ requestCode({ role: "owner" }, pairButton, "Making a code…", "Show a new code");
+ });
+ }
+
+ var shareButton = document.getElementById("app-link-share");
+ if (shareButton) {
+ shareButton.addEventListener("click", function () {
+ requestCode({ role: "viewer" }, shareButton, "Making a code…", "Let someone see this home");
+ });
+ }
// The shared data-checkbox-path handler writes the config; this only
// repaints the line underneath, so the wording follows the checkbox
@@ -241,11 +394,21 @@
"you save.
" +
'checking…
' +
'
' +
+ '' +
'Show pairing code ' +
+ 'Let someone see this home ' +
+ "
" +
'Scan the code with the FTW app to add a phone. It works ' +
"once and expires in a few minutes, so ask for a new one when you need it. " +
"Everything the app needs is in the code itself, which is why the app can " +
"be sure it is talking to this box.
" +
+ 'A code can be read out instead of scanned. The offer sits ' +
+ "under whichever code is on screen and lets in exactly the same access. It " +
+ "is for a phone that has been here before and is being set up again — a " +
+ "phone that has never seen this box has to scan, because only the square " +
+ "carries what it needs to find this box and be sure it is this one. Only " +
+ "one code is live at a time, so asking for any of them stops the last " +
+ "one.
" +
'
' +
""
);
diff --git a/web/style.css b/web/style.css
index 2c1d99ae..aaeb604d 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1804,6 +1804,51 @@ footer {
color: var(--text-dim);
}
+/* ---- The FTW app settings tab ---- */
+/* One paired phone. The row is the whole of sharing: who this is, what they
+ may do, when they were last here, and the buttons that change it. It went
+ unstyled while it carried only a name and a timestamp, which already ran
+ them into one word; a role and a second button make that unreadable. */
+.app-device-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 10px;
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border);
+ font-size: 0.82rem;
+}
+/* The name takes the slack, so the buttons sit together at the end and land
+ in the same place on every row. */
+.app-device-row > .mono {
+ flex: 1 1 auto;
+}
+.app-device-row .hint {
+ margin: 0;
+}
+.app-device-row button {
+ flex: 0 0 auto;
+}
+/* Who the next code is for, on one line and wrapping on a narrow screen.
+ Only one code is ever live, so these belong together rather than scattered
+ down the panel as if they were independent settings. How the code travels —
+ scanned, or read out loud — is offered under the code itself, once the
+ household has said who it is for. */
+.app-link-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 12px 0 8px;
+}
+/* The box code is read out loud, so it is set large and spaced: somebody is
+ reading it off this screen while talking into a phone. */
+.app-box-code {
+ font-size: 1.6rem;
+ letter-spacing: 0.12em;
+ margin: 10px 0 6px;
+ user-select: all;
+}
+
/* ---- Fleet ping settings tab ---- */
/* What the message cannot hide is a second thought, not the tail of the first
one. .hint carries no margin, so two of them in a row set as one slab of
From 92e26b7ff024ff12d694e7b42e4ce4f65509cf6c Mon Sep 17 00:00:00 2001
From: Fredrik Ahlgren
Date: Fri, 7 Aug 2026 09:57:19 +0200
Subject: [PATCH 2/3] ci: let the registry guard follow a coordinated pair
The guard compares this repository's copy against the app's default branch,
and the app's compares against this one's. A change that lands in both at once
therefore waits for itself: neither side can go first, and the guard's own
error message tells you to change both copies in the same pair of pull
requests, which is the thing it forbids.
A pull request that names where its pair lives is now compared against that
branch. Everything else still meets the default branch, which is the drift this
job exists to catch.
Co-Authored-By: Claude Opus 5
---
.github/workflows/test.yml | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 0444d427..ea63eb13 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -416,12 +416,27 @@ jobs:
steps:
- uses: actions/checkout@v5
+ # A change that lands in both repositories at once cannot be compared
+ # against the other one's default branch: the counterpart is not there
+ # yet, and each side would wait for the other forever. A pull request
+ # that names where its pair lives is compared against that instead.
+ # Everything else — a stray edit, a rename, a copy someone forgot — still
+ # meets the default branch, which is the case this job exists for.
+ - name: Which copy of the app to compare against
+ id: pair
+ env:
+ BODY: ${{ github.event.pull_request.body }}
+ run: |
+ REF=$(printf '%s\n' "$BODY" | sed -n 's|^Contract-pair: *srcfl/ftw-webapp@||p' | head -1 | tr -d '\r')
+ echo "ref=${REF:-main}" >> "$GITHUB_OUTPUT"
+ echo "comparing against srcfl/ftw-webapp@${REF:-main}"
+
# One file, not the app's whole history.
- name: Check out the app
uses: actions/checkout@v5
with:
repository: srcfl/ftw-webapp
- ref: main
+ ref: ${{ steps.pair.outputs.ref }}
path: .app
token: ${{ secrets.FTW_CONTRACT_TOKEN || github.token }}
sparse-checkout: contract/registry.yaml
From 86af6e479cf59f1c2b131dd0481a0c1efb58ba84 Mon Sep 17 00:00:00 2001
From: Fredrik Ahlgren
Date: Fri, 7 Aug 2026 10:00:48 +0200
Subject: [PATCH 3/3] ci: read the declared pair live, not from the event
snapshot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
github.event.pull_request.body is captured when the run is queued, so a pair
declared after the last push is invisible to it — the job compares against the
default branch and looks like it worked. Ask the API for the body instead.
Co-Authored-By: Claude Opus 5
---
.github/workflows/test.yml | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ea63eb13..4f7f4ba0 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -425,8 +425,14 @@ jobs:
- name: Which copy of the app to compare against
id: pair
env:
- BODY: ${{ github.event.pull_request.body }}
+ # Read live rather than from github.event: that payload is a snapshot
+ # taken when the run was queued, so a pair declared after the last
+ # push would be invisible and the job would compare against the wrong
+ # branch while looking like it had worked.
+ GH_TOKEN: ${{ github.token }}
run: |
+ BODY=$(gh pr view "${{ github.event.pull_request.number }}" \
+ --repo "${{ github.repository }}" --json body -q .body)
REF=$(printf '%s\n' "$BODY" | sed -n 's|^Contract-pair: *srcfl/ftw-webapp@||p' | head -1 | tr -d '\r')
echo "ref=${REF:-main}" >> "$GITHUB_OUTPUT"
echo "comparing against srcfl/ftw-webapp@${REF:-main}"