# NectarLink Protocol, version 0 (draft) > Status: **draft for Phase 0.** Breaking changes are allowed until v1. > License: CC BY 4.0. Anyone may implement this protocol. This document specifies how two NectarLink devices find each other, pair, connect and exchange messages. Version 0 covers the Phase 0 scope: identity, pairing, the session handshake, capabilities, heartbeat and the first events (device info, battery, ping). Feature services are specified in separate documents that build on this one: [notifications](notifications.md), [clipboard](clipboard.md), [files](files.md), [media](media.md), [actions](actions.md) and [remote](remote.md) so far; mirroring follows. Key words **MUST**, **SHOULD** and **MAY** are used as in RFC 2119. --- ## 1. Concepts | Term | Meaning | |---|---| | **Device** | One installation of NectarLink (a phone, a PC). | | **Device key** | An Ed25519 key pair generated on first launch. Never leaves the device. | | **Device ID** | The Ed25519 public key (32 bytes). Equal to the iroh `EndpointId`. Displayed as lowercase z-base-32. | | **Pairing** | A one-time ceremony after which each device stores the other's Device ID as trusted. | | **Session** | One authenticated QUIC connection between two paired devices. | | **Service** | A feature area (e.g. `notifications`, `transfer`) that owns its own streams and messages. | ## 2. Transport - All traffic runs over **QUIC via iroh**. TLS 1.3 with raw public keys authenticates both ends by Device ID. There is no other encryption layer and no unencrypted mode. - Two **ALPN** values are defined: | ALPN | Purpose | Who may connect | |---|---|---| | `nectarlink/0` | Normal sessions | Paired devices only | | `nectarlink-pair/0` | Pairing ceremony | Anyone, but only while the receiving device is in pairing mode | - A device **MUST** close a `nectarlink/0` connection immediately (application error `UNPAIRED`, see §8) if the peer's Device ID is not in its trust store. - A device **MUST** reject `nectarlink-pair/0` connections when it is not in pairing mode, and **MUST** leave pairing mode after a successful pairing or a timeout (default 5 minutes). ### 2.1 Address discovery Devices locate each other by Device ID. Implementations use, in order of preference (iroh races them): 1. Local-network discovery (mDNS address lookup). 2. Last known direct addresses, cached per paired device. 3. A small UDP beacon on the LAN as a fallback where mDNS is filtered (format to be specified in v0.1). 4. Relay servers, only if the user enabled "away from home". ## 3. Framing Every QUIC stream carries a sequence of **frames**: ``` frame = length (unsigned LEB128, 1 to 4 bytes) || payload (CBOR, `length` bytes) ``` - `length` **MUST NOT** exceed 1 MiB (1,048,576) for control and RPC streams. Receivers close the stream with `FRAME_TOO_LARGE` otherwise. - Bulk data (file contents, video) does **not** use frames. After its header frame, a bulk stream switches to raw bytes as defined by its service. - All CBOR payloads are maps. Unknown keys **MUST** be ignored. ### 3.1 Message envelope Every frame payload is an envelope: | Key | Type | Required | Meaning | |---|---|---|---| | `t` | text | yes | Message type, e.g. `hello`, `event.battery` | | `id` | uint | no | Request ID, unique per stream direction, for messages expecting a reply | | `re` | uint | no | The `id` this message replies to | | `b` | map | no | Message body (type-specific) | A receiver that gets an unknown `t` carrying an `id` **MUST** reply with `error` code `UNSUPPORTED`. Unknown `t` without an `id` is silently ignored. ## 4. Streams | Stream | Opened by | Kind | Lifetime | |---|---|---|---| | **Control** | The dialing device | Bidirectional | The whole session. Must be the first stream opened | | **RPC / service** | Either device | Bidirectional | One operation (a request and its response, or a service sub-session) | | **Bulk** | Either device | Uni- or bidirectional | One transfer or media session | | **Datagrams** | Either device | QUIC datagrams | Loss-tolerant, latency-critical data (pointer motion, input) | Every non-control stream starts with a **stream header** frame: ``` t = "stream" b = { svc: text, op: text, v: uint } ``` `svc` names the service, `op` the operation, `v` the operation's version. If the receiver doesn't support it, it replies `error UNSUPPORTED` and closes the stream. Datagrams start with a one-byte channel tag (assigned per service in the service specs), followed by service-defined bytes. ## 5. Session handshake After the QUIC handshake, the dialer opens the control stream and sends `hello`. The listener replies with its own `hello`. The session is **established** once both `hello`s are exchanged and the version check passes. ``` t = "hello" b = { proto: uint, // protocol version spoken (0 for this document) min: uint, // oldest protocol version this device still accepts app: text, // app version, e.g. "0.1.0" device: DeviceInfo, caps: [text], // capability IDs this device offers (see §6) power: text, // "basic" | "assist" | "elevated" | "n/a" (desktop); unknown values are treated as "basic" } DeviceInfo = { name: text, // user-visible name, e.g. "Pixel 9" (receivers keep 64 chars) kind: text, // "phone" | "tablet" | "desktop" | "laptop" os: text, // "android" | "windows" | "macos" (32 chars) os_ver: text, // "16" / "10.0.26200" (32 chars) model: text?, // e.g. "Google Pixel 9" (64 chars) accent: uint?, // optional ARGB seed color (Material You sync) screen: ScreenShape?, // optional measured front-screen geometry (phones/tablets) } ScreenShape = { aspect: float, // upright display width / height (0.25..=2.5) corners: ScreenCorners?, // per-corner radii as fractions of upright screen width (0.0..=0.5) cutouts: [ScreenRect]?, // up to 4 front cutout bounding boxes in 0.0..=1.0 upright screen coords cutout_path: text?, // optional normalized SVG path (<= 512 chars, M/L/H/V/C/Q/Z in 0.0..=1.0) } ScreenCorners = { tl: float, tr: float, br: float, bl: float } ScreenRect = { x: float, y: float, w: float, h: float } ``` **Version negotiation:** the session uses `min(proto_a, proto_b)`. If that is below either side's `min`, the side that detects it sends `error VERSION_TOO_OLD` and closes the connection, and both UIs tell the user which side needs an update. Either device may send an updated `hello.update` on the control stream at any time (for example when the power level or capabilities change): ``` t = "hello.update" b = { caps: [text]?, power: text?, device: DeviceInfo? } ``` ## 6. Capabilities A capability ID is a lowercase dotted string (`[a-z0-9._-]`, at most 64 bytes) naming something a device *can offer*. Receivers ignore malformed and unknown IDs and anything beyond 256 entries. Examples: | Capability | Offered by | Meaning | |---|---|---| | `core.ping` | both | Responds to ping | | `device.battery` | both | Sends battery events | | `notify.mirror` | phone | Can forward notifications | | `notify.sensitive` | phone | Can forward sensitive (OTP) notifications | | `clip.read.auto` | phone | Can read the clipboard in the background | | `mirror.input` | phone | Accepts input while mirrored (Assist and up) | Capabilities describe **what the device can do right now** given its power level, OS version, permissions and add-ons. Each app combines both devices' capabilities with its own local state to compute the **capability matrix** that drives the UI (see `docs/architecture/capabilities.md`). The full registry of capability IDs lives in `docs/protocol/capabilities.md` and grows with each service. ## 7. Messages in v0 ### 7.1 Heartbeat QUIC keep-alives keep NAT bindings open. In addition, either side **MAY** send on the control stream: ``` t = "ping" id = n b = { ts: uint } // sender's monotonic ms t = "pong" re = n b = { ts: uint } // echoes ts ``` A session with no traffic for 45 s **SHOULD** send a `ping`. Implementations measure round-trip time from `ping`/`pong` for the connection-health indicator. ### 7.2 Events Events are one-way notifications on the control stream. Their type starts with `event.`. ``` t = "event.battery" b = { level: uint (0 to 100), charging: bool, plugged: text? ("ac"|"usb"|"wireless"), full_in: uint? (estimated minutes until full, 1 to 1440, when charging and known), cell_bars: uint? (0 to 4, cellular signal strength in bars, when known), cell_type: text? (cellular network generation/type, e.g. "5G", "LTE", "4G", "3G", "2G"), wifi_bars: uint? (0 to 4, Wi-Fi signal strength in bars, when connected to Wi-Fi and known), } t = "event.device" b = DeviceInfo // name, accent or screen shape changed ``` ### 7.3 Find my device ``` t = "device.ring" id = n b = { on: bool } // start or stop ringing t = "ok" re = n ``` ### 7.4 Errors ``` t = "error" re = n? b = { code: text, msg: text? } ``` ## 8. Error codes | Code | Meaning | |---|---| | `UNSUPPORTED` | Unknown message type, service or operation version | | `UNPAIRED` | Peer is not in the trust store (also used as the QUIC close reason) | | `VERSION_TOO_OLD` | Protocol version below the receiver's minimum | | `FRAME_TOO_LARGE` | Frame exceeds the size limit | | `BAD_MESSAGE` | Malformed CBOR or missing required fields | | `DENIED` | The user or a per-device permission denied the operation | | `BUSY` | Temporarily unable; retry later | | `INTERNAL` | Unexpected failure on the receiver | | `NOT_FOUND` | The thing the request names no longer exists (e.g. a dismissed notification) | ## 9. Pairing (`nectarlink-pair/0`) Pairing establishes mutual trust between two Device IDs. Two flows are defined. Both run over a `nectarlink-pair/0` QUIC connection, so the TLS layer already authenticates the raw public keys; the ceremony proves that the **human** intended *this* peer. ### 9.1 QR flow (primary) 1. The PC enters pairing mode, generates a random 128-bit **pairing secret** `S`, and shows a QR code containing a pairing URI: ``` nectarlink://pair?v=0&id=&s=&n=[&a=,...] ``` 2. The phone scans it and dials the PC's Device ID with ALPN `nectarlink-pair/0`. 3. The phone sends: ``` t = "pair.request" id = 1 b = { device: DeviceInfo, proof: bytes } // HMAC-SHA256(S, "nectarlink-pair-v0" || phone_id || pc_id) ``` 4. The PC verifies `proof` against the phone's TLS-authenticated Device ID. On success it replies: ``` t = "pair.accept" re = 1 b = { device: DeviceInfo } ``` On failure it replies `error DENIED` and leaves pairing mode after 5 failed attempts. 5. The ceremony finishes as described in §9.4, after which the phone opens a normal `nectarlink/0` session. Because the phone learned the PC's Device ID from the QR code and the PC verifies knowledge of `S`, both directions are authenticated with no codes to compare. ### 9.2 Nearby flow (numeric comparison) Used when the user picks a discovered device instead of scanning. The initiator is **A**, the responder **B**. 1. A → B: `pair.commit { device, c }` where `c = SHA-256(nA)` and `nA` is a random 16-byte nonce. 2. B → A: `pair.nonce { device, nB }` with a random 16-byte nonce `nB`. 3. A → B: `pair.reveal { nA }`. B checks `SHA-256(nA) == c`. 4. Both compute the **short authentication string**: ``` sas = uint32(HMAC-SHA256(nA || nB, "nectarlink-sas-v0" || idA || idB)[0..4]) mod 1,000,000 ``` shown as a 6-digit code on both screens. 5. The user confirms the codes match on **both** devices. A sends `pair.confirm` as soon as its user confirms. B replies `pair.confirm` only after both its own user and A confirmed, so B always sends the last message. A decline on either side is sent as `error DENIED` and ends the ceremony immediately. 6. The ceremony finishes as described in §9.4. The commitment in step 1 prevents an attacker from choosing a nonce to force a matching code; a man-in-the-middle would have different Device IDs on each side and therefore different codes. ### 9.3 USB flow When an ADB connection exists, the PC can exchange Device IDs with the phone app over ADB directly. Specified in v0.1 together with the ADB integration. ### 9.4 Finishing a ceremony A QUIC connection close discards data the peer hasn't read yet, so the end of a ceremony is acknowledged explicitly: 1. The host (B) sends the last protocol message (`pair.accept` or `pair.confirm`). 2. The initiator (A) replies `t = "pair.done"` and finishes its stream. 3. The host, after reading `pair.done`, stores the initiator as trusted and closes the connection with application code **0**. 4. The initiator stores the host as trusted only when it observes the close with code 0. Any failure closes with application code **1** and neither side stores anything. A host that fails waits briefly (up to 2 s) for the initiator to close first, so its `error` message is read before the connection goes away. ### 9.5 Unpairing Either device may send `pair.revoke` on an established session, then deletes the trust entry. A device receiving it deletes its entry too. Unpairing locally without notifying the peer is always allowed; the peer will then be rejected with `UNPAIRED` on its next connection attempt. ## 10. Compatibility rules 1. Adding a message type, a field, a capability or a service is **not** a breaking change. 2. Removing or changing the meaning of a field, message or operation **is** breaking: it requires a new operation version (`v`) or a new protocol version. 3. Every app version **MUST** keep accepting the previous two protocol versions once v1 ships. 4. CI runs the current build against the previous two releases in both directions. ## 11. Security notes - Device keys are stored encrypted at rest (DPAPI on Windows, Android Keystore wrapping on Android). - The pairing secret is single-use, kept only in memory, and expires with pairing mode. - Implementations **MUST NOT** log message bodies of notification, message, clipboard or file-name content. - Frame and message decoders are fuzzed in CI. ## 12. Continuity Camera (`camera.capture.*`) A paired PC that announces `camera.continuity` can ask a paired phone that announces `camera.capture` to open its camera UI and capture either a still photo (`"photo"`) or a perspective-cropped, contrast-enhanced document scan (`"scan"`). Continuity Camera is gated by the per-device `"photos"` toggle on both ends. ### 12.1 Control messages - `camera.capture.request`: `{ request_id: text, mode: "photo" | "scan" }`, sent by the PC on the control stream (`request_id` is 1..=64 non-control bytes). - `camera.capture.ok`: `{ request_id: text }`: sent by the phone when the capture UI has been launched. - `camera.capture.cancel`: `{ request_id: text, reason?: text }`: sent by either device to cancel an in-flight capture request (or by the phone if `"photos"` sharing is disabled or the camera UI cannot be launched). ### 12.2 Result stream (`svc = "camera"`, `op = "result"`, `v = 1`) When the user accepts the captured photo or scanned document on the phone: 1. The phone opens a bidirectional QUIC stream and sends the stream header: `t = "stream"`, `b = { svc: "camera", op: "result", v: 1 }`. 2. Next, the phone sends a metadata frame: `t = "camera.capture.meta"`, `b = { request_id: text, mode: "photo" | "scan", file_name: text, mime: "image/jpeg" | "image/png", size: uint, width: uint, height: uint }` (`1 <= size <= 33_554_432` bytes, `file_name` up to 255 bytes). 3. Immediately after the metadata frame, the phone writes `size` raw image bytes and finishes its send side. 4. The PC saves a copy into its downloads directory, replies `t = "ok"` (or `t = "error"` if denied or malformed), finishes its send side, records a `Photo` entry in the local timeline, and surfaces the image to the UI for one-click clipboard copy, Save As, or drag-and-drop. ## 13. LocalSend v2.1 Interoperability (Optional LAN Interop) In addition to NectarLink's native QUIC/iroh protocol, any NectarLink node can optionally enable **LocalSend v2.1** interoperability (off by default, stored in the local settings table as `localsend.enabled`) to send files to and receive files from unpaired LocalSend devices (iOS, macOS, Linux, Windows, Android) on the local network. ### 13.1 Discovery & Coexistence - **Multicast group:** UDP `224.0.0.167:53317` (`SO_REUSEADDR` enabled so multiple listeners can share the multicast group). - **HTTP/HTTPS server port:** TCP `53317` by default (configurable via `NodeConfig::localsend_port` or `NECTARLINK_LOCALSEND_PORT`). The TCP listener peeks at the first byte of each incoming connection (`0x16` = TLS ClientHello) to serve both HTTPS and plain HTTP on the same socket. - **Coexistence:** If TCP port `53317` is already bound by a standalone LocalSend app on the same machine, NectarLink logs a warning, sets `localsend_receiving() = false` (leaving incoming transfers to the standalone LocalSend app), and still joins UDP multicast discovery and supports outgoing transfers to discovered LocalSend peers. - **Identity & TLS:** NectarLink generates a deterministic self-signed X.509 v3 certificate (Ed25519) from the node's device key and advertises its uppercase hex SHA-256 DER digest as the LocalSend `fingerprint`. If a peer's fingerprint matches a paired NectarLink `DeviceId`, it is de-duplicated in favor of the native NectarLink session; otherwise a deterministic synthetic `DeviceId` is derived from `BLAKE3("nectarlink-localsend-peer-v1:" || fingerprint_or_addr)`. ### 13.2 REST Endpoints (`/api/localsend/v2/...`) - `GET /api/localsend/v2/info` (and `GET /api/localsend/v1/info`): returns `{ alias, version: "2.1", deviceModel, deviceType, fingerprint, download: false }`. - `POST /api/localsend/v2/register` (and `POST /api/localsend/v1/register`): records the announcing peer in the in-memory `LocalSendPeer` table and replies with this node's info JSON. - `POST /api/localsend/v2/prepare-upload`: validates the incoming file manifest (up to 1,000 files, valid relative paths with no `..` traversal), creates a transfer in `TransferState::Requested`, emits `NodeEvent::TransferUpdated`, and waits up to 60 seconds for explicit user consent (`accept_transfer` or `cancel_transfer`) before returning `{ sessionId, files: { fileId: token } }` or HTTP `403` when declined. - `POST /api/localsend/v2/upload?sessionId=...&fileId=...&token=...`: streams the raw request body to a `.part` file in the downloads folder, atomically renames it to the final destination path on completion, updates `NodeEvent::TransferUpdated`, and records the completed transfer in the timeline and received-files store. - `POST /api/localsend/v2/cancel?sessionId=...`: cancels an in-flight upload session and transitions the transfer to `TransferState::Cancelled`.