Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y \
dbus \
libnfc-dev \
libpcsclite-dev

Expand Down Expand Up @@ -290,6 +291,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y \
dbus \
libnfc-dev \
libpcsclite-dev

Expand Down
2 changes: 2 additions & 0 deletions Taskfile.dist.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ tasks:
"FuzzParseTitleFromFilename ./pkg/database/tags"
"FuzzExtractSpecialPatterns ./pkg/database/tags"
"FuzzParseLine ./pkg/readers/rs232barcode"
"FuzzParseChunk ./pkg/bluetooth/apigatt"
"FuzzReassembler ./pkg/bluetooth/apigatt"
"FuzzDecodeURIIfNeeded ./pkg/helpers"
"FuzzIsValidExtension ./pkg/helpers"
"FuzzFilenameFromPath ./pkg/helpers"
Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Reference material for Zaparoo Core's architecture, APIs, and subsystems. For de
- **Launch endpoint**: `/l/{zapscript}` - GET-based execution for QR codes
- **Auth**: API keys via `auth.toml`, anonymous access from localhost
- **Discovery**: mDNS (`_zaparoo._tcp`)
- **Bluetooth LE** (Linux, off by default): the same JSON-RPC API and pairing over a GATT service, `pkg/bluetooth` + `pkg/api/ble_*.go`. See the "Bluetooth LE" section of `docs/api/index.md`.
- **Notifications**: Real-time WebSocket events (readers, tokens, media, indexing, playtime, global UI). See `docs/api/notifications.md`.
- **Full docs**: `docs/api/`

Expand Down Expand Up @@ -69,4 +70,4 @@ Device profiles are named buckets of preferences and limits, with no passwords o

## Reader Auto-Detection

11 reader types: acr122pcsc, externaldrive, file, libnfc, mqtt, operator (MiSTer only), opticaldrive, pn532, rs232barcode, simpleserial, tty2oled
12 reader types: acr122pcsc, externaldrive, file, libnfc, mqtt, operator (MiSTer only), opticaldrive, pn532, rs232barcode, simpleserial, simpleserialble (Linux only, Nordic UART Service over Bluetooth LE, manual configuration only), tty2oled
10 changes: 7 additions & 3 deletions docs/api/encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,14 @@ Counters don't wrap. Disconnect and reconnect with a fresh salt to start over.

### AAD

All encrypt/decrypt operations bind ciphertext to the session:
All encrypt/decrypt operations bind ciphertext to the session and to the transport it travels over:

```text
aad = authToken + ":ws"
aad = authToken + ":" + transport
```

`transport` is `ws` for WebSocket and `ble` for Bluetooth LE. A frame encrypted for one transport fails authentication on the other, so credentials captured on one link cannot be replayed on another.

## Security limits

- **Salt reuse**: The server rejects duplicate session salts per client (200-entry / 10-minute sliding window). Always use a CSPRNG for session salts.
Expand Down Expand Up @@ -242,6 +244,8 @@ WebSocket errors (plaintext JSON-RPC error, then connection closed):
|---|---|
| -32001 | Unsupported encryption version |
| -32002 | Encryption required. Remote clients must send an encrypted first frame. |
| -32004 | Response too large for the transport (Bluetooth LE only). `data.limit` and `data.size` say by how much. |
| -32005 | Pairing failed (Bluetooth LE `pair.start` / `pair.finish` only). `data.status` and `data.message` mirror the HTTP endpoints. |

## Connection lifecycle

Expand Down Expand Up @@ -280,7 +284,7 @@ function connect(url, authToken, pairingKey):
c2sBase = hkdf_expand(prk, info="zaparoo-c2s-nonce-v1", len=12)
s2cBase = hkdf_expand(prk, info="zaparoo-s2c-nonce-v1", len=12)

aad = encode(authToken + ":ws")
aad = encode(authToken + ":ws") // ":ble" over Bluetooth LE
sendCounter = 0
recvCounter = 0

Expand Down
33 changes: 33 additions & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,39 @@ data: {"jsonrpc":"2.0","method":"media.started","params":{"systemId":"NES","syst

SSE connections are long-lived and will continue receiving events until the client disconnects. To call methods, use HTTP POST to the standard API endpoint alongside the SSE connection.

### Bluetooth LE

On Linux platforms with a Bluetooth adapter, Core can also serve the API over Bluetooth Low Energy, so the app works with no shared Wi-Fi at all: discovery, pairing and every method run over the one Bluetooth connection. The transport is off by default; enable it with the `bleEnabled` setting (`[service.ble] enabled = true` in the config file). Core is the peripheral and the client is the central.

Core advertises a primary service and the local name from `[service.ble] name`, falling back to the mDNS discovery instance name. The name is read when advertising starts, so a rename shows up after the transport restarts:

| Characteristic | UUID | Properties | Purpose |
| -------------- | -------------------------------------- | ------------------------------ | ----------------------------------------------------------------------- |
| Service | `0da70001-b359-443b-836f-477d34b6a638` | | Primary service, also in the advertisement so clients can filter on it. |
| RX | `0da70002-b359-443b-836f-477d34b6a638` | write, write without response | Chunks from the client to Core. |
| TX | `0da70003-b359-443b-836f-477d34b6a638` | notify | Chunks from Core to the client. |
| Info | `0da70004-b359-443b-836f-477d34b6a638` | read | JSON description of the endpoint, readable before authenticating. |

Info returns `{"v": 1, "deviceId": "<device id>", "maxMessage": 262144, "preferredMtu": 512}`. `deviceId` lets a client pick the stored credentials for this device before it speaks; peer addresses are not stable enough for that. Clients should request the largest ATT MTU the platform allows.

**Framing.** A message is one complete WebSocket-equivalent frame: an encrypted frame, or one of the plaintext pairing requests below. It is split into chunks that fit `MTU - 3` bytes and written to RX one after another; Core sends replies the same way on TX. Every chunk starts with a header:

```text
byte 0 flags bits 7..4 = protocol version (1), bit 1 = LAST, bit 0 = FIRST, bits 3..2 reserved (0)
byte 1 seq 0 on the FIRST chunk of a message, +1 per chunk, wrapping at 256
byte 2-3 tag session tag, big-endian
byte 4-7 length total message length, big-endian, FIRST chunk only
payload at least one byte
```

The client picks a random non-zero 16-bit tag for the connection and sends it on every chunk. Core stamps the same tag on every chunk it sends that client, and clients must drop TX chunks carrying any other tag, because the radio delivers notifications to every subscribed central: every connected central sees every TX chunk, including pairing replies, and only the per-session encryption keeps another client's traffic unreadable. A message may not exceed `maxMessage` bytes in either direction; a response that would is replaced by error `-32004` (`response too large for transport`) whose `data` carries the `limit` and `size`. Chunks may reach Core slightly out of order; a chunk more than 128 places ahead of the one expected, a repeated sequence number, or a length that does not add up ends the connection.

**Pairing.** Pairing runs the same exchange as the [HTTP pairing endpoints](./encryption#pairing-flow), carried as two plaintext JSON-RPC requests that exist only on this transport: `pair.start` with params `{"pake": "<base64 PAKE message A>", "name": "<client name>"}` returning `{"session": "...", "pake": "<base64 PAKE message B>"}`, then `pair.finish` with params `{"session": "...", "confirm": "<base64 client HMAC>"}` returning `{"authToken": "...", "clientId": "...", "confirm": "<base64 server HMAC>"}`. The pairing PIN is still generated and shown on the device by `clients.pair.start`. Failures return error `-32005` (`pairing failed`) whose `data` carries the HTTP `status` and `message` the endpoints would have used, including `429` when a connection sends more than a couple of pairing requests per second.

**Sessions.** A connection accepts only pairing requests and an [encrypted first frame](./encryption#first-frame-client--server); plaintext method calls end the connection, and there is no localhost or legacy access over Bluetooth. The encrypted frames are exactly the WebSocket ones with the AAD transport label `ble` (see [AAD](./encryption#aad)). After pairing, the client sends its encrypted first frame on the same connection. An unauthenticated connection that stays silent for two minutes is dropped, and an authenticated one that sends nothing for five minutes is dropped too, so send the `ping` heartbeat at least once a minute; it works unchanged over Bluetooth. Once authenticated, notifications arrive on TX like WebSocket notifications, except that `media.indexing` and `media.scraping` are skipped while the link is backed up.

**Readers.** The same adapter also serves the `simpleserial_ble` reader driver, which connects to a configured Nordic UART Service device as a central. A configured reader that cannot be reached scans for it with pauses growing from one to thirty seconds; scanning shares the radio with advertising, so the device can be a little harder for the app to discover until the reader turns up.

### JSON Payloads

Server and clients communicate back and forth using JSON payloads, following the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) protocol.
Expand Down
3 changes: 3 additions & 0 deletions docs/api/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -2676,6 +2676,7 @@ None.
| readersScanIgnoreSystems | string[] | Yes | List of system IDs to ignore during scanning. |
| errorReporting | boolean | Yes | Whether error reporting is enabled. |
| encryption | boolean | Yes | Whether paired encryption is required for remote WebSocket connections. Localhost remains exempt. |
| bleEnabled | boolean | Yes | Whether the API is also served over [Bluetooth LE](./#bluetooth-le). Defaults to false. |
| readersConnect | [ReaderConnection](#reader-connection-object)[] | Yes | List of manually configured reader connections. |
| systemDefaults | [SystemDefault](#system-default-object)[] | Yes | Per-system overrides for default launcher and exit ZapScript. |
| profilesRequireForLaunch | boolean | Yes | Whether media launches are blocked while no personal profile is active. |
Expand Down Expand Up @@ -2737,6 +2738,7 @@ None.
"readersScanIgnoreSystems": ["DOS"],
"errorReporting": true,
"encryption": false,
"bleEnabled": false,
"readersConnect": [],
"systemDefaults": [
{
Expand Down Expand Up @@ -2771,6 +2773,7 @@ An object containing any of the following optional keys:
| readersScanIgnoreSystems | string[] | No | List of system IDs to ignore during scanning. |
| errorReporting | boolean | No | Whether error reporting is enabled. |
| encryption | boolean | No | Require paired encryption for remote WebSocket connections. This setting can only be changed from localhost. |
| bleEnabled | boolean | No | Serve the API over [Bluetooth LE](./#bluetooth-le) as well. Takes effect within about fifteen seconds without a restart. |
| readersConnect | [ReaderConnection](#reader-connection-object)[] | No | List of manually configured reader connections. |
| systemDefaults | [SystemDefault](#system-default-object)[] | No | Replace the full list of per-system launcher/exit-script overrides. Each `launcher` value, if non-empty, must match a known launcher ID or group (case-insensitive). |
| profilesRequireForLaunch | boolean | No | Whether media launches are blocked while no personal profile is active. |
Expand Down
Loading
Loading