diff --git a/lib/universal_proxy/audio/input.ex b/lib/universal_proxy/audio/input.ex index f497ea7e..0a1d51c9 100644 --- a/lib/universal_proxy/audio/input.ex +++ b/lib/universal_proxy/audio/input.ex @@ -77,4 +77,45 @@ defmodule UniversalProxy.Audio.Input do catch :exit, _ -> :ok end + + @doc """ + Read an input's Sendspin pairing token (the `"SP:0…"` string a server such + as Music Assistant takes to pair with it over the Pairing PSK method), + minting its pairing PSK on first use. + + Built on every call from the persisted identity and never cached or + broadcast: it is a long-lived pairing secret. `{:error, :unavailable}` when + the input isn't present, the subsystem is down, or the store can't answer. + """ + @spec pairing_token(key()) :: {:ok, String.t()} | {:error, :unavailable} + def pairing_token({_, _, _} = key) do + Server.pairing_token(key) + catch + :exit, _ -> {:error, :unavailable} + end + + @doc """ + Unpair an input from one server (`server_id`) or from every server + (`:all`): the pairing record goes, and a live session it keyed is closed so + the server sees the change and can re-pair. `{:error, :unavailable}` when + the input isn't present or the subsystem is down. + """ + @spec unpair(key(), String.t() | :all) :: :ok | {:error, :unavailable} + def unpair({_, _, _} = key, which) when is_binary(which) or which == :all do + Server.unpair(key, which) + catch + :exit, _ -> {:error, :unavailable} + end + + @doc """ + Replace an input's pairing PSK, so its old pairing token no longer pairs. + Existing pairing records keep working. `{:error, :unavailable}` when the + input isn't present or the subsystem is down. + """ + @spec regenerate_pairing_token(key()) :: :ok | {:error, :unavailable} + def regenerate_pairing_token({_, _, _} = key) do + Server.regenerate_pairing_token(key) + catch + :exit, _ -> {:error, :unavailable} + end end diff --git a/lib/universal_proxy/audio/input/server.ex b/lib/universal_proxy/audio/input/server.ex index f5b18092..8c6412c2 100644 --- a/lib/universal_proxy/audio/input/server.ex +++ b/lib/universal_proxy/audio/input/server.ex @@ -33,9 +33,13 @@ defmodule UniversalProxy.Audio.Input.Server do "sendspin:input_state" {:input_state, key, state_map} `input_map` is what `list_inputs/1` returns for a single entry: enumerate - info merged with the *non-secret* half of the persisted config. The PSK, + info merged with the *non-secret* half of the persisted config (the name, + and a `pairings` summary of `%{server_id, paired_at}` per paired server, + which also rides `input_state` when it changes). The PSKs, the PSK id and the client keypair never leave this subsystem — a LiveView - assign is the last place long-term key material should live. + assign is the last place long-term key material should live. The one + exception is `pairing_token/2`, answered only to an explicit request (the + operator's "Show" on the Audio tab) and never broadcast. `state_map` is the derived live state of one input: @@ -197,6 +201,38 @@ defmodule UniversalProxy.Audio.Input.Server do GenServer.call(server, {:allow_pairing, key}) end + @doc """ + The pairing token of a currently-present input (see + `Audio.Input.pairing_token/1`). `{:error, :unavailable}` for an absent input + or a store failure — the reason is never passed through. + """ + @spec pairing_token(GenServer.server(), key()) :: {:ok, String.t()} | {:error, :unavailable} + def pairing_token(server \\ __MODULE__, {slot_sub, _vid, _pid} = key) + when is_binary(slot_sub) do + GenServer.call(server, {:pairing_token, key}) + end + + @doc """ + Remove an input's pairing record for `server_id`, or all of them (`:all`), + closing a live session that one keyed. `{:error, :unavailable}` for an + absent input or a store failure. + """ + @spec unpair(GenServer.server(), key(), String.t() | :all) :: :ok | {:error, :unavailable} + def unpair(server \\ __MODULE__, {slot_sub, _vid, _pid} = key, which) + when is_binary(slot_sub) and (is_binary(which) or which == :all) do + GenServer.call(server, {:unpair, key, which}) + end + + @doc """ + Mint a new pairing PSK for an input, invalidating its pairing token (and + closing a live session keyed by the old one). Pairing records are kept. + """ + @spec regenerate_pairing_token(GenServer.server(), key()) :: :ok | {:error, :unavailable} + def regenerate_pairing_token(server \\ __MODULE__, {slot_sub, _vid, _pid} = key) + when is_binary(slot_sub) do + GenServer.call(server, {:regenerate_pairing_token, key}) + end + # -- Server callbacks -- @impl GenServer @@ -281,6 +317,37 @@ defmodule UniversalProxy.Audio.Input.Server do {:reply, :ok, refresh_inputs(state)} end + def handle_call({:pairing_token, key}, _from, state) do + reply = + with true <- Map.has_key?(state.inputs, key), + {:ok, token} <- store_pairing_token(state.store, key) do + {:ok, token} + else + _ -> {:error, :unavailable} + end + + {:reply, reply, state} + end + + def handle_call({:unpair, key, which}, _from, state) do + with true <- Map.has_key?(state.inputs, key), + :ok <- store_unpair(state.store, key, which) do + {:reply, :ok, + state |> pairings_changed(key) |> refresh_config(key) |> publish_pairings(key)} + else + _ -> {:reply, {:error, :unavailable}, state} + end + end + + def handle_call({:regenerate_pairing_token, key}, _from, state) do + with true <- Map.has_key?(state.inputs, key), + :ok <- store_regenerate(state.store, key) do + {:reply, :ok, pairings_changed(state, key)} + else + _ -> {:reply, {:error, :unavailable}, state} + end + end + def handle_call({:allow_pairing, key}, _from, state) do case Map.fetch(state.sources, key) do {:ok, %{pid: pid}} -> source_allow_pairing(state.source_module, pid) @@ -491,17 +558,28 @@ defmodule UniversalProxy.Audio.Input.Server do defp default_friendly_name(%{name: name}), do: name @hardware_keys [:name, :alsa_device, :card_index, :vid, :pid, :usb_port] - # Only the non-secret half of the persisted config crosses this boundary. - # `psk`, `psk_id` and `client_keypair` are long-term key material and stay - # inside the Store / Source pair. - @public_config_keys [:friendly_name, :paired_at] + # Only the non-secret half of the persisted config crosses this boundary: + # the name, and per pairing record just the server's public key and when it + # paired. The records' PSKs and ids, the pairing PSK and `client_keypair` + # are key material and stay inside the Store / Source pair. + @public_config_keys [:friendly_name] defp merge(key, info, config) do + pairings = pairing_summaries(config) + info |> Map.take(@hardware_keys) |> Map.put(:key, key) |> Map.merge(Map.take(config, @public_config_keys)) - |> Map.put(:paired, not is_nil(Map.get(config, :paired_at))) + |> Map.put(:pairings, pairings) + |> Map.put(:paired, pairings != []) + end + + defp pairing_summaries(config) do + config + |> Map.get(:pairings, []) + |> Enum.map(&Map.take(&1, [:server_id, :paired_at])) + |> Enum.sort_by(& &1.paired_at, {:desc, DateTime}) end # Keys present in both cache and enumeration whose enumerated hardware fields @@ -638,6 +716,47 @@ defmodule UniversalProxy.Audio.Input.Server do # Drives the source's `allow_pairing/1` consent gesture through the same # `:source_module` seam the rest of the lifecycle uses. Tolerant of a test # stub that doesn't implement it. + defp pairings_changed(state, key) do + case Map.fetch(state.sources, key) do + {:ok, %{pid: pid}} -> source_pairings_changed(state.source_module, pid) + :error -> :ok + end + + state + end + + defp source_pairings_changed(module, pid) do + module.pairings_changed(pid) + rescue + UndefinedFunctionError -> :ok + catch + :exit, _ -> :ok + end + + # The row's non-secret pairing summary changed: tell subscribers through the + # live-state topic (an `input_added` upsert would reset the card's state). + defp publish_pairings(state, key) do + case Map.fetch(state.inputs, key) do + {:ok, %{pairings: pairings}} -> update_input_state(state, key, %{pairings: pairings}) + _ -> state + end + end + + defp store_unpair(store, key, which) do + case which do + :all -> Store.clear_pairing(store, key) + server_id -> Store.remove_pairing(store, key, server_id: server_id) + end + catch + :exit, _ -> {:error, :unavailable} + end + + defp store_regenerate(store, key) do + Store.regenerate_pairing_psk(store, key) + catch + :exit, _ -> {:error, :unavailable} + end + defp source_allow_pairing(module, pid) do module.allow_pairing(pid) rescue @@ -738,6 +857,12 @@ defmodule UniversalProxy.Audio.Input.Server do state |> refresh_config(key) |> update_input_state(key, %{status: :paired, pin: nil}) + |> publish_pairings(key) + end + + # The server dropped its record (`server/unpair`); the source closes next. + defp handle_source_event(state, key, :unpaired) do + state |> refresh_config(key) |> publish_pairings(key) end # A failed attempt leaves the connection up: MA retries by offering the @@ -854,6 +979,12 @@ defmodule UniversalProxy.Audio.Input.Server do |> String.slice(0, @max_error_length) end + defp store_pairing_token(store, key) do + Store.pairing_token(store, key) + catch + :exit, _ -> {:error, :unavailable} + end + # -- mDNS -- defp register_mdns(%{mdns_module: nil} = state, _key, _port), do: state diff --git a/lib/universal_proxy/audio/input/source.ex b/lib/universal_proxy/audio/input/source.ex index 1b67d6bb..f6dbeafd 100644 --- a/lib/universal_proxy/audio/input/source.ex +++ b/lib/universal_proxy/audio/input/source.ex @@ -36,11 +36,12 @@ defmodule UniversalProxy.Audio.Input.Source do :awaiting_handshake waiting for Noise message 1 :awaiting_server_hello sent Noise message 2, transport mode is up :awaiting_activate sent client/hello; the `client/time` clock-sync - loop runs from here for the rest of the connection - :pairing_required activate withheld source@v1 and granted the - pairing activity while we are unpaired; we are - holding the offer for operator consent, or a PIN - attempt is running or waiting to be re-offered + loop runs from the first server/activate on, for + the rest of the connection + :pairing_required an unpaired session was offered the pairing + activity; we are holding a code offer for operator + consent, or an attempt is running or waiting to be + re-offered :awaiting_rehandshake pairing completed; waiting for the server to re-run the Noise handshake in band :syncing source@v1 active; reports available once the @@ -53,57 +54,79 @@ defmodule UniversalProxy.Audio.Input.Source do Exactly one MA connection at a time. A second inbound connection **replaces** the first (see "Protocol decisions" below). + ## Keys and activation + + Noise message 1 names the PSK the server keys the session with (`psk_id`) + and the category it uses it as (`psk_category`): our **long-term** PSK from + a completed pairing, our **pairing PSK** (the secret behind the input's + pairing token, `Store.pairing_token/2`), or the public **Sentinel**. A + `psk_id` we do not hold under that category falls back to the Sentinel in + the initial handshake (`connection.md` "Sentinel Fallback") and fails a + re-handshake. We advertise `unpaired_access: false`, so only a long-term + session may activate `source@v1` (internally: trust `user`); an unpaired one + that asks for playback or roles is closed with `client/goodbye` + `pairing_required`, and a long-term one offered pairing with + `unauthorized`. + ## Pairing - `source@v1` can never activate at trust `none`, so a capture card must pair - before it can stream. The server signals that by granting the `pairing` - activity while withholding `source@v1`. - - Pairing is gated on an explicit **local operator gesture** - (`allow_pairing/1`, a button on the Audio tab). A peer-sent pairing - `server/activate` on its own does **not** open an attempt — but it is not - instantly aborted either. We **hold** it in `:pairing_required` (surfacing the - "Allow pairing" affordance) and wait, mirroring MA's `start_pin_pairing`, - which sends the offer and then waits for `client/pair-init` rather than - demanding an immediate reply. Only once the operator opens a time-boxed - (120 s) window do we send `client/pair-init` and drive - `UniversalProxy.Sendspin.Pairing` through the CPace exchange. If the operator - never consents, a consent-wait timeout (also 120 s, matching MA's window) - sends `pair/abort` and returns the connection to idle — an un-consented offer - never pairs, so an attacker gains nothing. Attempts are - capped at 3 per connection with a backoff between - retries (bounding an online PIN brute force), and an attempt is refused - outright while a long-term PSK already exists (an operator must unpair first) - so a Sentinel-keyed attacker can't overwrite the stored PSK. - - **We derive and display the PIN** — the operator types it into Music + We offer two methods (`client/hello` `supported_pair_methods`): + + * **Pairing PSK** — the operator pastes the input's pairing token into + Music Assistant, which (re-)handshakes to the pairing PSK and offers + `pairing_psk`. Holding the token is the authorization, so the attempt + opens at once: `client/pair-init` + `client/pair-finalize + {long_term_psk}`. + * **Dynamic pairing code** (`digits`, shown on the Audio tab). This is + gated on an explicit **local operator gesture** (`allow_pairing/1`, a + button on the Audio tab). A peer-sent offer on its own does **not** open + an attempt: we **hold** it in `:pairing_required` and tell the server so + with `client/pair-pending`, surfacing the "Allow pairing" affordance. Only + once the operator opens a time-boxed (120 s) window do we send + `client/pair-init` and drive `UniversalProxy.Sendspin.Pairing` through + the CPace exchange. If the operator never consents, a consent-wait + timeout (also 120 s) sends `pair/abort` and returns the connection to + idle — an un-consented offer never pairs. Attempts are capped at 3 per + connection with a backoff between them, and mistyped codes retry in + further rounds up to the spec's 20-round limit per consent window. + + Either method stores a **pairing record** for the server (up to 8 per + input, `Store.save_pairing/4`): it replaces that server's earlier record or + is added beside the others, so pairing a second server never evicts the + first unless storage is full. A paired server may drop its record with + `server/unpair` (we remove ours, say `client/goodbye` `unpaired` and close); + the operator can do the same per server from the Audio tab, or regenerate the + pairing token (`pairings_changed/1` closes a session whose record or pairing + PSK is gone). + + **We derive and display the code** — the operator types it into Music Assistant, not the other way round. It surfaces as `{:pairing_pin, pin}` once `server/pair-init` reveals the server's nonce. - A completed attempt persists the minted PSK via `Store.save_pairing/3` and - the server then re-runs the Noise handshake **in band** (ground truth §4): - two `noise/handshake` messages carried as ordinary encrypted JSON, prologued - with the *previous* handshake's hash rather than the `client/init` + - `server/init` bytes, our message 2 still encrypted under the old session. - Afterwards the connection replays `server/hello` → `client/hello` (trust - now `user`) → `server/activate`, which is why a re-handshake rewinds the FSM - to `:awaiting_server_hello` and resets the pairing counter. - - An attempt that fails — the server's MCF tag not verifying (a mistyped - PIN), a `pair/abort` from the server, or our own 120 s window expiring — - leaves the connection up and the FSM back in `:pairing_required`, because - the server retries by sending another pairing `server/activate` and each one - increments `pairing_index`. + A completed attempt persists the minted PSK via `Store.save_pairing/4` and + the server then re-runs the Noise handshake **in band**: two + `noise/handshake` messages carried as ordinary encrypted JSON, prologued + with the *previous* handshake's hash, our message 2 still encrypted under the + old session. The server's next message is a fresh `server/activate` (no + hellos are replayed), and until it arrives we send nothing else. Connection + state — the FSM, an open stream, the clock filter — persists across it; only + the keys, the matched PSK and the pairing counter change. + + An attempt that fails — a `pair/abort` from the server, our round limit, or + our own 120 s window expiring — leaves the connection up and the FSM back in + `:pairing_required`, because the server retries by sending another pairing + `server/activate` and each one increments `pairing_index`. ## Streaming lifecycle `source@v1` streaming is *server-commanded*: the default after a handshake is stopped, and a server MUST NOT be sent audio until it asks - (`roles/source/v1.md` lines 44-50; aiosendspin's server role silently drops - — and flags as noncompliant — a `client_stream/start` that arrives without a - preceding `server/command.source.command = "start"`). So capture is spawned - and `client_stream/start` sent only on that command, and `stop` tears both - down again with a `client_stream/end`. Both directions are idempotent. + (`roles/source/v1.md` "Default streaming behavior"; a `client-stream/start` + without a preceding `server/command.source.command = "start"` is a protocol + error). So capture is spawned and `client-stream/start` sent only on that + command, and `stop` tears both down again with a `client-stream/end`. Both + directions are idempotent. Every `client/state` an active source sends + carries the (full) `source` object. Audio frames are stamped in the **server's** clock domain: `ClockFilter.server_time/2` maps our local capture time forward. Both the @@ -127,13 +150,15 @@ defmodule UniversalProxy.Audio.Input.Source do second) or closed (`nil`) :pairing_started client/pair-init sent, attempt running {:pairing_pin, pin} show this PIN; the user types it into MA + :unpaired the server unpaired us (`server/unpair`); + its record is removed :paired PSK minted and persisted; awaiting the post-pairing re-handshake :pairing_declined a held offer expired without the operator's consent; we sent `pair/abort` and are back connected-and-idle {:pairing_failed, reason} attempt abandoned; MA may offer another - :streaming capture up, client_stream/start sent + :streaming capture up, client-stream/start sent :stopped streaming ended (server stop or capture exit) :disconnected connection gone; the listener stays up {:capture_missing, path} no arecord binary — FSM is :degraded @@ -156,15 +181,13 @@ defmodule UniversalProxy.Audio.Input.Source do incumbent fails that probe. Accepts are also rate-limited per source. * **No outbound fragmentation.** Every message we send is far below the 65535-byte Noise transport limit (a 20 ms PCM frame is 3,849 bytes), so - we only *decode* fragment types 2/3 (via `Wire.Reassembler`), never emit + we only *decode* fragments (type 1, via `Wire.Reassembler`), never emit them. - * **A re-handshake stops an open stream.** The spec is ambiguous about - whether streaming survives one (`roles/source/v1.md` says a fresh - handshake resets to "not streaming", but the server's role object — - and therefore its `_start_requested` flag — outlives an in-band - re-handshake). We stop, because the replayed `server/activate` is - free to withhold the role entirely; a server that wanted the stream - back re-sends `server/command.start`, which spec explicitly permits. + * **Capture frames are dropped across a re-handshake.** Between Noise + message 1 and the next `server/activate` the client may send nothing but + the handshake, so frames captured in that window are discarded rather + than queued; the stream itself stays open and resumes, or is ended with + `client-stream/end`, depending on what that activation keeps. """ # `:temporary` — the owner (`Audio.Input.Server`, P4-T2) holds the per-card @@ -237,17 +260,27 @@ defmodule UniversalProxy.Audio.Input.Source do :server_pair_finalize ] - # We advertise one method and can drive one method. - @pair_method :dynamic_pin - @min_pin_digits 4 - @max_pin_digits 12 - @default_min_pin_length 6 - - # Pairing is gated on an explicit local operator gesture - # (`allow_pairing/1`): a peer-sent pairing `server/activate` never opens an - # attempt on its own. The window is time-boxed, at most three attempts run - # per connection, and each retry backs off — bounding an online PIN brute - # force to a handful of guesses. + # The `source@v1` states, in which the role is active on this connection. + @active_states [:syncing, :ready, :streaming, :degraded] + + # Shown by the server while we hold a code offer for the operator's gesture + # (`client/pair-pending`, at most 200 characters). + @pair_pending_message "Open the Audio tab on the device and click Allow pairing." + + # In-band re-handshakes an unpaired session may drive per connection. Each of + # the `@max_pairing_attempts` (3) attempts a server may run costs at most two: + # into the pairing PSK and, if it is cancelled, back to its record (or the + # post-pairing re-key to the new record on success). Two more cover a key + # rotation and a retry after a dropped re-key. The cap bounds a + # Sentinel-keyed peer looping handshakes for the X25519 work. + @max_unpaired_rehandshakes 8 + + # Code pairing is gated on an explicit local operator gesture + # (`allow_pairing/1`): a peer-sent pairing `server/activate` never opens a + # code attempt on its own. The window is time-boxed, at most three attempts + # run per connection, each retry backs off, and the code rounds within one + # window are capped at the spec's round limit — bounding an online code + # brute force. @max_pairing_attempts 3 @pairing_backoff_ms 300 @pairing_window_ms 120_000 @@ -295,6 +328,15 @@ defmodule UniversalProxy.Audio.Input.Source do # before it reaches the log so a peer can't flood RingLogger. @log_reason_max 200 + # Per-connection bookkeeping (see the `conn` field). + @conn_defaults %{ + rekeying: false, + unpaired_rehandshakes: 0, + pairing_attempts: 0, + record_id: nil, + stream_end_pending: false + } + @derive {Inspect, except: [:keypair]} defstruct [ :key, @@ -333,7 +375,6 @@ defmodule UniversalProxy.Audio.Input.Source do :parked_monitor, :liveness_timer, :last_pairing_params, - :pairing_window_timer, :pairing_attempt_timer, :consent_wait_timer, :consent_wait_gen, @@ -343,12 +384,23 @@ defmodule UniversalProxy.Audio.Input.Source do :incumbent_probe_ms, fsm: :listening, trust: :none, + # Per-connection bookkeeping, reset at teardown: `rekeying` holds between + # Noise message 1 of an in-band re-handshake and the next + # `server/activate` (nothing but the handshake may be sent), with a + # `client-stream/end` due at that activation in `stream_end_pending`; the + # two counters bound unpaired re-handshakes and pairing attempts; and + # `record_id` is the `psk_id` of the pairing record (or pairing PSK) keying + # the session, `nil` under the Sentinel. + conn: @conn_defaults, + # The operator's open "allow pairing" consent window, `nil` when closed: + # its timer and the code rounds emitted under it (`pairing.md` "Rounds" — + # opening a window is the operator action that resets the count). + pairing_window: nil, reassembler: nil, filter: nil, outstanding: [], start_requested: false, pairing_index: 0, - pairing_allowed: false, accepts: [], frames_dropped: 0, # A monotonic counter stamped into every cross-connection-risky timer @@ -378,6 +430,7 @@ defmodule UniversalProxy.Audio.Input.Source do | :pairing_started | {:pairing_pin, String.t()} | :paired + | :unpaired | {:pairing_failed, term()} | :pairing_declined | :streaming @@ -417,7 +470,7 @@ defmodule UniversalProxy.Audio.Input.Source do product/manufacturer/version/mac `Audio.Player` reports. * `:time_burst_ms` — pre-convergence `client/time` cadence (default #{@time_burst_ms}). - * `:pairing_timeout_ms` — how long one PIN attempt may take before we + * `:pairing_timeout_ms` — how long one pairing attempt may take before we send `pair/abort` with `attempt_timeout` (default `Sendspin.Pairing.attempt_timeout_ms/0`, 120 s). * `:pairing_window_ms` — the consent window: both how long a held pairing @@ -447,6 +500,14 @@ defmodule UniversalProxy.Audio.Input.Source do @spec allow_pairing(GenServer.server()) :: :ok def allow_pairing(server), do: GenServer.cast(server, :allow_pairing) + @doc """ + The input's pairing records or pairing PSK changed outside this process (an + operator unpaired a server or regenerated the token). A live session keyed + by a record or pairing PSK that is gone is closed; the server can re-pair. + """ + @spec pairings_changed(GenServer.server()) :: :ok + def pairings_changed(server), do: GenServer.cast(server, :pairings_changed) + # -- Server callbacks -- @impl GenServer @@ -503,6 +564,22 @@ defmodule UniversalProxy.Audio.Input.Source do {:noreply, open_pairing_window(state)} end + def handle_cast(:pairings_changed, state) do + if session_key_revoked?(state) do + Logger.info( + "Audio.Input.Source #{inspect(state.key)} closing a session whose pairing was revoked" + ) + + # No `client/goodbye`: the server's next dial lands in the Sentinel + # Fallback, which tells it our record is gone and offers re-pairing. + state = teardown_connection(state, :pairing_revoked) + notify(state, :disconnected) + {:noreply, state} + else + {:noreply, state} + end + end + @impl GenServer def handle_info({:sendspin_ws_open, pid}, state) do cond do @@ -643,7 +720,7 @@ defmodule UniversalProxy.Audio.Input.Source do end def handle_info({:open_pairing_attempt, params}, state) do - if can_open_pairing?(state) do + if can_open_pairing?(state, params) do case open_pairing_now(%{state | pairing_attempt_timer: nil}, params) do {:ok, state} -> {:noreply, state} {:error, reason, state} -> {:noreply, fail(state, reason)} @@ -678,7 +755,10 @@ defmodule UniversalProxy.Audio.Input.Source do # Stale (old-connection or superseded) or fired-after-resolve pairing timeout. def handle_info({:pairing_timeout, _gen}, state), do: {:noreply, state} - def handle_info({:capture_frame, ts_us, frame}, %{fsm: :streaming} = state) do + def handle_info( + {:capture_frame, ts_us, frame}, + %{fsm: :streaming, conn: %{rekeying: false}} = state + ) do if outbound_congested?(state) do {:noreply, drop_frame(state)} else @@ -695,8 +775,9 @@ defmodule UniversalProxy.Audio.Input.Source do end end - # Frames that raced a `stop`, or arrived while degraded, are dropped: the - # server MUST NOT receive chunks outside an open stream. + # Frames that raced a `stop`, arrived while degraded, or were captured during + # a re-handshake are dropped: the server MUST NOT receive chunks outside an + # open stream, and nothing but the handshake may go out while re-keying. def handle_info({:capture_frame, _ts_us, _frame}, state), do: {:noreply, state} def handle_info({:capture_exit, status}, state) do @@ -741,6 +822,21 @@ defmodule UniversalProxy.Audio.Input.Source do :ok end + defp session_key_revoked?(%__MODULE__{socket: nil}), do: false + + defp session_key_revoked?(%__MODULE__{psk_category: :long_term} = state) do + not Enum.any?(Store.pairings(stored_pairing(state)), &(&1.psk_id == state.conn.record_id)) + end + + defp session_key_revoked?(%__MODULE__{psk_category: :pairing} = state) do + case stored_pairing(state) do + %{pairing_psk: <<_::256>> = psk} -> Pairing.psk_id_for(psk) != state.conn.record_id + _other -> true + end + end + + defp session_key_revoked?(_state), do: false + # -- Listener -- defp start_listener(state) do @@ -950,17 +1046,20 @@ defmodule UniversalProxy.Audio.Input.Source do # -- Cleartext handshake -- defp send_client_init(state) do - case Store.ensure_client_keypair(state.store, state.key) do - {:ok, {pub, _priv} = keypair} -> - client_id = Store.client_id(pub) - text = Wire.encode_client_init(client_id, @protocol_version, state.suite) - push(state, {:text, text}) - - {:ok, %{state | keypair: keypair, client_id: client_id, client_init: text}} - - {:error, reason} -> - {:error, {:keypair_unavailable, reason}} + # The pairing PSK must already exist when Noise message 1 arrives: a + # server holding our token keys the very first handshake with it. + with {:ok, {pub, _priv} = keypair} <- Store.ensure_client_keypair(state.store, state.key), + {:ok, _pairing_psk} <- Store.ensure_pairing_psk(state.store, state.key) do + client_id = Store.client_id(pub) + text = Wire.encode_client_init(client_id, @protocol_version, state.suite) + push(state, {:text, text}) + + {:ok, %{state | keypair: keypair, client_id: client_id, client_init: text}} + else + {:error, reason} -> {:error, {:keypair_unavailable, reason}} end + catch + :exit, reason -> {:error, {:keypair_unavailable, {:store_unavailable, reason}}} end defp handle_frame(%{fsm: :awaiting_server_init} = state, :text, text) do @@ -1013,18 +1112,20 @@ defmodule UniversalProxy.Audio.Input.Source do end # The server is the Noise initiator. Message 1's payload carries the - # `psk_id` it chose, and psk2 means the PSK is not mixed until message 2 — - # so message 1 authenticates under *any* PSK and we can read the id out of - # it before committing. Decibel fixes the PSK at session creation, so a - # mismatch means opening a second session and re-reading the same bytes. + # `psk_id` it chose and its `psk_category`, and psk2 means the PSK is not + # mixed until message 2 — so message 1 authenticates under *any* PSK and we + # can read them out of it before committing. Decibel fixes the PSK at + # session creation, so a mismatch means opening a second session and + # re-reading the same bytes. defp run_handshake(state, message_1) do prologue = Wire.prologue(state.client_init, state.server_init) - case exchange_handshake(state, message_1, prologue) do - {:ok, session, category} -> + case exchange_handshake(state, message_1, prologue, :sentinel_fallback) do + {:ok, session, category, record_id} -> case Noise.write_handshake(session, @noise_msg2_payload) do {:ok, message_2} -> push(state, {:text, Wire.encode_noise_handshake(message_2)}) + touch_record(state, category, record_id) {:ok, %{ @@ -1034,6 +1135,7 @@ defmodule UniversalProxy.Audio.Input.Source do psk_category: category, trust: trust_level(category), pairing_index: 0, + conn: %{state.conn | record_id: record_id}, fsm: :awaiting_server_hello }} @@ -1057,27 +1159,34 @@ defmodule UniversalProxy.Audio.Input.Source do # dictionary, so every error branch here MUST close whatever candidate # session it is holding; otherwise a peer that loops `connect → garbage # msg1 → disconnect` permanently accretes key material. - defp exchange_handshake(state, message_1, prologue) do - candidate = candidate_psk(state) + defp exchange_handshake(state, message_1, prologue, on_miss) do + # One Store read per handshake; every lookup below works on this config. + config = stored_pairing(state) + candidate = candidate_psk(config) with {:ok, session, payload} <- open_session(state, candidate, message_1, prologue) do - finalize_session(state, session, candidate, payload, message_1, prologue) + finalize_session( + %{state: state, config: config, on_miss: on_miss}, + {session, candidate}, + payload, + {message_1, prologue} + ) end end # Resolves the real PSK from message 1's plaintext and, if it differs from # the candidate we opened with, reopens under it. Closes the session it holds # on any error path so nothing leaks. - defp finalize_session(state, session, candidate, payload, message_1, prologue) do - with {:ok, psk_id} <- parse_psk_id(payload), - {:ok, psk, category} <- resolve_psk(state, psk_id) do - if psk == candidate do - {:ok, session, category} + defp finalize_session(ctx, {session, candidate}, payload, {message_1, prologue}) do + with {:ok, reference} <- Wire.decode_noise_msg1_payload(payload), + {:ok, psk, category, record_id} <- resolve_psk(ctx, reference) do + if secure_equal?(psk, candidate) do + {:ok, session, category, record_id} else close_noise(session) - case open_session(state, psk, message_1, prologue) do - {:ok, new_session, _payload} -> {:ok, new_session, category} + case open_session(ctx.state, psk, message_1, prologue) do + {:ok, new_session, _payload} -> {:ok, new_session, category, record_id} {:error, reason} -> {:error, reason} end end @@ -1114,61 +1223,82 @@ defmodule UniversalProxy.Audio.Input.Source do reason end - defp parse_psk_id(payload) do - case Jason.decode(payload) do - {:ok, %{"psk_id" => psk_id}} when is_binary(psk_id) -> {:ok, psk_id} - {:ok, _other} -> {:error, :missing_psk_id} - {:error, reason} -> {:error, {:invalid_json, reason}} - end - end + # Look the `psk_id` up among our PSKs of the declared category only. A miss + # in the initial handshake completes under the Sentinel (`connection.md` + # "Sentinel Fallback": the server learns we could not use the PSK it + # referenced); a miss during a re-handshake fails it. On a match the result + # carries the id of the record (or pairing PSK) now backing the session. + defp resolve_psk(ctx, %{psk_id: psk_id, psk_category: category}) do + case lookup_psk(ctx.config, category, psk_id) do + {:ok, psk, record} -> + verify_record_server(ctx.state, category, psk, record) - # Unpaired connections use the published Sentinel PSK; a paired one uses the - # long-term PSK we minted during pairing. Anything else is a lookup miss and - # the handshake cannot proceed (ground truth §1). - defp resolve_psk(_state, @sentinel_psk_id), do: {:ok, Noise.sentinel_psk(), :sentinel} + :miss when ctx.on_miss == :sentinel_fallback -> + Logger.info( + "Audio.Input.Source #{inspect(ctx.state.key)} does not hold the PSK the server " <> + "referenced (#{category}); completing the handshake under the Sentinel PSK" + ) - defp resolve_psk(state, psk_id) do - case stored_pairing(state) do - %{psk: <<_::256>> = psk, psk_id: stored_id} = pairing when is_binary(stored_id) -> - # `psk_id` is a public hash, so timing is a negligible leak — but a - # constant-time compare keeps the module uniformly constant-time. - if secure_equal?(stored_id, psk_id) do - pin_server_id(state, Map.get(pairing, :server_id)) - {:ok, psk, :long_term} - else - {:error, {:unknown_psk_id, psk_id}} - end + {:ok, Noise.sentinel_psk(), :sentinel, nil} - _other -> - {:error, {:unknown_psk_id, psk_id}} + :miss -> + {:error, {:unknown_psk_id, category}} end end - # Detect a stolen-PSK replay from a different host: the PSK is the - # authenticator (so we still proceed), but a long-term PSK presented under a - # server static key other than the one we paired with is worth flagging. - defp pin_server_id(%{server_id: current}, stored) - when is_binary(current) and is_binary(stored) do - unless secure_equal?(stored, current) do - Logger.warning( - "Audio.Input.Source long-term PSK presented from an unexpected server_id; " <> - "possible stolen-PSK replay" - ) + # `psk_id` is a public hash, so timing is a negligible leak — but a + # constant-time compare keeps the module uniformly constant-time. + defp lookup_psk(_config, :sentinel, psk_id) do + if secure_equal?(@sentinel_psk_id, psk_id), do: {:ok, Noise.sentinel_psk(), nil}, else: :miss + end + + defp lookup_psk(config, :long_term, psk_id) do + case Enum.find(Store.pairings(config), &secure_equal?(&1.psk_id, psk_id)) do + %{psk: psk} = record -> {:ok, psk, record} + nil -> :miss end end - defp pin_server_id(_state, _stored), do: :ok + defp lookup_psk(%{pairing_psk: <<_::256>> = psk}, :pairing, psk_id) do + if secure_equal?(Pairing.psk_id_for(psk), psk_id), do: {:ok, psk, nil}, else: :miss + end + + defp lookup_psk(_config, _category, _psk_id), do: :miss + + # A long-term PSK is bound to the server it was paired with: after a match, + # the record's `server_id` must equal the one in `server/init`, or the + # handshake fails (a misbinding, not a miss — no Sentinel Fallback). + defp verify_record_server(state, :long_term, psk, %{server_id: stored, psk_id: id}) do + if is_binary(state.server_id) and secure_equal?(stored, state.server_id), + do: {:ok, psk, :long_term, id}, + else: {:error, :pairing_record_server_mismatch} + end + + defp verify_record_server(_state, :pairing, psk, _record), + do: {:ok, psk, :pairing, Pairing.psk_id_for(psk)} + + defp verify_record_server(_state, category, psk, _record), do: {:ok, psk, category, nil} defp secure_equal?(a, b) when byte_size(a) == byte_size(b), do: :crypto.hash_equals(a, b) defp secure_equal?(_a, _b), do: false - defp candidate_psk(state) do - case stored_pairing(state) do - %{psk: <<_::256>> = psk} -> psk - _other -> Noise.sentinel_psk() + # Open the first session under the most recently used record (the likeliest + # server to be dialing), else the Sentinel. + defp candidate_psk(config) do + case Store.pairings(config) do + [%{psk: psk} | _rest] -> psk + [] -> Noise.sentinel_psk() end end + defp touch_record(state, :long_term, psk_id) when is_binary(psk_id) do + Store.touch_pairing(state.store, state.key, psk_id) + catch + :exit, _ -> :ok + end + + defp touch_record(_state, _category, _record_id), do: :ok + defp stored_pairing(state) do case Store.get_config(state.store, state.key) do {:ok, config} -> config @@ -1178,8 +1308,8 @@ defmodule UniversalProxy.Audio.Input.Source do :exit, _ -> %{} end - # `trust_level` is exactly "did the matched PSK come from a long-term - # pairing record" (aiosendspin `_compute_trust`). + # Trust `user` is exactly "the matched PSK is our long-term pairing record": + # only such a session may activate roles (we advertise no unpaired access). defp trust_level(:long_term), do: :user defp trust_level(_category), do: :none @@ -1197,27 +1327,46 @@ defmodule UniversalProxy.Audio.Input.Source do defp handle_transport_frame(state, _frame), do: {:ok, state} defp log_unknown(state, type) do - Logger.debug("Audio.Input.Source #{inspect(state.key)} ignoring message type #{type}") + Logger.debug( + "Audio.Input.Source #{inspect(state.key)} ignoring message type #{truncate_text(type)}" + ) + state end defp handle_message(%{fsm: :awaiting_server_hello} = state, :server_hello, _payload) do case send_client_hello(state) do - :ok -> - # Start the `client/time` clock-sync loop now, at connection setup — - # NOT at role activation. Time sync is connection-level and - # role-independent: aiosendspin's `_time_sync_loop` runs `while - # self.connected` and its server replies to every `client/time` - # regardless of any active role (ground truth §6), and a source@v1 MUST - # converge its filter before it can report available. Running it from - # here also keeps `server/time` replies flowing so a live-but-idle - # (connected-yet-unpaired) session never trips Cowboy's idle timeout — - # which is what lets MA hold the connection long enough to present its - # pairing controls. - {:ok, schedule_time_tick(%{state | fsm: :awaiting_activate}, 0)} + :ok -> {:ok, %{state | fsm: :awaiting_activate}} + {:error, reason} -> {:error, reason, state} + end + end - {:error, reason} -> - {:error, reason, state} + # The first `server/activate` after an in-band re-handshake. Whatever the + # FSM state, the session's PSK (and so its trust) may have changed: a role + # we hold active under a session that is no longer long-term is dropped + # first, then the activation is applied like any other. + defp handle_message(%{conn: %{rekeying: true} = conn} = state, :server_activate, payload) do + state = ensure_time_loop(%{state | conn: %{conn | rekeying: false}}) + + # A stream that ended while re-keying is closed now, ahead of the + # activation's own effects. + state = + if state.conn.stream_end_pending do + _ = send_json(state, Wire.encode_client_stream_end()) + put_in(state.conn.stream_end_pending, false) + else + state + end + + cond do + state.fsm in @active_states and state.trust != :user -> + handle_activate(deactivate_source(state), payload) + + state.fsm in @active_states -> + reaffirm_activate(state, payload) + + true -> + handle_activate(state, payload) end end @@ -1225,33 +1374,21 @@ defmodule UniversalProxy.Audio.Input.Source do # after a failed attempt, so `:pairing_required` accepts activate too. This # is the *initial* activation / pairing-retry path; a role change once # `source@v1` is already active is handled by the clause below. + # + # The `client/time` clock-sync loop starts at the first activation — the + # client may send nothing before it (`messaging.md` "Communication") — and + # runs for the rest of the connection: time sync is connection-level and + # role-independent, a `source@v1` must converge before it reports available, + # and the `server/time` replies keep a live-but-idle (connected-yet-unpaired) + # session from tripping Cowboy's idle timeout. defp handle_message(%{fsm: fsm} = state, :server_activate, payload) when fsm in [:awaiting_activate, :pairing_required] do - handle_activate(state, payload) + handle_activate(ensure_time_loop(state), payload) end - # `source@v1` is already active (`:syncing`/`:ready`/`:streaming`, or - # `:degraded` where capture couldn't open) and the server re-sends - # `server/activate`. Two cases, and neither may be silently ignored — doing so - # would keep us capturing after the role was revoked: - # - # * `source@v1` still present — a benign re-affirmation. We are already - # active, so this is a no-op: don't tear down a healthy stream or reset - # the converged clock filter (the source@v1 contract carries no "re-assert - # `client/state`" obligation, so nothing needs re-sending). - # * `source@v1` absent — MA revoked the role (another source took the - # target, an admin disabled it, …). Stop capture and, if streaming, send - # `client_stream/end`; rewind to `:awaiting_activate` holding the - # connection so a later `server/activate` re-activates without a - # reconnect. Disjoint from the pairing-retry path above (those states are - # not active), so the two never conflict. - defp handle_message(%{fsm: fsm} = state, :server_activate, %{active_roles: roles}) - when fsm in [:syncing, :ready, :streaming, :degraded] do - if is_list(roles) and @source_role in roles do - {:ok, state} - else - {:ok, deactivate_source(state)} - end + defp handle_message(%{fsm: fsm} = state, :server_activate, payload) + when fsm in @active_states do + reaffirm_activate(state, payload) end defp handle_message(state, :server_time, payload), do: handle_server_time(state, payload) @@ -1260,17 +1397,23 @@ defmodule UniversalProxy.Audio.Input.Source do handle_source_command(state, source) end - # The in-band re-handshake (ground truth §4). The spec expects it right after - # a successful pairing (`:awaiting_rehandshake`) or to rotate keys on an - # already-authenticated (trust `user`) session. Accepting it from any state - # let an unauthenticated peer drive unlimited X25519 handshakes on one - # connection (CPU amplification), so it is now gated to those two windows. - defp handle_message(%{fsm: :awaiting_rehandshake} = state, :noise_handshake, %{data: message_1}) do + # The in-band re-handshake. Expected to rotate keys on an authenticated + # (trust `user`) session, which is uncapped; and on an unpaired one right + # after a successful pairing, when the server switches it to our pairing PSK, + # or back to its record after a cancelled attempt. Those are capped per + # connection (`@max_unpaired_rehandshakes`) so an unauthenticated peer can't + # drive unlimited X25519 handshakes on one connection (CPU amplification). + defp handle_message(%{trust: :user} = state, :noise_handshake, %{data: message_1}) do run_rehandshake(state, message_1) end - defp handle_message(%{trust: :user} = state, :noise_handshake, %{data: message_1}) do - run_rehandshake(state, message_1) + defp handle_message( + %{conn: %{unpaired_rehandshakes: count} = conn} = state, + :noise_handshake, + %{data: m1} + ) + when count < @max_unpaired_rehandshakes do + run_rehandshake(%{state | conn: %{conn | unpaired_rehandshakes: count + 1}}, m1) end defp handle_message(state, :noise_handshake, %{data: _message_1}) do @@ -1281,6 +1424,22 @@ defmodule UniversalProxy.Audio.Input.Source do handle_pairing_message(state, tag, payload) end + # `messaging.md` `server/unpair`: a paired server drops its own record. In a + # session keyed by that record we drop ours too, say so and close; in an + # unpaired session it is ignored. + defp handle_message(%{trust: :user} = state, :server_unpair, _payload) do + Logger.info("Audio.Input.Source #{inspect(state.key)} unpaired by the server") + + case Store.remove_pairing(state.store, state.key, psk_id: state.conn.record_id) do + :ok -> notify(state, :unpaired) + {:error, reason} -> Logger.error("Audio.Input.Source unpair failed: #{inspect(reason)}") + end + + goodbye(state, :unpaired) + catch + :exit, _ -> goodbye(state, :unpaired) + end + defp handle_message(state, :pair_abort, %{reason: reason}) do {:ok, abandon_pairing(state, {:peer_abort, reason})} end @@ -1297,88 +1456,180 @@ defmodule UniversalProxy.Audio.Input.Source do Wire.encode_client_hello(%{ name: state.name, device_info: state.device_info, - trust_level: state.trust, supported_roles: [@source_role], - # Formats are NOT announced here — they go in `client_stream/start`. - # `features` is required whenever the role is listed. + # Formats are NOT announced here — they go in `client-stream/start`. source_v1_support: %{features: %{line_sense: false}}, supported_pair_methods: state.pair_methods, - # A source can never activate at trust `none`, so unpaired playback - # access is meaningless for us and stays off. + # A capture card exposes a live input to whoever streams it, so it + # only ever streams to a server it has paired with. unpaired_access: %{enabled: false} }) ) end - defp handle_activate(state, %{active_roles: roles, activities: activities, pairing: pairing}) do + # Apply an activation to a connection that has no active `source@v1` + # (`messaging.md` `server/activate`: allowed activity sets per matched PSK, + # and the rejection order when one is not admissible). An activation without + # `pairing` also cancels any attempt or held offer in progress. + defp handle_activate(state, %{active_roles: roles, activities: activities} = payload) do + roles = roles || [] + pairing? = :pairing in activities + state = if pairing?, do: state, else: leave_pairing(state) + cond do - is_list(roles) and @source_role in roles and state.trust == :user -> + # Not an allowed activity set for any key we hold: one we don't know, or + # pairing on a long-term session. + not allowed_activities?(state, activities) -> + goodbye(state, :unauthorized) + + state.psk_category == :long_term and @source_role in roles -> notify(state, :activated) state |> discard_pairing() |> activate() - # `source@v1` MUST NOT activate at trust `none` (ground truth §7 / §964). - # A spec-compliant server never does this; one that does is violating the - # protocol, so we refuse and close rather than silently opening capture. - is_list(roles) and @source_role in roles -> - {:error, {:source_activated_untrusted, state.trust}, state} - - :pairing in activities and state.trust == :none -> - notify(state, {:pairing_required, pairing}) - state = %{discard_pairing(state) | fsm: :pairing_required, last_pairing_params: pairing} - maybe_start_pairing(state, pairing) - - # An idle `server/activate`: neither a `source@v1` role nor a pairing - # activity. This is MA's steady state before the operator initiates - # pairing (it dials every discovered source and holds it). Stay connected - # and idle in `:awaiting_activate` so MA keeps a stable connection and can - # later send a pairing or `source@v1` activate; closing here made the - # source flap "unavailable" and never present a device to pair. + # Unpaired, and we admit no unpaired access: playback or roles would only + # be admissible with it enabled. + unpaired_playback?(state, activities, roles) -> + goodbye(state, :pairing_required) + + pairing? -> + pairing_offer(state, payload.pairing) + + # An idle `server/activate`: no role, no pairing. This is MA's steady + # state before the operator acts (it dials every discovered source and + # holds it). Stay connected and idle in `:awaiting_activate` so MA keeps a + # stable connection and can later send a pairing or `source@v1` activate; + # closing here made the source flap "unavailable" and never present a + # device to pair. true -> Logger.debug( "Audio.Input.Source #{inspect(state.key)} idle activate (roles=#{inspect(roles)}); staying idle" ) + {:ok, %{state | fsm: :awaiting_activate}} + end + end + + # `source@v1` is already active and the server re-sends `server/activate`. + # Neither case may be silently ignored — doing so would keep us capturing + # after the role was revoked: + # + # * `source@v1` still present (or `active_roles` omitted, which keeps the + # previous roles) — a benign re-affirmation: don't tear down a healthy + # stream or reset the converged clock filter. + # * `source@v1` absent — the role was revoked. Stop capture and, if + # streaming, send `client-stream/end`; rewind to `:awaiting_activate` + # holding the connection, then apply the rest of the activation (which + # may, for instance, offer pairing). + defp reaffirm_activate(state, %{active_roles: roles} = payload) do + cond do + :pairing in payload.activities or unknown_activity?(payload.activities) -> + goodbye(state, :unauthorized) + + is_nil(roles) or @source_role in roles -> {:ok, state} + + true -> + handle_activate(deactivate_source(state), payload) end end defp activate(state) do - # The server ignores binary chunks until it has seen an initial - # `client/state`, and `available` must stay false until the clock filter - # converges (ground truth §6/§7). The `client/time` loop has been running - # since `:awaiting_activate`, so the filter may already be converged: do NOT - # reset it, drop in-flight measurements, or re-start the tick loop (that - # would re-run convergence from zero). Just move to `:syncing` and re-check - # availability, so an already-converged filter reports available promptly. - case send_json(state, Wire.encode_client_state(false)) do + # The server holds the role until it has the `source` object this + # activation requires, and `available` must stay false until the clock + # filter converges. The `client/time` loop has been running since the first + # activation, so the filter may already be converged: do NOT reset it, drop + # in-flight measurements, or re-start the tick loop (that would re-run + # convergence from zero). Just move to `:syncing` and re-check availability, + # so an already-converged filter reports available promptly. + case send_json(state, client_state(false)) do :ok -> maybe_become_available(%{state | fsm: :syncing}) {:error, reason} -> {:error, reason, state} end end + defp unknown_activity?(activities), do: Enum.any?(activities, &match?({:unknown, _}, &1)) + + defp allowed_activities?(state, activities) do + not unknown_activity?(activities) and + not (state.psk_category == :long_term and :pairing in activities) + end + + defp unpaired_playback?(state, activities, roles) do + state.psk_category != :long_term and (:playback in activities or roles != []) + end + + # Full state only: every `client/state` an active source sends carries the + # complete `source` object (no line sensing, so it is empty). + defp client_state(available), do: Wire.encode_client_state(available, %{signal: nil}) + + # Say why, then close (`messaging.md` `client/goodbye`). + defp goodbye(state, reason) do + Logger.info("Audio.Input.Source #{inspect(state.key)} closing the connection: #{reason}") + _ = send_json(state, Wire.encode_client_goodbye(reason)) + {:error, {:goodbye, reason}, state} + end + # -- Pairing -- - # The consent gate. A pairing `server/activate` only opens an attempt when - # (a) no long-term PSK already exists — refusing to let a Sentinel-keyed - # attacker overwrite MA's stored PSK (`Store.save_pairing` clobbers) — and - # (b) the operator has opened a local "allow pairing" window. Absent consent - # we do NOT abort: we HOLD the offer in `:pairing_required` (surfacing the - # "Allow pairing" affordance) and wait, mirroring how MA's `start_pin_pairing` - # sends the offer and then waits for `client/pair-init` rather than demanding - # an immediate reply. Pairing still requires the explicit operator gesture — - # an un-consented offer just waits, then times out with no pairing. - defp maybe_start_pairing(state, params) do - cond do - already_paired?(state) -> refuse_pairing(state) - state.pairing_allowed -> start_pairing(state, params) - true -> hold_for_consent(state) + # A pairing `server/activate` on an unpaired session. Each one admits one + # attempt and advances `pairing_index` (the count of them since the last + # handshake), whether or not we open the attempt. The method must be one we + # offer, and `pairing_psk` exactly when the session is keyed by our pairing + # PSK; anything else is answered with `pair/abort` `method_not_supported`. + defp pairing_offer(state, params) do + state = %{ + discard_pairing(state) + | fsm: :pairing_required, + last_pairing_params: params, + pairing_index: state.pairing_index + 1 + } + + case offered_method(state, params) do + :pairing_psk -> + # Holding the token is the operator's authorization: no consent hold. + start_pairing(state, params) + + :dynamic_pairing_code -> + notify(state, {:pairing_required, params}) + maybe_start_pairing(state, params) + + :unsupported -> + refuse_method(state, params) end end - # No operator consent yet. Send neither `pair/abort` nor `client/pair-init`; - # just hold in `:pairing_required` (already set by the caller, along with the - # `{:pairing_required, params}` notification and `last_pairing_params`) and - # arm a consent-wait timeout sized to MA's own pairing window. The `client/time` + defp offered_method(%{psk_category: :pairing}, %{method: :pairing_psk}), do: :pairing_psk + + defp offered_method(%{psk_category: :sentinel}, %{ + method: :dynamic_pairing_code, + format: :digits + }), + do: :dynamic_pairing_code + + defp offered_method(_state, _params), do: :unsupported + + # The consent gate for the dynamic pairing code. An offer only opens an + # attempt when the operator has opened a local "allow pairing" window that + # still has code rounds left; the record it produces is added beside any the + # input already holds. Absent consent we do NOT abort: we HOLD + # the offer in `:pairing_required` (surfacing the "Allow pairing" affordance) + # and tell the server with `client/pair-pending`, which keeps its own wait + # open for the operator's gesture. An un-consented offer just waits, then + # times out with no pairing. + defp maybe_start_pairing(state, params) do + if rounds_left(state) > 0, do: start_pairing(state, params), else: hold_for_consent(state) + end + + # Code rounds the open consent window still admits; none while it is closed. + defp rounds_left(%__MODULE__{pairing_window: %{rounds: rounds}}), + do: Pairing.round_limit() - rounds + + defp rounds_left(_state), do: 0 + + # No operator consent yet. Don't open the attempt: answer + # `client/pair-pending` and hold in `:pairing_required` (already set by the + # caller, along with the `{:pairing_required, params}` notification and + # `last_pairing_params`), arming a consent-wait timeout. The `client/time` # keepalive holds the connection open in the meantime. # # Once a source has burned `@max_holds` un-consented offers within @@ -1395,7 +1646,12 @@ defmodule UniversalProxy.Audio.Input.Source do {:ok, decline_held_offer(state, :user_cancelled)} else - {:ok, arm_consent_wait(record_hold(state))} + pending = Wire.encode_client_pair_pending(state.pairing_index, @pair_pending_message) + + case send_json(state, pending) do + :ok -> {:ok, arm_consent_wait(record_hold(state))} + {:error, reason} -> {:error, reason, state} + end end end @@ -1446,39 +1702,16 @@ defmodule UniversalProxy.Audio.Input.Source do state end - defp already_paired?(state) do - case stored_pairing(state) do - %{psk: <<_::256>>} -> true - _other -> false - end - end - - # A long-term PSK already exists: an operator must unpair first, so we refuse - # outright rather than let a Sentinel-keyed peer overwrite the stored PSK. The - # peer stays connected; only this offer is declined. - defp refuse_pairing(state) do - Logger.info( - "Audio.Input.Source #{inspect(state.key)} declining pairing offer (already_paired)" - ) - - notify(state, {:pairing_failed, :already_paired}) - - case send_json(state, Wire.encode_pair_abort(:user_cancelled)) do - :ok -> {:ok, state} - {:error, err} -> {:error, err, state} - end - end - - # `pairing_index` counts the attempts opened since the last Noise handshake, - # first being 1 (ground truth §3). Capped at `@max_pairing_attempts` per - # connection to bound an online PIN brute force; retries back off. + # Attempts are capped at `@max_pairing_attempts` per connection to bound an + # online code brute force; retries back off. The attempt runs under the + # current `pairing_index` (set when its offer arrived). defp start_pairing(state, params) do # Opening an attempt supersedes any held-offer wait. state = cancel_consent_wait_timer(state) - index = state.pairing_index + 1 + attempt = state.conn.pairing_attempts + 1 cond do - index > @max_pairing_attempts -> + attempt > @max_pairing_attempts -> Logger.warning( "Audio.Input.Source #{inspect(state.key)} exceeded #{@max_pairing_attempts} " <> "pairing attempts; closing the connection" @@ -1486,39 +1719,37 @@ defmodule UniversalProxy.Audio.Input.Source do {:error, :pairing_attempts_exhausted, state} - index > 1 -> - # Back off before a retry so a wrong-PIN loop can't be hammered. - state = %{state | pairing_index: index} - delay = @pairing_backoff_ms * (index - 1) + attempt > 1 -> + # Back off before a retry so a wrong-code loop can't be hammered. + state = put_in(state.conn.pairing_attempts, attempt) + delay = @pairing_backoff_ms * (attempt - 1) timer = Process.send_after(self(), {:open_pairing_attempt, params}, delay) {:ok, %{state | pairing_attempt_timer: timer}} true -> - open_pairing_now(%{state | pairing_index: index}, params) + open_pairing_now(put_in(state.conn.pairing_attempts, attempt), params) end end defp open_pairing_now(state, params) do # Transitioning into an active attempt: cancel any queued backoff retry so a # stray `{:open_pairing_attempt, _}` can't open a second concurrent attempt. - state = cancel_pairing_attempt_timer(state) - - case pair_method(params) do - @pair_method -> open_attempt(state, params) - other -> refuse_method(state, other) - end + state |> cancel_pairing_attempt_timer() |> open_attempt(params) end # Whether a deferred (backed-off) attempt is still valid to open: the - # connection is up, still awaiting pairing, consent stands, no attempt is - # already in flight, and no long-term PSK has appeared meanwhile. - defp can_open_pairing?(state) do + # connection is up, still awaiting pairing, no attempt is already in flight, + # and — for a code attempt — consent stands and no long-term PSK has + # appeared meanwhile. + defp can_open_pairing?(state, params) do state.fsm == :pairing_required and not is_nil(state.socket) and is_nil(state.pairing) and - state.pairing_allowed and not already_paired?(state) + (offered_method(state, params) == :pairing_psk or not is_nil(state.pairing_window)) end # -- Pairing consent window -- + # Opening the window is the operator action that also resets the code-round + # count (`pairing.md` "Rounds"). defp open_pairing_window(state) do state = arm_pairing_window(state) @@ -1530,7 +1761,8 @@ defmodule UniversalProxy.Audio.Input.Source do # `pairing_index` and race two writers of `state.pairing`. if state.fsm == :pairing_required and not is_nil(state.socket) and not is_nil(state.last_pairing_params) and is_nil(state.pairing) and - is_nil(state.pairing_attempt_timer) and not already_paired?(state) do + is_nil(state.pairing_attempt_timer) and + offered_method(state, state.last_pairing_params) == :dynamic_pairing_code do case start_pairing(state, state.last_pairing_params) do {:ok, state} -> state {:error, reason, state} -> fail(state, reason) @@ -1541,25 +1773,25 @@ defmodule UniversalProxy.Audio.Input.Source do end defp arm_pairing_window(state) do - state = cancel_pairing_window_timer(state) + state = cancel_pairing_window(state) timer = Process.send_after(self(), :pairing_window_timeout, state.pairing_window_ms) expires_at = System.system_time(:second) + div(state.pairing_window_ms, 1_000) - state = %{state | pairing_allowed: true, pairing_window_timer: timer} + state = %{state | pairing_window: %{timer: timer, rounds: 0}} notify(state, {:pairing_window, expires_at}) state end defp close_pairing_window(state) do - state = %{cancel_pairing_window_timer(state) | pairing_allowed: false} + state = cancel_pairing_window(state) notify(state, {:pairing_window, nil}) state end - defp cancel_pairing_window_timer(%__MODULE__{pairing_window_timer: nil} = state), do: state + defp cancel_pairing_window(%__MODULE__{pairing_window: nil} = state), do: state - defp cancel_pairing_window_timer(%__MODULE__{pairing_window_timer: timer} = state) do + defp cancel_pairing_window(%__MODULE__{pairing_window: %{timer: timer}} = state) do _ = Process.cancel_timer(timer) - %{state | pairing_window_timer: nil} + %{state | pairing_window: nil} end defp open_attempt(state, params) do @@ -1567,8 +1799,8 @@ defmodule UniversalProxy.Audio.Input.Source do handshake_hash: state.handshake_hash, pairing_index: state.pairing_index, suite: state.suite, - method: @pair_method, - pin_length: pin_length(state, params) + method: params.method, + max_rounds: max(rounds_left(state), 1) ] case Pairing.start(opts) do @@ -1582,15 +1814,19 @@ defmodule UniversalProxy.Audio.Input.Source do end end - # We advertise `dynamic_pin` alone, so anything else is a server bug; the - # spec's own answer to it is `pair/abort` with `method_not_supported`. - defp refuse_method(state, method) do + # A method we don't offer, a format we don't offer, or a method the matched + # PSK disallows: the spec's answer is `pair/abort` with + # `method_not_supported`, leaving the connection open. + defp refuse_method(state, params) do case send_json(state, Wire.encode_pair_abort(:method_not_supported)) do - :ok -> {:ok, pairing_failed(state, {:unsupported_method, method})} + :ok -> {:ok, pairing_failed(state, {:unsupported_method, pairing_method(params)})} {:error, reason} -> {:error, reason, state} end end + defp pairing_method(%{method: method}), do: method + defp pairing_method(_params), do: nil + defp handle_pairing_message(%{pairing: nil} = state, tag, _payload) do Logger.debug( "Audio.Input.Source #{inspect(state.key)} ignoring #{tag} with no pairing attempt open" @@ -1611,6 +1847,9 @@ defmodule UniversalProxy.Audio.Input.Source do # so that the same module can be driven from a scripted peer and from here. # `Wire` has already decoded these fields, and re-encoding four small # strings per attempt is cheaper than parsing every frame twice. + defp pairing_wire_message(:server_pair_init, %{nonce_a: nil}), + do: {"server/pair-init", %{}} + defp pairing_wire_message(:server_pair_init, %{nonce_a: nonce_a}), do: {"server/pair-init", %{"nonce_A" => b64(nonce_a)}} @@ -1627,8 +1866,9 @@ defmodule UniversalProxy.Audio.Input.Source do send_pairing_messages(%{state | pairing: pairing}, messages) end + # Each round emits the code (again) and counts against the window's limit. defp apply_pairing_step({:pin, pin, pairing}, state) do - state = %{state | pairing: pairing} + state = %{state | pairing: pairing} |> count_round() notify(state, {:pairing_pin, pin}) {:ok, state} end @@ -1654,14 +1894,10 @@ defmodule UniversalProxy.Audio.Input.Source do {:error, {:pairing_error, reason}, discard_pairing(state)} end - defp complete_pairing(state, %{psk: psk, psk_id: psk_id, category: category}) do - attrs = %{ - psk: psk, - psk_id: psk_id, - psk_category: category, - server_id: state.server_id, - paired_at: DateTime.utc_now() - } + # The new record replaces this server's, or is added beside the others + # (`Store.save_pairing/4`), whichever method produced it. + defp complete_pairing(state, %{psk: psk}) do + attrs = %{psk: psk, server_id: state.server_id, paired_at: DateTime.utc_now()} case save_pairing(state, attrs) do :ok -> @@ -1673,7 +1909,7 @@ defmodule UniversalProxy.Audio.Input.Source do # the new session keys takes effect "from the next frame onwards" (ground # truth §4), so a `client/time` sent under the old session whose # `server/time` reply lands after we swap would fail to decrypt and drop - # the connection. The loop restarts at the replayed `server/hello`. + # the connection. The loop restarts at the activation that follows. state = %{ cancel_time_timer(discard_pairing(close_pairing_window(state))) @@ -1714,7 +1950,9 @@ defmodule UniversalProxy.Audio.Input.Source do # server retries by sending another pairing `server/activate`, which is also # what advances `pairing_index`. defp pairing_failed(state, reason) do - Logger.info("Audio.Input.Source #{inspect(state.key)} pairing failed: #{inspect(reason)}") + Logger.info( + "Audio.Input.Source #{inspect(state.key)} pairing failed: #{truncate_reason(reason)}" + ) state = %{discard_pairing(state) | fsm: :pairing_required} notify(state, {:pairing_failed, reason}) @@ -1750,10 +1988,30 @@ defmodule UniversalProxy.Audio.Input.Source do # harmless no-op. defp abandon_pairing(state, _reason), do: state + defp count_round(%__MODULE__{pairing_window: %{rounds: rounds} = window} = state), + do: %{state | pairing_window: %{window | rounds: rounds + 1}} + + defp count_round(state), do: state + defp discard_pairing(state) do %{cancel_pairing_attempt_timer(cancel_pairing_timer(state)) | pairing: nil} end + # An activation without `pairing` ends any attempt or held offer: the client + # abandons it, discarding all pairing state (`pairing.md` "Entering and + # leaving pairing"). + defp leave_pairing(%__MODULE__{pairing: nil, last_pairing_params: nil} = state), do: state + + defp leave_pairing(state) do + if state.pairing, do: notify(state, {:pairing_failed, :cancelled}) + + %{ + cancel_consent_wait_timer(discard_pairing(state)) + | last_pairing_params: nil, + fsm: if(state.fsm == :pairing_required, do: :awaiting_activate, else: state.fsm) + } + end + defp cancel_pairing_attempt_timer(%__MODULE__{pairing_attempt_timer: nil} = state), do: state defp cancel_pairing_attempt_timer(%__MODULE__{pairing_attempt_timer: timer} = state) do @@ -1779,34 +2037,9 @@ defmodule UniversalProxy.Audio.Input.Source do defp resolve_pairing_step({:ok, state}), do: {:noreply, state} defp resolve_pairing_step({:error, reason, state}), do: {:noreply, fail(state, reason)} - defp pair_method(%{method: method}) when is_atom(method) and not is_nil(method), do: method - defp pair_method(_params), do: @pair_method - - # `L = max(client_min, server_min)`, clamped to 4..12 (ground truth §3). - defp pin_length(state, params) do - client_min = client_min_pin_length(state) - server_min = server_min_pin_length(params) - - client_min - |> max(server_min) - |> min(@max_pin_digits) - |> max(@min_pin_digits) - end - - defp client_min_pin_length(state) do - Enum.find_value(state.pair_methods, @default_min_pin_length, fn descriptor -> - case descriptor do - %{method: @pair_method, min_pin_length: length} when is_integer(length) -> length - _other -> nil - end - end) - end - - defp server_min_pin_length(%{pin_length: length}) when is_integer(length), do: length - defp server_min_pin_length(_params), do: 0 - + # The record keying this session (if any) is the one eviction must spare. defp save_pairing(state, attrs) do - Store.save_pairing(state.store, state.key, attrs) + Store.save_pairing(state.store, state.key, attrs, protect: state.conn.record_id) catch :exit, reason -> {:error, {:store_unavailable, reason}} end @@ -1825,8 +2058,8 @@ defmodule UniversalProxy.Audio.Input.Source do defp run_rehandshake(state, message_1) do previous = state.noise - case exchange_handshake(state, message_1, state.handshake_hash) do - {:ok, session, category} -> + case exchange_handshake(state, message_1, state.handshake_hash, :fail) do + {:ok, session, category, record_id} -> # `send_json/2` still encrypts message 2 under the *old* session # (`state.noise` = `previous`); the new keys take effect only from the # next frame. If message 2 can't be produced or sent, close the freshly @@ -1834,7 +2067,8 @@ defmodule UniversalProxy.Audio.Input.Source do with {:ok, message_2} <- Noise.write_handshake(session, @noise_msg2_payload), :ok <- send_json(state, Wire.encode_noise_handshake(message_2)) do close_noise(previous) - {:ok, swap_session(state, session, category)} + touch_record(state, category, record_id) + {:ok, swap_session(state, session, category, record_id)} else {:error, reason} -> close_noise(session) @@ -1846,26 +2080,32 @@ defmodule UniversalProxy.Audio.Input.Source do end end - # Everything but `client_id`, `server_id` and the cipher suite resets: the - # connection replays server/hello → client/hello → server/activate, and the - # pairing counter restarts at 0 for the new handshake hash. - defp swap_session(state, session, category) do - if state.fsm == :streaming, do: notify(state, :stopped) - - state = state |> stop_capture() |> cancel_time_timer() |> discard_pairing() + # Only the keys, the matched PSK (and so trust), the handshake hash and the + # pairing counter change; connection state — the FSM, an open stream, the + # clock filter — persists. No hellos are replayed: the server's next message + # is `server/activate`, and until it arrives we send nothing (`rekeying`), so + # the clock-sync loop pauses and capture frames are dropped. + # + # Any pairing attempt or held offer ends (its consent-wait timer must not + # fire into the re-keyed session). A session that drops below trust `user` + # stops capturing at once; its `client-stream/end` waits for the activation + # (`end_stream/1`). + defp swap_session(state, session, category, record_id) do + downgrade? = state.trust == :user and trust_level(category) != :user + state = state |> cancel_time_timer() |> leave_pairing() - %{ + state = %{ state | noise: session, handshake_hash: Noise.handshake_hash(session), psk_category: category, trust: trust_level(category), pairing_index: 0, - fsm: :awaiting_server_hello, - filter: ClockFilter.reset(state.filter), - outstanding: [], - start_requested: false + conn: %{state.conn | rekeying: true, record_id: record_id}, + outstanding: [] } + + if downgrade?, do: end_stream(state), else: state end # -- Time sync -- @@ -1887,6 +2127,9 @@ defmodule UniversalProxy.Audio.Input.Source do # guard closes the queued-tick race the cancel can't. defp send_time_request(%__MODULE__{fsm: :awaiting_rehandshake} = state), do: state + # Nor between a re-handshake's Noise message 1 and the activation after it. + defp send_time_request(%__MODULE__{conn: %{rekeying: true}} = state), do: state + defp send_time_request(%__MODULE__{noise: nil} = state), do: state defp send_time_request(state) do @@ -1921,7 +2164,7 @@ defmodule UniversalProxy.Audio.Input.Source do defp maybe_become_available(%{fsm: :syncing} = state) do if ClockFilter.converged?(state.filter) do - case send_json(state, Wire.encode_client_state(true)) do + case send_json(state, client_state(true)) do :ok -> maybe_start_streaming(%{state | fsm: :ready}) {:error, reason} -> {:error, reason, state} end @@ -1944,6 +2187,10 @@ defmodule UniversalProxy.Audio.Input.Source do end end + # Start the clock-sync loop if it is not already running. + defp ensure_time_loop(%__MODULE__{time_timer: nil} = state), do: schedule_time_tick(state, 0) + defp ensure_time_loop(state), do: state + defp schedule_time_tick(state, delay) do state = cancel_time_timer(state) %{state | time_timer: Process.send_after(self(), :time_tick, delay)} @@ -2063,7 +2310,7 @@ defmodule UniversalProxy.Audio.Input.Source do # (reusing the async `stop_capture` path — never a synchronous stop from a # handler) and rewind to `:awaiting_activate` while holding the connection, so # a later `server/activate` re-activates without a reconnect. `end_stream/1` - # emits `client_stream/end` + `:stopped` only when a stream was actually open. + # emits `client-stream/end` + `:stopped` only when a stream was actually open. # Trust and the Noise session are kept — and so are the `client/time` loop and # the (converged) filter: time sync is connection-level, so it must keep # running here both to hold the connection and to leave the filter ready for a @@ -2079,9 +2326,19 @@ defmodule UniversalProxy.Audio.Input.Source do # Closes an open stream if there is one; a no-op otherwise, so both a # redundant `stop` command and a capture crash land here safely. + # While re-keying nothing but the handshake may go out, so the + # `client-stream/end` is held until the activation that follows. defp end_stream(%{fsm: :streaming} = state) do state = stop_capture(state) - _ = send_json(state, Wire.encode_client_stream_end()) + + state = + if state.conn.rekeying do + put_in(state.conn.stream_end_pending, true) + else + _ = send_json(state, Wire.encode_client_stream_end()) + state + end + notify(state, :stopped) %{state | fsm: :ready} end @@ -2170,8 +2427,8 @@ defmodule UniversalProxy.Audio.Input.Source do if state.socket, do: send(state.socket, {:sendspin_ws_close, reason}) close_noise(state.noise) - # The operator's pairing consent window (`pairing_allowed` / - # `pairing_window_timer`) deliberately survives a teardown: a transient + # The operator's pairing consent window (`pairing_window`) deliberately + # survives a teardown: a transient # reconnect inside the 120 s window shouldn't force the operator to click # "allow pairing" again. %{ @@ -2187,6 +2444,7 @@ defmodule UniversalProxy.Audio.Input.Source do remote_static: nil, handshake_hash: nil, pairing_index: 0, + conn: @conn_defaults, last_pairing_params: nil, frames_dropped: 0, fsm: :listening, @@ -2262,10 +2520,12 @@ defmodule UniversalProxy.Audio.Input.Source do defp b64(bytes), do: Base.url_encode64(bytes, padding: false) - # Dynamic PIN only: we derive and display the PIN, the operator types it - # into MA (P1-T6 correction). `min_pin_length` is the spec's RECOMMENDED - # initial value. - defp default_pair_methods, do: [%{method: :dynamic_pin, min_pin_length: 6}] + # Every client offers the Pairing PSK method; beside it, the dynamic pairing + # code in `digits` form, which we show on the Audio tab (the operator types it + # into MA). + defp default_pair_methods do + %{pairing_psk: %{}, dynamic_pairing_code: %{out_channels: ["display"], formats: ["digits"]}} + end # Same four fields the C++ player reports, sourced the same way, so both # roles of one device present identically in MA. diff --git a/lib/universal_proxy/audio/input/store.ex b/lib/universal_proxy/audio/input/store.ex index 30cd485e..aab316d4 100644 --- a/lib/universal_proxy/audio/input/store.ex +++ b/lib/universal_proxy/audio/input/store.ex @@ -1,7 +1,7 @@ defmodule UniversalProxy.Audio.Input.Store do @moduledoc """ DETS-backed persistence for Sendspin audio-**input** (capture card) - configuration and pairing state. + configuration and pairing records. Keyed by `{slot_sub, vendor_id, product_id}` — the same shape as `UniversalProxy.Audio.Store` and `UniversalProxy.UART.Store`. For @@ -13,11 +13,16 @@ defmodule UniversalProxy.Audio.Input.Store do %{ friendly_name: String.t(), client_keypair: {pub :: <<_::256>>, priv :: binary()} | nil, - psk: <<_::256>> | nil, - psk_id: String.t() | nil, - psk_category: atom() | nil, - server_id: String.t() | nil, - paired_at: DateTime.t() | nil + pairing_psk: <<_::256>> | nil, + pairings: [ + %{ + psk: <<_::256>>, + psk_id: String.t(), + server_id: String.t(), + paired_at: DateTime.t(), + last_used: DateTime.t() + } + ] } Unlike `Audio.Store`'s `client_id` (generated eagerly on every save), @@ -29,6 +34,29 @@ defmodule UniversalProxy.Audio.Input.Store do long-lived identity, persisted across reboots, that must be stable across reconnections. + `pairing_psk` is the input's Sendspin **pairing PSK** — the secret behind + its pairing token (`pairing_token/2`), which an operator pastes into + Music Assistant to pair over the Pairing PSK method. Like the keypair it is + minted lazily from the CSPRNG (`ensure_pairing_psk/2`), long-lived, and + not pairing *state*: a pairing does not consume it and `clear_pairing/2` + leaves it alone. `regenerate_pairing_psk/2` replaces it (the operator's + "Regenerate pairing token"). + + `pairings` are the input's **pairing records** (`pairing.md` "Pairing + Records"): one long-term PSK per server it has paired with, bound to that + server's `server_id`. Up to 8 are kept; a new pairing replaces the record + for the same server, else is added, evicting the least recently used record + — never the one the caller marks as backing its open connection — so a + pairing never fails for lack of storage. + + Rows written before Sendspin 1.0.0-rc1 held one flat record (`psk`, + `psk_id`, `psk_category`, `server_id`, `paired_at`). They are converted on + read: a record with a 32-byte PSK and a `server_id` becomes the first entry + of `pairings` (its `psk_id` recomputed, not trusted), anything else is + dropped, and the flat keys are discarded on the next write. + + The file holds private key material, so it is kept owner-only (`0600`). + The DETS file lives on the writable data partition on Nerves (`/data/audio_inputs.dets`) and in `_build/` on the host for development. Tests can override via the `:dets_path`, `:table`, and @@ -41,6 +69,8 @@ defmodule UniversalProxy.Audio.Input.Store do alias UniversalProxy.Audio.Params alias UniversalProxy.Sendspin.Noise + alias UniversalProxy.Sendspin.Pairing + alias UniversalProxy.Sendspin.PairingToken @default_table :audio_inputs @@ -54,13 +84,23 @@ defmodule UniversalProxy.Audio.Input.Store do @type config :: %{ friendly_name: String.t(), client_keypair: keypair() | nil, - psk: <<_::256>> | nil, - psk_id: String.t() | nil, - psk_category: atom() | nil, - server_id: String.t() | nil, - paired_at: DateTime.t() | nil + pairing_psk: <<_::256>> | nil, + pairings: [pairing_record()] + } + + @type pairing_record :: %{ + psk: <<_::256>>, + psk_id: String.t(), + server_id: String.t(), + paired_at: DateTime.t(), + last_used: DateTime.t() } + # `pairing.md`: a client MUST be able to store at least 5 records. + @max_pairings 8 + + @legacy_pairing_keys [:psk, :psk_id, :psk_category, :server_id, :paired_at] + # -- Client API -- def start_link(opts \\ []) do @@ -127,6 +167,36 @@ defmodule UniversalProxy.Audio.Input.Store do GenServer.call(server, {:ensure_keypair, key}) end + @doc """ + Get the persisted pairing PSK for `key`, minting and persisting a fresh + 32-byte CSPRNG one if none exists yet. Stable across calls and restarts. + + Returns `{:ok, psk}` or `{:error, reason}` if DETS rejects a first-use + write. The PSK is a long-lived pairing secret: never log or broadcast it. + """ + @spec ensure_pairing_psk(GenServer.server(), input_key()) :: + {:ok, <<_::256>>} | {:error, term()} + def ensure_pairing_psk(server \\ __MODULE__, {slot_sub, _vid, _pid} = key) + when is_binary(slot_sub) do + GenServer.call(server, {:ensure_pairing_psk, key}) + end + + @doc """ + The input's version-0 Sendspin pairing token (`"SP:0…"`), built from its + static public key and pairing PSK — both minted on first use. + + `{:ok, token}` or `{:error, reason}`; the token never appears in any other + return value, so it cannot leak through a logged error tuple. + """ + @spec pairing_token(GenServer.server(), input_key()) :: {:ok, String.t()} | {:error, term()} + def pairing_token(server \\ __MODULE__, {slot_sub, _vid, _pid} = key) + when is_binary(slot_sub) do + with {:ok, {pub, _priv}} <- ensure_client_keypair(server, key), + {:ok, pairing_psk} <- ensure_pairing_psk(server, key) do + {:ok, PairingToken.encode_psk_token(pub, pairing_psk)} + end + end + @doc """ The Sendspin `client_id` for a static public key: base64url encoding (no padding) of the raw 32-byte X25519 public key, per the @@ -136,35 +206,80 @@ defmodule UniversalProxy.Audio.Input.Store do def client_id(<>), do: Base.url_encode64(pub, padding: false) @doc """ - Persist a completed pairing: `psk`, `psk_id`, `psk_category`, - `server_id` (the server's static pubkey), and `paired_at`. Defaults - `paired_at` to `DateTime.utc_now/0` if the caller doesn't supply one. - Goes through the same presence-check merge as `save_config/3`, so - `friendly_name` and `client_keypair` are left untouched. + Persist a completed pairing: the new long-term `psk` for `server_id` (the + server's static pubkey), `paired_at` defaulting to now. The record for the + same server is replaced, else one is added, evicting the least recently + used record at capacity but never the one whose `psk_id` is `opts[:protect]` + (the record backing the caller's open connection). `friendly_name`, the + keypair and the pairing PSK are untouched. """ - @spec save_pairing(GenServer.server(), input_key(), map()) :: :ok | {:error, term()} - def save_pairing(server \\ __MODULE__, {slot_sub, _vid, _pid} = key, attrs) + @spec save_pairing(GenServer.server(), input_key(), map(), keyword()) :: :ok | {:error, term()} + def save_pairing(server \\ __MODULE__, {slot_sub, _vid, _pid} = key, attrs, opts \\ []) when is_binary(slot_sub) and is_map(attrs) do + psk = Params.get(attrs, :psk) paired_at = Params.get(attrs, :paired_at, DateTime.utc_now()) - save_config(server, key, Map.put(attrs, :paired_at, paired_at)) + + record = %{ + psk: psk, + psk_id: Pairing.psk_id_for(psk), + server_id: Params.get(attrs, :server_id), + paired_at: paired_at, + last_used: paired_at + } + + GenServer.call(server, {:save_pairing, key, record, Keyword.get(opts, :protect)}) + end + + @doc "The pairing records of a config (as `get_config/2` returns it), most recently used first." + @spec pairings(map()) :: [pairing_record()] + def pairings(config) when is_map(config) do + config |> Map.get(:pairings, []) |> Enum.sort_by(& &1.last_used, {:desc, DateTime}) end @doc """ - Clear a device's pairing state (`psk`, `psk_id`, `psk_category`, - `server_id`, `paired_at` all reset to `nil`), leaving `friendly_name` - and `client_keypair` untouched — the client keypair is an identity, - not pairing state, and must survive an unpair. + Note that the record with `psk_id` just authenticated a connection, for the + least-recently-used eviction order. Best effort and asynchronous. + """ + @spec touch_pairing(GenServer.server(), input_key(), String.t()) :: :ok + def touch_pairing(server \\ __MODULE__, {slot_sub, _vid, _pid} = key, psk_id) + when is_binary(slot_sub) and is_binary(psk_id) do + GenServer.cast(server, {:touch_pairing, key, psk_id}) + end + + @doc """ + Remove the pairing record matching `server_id: id` or `psk_id: id`. + Removing a record that isn't there is `:ok`. + """ + @spec remove_pairing( + GenServer.server(), + input_key(), + [server_id: String.t()] | [psk_id: String.t()] + ) :: + :ok | {:error, term()} + def remove_pairing(server \\ __MODULE__, {slot_sub, _vid, _pid} = key, [{field, id}]) + when is_binary(slot_sub) and field in [:server_id, :psk_id] and is_binary(id) do + GenServer.call(server, {:remove_pairing, key, field, id}) + end + + @doc """ + Clear every pairing record of a device, leaving `friendly_name`, + `client_keypair` and `pairing_psk` untouched — the keypair is an identity + and the pairing PSK a long-lived credential, not pairing state. """ @spec clear_pairing(GenServer.server(), input_key()) :: :ok | {:error, term()} def clear_pairing(server \\ __MODULE__, {slot_sub, _vid, _pid} = key) when is_binary(slot_sub) do - save_config(server, key, %{ - psk: nil, - psk_id: nil, - psk_category: nil, - server_id: nil, - paired_at: nil - }) + save_config(server, key, %{pairings: []}) + end + + @doc """ + Replace the input's pairing PSK with a fresh one, invalidating its pairing + token. Existing pairing records are unaffected. + """ + @spec regenerate_pairing_psk(GenServer.server(), input_key()) :: :ok | {:error, term()} + def regenerate_pairing_psk(server \\ __MODULE__, {slot_sub, _vid, _pid} = key) + when is_binary(slot_sub) do + save_config(server, key, %{pairing_psk: Pairing.generate_psk()}) end # -- Server Callbacks -- @@ -180,6 +295,7 @@ defmodule UniversalProxy.Audio.Input.Store do case :dets.open_file(table_name, file: to_charlist(path), type: :set) do {:ok, table} -> + restrict_permissions(path) Logger.info("Audio input store opened at #{path}") {:ok, %{table: table}} @@ -212,13 +328,25 @@ defmodule UniversalProxy.Audio.Input.Store do def handle_call({:get, key}, _from, state) do result = case :dets.lookup(state.table, key) do - [{^key, cfg}] when is_map(cfg) -> {:ok, cfg} + [{^key, cfg}] when is_map(cfg) -> {:ok, normalize(cfg)} _ -> :error end {:reply, result, state} end + def handle_call({:save_pairing, key, record, protect}, _from, state) do + existing = lookup(state.table, key) + pairings = upsert_pairing(Map.get(existing, :pairings, []), record, protect) + {:reply, write(state.table, key, merge_defaults(existing, %{pairings: pairings}, key)), state} + end + + def handle_call({:remove_pairing, key, field, id}, _from, state) do + existing = lookup(state.table, key) + pairings = Enum.reject(Map.get(existing, :pairings, []), &(Map.fetch!(&1, field) == id)) + {:reply, write(state.table, key, merge_defaults(existing, %{pairings: pairings}, key)), state} + end + def handle_call({:delete, key}, _from, state) do with :ok <- :dets.delete(state.table, key), :ok <- :dets.sync(state.table) do @@ -234,8 +362,11 @@ defmodule UniversalProxy.Audio.Input.Store do all = :dets.foldl( fn - {{_slot, _vid, _pid} = key, cfg}, acc when is_map(cfg) -> Map.put(acc, key, cfg) - _other, acc -> acc + {{_slot, _vid, _pid} = key, cfg}, acc when is_map(cfg) -> + Map.put(acc, key, normalize(cfg)) + + _other, acc -> + acc end, %{}, state.table @@ -244,6 +375,32 @@ defmodule UniversalProxy.Audio.Input.Store do {:reply, all, state} end + def handle_call({:ensure_pairing_psk, key}, _from, state) do + existing = lookup(state.table, key) + + case existing[:pairing_psk] do + <<_::256>> = psk -> + {:reply, {:ok, psk}, state} + + _ -> + psk = Pairing.generate_psk() + merged = merge_defaults(existing, %{pairing_psk: psk}, key) + + with :ok <- :dets.insert(state.table, {key, merged}), + :ok <- :dets.sync(state.table) do + {:reply, {:ok, psk}, state} + else + {:error, reason} -> + Logger.error( + "Audio input store pairing PSK generation failed for #{inspect(key)}: " <> + inspect(reason) + ) + + {:reply, {:error, reason}, state} + end + end + end + def handle_call({:ensure_keypair, key}, _from, state) do existing = lookup(state.table, key) @@ -275,30 +432,96 @@ defmodule UniversalProxy.Audio.Input.Store do end end + @impl true + def handle_cast({:touch_pairing, key, psk_id}, state) do + existing = lookup(state.table, key) + now = DateTime.utc_now() + + pairings = + Enum.map(Map.get(existing, :pairings, []), fn + %{psk_id: ^psk_id} = record -> %{record | last_used: now} + record -> record + end) + + _ = write(state.table, key, merge_defaults(existing, %{pairings: pairings}, key)) + {:noreply, state} + end + # -- Private -- defp lookup(table, key) do case :dets.lookup(table, key) do - [{^key, cfg}] when is_map(cfg) -> cfg + [{^key, cfg}] when is_map(cfg) -> normalize(cfg) _ -> %{} end end + defp write(table, key, cfg) do + with :ok <- :dets.insert(table, {key, cfg}), + :ok <- :dets.sync(table) do + :ok + else + {:error, reason} = error -> + Logger.error("Audio input store write failed for #{inspect(key)}: #{inspect(reason)}") + error + end + end + + # Replace the same server's record, else add one, evicting the least + # recently used unprotected records past capacity. + defp upsert_pairing(pairings, record, protect) do + others = Enum.reject(pairings, &(&1.server_id == record.server_id)) + keep = [record | Enum.sort_by(others, & &1.last_used, {:desc, DateTime})] + + if length(keep) <= @max_pairings do + keep + else + {protected, evictable} = Enum.split_with(tl(keep), &(&1.psk_id == protect)) + [record | protected] ++ Enum.take(evictable, @max_pairings - 1 - length(protected)) + end + end + + # Convert a row written before rc1 (one flat record) to the `pairings` list. + defp normalize(%{pairings: pairings} = cfg) when is_list(pairings), + do: Map.drop(cfg, @legacy_pairing_keys) + + defp normalize(cfg) do + cfg |> Map.drop(@legacy_pairing_keys) |> Map.put(:pairings, legacy_pairings(cfg)) + end + + defp legacy_pairings(%{psk: <<_::256>> = psk, server_id: server_id} = cfg) + when is_binary(server_id) and byte_size(server_id) == 43 do + paired_at = + case Map.get(cfg, :paired_at) do + %DateTime{} = at -> at + _other -> DateTime.from_unix!(0) + end + + [ + %{ + psk: psk, + psk_id: Pairing.psk_id_for(psk), + server_id: server_id, + paired_at: paired_at, + last_used: paired_at + } + ] + end + + defp legacy_pairings(_cfg), do: [] + # The default `friendly_name` is the capture card's slot; users can - # rename per input via the UI. Every pairing-related field defaults - # to `nil` (unpaired) rather than being synthesised, unlike - # `Audio.Store`'s eager `client_id` generation — the keypair and - # pairing state only come into existence via `ensure_client_keypair/2` - # and `save_pairing/3` respectively. + # rename per input via the UI. The keypair and pairing PSK default to + # `nil` and `pairings` to `[]` (unpaired) rather than being synthesised, + # unlike `Audio.Store`'s eager `client_id` generation — they only come into + # existence via `ensure_client_keypair/2`, `ensure_pairing_psk/2` and + # `save_pairing/4`. defp merge_defaults(existing, params, {slot_sub, _vid, _pid}) do %{ friendly_name: pick(params, existing, :friendly_name, slot_sub), client_keypair: pick(params, existing, :client_keypair, nil), - psk: pick(params, existing, :psk, nil), - psk_id: pick(params, existing, :psk_id, nil), - psk_category: pick(params, existing, :psk_category, nil), - server_id: pick(params, existing, :server_id, nil), - paired_at: pick(params, existing, :paired_at, nil) + pairing_psk: pick(params, existing, :pairing_psk, nil), + pairings: pick(params, existing, :pairings, []) } end @@ -324,6 +547,18 @@ defmodule UniversalProxy.Audio.Input.Store do end end + # Owner-only: the table holds private keys and PSKs. Best effort — a + # filesystem without POSIX modes keeps working. + defp restrict_permissions(path) do + case File.chmod(path, 0o600) do + :ok -> + :ok + + {:error, reason} -> + Logger.warning("Audio input store chmod #{path} failed: #{inspect(reason)}") + end + end + defp dets_path do if File.dir?("/data") do "/data/audio_inputs.dets" diff --git a/lib/universal_proxy/sendspin/pairing.ex b/lib/universal_proxy/sendspin/pairing.ex index c9af2fe9..5b27311c 100644 --- a/lib/universal_proxy/sendspin/pairing.ex +++ b/lib/universal_proxy/sendspin/pairing.ex @@ -1,61 +1,76 @@ defmodule UniversalProxy.Sendspin.Pairing do @moduledoc """ - Sendspin PIN pairing — the client half (CPace party B / responder). + Sendspin pairing — the client half (CPace party B / responder), per + Sendspin 1.0.0-rc1 `pairing.md`. Pairing runs *inside* an established Noise session: the server grants the `pairing` activity in `server/activate`, we open the attempt, and both - sides end up holding a fresh 32-byte long-term PSK that promotes the next - handshake from trust `none` to trust `user`. `source@v1` can never - activate below trust `user`, so a capture-card client must pair before it - can stream. + sides end up holding a fresh 32-byte long-term PSK. Only a session keyed by + a long-term PSK may activate roles on a client without unpaired access, so + a capture-card source must pair before it can stream. This module is pure: it holds the attempt state in a struct and turns inbound decoded-JSON payloads into outbound ones. The connection FSM owns the websocket, the JSON codec, the Noise transport frames, the pairing counter and the 120 s attempt timeout (`attempt_timeout_ms/0`). - ## Message flow (dynamic PIN) + ## Methods + + **Pairing PSK** (`:pairing_psk`) — the session's Noise handshake already + authenticated both sides with the client's pairing PSK (the secret behind + its pairing token), so there is no PAKE: `client/pair-init` and + `client/pair-finalize {long_term_psk}` go out back to back, and + `server/pair-finalize` completes it. + + **Dynamic pairing code** (`:dynamic_pairing_code`, `digits` format): client -> client/pair-init {pairing_index, commit_B} start/1 - client <- server/pair-init {nonce_A} → {:pin, pin, _} + client <- server/pair-init {nonce_A} → {:pin, code, _} client <- server/pair-auth {pake_msg_1 = Ya} client -> client/pair-auth {pake_msg_2 = Yb} client <- server/pair-confirm {server_kc = Ta} - client -> client/pair-confirm {client_kc = Tb, nonce_B} + client -> client/pair-confirm {client_kc = Tb, wrapped_nonce_B} client -> client/pair-finalize {wrapped_psk} client <- server/pair-finalize {} → {:paired, _, _} - Static PIN is the same flow minus `server/pair-init`, `commit_B` and - `nonce_B`; the PIN is configured out of band and must be exactly 8 digits. + A `server_kc` that fails to verify (a mistyped code) answers + `client/pair-retry` instead of `client/pair-confirm`, and the server starts + another **round** with a `server/pair-init` carrying no nonce: the code is + unchanged, but each round runs a fresh CPace under its own `sid` (the round + number is part of it). At `:max_rounds` we abort with + `pairing_code_mismatch` instead of retrying. + + **Static pairing code** (`:static_pairing_code`) is the same exchange + minus `server/pair-init`, `commit_B`, `wrapped_nonce_B` and retries; the + code is configured out of band and must be exactly 8 digits. - ## PIN direction + ## Code direction - In the dynamic flow **we derive and display the PIN** and the operator - types it into Music Assistant — not the other way round. Both nonces and - the Noise handshake hash determine it (`derive_pin/4`), and our `commit_B` - is sent before `nonce_A` exists so we cannot grind the digits. The server - re-derives it from the `nonce_B` we reveal in `client/pair-confirm` and - compares against what the operator typed (ground truth §3, "Server-side - verification order"; `aiosendspin/noise/pairing.py` `run_dynamic_pin_client` - calls `pin_emitter(pin)`). + In the dynamic flow **we derive and display the code** and the operator + types it into the server. Both nonces and the Noise handshake hash + determine it (`derive_pairing_code/3`), and our `commit_B` is sent before + `nonce_A` exists so we cannot grind the digits. The server re-derives it + from the `nonce_B` we reveal (sealed) in `client/pair-confirm`. ## PSK direction - **We** mint the long-term PSK, wrap it under `K_wrap` and send it in - `client/pair-finalize`; the server unwraps and stores it (ground truth §3, - "PSK wrapping"). Nothing is persisted until `server/pair-finalize` arrives. + **We** mint the long-term PSK and send it in `client/pair-finalize` — + wrapped under the CPace output in the code flows, in the clear (inside + the Noise channel) in the Pairing PSK flow. Nothing is persisted until + `server/pair-finalize` arrives. """ alias UniversalProxy.Sendspin.CPace - # Sendspin labels — ground truth §3 "Constants". Literal ASCII, no - # separator and no NUL between a label and what follows it. + # Sendspin labels (`pairing.md`). Literal ASCII, no separator and no NUL + # between a label and what follows it. @sid_label "sendspin-pair-pake-v1" @ad_server "server" @ad_client "client" - @pin_derive_label "sendspin-pin-derive-v1" + @code_derive_label "sendspin-pairing-code-derive-v1" @commit_label "sendspin-pair-commit-v1" @psk_wrap_label "sendspin-pair-psk-wrap-v1" + @nonce_wrap_label "sendspin-pair-nonce-wrap-v1" @psk_id_label "sendspin-psk-id-v1" # CPace's own MCF label (draft-irtf-cfrg-cpace-21 Section 7.4); the generic @@ -68,16 +83,18 @@ defmodule UniversalProxy.Sendspin.Pairing do @tag_bytes 64 @psk_bytes 32 @aead_tag_bytes 16 - @wrapped_psk_bytes @psk_bytes + @aead_tag_bytes + @wrapped_bytes @psk_bytes + @aead_tag_bytes - # K_wrap is freshly derived per attempt and used once, so a fixed all-zero - # nonce is safe (ground truth §3, "PSK wrapping"). - @psk_wrap_nonce :binary.copy(<<0>>, 12) + # Every wrap key is per-field and used once, so a fixed all-zero nonce is + # safe (`pairing.md` "Wrapping"). + @wrap_nonce :binary.copy(<<0>>, 12) - @min_pin_digits 4 - @max_pin_digits 12 - @default_pin_digits 6 - @static_pin_digits 8 + @dynamic_code_digits 6 + @static_code_digits 8 + + # `pairing.md` "Rounds": after 20 rounds since the last verified + # `server_kc` the client MUST abort rather than retry. + @round_limit 20 @attempt_timeout_ms 120_000 @@ -85,18 +102,16 @@ defmodule UniversalProxy.Sendspin.Pairing do attempt_timeout: "attempt_timeout", concurrent_attempt: "concurrent_attempt", method_not_supported: "method_not_supported", - pin_length_unacceptable: "pin_length_unacceptable", - pin_mismatch: "pin_mismatch", + pairing_code_mismatch: "pairing_code_mismatch", user_cancelled: "user_cancelled" } - @type method :: :dynamic_pin | :static_pin + @type method :: :dynamic_pairing_code | :static_pairing_code | :pairing_psk @type abort_reason :: :attempt_timeout | :concurrent_attempt | :method_not_supported - | :pin_length_unacceptable - | :pin_mismatch + | :pairing_code_mismatch | :user_cancelled @type stage :: @@ -116,7 +131,8 @@ defmodule UniversalProxy.Sendspin.Pairing do What the caller must do next. * `{:send, msgs, state}` — send each message, in order, and keep reading. - * `{:pin, pin, state}` — show `pin` to the user; nothing to send yet. + * `{:pin, code, state}` — show `code` to the user (again, on a retry + round); nothing to send yet. * `{:paired, outcome, state}` — persist the PSK; pairing is complete and the server will re-run the Noise handshake in band. * `{:abort, reason, msgs, state}` — send the `pair/abort` and stop. @@ -137,9 +153,10 @@ defmodule UniversalProxy.Sendspin.Pairing do stage: stage(), handshake_hash: binary(), pairing_index: non_neg_integer(), + round: pos_integer(), + max_rounds: pos_integer(), sid: binary(), suite: String.t(), - pin_length: pos_integer(), pin: String.t() | nil, nonce_a: binary() | nil, nonce_b: binary() | nil, @@ -152,7 +169,7 @@ defmodule UniversalProxy.Sendspin.Pairing do } # Redact the secret fields from any crash report / RingLogger dump / observer - # inspection: the minted long-term PSK, the derived PIN, the CPace + # inspection: the minted long-term PSK, the derived code, the CPace # intermediate/MAC keys, the commit/reveal nonce (secret until revealed in # `client/pair-confirm`) and the (test-only) CPace scalar. The live CPace # scalar lives inside the `:cpace` field's `%CPace{}`, which self-redacts it @@ -175,26 +192,29 @@ defmodule UniversalProxy.Sendspin.Pairing do :mac_key, :psk, :cpace_scalar, - pin_length: @default_pin_digits + round: 1, + max_rounds: @round_limit ] # -- Attempt lifecycle -- @doc """ - Open a pairing attempt, returning the `client/pair-init` to send. + Open a pairing attempt, returning the message(s) to send. Options: * `:handshake_hash` (required) — the 32-byte Noise handshake hash of the session this attempt runs inside. * `:pairing_index` (required) — the count of pairing `server/activate` - messages since the last Noise handshake, first attempt being 1. It + messages received since the last Noise handshake, first being 1. It resets to 0 on every handshake, re-handshakes included. * `:suite` (required) — the negotiated Noise cipher suite string; it - selects the AEAD that wraps the PSK. - * `:method` — `:dynamic_pin` (default) or `:static_pin`. - * `:pin_length` — dynamic PIN digits, 4..12, default 6. - * `:pin` — required for `:static_pin`; exactly 8 decimal digits. + selects the AEAD that wraps the PSK and `nonce_B`. + * `:method` — `:dynamic_pairing_code` (default), `:static_pairing_code` + or `:pairing_psk`. + * `:max_rounds` — dynamic code rounds this attempt may run before it + aborts instead of retrying, 1..#{@round_limit} (default #{@round_limit}). + * `:pin` — required for `:static_pairing_code`; exactly 8 decimal digits. * `:psk` — the long-term PSK to hand the server. Freshly sampled unless given; supply it only in tests. * `:nonce_b` — our 32-byte commit/reveal nonce. Test-only, as above. @@ -202,10 +222,11 @@ defmodule UniversalProxy.Sendspin.Pairing do """ @spec start(keyword()) :: {:ok, [message()], t()} | {:error, term()} def start(opts) do - method = Keyword.get(opts, :method, :dynamic_pin) + method = Keyword.get(opts, :method, :dynamic_pairing_code) with {:ok, hash} <- fetch_hash(opts), {:ok, index} <- fetch_index(opts), + {:ok, max_rounds} <- fetch_max_rounds(opts), {:ok, _cipher} <- cipher_for(Keyword.get(opts, :suite)), {:ok, psk} <- fetch_psk(opts) do state = %__MODULE__{ @@ -213,7 +234,8 @@ defmodule UniversalProxy.Sendspin.Pairing do stage: :awaiting_server_init, handshake_hash: hash, pairing_index: index, - sid: sid(hash, index), + max_rounds: max_rounds, + sid: sid(hash, index, 1), suite: Keyword.fetch!(opts, :suite), psk: psk, cpace_scalar: Keyword.get(opts, :cpace_scalar) @@ -237,28 +259,40 @@ defmodule UniversalProxy.Sendspin.Pairing do {:aborted, reason, fail(state, {:peer_abort, reason})} end + # The first round carries the server's nonce and fixes the code. def handle( - %__MODULE__{stage: :awaiting_server_init, method: :dynamic_pin} = state, + %__MODULE__{stage: :awaiting_server_init, method: :dynamic_pairing_code, round: 1} = + state, "server/pair-init", payload ) do with {:ok, nonce_a} <- decode_field(payload, "nonce_A", @nonce_bytes), - pin = derive_pin(state.handshake_hash, nonce_a, state.nonce_b, state.pin_length), - {:ok, cpace} <- start_cpace(state, pin) do - state = %{ - state - | nonce_a: nonce_a, - pin: pin, - cpace: cpace, - stage: :awaiting_pair_auth - } - - {:pin, pin, state} + code = derive_pairing_code(state.handshake_hash, nonce_a, state.nonce_b), + {:ok, cpace} <- start_cpace(state, code) do + {:pin, code, + %{state | nonce_a: nonce_a, pin: code, cpace: cpace, stage: :awaiting_pair_auth}} else {:error, reason} -> {:error, reason, fail(state, reason)} end end + # A retry round: same code, no nonce, a fresh CPace under the round's sid. + def handle( + %__MODULE__{stage: :awaiting_server_init, method: :dynamic_pairing_code} = state, + "server/pair-init", + payload + ) do + if Map.has_key?(payload, "nonce_A") do + reason = {:unexpected_field, "nonce_A"} + {:error, reason, fail(state, reason)} + else + case start_cpace(state, state.pin) do + {:ok, cpace} -> {:pin, state.pin, %{state | cpace: cpace, stage: :awaiting_pair_auth}} + {:error, reason} -> {:error, reason, fail(state, reason)} + end + end + end + def handle(%__MODULE__{stage: :awaiting_pair_auth} = state, "server/pair-auth", payload) do with {:ok, ya} <- decode_field(payload, "pake_msg_1", @share_bytes), {:ok, isk} <- finish_cpace(state.cpace, ya) do @@ -280,13 +314,18 @@ defmodule UniversalProxy.Sendspin.Pairing do def handle(%__MODULE__{stage: :awaiting_pair_confirm} = state, "server/pair-confirm", payload) do case decode_field(payload, "server_kc", @tag_bytes) do {:ok, ta} -> - if server_tag_valid?(state, ta) do - # The server's Tb check and the PSK unwrap both happen on its side - # of one round trip, so confirm and finalize go out together. - {:send, [confirm_message(state), finalize_message(state)], - %{state | stage: :awaiting_pair_finalize}} - else - abort(state, :pin_mismatch) + cond do + server_tag_valid?(state, ta) -> + # The server's checks and the PSK unwrap all happen on its side + # of one round trip, so confirm and finalize go out together. + {:send, [confirm_message(state), finalize_message(state)], + %{state | stage: :awaiting_pair_finalize}} + + state.method == :dynamic_pairing_code and state.round < state.max_rounds -> + retry(state) + + true -> + abort(state, :pairing_code_mismatch) end {:error, reason} -> @@ -321,32 +360,41 @@ defmodule UniversalProxy.Sendspin.Pairing do def stage(%__MODULE__{stage: stage}), do: stage @doc """ - The PIN to display, or `nil` before `server/pair-init` in a dynamic-PIN + The code to display, or `nil` before `server/pair-init` in a dynamic attempt. """ @spec pin(t()) :: String.t() | nil def pin(%__MODULE__{pin: pin}), do: pin + @doc "The current dynamic-pairing-code round, 1 for the first." + @spec round(t()) :: pos_integer() + def round(%__MODULE__{round: round}), do: round + @doc """ How long the client may take over the whole exchange before it must send - `pair/abort` with `attempt_timeout` (`aiosendspin/noise/pairing.py`). + `pair/abort` with `attempt_timeout` (`pairing.md` recommends 2 minutes). """ @spec attempt_timeout_ms() :: pos_integer() def attempt_timeout_ms, do: @attempt_timeout_ms + @doc "The spec's round limit since the last verified `server_kc`." + @spec round_limit() :: pos_integer() + def round_limit, do: @round_limit + # -- Protocol primitives -- # # Public so the connection FSM, the credential store and the scripted # server in the tests all compute these the one documented way. @doc """ - The CPace session id: label, the Noise handshake hash, and the pairing - counter as a big-endian `uint32` (ground truth §3, "`sid` construction"). + The CPace session id: label, the Noise handshake hash, the pairing index + and the round, both as big-endian `uint32` (`pairing.md` "PAKE"). """ - @spec sid(binary(), non_neg_integer()) :: binary() - def sid(handshake_hash, pairing_index) - when byte_size(handshake_hash) == 32 and pairing_index in 0..0xFFFFFFFF do - @sid_label <> handshake_hash <> <> + @spec sid(binary(), non_neg_integer(), pos_integer()) :: binary() + def sid(handshake_hash, pairing_index, round) + when byte_size(handshake_hash) == 32 and pairing_index in 0..0xFFFFFFFF and + round in 1..0xFFFFFFFF do + @sid_label <> handshake_hash <> <> end @doc "`commit_B` — our commitment to `nonce_B`, sent before `nonce_A` exists." @@ -363,30 +411,31 @@ defmodule UniversalProxy.Sendspin.Pairing do def commit_valid?(_nonce, _commitment), do: false @doc """ - The dynamic PIN: `SHA-256(label || h || nonce_A || nonce_B)` read as a - big-endian integer, reduced mod `10^length` and zero-padded to `length` - ASCII digits (ground truth §3, "PIN derivation"). + The dynamic pairing code in the `digits` format: + `SHA-256("sendspin-pairing-code-derive-v1" || h || nonce_A || nonce_B)` + read as a big-endian integer, reduced mod `10^6` and zero-padded to 6 ASCII + digits. """ - @spec derive_pin(binary(), binary(), binary(), pos_integer()) :: String.t() - def derive_pin(handshake_hash, nonce_a, nonce_b, length) - when byte_size(nonce_a) == @nonce_bytes and byte_size(nonce_b) == @nonce_bytes and - length in @min_pin_digits..@max_pin_digits do + @spec derive_pairing_code(binary(), binary(), binary()) :: String.t() + def derive_pairing_code(handshake_hash, nonce_a, nonce_b) + when byte_size(handshake_hash) == 32 and byte_size(nonce_a) == @nonce_bytes and + byte_size(nonce_b) == @nonce_bytes do <> = - :crypto.hash(:sha256, @pin_derive_label <> handshake_hash <> nonce_a <> nonce_b) + :crypto.hash(:sha256, @code_derive_label <> handshake_hash <> nonce_a <> nonce_b) digest - |> rem(Integer.pow(10, length)) + |> rem(Integer.pow(10, @dynamic_code_digits)) |> Integer.to_string() - |> String.pad_leading(length, "0") + |> String.pad_leading(@dynamic_code_digits, "0") end - @doc "Whether `pin` is a well-formed static PIN — exactly 8 ASCII digits." - @spec valid_static_pin?(term()) :: boolean() - def valid_static_pin?(pin) when is_binary(pin) do - byte_size(pin) == @static_pin_digits and pin =~ ~r/\A[0-9]+\z/ + @doc "Whether `code` is a well-formed static pairing code — exactly 8 ASCII digits." + @spec valid_static_pairing_code?(term()) :: boolean() + def valid_static_pairing_code?(code) when is_binary(code) do + byte_size(code) == @static_code_digits and code =~ ~r/\A[0-9]+\z/ end - def valid_static_pin?(_pin), do: false + def valid_static_pairing_code?(_code), do: false @doc """ The CPace MCF key, `SHA-512("CPaceMac" || sid || ISK)` — a plain @@ -399,8 +448,7 @@ defmodule UniversalProxy.Sendspin.Pairing do An MCF tag, `HMAC-SHA-512(mac_key, lv_cat(share, ad))`. `Ta` covers the server's `(Ya, "server")` and `Tb` the client's - `(Yb, "client")`, whichever side is computing them (ground truth §3, "MCF - tag construction"). + `(Yb, "client")`, whichever side is computing them. """ @spec confirmation_tag(binary(), binary(), binary()) :: binary() def confirmation_tag(mac_key, share, ad), @@ -415,26 +463,33 @@ defmodule UniversalProxy.Sendspin.Pairing do def client_ad, do: @ad_client @doc """ - `K_wrap = SHA-256(label || sid || ISK)`, the single-use key the PSK - travels under. + `K_wrap = SHA-256("sendspin-pair-psk-wrap-v1" || sid || ISK)`, the + single-use key the long-term PSK travels under. """ @spec wrap_key(binary(), binary()) :: binary() def wrap_key(sid, isk), do: :crypto.hash(:sha256, @psk_wrap_label <> sid <> isk) @doc """ - Seal a 32-byte PSK under `K_wrap` with the connection's AEAD, an all-zero - nonce and empty associated data. The result is 48 bytes. + `K_wrap = SHA-256("sendspin-pair-nonce-wrap-v1" || sid || ISK)`, the + single-use key `nonce_B` is revealed under (as `wrapped_nonce_B`). + """ + @spec nonce_wrap_key(binary(), binary()) :: binary() + def nonce_wrap_key(sid, isk), do: :crypto.hash(:sha256, @nonce_wrap_label <> sid <> isk) + + @doc """ + Seal a 32-byte value under a wrap key with the connection's AEAD, an + all-zero nonce and empty associated data. The result is 48 bytes. """ - @spec wrap_psk(binary(), binary(), binary()) :: binary() - def wrap_psk(suite, wrap_key, psk) when byte_size(psk) == @psk_bytes do + @spec wrap(binary(), binary(), binary()) :: binary() + def wrap(suite, wrap_key, value) when byte_size(value) == @psk_bytes do {:ok, cipher} = cipher_for(suite) {ciphertext, tag} = :crypto.crypto_one_time_aead( cipher, wrap_key, - @psk_wrap_nonce, - psk, + @wrap_nonce, + value, <<>>, @aead_tag_bytes, true @@ -444,41 +499,41 @@ defmodule UniversalProxy.Sendspin.Pairing do end @doc """ - Open a wrapped PSK. An AEAD failure is a protocol error, not a wrong PIN — - the MCF tags have already settled that question by this point. + Open a wrapped value. An AEAD failure is a protocol error, not a wrong + code — the MCF tags have already settled that question by this point. """ - @spec unwrap_psk(binary(), binary(), binary()) :: {:ok, binary()} | {:error, term()} - def unwrap_psk(suite, wrap_key, wrapped) when byte_size(wrapped) == @wrapped_psk_bytes do + @spec unwrap(binary(), binary(), binary()) :: {:ok, binary()} | {:error, term()} + def unwrap(suite, wrap_key, wrapped) when byte_size(wrapped) == @wrapped_bytes do <> = wrapped with {:ok, cipher} <- cipher_for(suite) do case :crypto.crypto_one_time_aead( cipher, wrap_key, - @psk_wrap_nonce, + @wrap_nonce, ciphertext, <<>>, tag, false ) do - :error -> {:error, :psk_unwrap_failed} - psk -> {:ok, psk} + :error -> {:error, :unwrap_failed} + value -> {:ok, value} end end end - def unwrap_psk(_suite, _wrap_key, _wrapped), do: {:error, {:malformed_field, "wrapped_psk"}} + def unwrap(_suite, _wrap_key, _wrapped), do: {:error, :malformed_wrapped_value} @doc """ The wire identifier for a PSK: `base64url(SHA-256(label || psk))`, no - padding. One namespace across long-term, pairing and sentinel PSKs, so a - lookup by `psk_id` must be unique across all three. + padding. One namespace across long-term, pairing and sentinel PSKs; the + `psk_category` beside it in Noise message 1 says which store to look in. """ @spec psk_id_for(binary()) :: String.t() def psk_id_for(psk) when byte_size(psk) == @psk_bytes, do: b64(:crypto.hash(:sha256, @psk_id_label <> psk)) - @doc "A fresh long-term PSK." + @doc "A fresh 32-byte PSK (long-term or pairing), from the CSPRNG." @spec generate_psk() :: binary() def generate_psk, do: :crypto.strong_rand_bytes(@psk_bytes) @@ -488,10 +543,9 @@ defmodule UniversalProxy.Sendspin.Pairing do # -- Attempt setup -- - defp start_method(:dynamic_pin, state, opts) do - with {:ok, length} <- fetch_pin_length(opts), - {:ok, nonce_b} <- fetch_nonce(opts) do - state = %{state | pin_length: length, nonce_b: nonce_b} + defp start_method(:dynamic_pairing_code, state, opts) do + with {:ok, nonce_b} <- fetch_nonce(opts) do + state = %{state | nonce_b: nonce_b} payload = %{ "pairing_index" => state.pairing_index, @@ -502,11 +556,11 @@ defmodule UniversalProxy.Sendspin.Pairing do end end - defp start_method(:static_pin, state, opts) do + defp start_method(:static_pairing_code, state, opts) do pin = Keyword.get(opts, :pin) - if valid_static_pin?(pin) do - # No nonces and no commitment: the PIN is known to both sides up + if valid_static_pairing_code?(pin) do + # No nonces and no commitment: the code is known to both sides up # front, so CPace can start before the server says anything. case start_cpace(state, pin) do {:ok, cpace} -> @@ -517,10 +571,21 @@ defmodule UniversalProxy.Sendspin.Pairing do {:error, reason} end else - {:error, {:invalid_static_pin, pin}} + {:error, :invalid_static_pairing_code} end end + # The session itself was keyed by the pairing PSK, so both sides are + # already authenticated: pair-init then the long-term PSK, back to back. + defp start_method(:pairing_psk, state, _opts) do + messages = [ + {"client/pair-init", %{"pairing_index" => state.pairing_index}}, + {"client/pair-finalize", %{"long_term_psk" => b64(state.psk)}} + ] + + {:ok, messages, %{state | stage: :awaiting_pair_finalize}} + end + defp start_method(other, _state, _opts), do: {:error, {:unsupported_method, other}} defp start_cpace(state, pin) do @@ -546,21 +611,41 @@ defmodule UniversalProxy.Sendspin.Pairing do end end + # Ask for another round under the same code. The next round's CPace runs + # under its own sid, so this round's keys are dropped. + defp retry(state) do + round = state.round + 1 + + state = %{ + state + | round: round, + sid: sid(state.handshake_hash, state.pairing_index, round), + cpace: nil, + peer_share: nil, + isk: nil, + mac_key: nil, + stage: :awaiting_server_init + } + + {:send, [{"client/pair-retry", %{}}], state} + end + # -- Message construction -- - defp confirm_message(%__MODULE__{method: :dynamic_pin} = state) do + defp confirm_message(%__MODULE__{method: :dynamic_pairing_code} = state) do tag = confirmation_tag(state.mac_key, CPace.public_share(state.cpace), @ad_client) + wrapped_nonce = wrap(state.suite, nonce_wrap_key(state.sid, state.isk), state.nonce_b) - {"client/pair-confirm", %{"client_kc" => b64(tag), "nonce_B" => b64(state.nonce_b)}} + {"client/pair-confirm", %{"client_kc" => b64(tag), "wrapped_nonce_B" => b64(wrapped_nonce)}} end - defp confirm_message(%__MODULE__{method: :static_pin} = state) do + defp confirm_message(%__MODULE__{method: :static_pairing_code} = state) do tag = confirmation_tag(state.mac_key, CPace.public_share(state.cpace), @ad_client) {"client/pair-confirm", %{"client_kc" => b64(tag)}} end defp finalize_message(%__MODULE__{} = state) do - wrapped = wrap_psk(state.suite, wrap_key(state.sid, state.isk), state.psk) + wrapped = wrap(state.suite, wrap_key(state.sid, state.isk), state.psk) {"client/pair-finalize", %{"wrapped_psk" => b64(wrapped)}} end @@ -596,6 +681,13 @@ defmodule UniversalProxy.Sendspin.Pairing do end end + defp fetch_max_rounds(opts) do + case Keyword.get(opts, :max_rounds, @round_limit) do + rounds when is_integer(rounds) and rounds in 1..@round_limit -> {:ok, rounds} + other -> {:error, {:invalid_max_rounds, other}} + end + end + defp fetch_psk(opts) do case Keyword.get(opts, :psk, generate_psk()) do <> -> {:ok, psk} @@ -610,13 +702,6 @@ defmodule UniversalProxy.Sendspin.Pairing do end end - defp fetch_pin_length(opts) do - case Keyword.get(opts, :pin_length, @default_pin_digits) do - length when length in @min_pin_digits..@max_pin_digits -> {:ok, length} - other -> {:error, {:invalid_pin_length, other}} - end - end - defp cipher_for("25519_AESGCM_SHA256"), do: {:ok, :aes_256_gcm} defp cipher_for("25519_ChaChaPoly_SHA256"), do: {:ok, :chacha20_poly1305} defp cipher_for(other), do: {:error, {:unsupported_suite, other}} diff --git a/lib/universal_proxy/sendspin/pairing_token.ex b/lib/universal_proxy/sendspin/pairing_token.ex new file mode 100644 index 00000000..aa5e81dc --- /dev/null +++ b/lib/universal_proxy/sendspin/pairing_token.ex @@ -0,0 +1,29 @@ +defmodule UniversalProxy.Sendspin.PairingToken do + @moduledoc """ + Sendspin pairing-token encoding (`pairing.md` "Pairing Token"). + + A token is `"SP:"`, a one-character version, and the payload as RFC 4648 + base32 with the `=` padding stripped and every `2` transliterated to `9`, + so it stays inside the QR alphanumeric set. Version `0` carries a client's + identity and pairing PSK, `client_key (32 bytes) || pairing_psk (32 + bytes)`: an operator pastes it into a server to pair over the Pairing PSK + method. + + The result is a long-lived pairing secret — callers must keep it out of + logs, PubSub and anything else that is not an explicit operator reveal. + """ + + @prefix "SP:" + @psk_token_version "0" + + @doc "Encode a version-0 (Pairing PSK) token from the raw client key and pairing PSK." + @spec encode_psk_token(<<_::256>>, <<_::256>>) :: String.t() + def encode_psk_token(<<_::256>> = client_key, <<_::256>> = pairing_psk) do + body = + (client_key <> pairing_psk) + |> Base.encode32(padding: false) + |> String.replace("2", "9") + + @prefix <> @psk_token_version <> body + end +end diff --git a/lib/universal_proxy/sendspin/wire.ex b/lib/universal_proxy/sendspin/wire.ex index f9e01395..60e24626 100644 --- a/lib/universal_proxy/sendspin/wire.ex +++ b/lib/universal_proxy/sendspin/wire.ex @@ -2,39 +2,39 @@ defmodule UniversalProxy.Sendspin.Wire do @moduledoc """ Pure Sendspin message codecs: no process, no I/O, no crypto. - Covers every wire layer a `source@v1` client touches, per - `research/aiosendspin-ground-truth.md` (fetched from `Sendspin/spec` and - `Sendspin/aiosendspin` @ `main`, 2026-08-16): + Covers every wire layer a `source@v1` client touches, per Sendspin + 1.0.0-rc1 (`Sendspin/spec` @ `1.0.0-rc1`, as implemented by aiosendspin + 10.x): * The two cleartext JSON messages exchanged before Noise starts (`client/init`, `server/init`), plus the `prologue/2` helper that concatenates their exact wire bytes — the Noise handshake prologue must - be the literal bytes sent/received, not a re-serialization (ground - truth §2). + be the literal bytes sent/received, not a re-serialization. * `noise/handshake`'s JSON envelope (base64url `data` field), reused - unmodified for the in-band re-handshake (ground truth §4). + unmodified for the in-band re-handshake, and the inner JSON payload of + Noise message 1 (`psk_id` + `psk_category`, `decode_noise_msg1_payload/1`). * Post-handshake plaintext framing: `wrap_json/1` / `unwrap_json/1` prefix/strip the type-`0` byte that marks a JSON body inside one Noise transport plaintext (`messaging.md` "Binary Message ID Structure"). * The source-role binary audio frame: type byte `12` (`0x0C`) + **big-endian signed** int64 microsecond timestamp + raw audio bytes. Only the - timestamp prefix is big-endian; PCM sample payloads are little-endian - (ground truth correction #1 and #3). - * Fragmentation (types `2`/`3`) via the pure `Reassembler` struct — a - single Noise transport message is capped at 65535 bytes minus the - 16-byte AEAD tag, so large messages arrive as an opening fragment-more - frame (carrying `orig_type`), zero or more continuations, and a closing - fragment-end frame (`messaging.md` "Fragmentation"). + timestamp prefix is big-endian; PCM sample payloads are little-endian. + * Fragmentation (type `1`, `[1][flags][orig_type][data]` then + `[1][flags][data]`) via the pure `Reassembler` struct — a single Noise + transport message is capped at 65535 bytes minus the 16-byte AEAD tag, + so large messages arrive split across several (`messaging.md` + "Fragmentation"). * JSON payload builders/parsers for every message the source FSM needs: `client/hello` / `server/hello`, `server/activate`, `client/state`, - `client/time` / `server/time`, `server/command`, `client_stream/start` / - `client_stream/end`, and every pairing message that carries the CPace - PAKE and PSK wrap (`client/pair-init`, `server/pair-init`, + `client/time` / `server/time`, `server/command`, `client-stream/start` / + `client-stream/end`, `client/goodbye`, and every pairing message + (`client/pair-pending`, `client/pair-init`, `server/pair-init`, `server/pair-auth`, `client/pair-auth`, `server/pair-confirm`, - `client/pair-confirm`, `client/pair-finalize`, `server/pair-finalize`, - `pair/abort`). Pairing fields are typed passthrough only — base64url - text on the wire becomes a raw binary here, nothing more; the - CPace/PSK-wrap math lives in `UniversalProxy.Sendspin.{CPace,Pairing}`. + `client/pair-retry`, `client/pair-confirm`, `client/pair-finalize`, + `server/pair-finalize`, `pair/abort`). Pairing fields are typed + passthrough only — base64url text on the wire becomes a raw binary + here, nothing more; the CPace/wrap math lives in + `UniversalProxy.Sendspin.{CPace,Pairing}`. **`UniversalProxy.Sendspin.Pairing` does not call the typed pairing functions below.** It works directly with `decode_envelope/1`'s raw, @@ -45,12 +45,8 @@ defmodule UniversalProxy.Sendspin.Wire do tests, and any future caller that wants atom-keyed, already-decoded fields — field names are kept in lockstep with what `Pairing` actually produces/consumes (`pake_msg_1`, `pake_msg_2`, `server_kc`, `client_kc`, - `wrapped_psk`, `nonce_A`/`nonce_B`, `commit_B`, `pairing_index`, `reason`). - - `client/pair-pending` and `client/goodbye` are intentionally out of scope - for this module — neither appears in `Pairing`'s implemented flow (gesture- - gated attempts and connection teardown are both out of scope there too); - add them here the same way if a later task needs them. + `wrapped_psk`, `long_term_psk`, `nonce_A`, `commit_B`, `wrapped_nonce_B`, + `pairing_index`, `reason`). ## Decode contract @@ -64,19 +60,27 @@ defmodule UniversalProxy.Sendspin.Wire do not an error — for anything else, so a future message type doesn't break an already-deployed client (forward compatibility). - Closed-enum wire strings (`trust_level`, `command`, `signal`, `codec`, - `activities`, pairing `method`) decode to fixed, already-existing atoms via - explicit `case`/function-head matching — never `String.to_atom/1` — so a - malicious or buggy peer can't grow the atom table. + Closed-enum wire strings (`command`, `signal`, `codec`, `activities`, + `psk_category`, pairing `method`/`format`) decode to fixed, already-existing + atoms via explicit `case`/function-head matching — never `String.to_atom/1` + — so a malicious or buggy peer can't grow the atom table. A pairing + `method` or `format` this module does not know decodes to + `{:unknown, string}`: the spec answers it with `pair/abort` + (`method_not_supported`), not a protocol error. """ # Transport-mode binary message type byte (byte 0 of the Noise-decrypted # plaintext). `messaging.md` "Binary Message ID Structure". @json_body 0 - @fragment_more 2 - @fragment_end 3 + @fragment 1 @source_audio_chunk 12 + # Fragment `flags` byte: bit 1 marks the first fragment, bit 0 the last; + # bits 2-7 are reserved and MUST be zero (`messaging.md` "Fragmentation"). + @fragment_first 0b10 + @fragment_last 0b01 + @fragment_reserved 0b1111_1100 + # Bounds a single connection's reassembly buffer against a peer streaming # endless fragments (mirrors aiosendspin's `MAX_REASSEMBLED_MESSAGE_BYTES`). # The largest legitimate inbound message a source@v1 client ever receives @@ -85,7 +89,7 @@ defmodule UniversalProxy.Sendspin.Wire do # a 1 GB Pi 3 shared across however many capture-card listeners it runs. @max_reassembled_bytes 1024 * 1024 - # A fragment-more frame can carry as little as 1 byte of payload, so the + # A fragment can carry as little as 1 byte of payload, so the # byte cap alone still permits `@max_reassembled_bytes` reduce steps # (again, per connection) before it trips. Capping the fragment count # bounds that work independent of how small the attacker makes each one. @@ -98,37 +102,58 @@ defmodule UniversalProxy.Sendspin.Wire do optional(:mac_address) => String.t() } - @type pair_method :: :dynamic_pin | :pairing_psk | :static_pin + @type pair_method :: :dynamic_pairing_code | :pairing_psk | :static_pairing_code + @typedoc """ + A pair-method descriptor (`pairing.md` "client/hello pair-method + descriptor"). `pairing_psk` / `static_pairing_code` take an optional + `locations` hint; `dynamic_pairing_code` requires non-empty `out_channels` + and `formats`. + """ @type pair_method_descriptor :: %{ - optional(:out_channels) => [String.t()], - optional(:min_pin_length) => pos_integer(), optional(:locations) => [String.t()], - method: pair_method() + optional(:out_channels) => [String.t()], + optional(:formats) => [String.t()] } + @typedoc "`supported_pair_methods`: an object keyed by method identifier." + @type supported_pair_methods :: %{optional(pair_method()) => pair_method_descriptor()} + @type client_hello :: %{ name: String.t(), device_info: device_info() | nil, - trust_level: :user | :none, supported_roles: [String.t()], source_v1_support: %{features: %{line_sense: boolean() | nil} | nil} | nil, - supported_pair_methods: [pair_method_descriptor()], + supported_pair_methods: supported_pair_methods(), unpaired_access: %{enabled: boolean()} } + @type pairing_format :: :digits | :qr_code + @type server_activate :: %{ - activities: [:playback | :pairing | :management], + activities: [:playback | :pairing | {:unknown, String.t()}], active_roles: [String.t()] | nil, pairing: %{ - method: pair_method(), - pin_length: pos_integer() | nil, - languages: [String.t()] | nil + method: pair_method() | {:unknown, String.t()}, + format: pairing_format() | {:unknown, String.t()} | nil } | nil } + @typedoc "Which category of PSK a Noise message 1 says it references (`connection.md`)." + @type psk_category :: :long_term | :pairing | :sentinel + + @type goodbye_reason :: + :another_server + | :shutdown + | :restart + | :user_request + | :unauthorized + | :pairing_required + | :concurrent_attempt + | :unpaired + @type client_stream_start :: %{ codec: :pcm | :opus | :flac, channels: pos_integer(), @@ -249,6 +274,41 @@ defmodule UniversalProxy.Sendspin.Wire do end end + @doc """ + Decode the inner plaintext of Noise message 1: a JSON object carrying the + `psk_id` the server's PSK hashes to and the `psk_category` it uses that PSK + as (`'lt'` long-term, `'pr'` pairing, `'sn'` Sentinel). Anything else — + not JSON, no `psk_id` string, a category outside the three codes — is a + malformed handshake payload, which the spec makes a silent failure. + """ + @spec decode_noise_msg1_payload(binary()) :: + {:ok, %{psk_id: String.t(), psk_category: psk_category()}} + | {:error, :malformed_handshake_payload} + def decode_noise_msg1_payload(payload) when is_binary(payload) do + with {:ok, %{"psk_id" => psk_id, "psk_category" => code}} when is_binary(psk_id) <- + Jason.decode(payload), + {:ok, category} <- decode_psk_category(code) do + {:ok, %{psk_id: psk_id, psk_category: category}} + else + _other -> {:error, :malformed_handshake_payload} + end + end + + @doc "Encode a Noise message 1 inner payload (the server side, for tests and peers)." + @spec encode_noise_msg1_payload(String.t(), psk_category()) :: binary() + def encode_noise_msg1_payload(psk_id, category) when is_binary(psk_id) do + Jason.encode!(%{"psk_id" => psk_id, "psk_category" => encode_psk_category(category)}) + end + + defp encode_psk_category(:long_term), do: "lt" + defp encode_psk_category(:pairing), do: "pr" + defp encode_psk_category(:sentinel), do: "sn" + + defp decode_psk_category("lt"), do: {:ok, :long_term} + defp decode_psk_category("pr"), do: {:ok, :pairing} + defp decode_psk_category("sn"), do: {:ok, :sentinel} + defp decode_psk_category(_other), do: :error + # -- Generic envelope -- @doc """ @@ -264,7 +324,7 @@ defmodule UniversalProxy.Sendspin.Wire do @doc """ Parse the generic envelope, returning the message type and its payload object (defaulting to `%{}` if `payload` is absent, as for messages with no - fields such as `client_stream/end`). + fields such as `client-stream/end`). """ @spec decode_envelope(binary()) :: {:ok, String.t(), map()} | {:error, term()} def decode_envelope(text) when is_binary(text) do @@ -357,51 +417,58 @@ defmodule UniversalProxy.Sendspin.Wire do {:pending, Reassembler.t()} | {:complete, frame(), Reassembler.t()} | {:error, term()} def decode_frame(<<>>, %Reassembler{}), do: {:error, :empty_plaintext} - def decode_frame( - <<@fragment_more, orig_type, data::binary>>, - %Reassembler{type: nil} = reassembler - ) - when orig_type != @fragment_more and orig_type != @fragment_end do - grow_reassembly(%{reassembler | type: orig_type}, data) + def decode_frame(<<@fragment, flags, _rest::binary>>, %Reassembler{}) + when Bitwise.band(flags, @fragment_reserved) != 0 do + {:error, :fragment_reserved_flags} end - # `orig_type` of 2 or 3 is an explicit protocol violation (a sender MUST NOT - # fragment a fragment), caught before the generic 1-byte-input clause below. - def decode_frame(<<@fragment_more, _orig_type, _data::binary>>, %Reassembler{type: nil}) do - {:error, :invalid_orig_type} + def decode_frame(<<@fragment, flags, rest::binary>>, %Reassembler{} = reassembler) do + fragment( + Bitwise.band(flags, @fragment_first) != 0, + Bitwise.band(flags, @fragment_last) != 0, + rest, + reassembler + ) end - def decode_frame(<<@fragment_more>>, %Reassembler{type: nil}) do - {:error, :fragment_missing_orig_type} - end + def decode_frame(<<@fragment>>, %Reassembler{}), do: {:error, :fragment_missing_flags} - def decode_frame(<<@fragment_more, data::binary>>, %Reassembler{type: type} = reassembler) - when not is_nil(type) do - grow_reassembly(reassembler, data) + # Any non-fragment frame while a fragmented message is in flight is a + # protocol error — a sender MUST finish one fragmented message first. + def decode_frame(_plaintext, %Reassembler{type: type}) when not is_nil(type) do + {:error, :fragment_interrupted} end - def decode_frame(<<@fragment_end, _data::binary>>, %Reassembler{type: nil}) do - {:error, :fragment_end_without_start} - end + def decode_frame(plaintext, %Reassembler{type: nil}), do: reassembled_frame(plaintext) - def decode_frame(<<@fragment_end, data::binary>>, %Reassembler{type: type} = reassembler) - when not is_nil(type) do - case grow_reassembly(reassembler, data) do - {:pending, %Reassembler{buffer: full_buffer}} -> - reassembled_frame(IO.iodata_to_binary([<>, full_buffer])) + defp fragment(true = _first, _last, _rest, %Reassembler{type: type}) when not is_nil(type), + do: {:error, :fragment_restarted} - {:error, _} = error -> - error - end + defp fragment(true, _last, <<>>, _reassembler), do: {:error, :fragment_missing_orig_type} + + # A sender MUST NOT use `1` as `orig_type`. + defp fragment(true, _last, <<@fragment, _data::binary>>, _reassembler), + do: {:error, :invalid_orig_type} + + defp fragment(true, last, <>, reassembler) do + %{reassembler | type: orig_type} + |> grow_reassembly(data) + |> maybe_complete(last) end - # Any non-fragment frame while a fragmented message is in flight is a - # protocol error — only fragment-more/fragment-end may interleave. - def decode_frame(_plaintext, %Reassembler{type: type}) when not is_nil(type) do - {:error, :fragment_interrupted} + defp fragment(false, _last, _data, %Reassembler{type: nil}), + do: {:error, :fragment_without_start} + + defp fragment(false, last, data, reassembler) do + reassembler + |> grow_reassembly(data) + |> maybe_complete(last) end - def decode_frame(plaintext, %Reassembler{type: nil}), do: reassembled_frame(plaintext) + defp maybe_complete({:pending, %Reassembler{type: type, buffer: buffer}}, true = _last), + do: reassembled_frame(IO.iodata_to_binary([<>, buffer])) + + defp maybe_complete(result, _last), do: result # Carries `size`/`fragment_count` as running totals on the struct instead # of recomputing `IO.iodata_length(buffer)` on every call — the latter @@ -472,7 +539,6 @@ defmodule UniversalProxy.Sendspin.Wire do %{} |> Map.put("name", Map.fetch!(fields, :name)) |> maybe_put("device_info", fields |> Map.get(:device_info) |> encode_device_info()) - |> Map.put("trust_level", encode_trust_level(Map.fetch!(fields, :trust_level))) |> Map.put("supported_roles", Map.fetch!(fields, :supported_roles)) |> maybe_put( "source@v1_support", @@ -480,9 +546,7 @@ defmodule UniversalProxy.Sendspin.Wire do ) |> Map.put( "supported_pair_methods", - fields - |> Map.fetch!(:supported_pair_methods) - |> Enum.map(&encode_pair_method_descriptor/1) + fields |> Map.fetch!(:supported_pair_methods) |> encode_supported_pair_methods() ) |> Map.put("unpaired_access", %{ "enabled" => fields |> Map.fetch!(:unpaired_access) |> Map.fetch!(:enabled) @@ -499,19 +563,16 @@ defmodule UniversalProxy.Sendspin.Wire do defp parse_client_hello_payload(payload) do with {:ok, name} <- fetch_string(payload, "name"), {:ok, device_info} <- parse_optional_device_info(payload), - {:ok, trust_level_str} <- fetch_string(payload, "trust_level"), - {:ok, trust_level} <- decode_trust_level(trust_level_str), {:ok, supported_roles} <- fetch_string_list(payload, "supported_roles"), {:ok, source_v1_support} <- parse_optional_source_v1_support(payload), - {:ok, pair_methods_raw} <- fetch_list(payload, "supported_pair_methods"), - {:ok, pair_methods} <- map_ok(pair_methods_raw, &parse_pair_method_descriptor/1), + {:ok, pair_methods_raw} <- fetch_map(payload, "supported_pair_methods"), + {:ok, pair_methods} <- parse_supported_pair_methods(pair_methods_raw), {:ok, unpaired_access_raw} <- fetch_map(payload, "unpaired_access"), {:ok, unpaired_enabled} <- fetch_boolean(unpaired_access_raw, "enabled") do {:ok, %{ name: name, device_info: device_info, - trust_level: trust_level, supported_roles: supported_roles, source_v1_support: source_v1_support, supported_pair_methods: pair_methods, @@ -584,43 +645,59 @@ defmodule UniversalProxy.Sendspin.Wire do end end + defp encode_supported_pair_methods(methods) when is_map(methods) do + Map.new(methods, fn {method, descriptor} -> + {encode_pair_method(method), encode_pair_method_descriptor(descriptor)} + end) + end + defp encode_pair_method_descriptor(descriptor) do %{} - |> Map.put("method", encode_pair_method(Map.fetch!(descriptor, :method))) - |> maybe_put("out_channels", Map.get(descriptor, :out_channels)) - |> maybe_put("min_pin_length", Map.get(descriptor, :min_pin_length)) |> maybe_put("locations", Map.get(descriptor, :locations)) + |> maybe_put("out_channels", Map.get(descriptor, :out_channels)) + |> maybe_put("formats", Map.get(descriptor, :formats)) end - defp parse_pair_method_descriptor(descriptor) when is_map(descriptor) do - with {:ok, method_str} <- fetch_string(descriptor, "method"), - {:ok, method} <- decode_pair_method(method_str) do - {:ok, - %{method: method} - |> maybe_put(:out_channels, Map.get(descriptor, "out_channels")) - |> maybe_put(:min_pin_length, Map.get(descriptor, "min_pin_length")) - |> maybe_put(:locations, Map.get(descriptor, "locations"))} - end - end + # Unknown method keys are ignored, as a server must (`pairing.md`). + defp parse_supported_pair_methods(raw) do + Enum.reduce_while(raw, {:ok, %{}}, fn {key, descriptor}, {:ok, acc} -> + case {decode_pair_method(key), descriptor} do + {{:unknown, _}, _descriptor} -> + {:cont, {:ok, acc}} - defp parse_pair_method_descriptor(_other), - do: {:error, {:invalid_field, "supported_pair_methods"}} + {method, descriptor} when is_map(descriptor) -> + {:cont, {:ok, Map.put(acc, method, parse_pair_method_descriptor(descriptor))}} - defp encode_trust_level(:user), do: "user" - defp encode_trust_level(:none), do: "none" + {_method, _other} -> + {:halt, {:error, {:invalid_field, "supported_pair_methods"}}} + end + end) + end - defp decode_trust_level("user"), do: {:ok, :user} - defp decode_trust_level("none"), do: {:ok, :none} - defp decode_trust_level(other), do: {:error, {:invalid_field, {"trust_level", other}}} + defp parse_pair_method_descriptor(descriptor) do + %{} + |> maybe_put(:locations, Map.get(descriptor, "locations")) + |> maybe_put(:out_channels, Map.get(descriptor, "out_channels")) + |> maybe_put(:formats, Map.get(descriptor, "formats")) + end - defp encode_pair_method(:dynamic_pin), do: "dynamic_pin" + defp encode_pair_method(:dynamic_pairing_code), do: "dynamic_pairing_code" defp encode_pair_method(:pairing_psk), do: "pairing_psk" - defp encode_pair_method(:static_pin), do: "static_pin" + defp encode_pair_method(:static_pairing_code), do: "static_pairing_code" + + defp decode_pair_method("dynamic_pairing_code"), do: :dynamic_pairing_code + defp decode_pair_method("pairing_psk"), do: :pairing_psk + defp decode_pair_method("static_pairing_code"), do: :static_pairing_code + defp decode_pair_method(other), do: {:unknown, other} + + defp encode_pairing_format(:digits), do: "digits" + defp encode_pairing_format(:qr_code), do: "qr_code" - defp decode_pair_method("dynamic_pin"), do: {:ok, :dynamic_pin} - defp decode_pair_method("pairing_psk"), do: {:ok, :pairing_psk} - defp decode_pair_method("static_pin"), do: {:ok, :static_pin} - defp decode_pair_method(other), do: {:error, {:invalid_field, {"method", other}}} + defp decode_pairing_format(nil), do: nil + defp decode_pairing_format("digits"), do: :digits + defp decode_pairing_format("qr_code"), do: :qr_code + defp decode_pairing_format(other) when is_binary(other), do: {:unknown, other} + defp decode_pairing_format(_other), do: :invalid # -- server/activate -- @@ -655,8 +732,7 @@ defmodule UniversalProxy.Sendspin.Wire do defp encode_pairing_params(params) do %{} |> Map.put("method", encode_pair_method(Map.fetch!(params, :method))) - |> maybe_put("pin_length", Map.get(params, :pin_length)) - |> maybe_put("languages", Map.get(params, :languages)) + |> maybe_put("format", params |> Map.get(:format) |> then(&(&1 && encode_pairing_format(&1)))) end defp parse_optional_pairing_params(payload) do @@ -665,14 +741,14 @@ defmodule UniversalProxy.Sendspin.Wire do {:ok, nil} params when is_map(params) -> - with {:ok, method_str} <- fetch_string(params, "method"), - {:ok, method} <- decode_pair_method(method_str) do - {:ok, - %{ - method: method, - pin_length: Map.get(params, "pin_length"), - languages: Map.get(params, "languages") - }} + with {:ok, method_str} <- fetch_string(params, "method") do + case decode_pairing_format(Map.get(params, "format")) do + :invalid -> + {:error, {:invalid_field, "pairing.format"}} + + format -> + {:ok, %{method: decode_pair_method(method_str), format: format}} + end end _other -> @@ -682,11 +758,14 @@ defmodule UniversalProxy.Sendspin.Wire do defp encode_activity(:playback), do: "playback" defp encode_activity(:pairing), do: "pairing" - defp encode_activity(:management), do: "management" + # An unrecognized value in a known field follows that field's rules + # (`messaging.md` "Forward compatibility"): an activity set holding one is not + # an allowed set, which the FSM answers with `client/goodbye` `unauthorized`. + # It decodes to `{:unknown, string}` (no new atom) so the FSM can say so. defp decode_activity("playback"), do: {:ok, :playback} defp decode_activity("pairing"), do: {:ok, :pairing} - defp decode_activity("management"), do: {:ok, :management} + defp decode_activity(other) when is_binary(other), do: {:ok, {:unknown, other}} defp decode_activity(other), do: {:error, {:invalid_field, {"activities", other}}} # -- client/time / server/time -- @@ -754,9 +833,12 @@ defmodule UniversalProxy.Sendspin.Wire do @doc """ Encode `client/state`. `source` is `nil` to omit the object entirely (no - `source` role, or an update that doesn't touch it), or `%{signal: nil | - :present | :absent}` — `signal: nil` sends `"source": {}` (line-sensing not - currently reported), distinct from omitting `source` altogether. + active `source` role), or `%{signal: nil | :present | :absent}` — + `signal: nil` sends `"source": {}` (line-sensing not reported), distinct + from omitting `source` altogether. rc1 carries full state only: every + message with a role object carries that object's complete state, and the + update an activation requires MUST include it (`messaging.md` + `client/state`). """ @spec encode_client_state(boolean(), %{signal: :present | :absent | nil} | nil) :: binary() def encode_client_state(available, source \\ nil) when is_boolean(available) do @@ -851,10 +933,10 @@ defmodule UniversalProxy.Sendspin.Wire do end end - # -- client_stream/start / client_stream/end -- + # -- client-stream/start / client-stream/end -- @doc """ - Encode `client_stream/start`. `codec_header` uses **standard** (padded) + Encode `client-stream/start`. `codec_header` uses **standard** (padded) Base64 — a different alphabet/padding convention than every other binary field in this protocol, which is base64url without padding (ground truth §7). `nil` omits the field, correct for `pcm`/`opus`. @@ -869,22 +951,22 @@ defmodule UniversalProxy.Sendspin.Wire do |> Map.put("bit_depth", Map.fetch!(fields, :bit_depth)) |> maybe_put("codec_header", fields |> Map.get(:codec_header) |> encode_codec_header()) - encode_message("client_stream/start", %{"source" => source}) + encode_message("client-stream/start", %{"source" => source}) end - @doc "Decode `client_stream/start`." + @doc "Decode `client-stream/start`." @spec decode_client_stream_start(binary()) :: {:ok, client_stream_start()} | {:error, term()} def decode_client_stream_start(text), - do: decode_as(text, "client_stream/start", &parse_client_stream_start_payload/1) + do: decode_as(text, "client-stream/start", &parse_client_stream_start_payload/1) - @doc "Encode `client_stream/end` (no payload fields)." + @doc "Encode `client-stream/end` (no payload fields)." @spec encode_client_stream_end() :: binary() - def encode_client_stream_end, do: encode_message("client_stream/end", %{}) + def encode_client_stream_end, do: encode_message("client-stream/end", %{}) - @doc "Decode `client_stream/end`." + @doc "Decode `client-stream/end`." @spec decode_client_stream_end(binary()) :: {:ok, %{}} | {:error, term()} def decode_client_stream_end(text), - do: decode_as(text, "client_stream/end", &parse_empty_payload/1) + do: decode_as(text, "client-stream/end", &parse_empty_payload/1) defp encode_codec_header(nil), do: nil defp encode_codec_header(data) when is_binary(data), do: base64_std_encode(data) @@ -925,13 +1007,50 @@ defmodule UniversalProxy.Sendspin.Wire do end end + # -- server/unpair -- + + @doc "Encode `server/unpair` (no payload fields): a paired server drops its record." + @spec encode_server_unpair() :: binary() + def encode_server_unpair, do: encode_message("server/unpair", %{}) + + # -- client/goodbye -- + + @doc """ + Encode `client/goodbye`, sent before we close a connection to say why + (`messaging.md` `client/goodbye`). + """ + @spec encode_client_goodbye(goodbye_reason()) :: binary() + def encode_client_goodbye(reason) when is_atom(reason) do + encode_message("client/goodbye", %{"reason" => encode_goodbye_reason(reason)}) + end + + defp encode_goodbye_reason(:another_server), do: "another_server" + defp encode_goodbye_reason(:shutdown), do: "shutdown" + defp encode_goodbye_reason(:restart), do: "restart" + defp encode_goodbye_reason(:user_request), do: "user_request" + defp encode_goodbye_reason(:unauthorized), do: "unauthorized" + defp encode_goodbye_reason(:pairing_required), do: "pairing_required" + defp encode_goodbye_reason(:concurrent_attempt), do: "concurrent_attempt" + defp encode_goodbye_reason(:unpaired), do: "unpaired" + # -- Pairing messages -- @doc """ - Encode `client/pair-init`, starting the PIN-pairing attempt. `commit_B` is - our commitment to `nonce_B` (`SHA-256("sendspin-pair-commit-v1" || - nonce_B)`) and is present only for dynamic-PIN pairing — `nil` omits it for - static-PIN pairing, which has no nonce/commit round trip. + Encode `client/pair-pending`: we are holding the selected attempt back + (awaiting the operator's local gesture). `message` is an optional short + operator-facing sentence (at most 200 characters). + """ + @spec encode_client_pair_pending(non_neg_integer(), String.t() | nil) :: binary() + def encode_client_pair_pending(pairing_index, message \\ nil) when is_integer(pairing_index) do + payload = maybe_put(%{"pairing_index" => pairing_index}, "message", message) + encode_message("client/pair-pending", payload) + end + + @doc """ + Encode `client/pair-init`, starting the pairing attempt. `commit_B` is our + commitment to `nonce_B` (`SHA-256("sendspin-pair-commit-v1" || nonce_B)`) + and is present only for the dynamic pairing code — `nil` omits it for the + static pairing code and the Pairing PSK flow. """ @spec encode_client_pair_init(non_neg_integer(), binary() | nil) :: binary() def encode_client_pair_init(pairing_index, commit_b \\ nil) when is_integer(pairing_index) do @@ -948,14 +1067,21 @@ defmodule UniversalProxy.Sendspin.Wire do def decode_client_pair_init(text), do: decode_as(text, "client/pair-init", &parse_client_pair_init_payload/1) - @doc "Encode `server/pair-init` (server's dynamic-PIN nonce contribution, `nonce_A`)." - @spec encode_server_pair_init(binary()) :: binary() - def encode_server_pair_init(nonce_a) when is_binary(nonce_a) do - encode_message("server/pair-init", %{"nonce_A" => b64url_encode(nonce_a)}) + @doc """ + Encode `server/pair-init`, which begins a dynamic-pairing-code round: + the server's `nonce_A` in the first round, `nil` (no field) after a + `client/pair-retry`. + """ + @spec encode_server_pair_init(binary() | nil) :: binary() + def encode_server_pair_init(nonce_a) do + encode_message( + "server/pair-init", + maybe_put(%{}, "nonce_A", nonce_a && b64url_encode(nonce_a)) + ) end @doc "Decode `server/pair-init`." - @spec decode_server_pair_init(binary()) :: {:ok, %{nonce_a: binary()}} | {:error, term()} + @spec decode_server_pair_init(binary()) :: {:ok, %{nonce_a: binary() | nil}} | {:error, term()} def decode_server_pair_init(text), do: decode_as(text, "server/pair-init", &parse_server_pair_init_payload/1) @@ -967,9 +1093,14 @@ defmodule UniversalProxy.Sendspin.Wire do end defp parse_server_pair_init_payload(payload) do - with {:ok, b64} <- fetch_string(payload, "nonce_A"), - {:ok, bin} <- b64url_decode(b64) do - {:ok, %{nonce_a: bin}} + with {:ok, nonce_a} <- parse_optional_b64url(payload, "nonce_A") do + {:ok, %{nonce_a: nonce_a}} + end + end + + defp parse_client_pair_pending_payload(payload) do + with {:ok, pairing_index} <- fetch_integer(payload, "pairing_index") do + {:ok, %{pairing_index: pairing_index, message: Map.get(payload, "message")}} end end @@ -1006,29 +1137,34 @@ defmodule UniversalProxy.Sendspin.Wire do def decode_server_pair_confirm(text), do: decode_as(text, "server/pair-confirm", &parse_server_pair_confirm_payload/1) + @doc "Encode `client/pair-retry`: ask for another dynamic-pairing-code round." + @spec encode_client_pair_retry() :: binary() + def encode_client_pair_retry, do: encode_message("client/pair-retry", %{}) + @doc """ - Encode `client/pair-confirm` (client's MCF tag `Tb`, plus `nonce_B` — the - dynamic-PIN commitment opening — when applicable; `nil` omits it for - static-PIN pairing). + Encode `client/pair-confirm` (client's MCF tag `Tb`, plus + `wrapped_nonce_B` — the dynamic pairing code's commitment opening, sealed + under the CPace output — when applicable; `nil` omits it for the static + pairing code). """ @spec encode_client_pair_confirm(binary(), binary() | nil) :: binary() - def encode_client_pair_confirm(client_kc, nonce_b \\ nil) when is_binary(client_kc) do + def encode_client_pair_confirm(client_kc, wrapped_nonce_b \\ nil) when is_binary(client_kc) do payload = %{"client_kc" => b64url_encode(client_kc)} - |> maybe_put("nonce_B", nonce_b && b64url_encode(nonce_b)) + |> maybe_put("wrapped_nonce_B", wrapped_nonce_b && b64url_encode(wrapped_nonce_b)) encode_message("client/pair-confirm", payload) end @doc "Decode `client/pair-confirm`." @spec decode_client_pair_confirm(binary()) :: - {:ok, %{client_kc: binary(), nonce_b: binary() | nil}} | {:error, term()} + {:ok, %{client_kc: binary(), wrapped_nonce_b: binary() | nil}} | {:error, term()} def decode_client_pair_confirm(text), do: decode_as(text, "client/pair-confirm", &parse_client_pair_confirm_payload/1) @doc """ Encode `client/pair-finalize`. Exactly one of `:long_term_psk` (Pairing PSK - flow) or `:wrapped_psk` (PIN flows) must be present — this is the wire's + flow) or `:wrapped_psk` (pairing-code flows) must be present — this is the wire's documented XOR, so unlike every other `encode_*/1` in this module the result is a tagged tuple rather than a bare binary. """ @@ -1065,13 +1201,12 @@ defmodule UniversalProxy.Sendspin.Wire do def decode_server_pair_finalize(text), do: decode_as(text, "server/pair-finalize", &parse_empty_payload/1) - @typedoc "One of the six documented `pair/abort` reasons (`pairing.md` `pair/abort`)." + @typedoc "One of the five documented `pair/abort` reasons (`pairing.md` `pair/abort`)." @type pair_abort_reason :: :attempt_timeout | :concurrent_attempt | :method_not_supported - | :pin_length_unacceptable - | :pin_mismatch + | :pairing_code_mismatch | :user_cancelled @doc "Encode `pair/abort`. Either side may send this — it aborts a pairing attempt, started or not." @@ -1099,15 +1234,13 @@ defmodule UniversalProxy.Sendspin.Wire do defp encode_pair_abort_reason(:attempt_timeout), do: "attempt_timeout" defp encode_pair_abort_reason(:concurrent_attempt), do: "concurrent_attempt" defp encode_pair_abort_reason(:method_not_supported), do: "method_not_supported" - defp encode_pair_abort_reason(:pin_length_unacceptable), do: "pin_length_unacceptable" - defp encode_pair_abort_reason(:pin_mismatch), do: "pin_mismatch" + defp encode_pair_abort_reason(:pairing_code_mismatch), do: "pairing_code_mismatch" defp encode_pair_abort_reason(:user_cancelled), do: "user_cancelled" defp decode_pair_abort_reason("attempt_timeout"), do: :attempt_timeout defp decode_pair_abort_reason("concurrent_attempt"), do: :concurrent_attempt defp decode_pair_abort_reason("method_not_supported"), do: :method_not_supported - defp decode_pair_abort_reason("pin_length_unacceptable"), do: :pin_length_unacceptable - defp decode_pair_abort_reason("pin_mismatch"), do: :pin_mismatch + defp decode_pair_abort_reason("pairing_code_mismatch"), do: :pairing_code_mismatch defp decode_pair_abort_reason("user_cancelled"), do: :user_cancelled defp decode_pair_abort_reason(other), do: {:unknown, other} @@ -1135,8 +1268,8 @@ defmodule UniversalProxy.Sendspin.Wire do defp parse_client_pair_confirm_payload(payload) do with {:ok, kc_b64} <- fetch_string(payload, "client_kc"), {:ok, client_kc} <- b64url_decode(kc_b64), - {:ok, nonce_b} <- parse_optional_b64url(payload, "nonce_B") do - {:ok, %{client_kc: client_kc, nonce_b: nonce_b}} + {:ok, wrapped} <- parse_optional_b64url(payload, "wrapped_nonce_B") do + {:ok, %{client_kc: client_kc, wrapped_nonce_b: wrapped}} end end @@ -1213,14 +1346,19 @@ defmodule UniversalProxy.Sendspin.Wire do defp type_tag_and_parser("server/command"), do: {:server_command, &parse_server_command_payload/1} - defp type_tag_and_parser("client_stream/start"), + defp type_tag_and_parser("client-stream/start"), do: {:client_stream_start, &parse_client_stream_start_payload/1} - defp type_tag_and_parser("client_stream/end"), do: {:client_stream_end, &parse_empty_payload/1} + defp type_tag_and_parser("client-stream/end"), do: {:client_stream_end, &parse_empty_payload/1} + + defp type_tag_and_parser("client/pair-pending"), + do: {:client_pair_pending, &parse_client_pair_pending_payload/1} defp type_tag_and_parser("client/pair-init"), do: {:client_pair_init, &parse_client_pair_init_payload/1} + defp type_tag_and_parser("client/pair-retry"), do: {:client_pair_retry, &parse_empty_payload/1} + defp type_tag_and_parser("server/pair-init"), do: {:server_pair_init, &parse_server_pair_init_payload/1} @@ -1243,6 +1381,7 @@ defmodule UniversalProxy.Sendspin.Wire do do: {:server_pair_finalize, &parse_empty_payload/1} defp type_tag_and_parser("pair/abort"), do: {:pair_abort, &parse_pair_abort_payload/1} + defp type_tag_and_parser("server/unpair"), do: {:server_unpair, &parse_empty_payload/1} defp type_tag_and_parser(_other), do: nil defp tag_result(tag, {:ok, parsed}), do: {:ok, {tag, parsed}} diff --git a/lib/universal_proxy_web/live/audio_live.ex b/lib/universal_proxy_web/live/audio_live.ex index dc13760a..c7700e9f 100644 --- a/lib/universal_proxy_web/live/audio_live.ex +++ b/lib/universal_proxy_web/live/audio_live.ex @@ -43,6 +43,23 @@ defmodule UniversalProxyWeb.AudioLive do one of: the "Allow pairing" button (offer pending, no window), an active "waiting" state (window open), or the PIN (once derived). + Each input card's "Pairing" disclosure can reveal its Sendspin pairing + token, read on demand through `Audio.Input.pairing_token/1` exactly like an + output's: the operator pastes it into Music Assistant to pair over the + Pairing PSK method, with no PIN or consent gesture. A revealed input token + hides itself after a minute and when the disclosure closes. The disclosure + also lists the servers the input is paired with (`server_id` short form and + when), with "Unpair" per server, "Unpair all" and "Regenerate pairing + token" (`Audio.Input.unpair/2`, `Audio.Input.regenerate_pairing_token/1`), + each armed by a first click and confirmed by a second. + + Residual risk: the device UI is plain HTTP and unauthenticated, so a + revealed token crosses the LAN in the clear in a LiveView diff, and anyone + who can open this page can reveal it. Holding it lets a server pair with the + input and stream its live audio. Revealing is deliberate and short-lived, + the paired-server list makes an unexpected pairing visible, and Regenerate + (plus Unpair) revokes a leaked token; gating the UI itself is separate work. + ## HDMI-CEC An output whose ALSA card is paired with a CEC adapter @@ -85,9 +102,10 @@ defmodule UniversalProxyWeb.AudioLive do # crash report (which inspects the socket assigns) cannot print it. @derive {Inspect, except: [:token]} @enforce_keys [:token] - defstruct [:token] + # `ref` tags an input token's auto-hide timer. + defstruct [:token, :ref] - @type t :: %__MODULE__{token: String.t()} + @type t :: %__MODULE__{token: String.t(), ref: reference() | nil} end require Logger @@ -183,6 +201,16 @@ defmodule UniversalProxyWeb.AudioLive do # be read. Absent means hidden. The token is read on demand only and # dropped again on Hide / disclosure close / output removal. |> assign(:pairing_tokens, %{}) + # The same, for input cards (a separate map: an input and an output of one + # USB device share a key). + |> assign(:input_pairing_tokens, %{}) + # Input cards whose "Pairing" disclosure is open, and the one destructive + # pairing action armed by a first click (`{id, action}`), if any. + |> assign(:input_more_open, MapSet.new()) + |> assign(:input_armed, nil) + # Per-input-card failure text from a revocation action that didn't land + # (the input subsystem was down), so the operator can retry. + |> assign(:input_action_errors, %{}) # Encoded id of the output reported to Home Assistant, or nil. The key # needn't be a shown card: a selected output that is unplugged stays # selected. @@ -377,6 +405,92 @@ defmodule UniversalProxyWeb.AudioLive do {:noreply, update(socket, :pairing_tokens, &Map.delete(&1, id))} end + def handle_event("toggle_input_more", %{"id" => id}, socket) do + open = socket.assigns.input_more_open + + {:noreply, + socket + |> assign(:input_more_open, toggle_member(open, id)) + |> update(:input_pairing_tokens, &Map.delete(&1, id)) + |> update(:input_action_errors, &Map.delete(&1, id)) + |> disarm_input(id)} + end + + def handle_event("arm_input_action", %{"id" => id, "action" => action} = params, socket) do + armed = + case action do + "unpair" -> unpair_target(Map.get(params, "server")) + "unpair_all" -> :unpair_all + "regenerate" -> :regenerate + _ -> nil + end + + if armed && Map.has_key?(socket.assigns.inputs, id) do + {:noreply, + socket + |> update(:input_action_errors, &Map.delete(&1, id)) + |> assign(:input_armed, {id, armed})} + else + {:noreply, socket} + end + end + + def handle_event("disarm_input_action", _params, socket) do + {:noreply, assign(socket, :input_armed, nil)} + end + + def handle_event("confirm_input_action", %{"id" => id}, socket) do + with {^id, action} <- socket.assigns.input_armed, + true <- Map.has_key?(socket.assigns.inputs, id), + {:ok, key} <- decode_key(id) do + case run_input_action(key, action) do + :ok -> + socket = + if action == :regenerate, + do: update(socket, :input_pairing_tokens, &Map.delete(&1, id)), + else: socket + + {:noreply, + socket + |> update(:input_action_errors, &Map.delete(&1, id)) + |> assign(:input_armed, nil)} + + {:error, _reason} -> + {:noreply, + update( + socket, + :input_action_errors, + &Map.put( + &1, + id, + "Couldn't complete that — the input service is unavailable. Try again." + ) + )} + end + else + _ -> {:noreply, assign(socket, :input_armed, nil)} + end + end + + def handle_event("show_input_pairing_token", %{"id" => id}, socket) do + case read_input_pairing_token(socket, id) do + {:ok, token} -> + ref = make_ref() + Process.send_after(self(), {:hide_input_pairing_token, id, ref}, input_token_reveal_ms()) + {:noreply, put_input_pairing_token(socket, id, %RevealedToken{token: token, ref: ref})} + + {:error, :unavailable} -> + {:noreply, put_input_pairing_token(socket, id, :unavailable)} + + {:error, :not_found} -> + {:noreply, socket} + end + end + + def handle_event("hide_input_pairing_token", %{"id" => id}, socket) do + {:noreply, update(socket, :input_pairing_tokens, &Map.delete(&1, id))} + end + # The view's selection is updated by the broadcast, not here, so every # open page shows the same one. def handle_event("toggle_ha_pairing", %{"id" => id}, socket) do @@ -501,7 +615,26 @@ defmodule UniversalProxyWeb.AudioLive do end def handle_info({:sendspin_input_removed, %{key: key}}, socket) do - {:noreply, update(socket, :inputs, &Map.delete(&1, encode_key(key)))} + id = encode_key(key) + + {:noreply, + socket + |> update(:inputs, &Map.delete(&1, id)) + |> update(:input_pairing_tokens, &Map.delete(&1, id)) + |> update(:input_more_open, &MapSet.delete(&1, id)) + |> disarm_input(id)} + end + + # A revealed input token's auto-hide; a stale timer (the token was hidden + # and shown again since) carries an old ref and is ignored. + def handle_info({:hide_input_pairing_token, id, ref}, socket) do + case Map.get(socket.assigns.input_pairing_tokens, id) do + %RevealedToken{ref: ^ref} -> + {:noreply, update(socket, :input_pairing_tokens, &Map.delete(&1, id))} + + _ -> + {:noreply, socket} + end end # `state_map` from `Audio.Input.Server` is always the FULL derived live @@ -584,7 +717,15 @@ defmodule UniversalProxyWeb.AudioLive do :if={map_size(@inputs) > 0} class="grid grid-cols-1 [grid-template-columns:repeat(auto-fit,minmax(360px,1fr))] gap-4" > - <.input_card :for={{id, input} <- sorted_inputs(@inputs)} id={id} input={input} /> + <.input_card + :for={{id, input} <- sorted_inputs(@inputs)} + id={id} + input={input} + pairing={Map.get(@input_pairing_tokens, id)} + more_open?={MapSet.member?(@input_more_open, id)} + armed={armed_for(@input_armed, id)} + action_error={Map.get(@input_action_errors, id)} + /> @@ -648,7 +789,8 @@ defmodule UniversalProxyWeb.AudioLive do

Each USB capture card is advertised as a Sendspin source. Add it as a - Live Input in Music Assistant, then pair it using the PIN shown here. + Live Input in Music Assistant, then pair it with its pairing token or + the PIN shown here.

""" @@ -670,6 +812,10 @@ defmodule UniversalProxyWeb.AudioLive do attr(:id, :string, required: true) attr(:input, :map, required: true) + attr(:pairing, :any, default: nil) + attr(:more_open?, :boolean, default: false) + attr(:armed, :any, default: nil) + attr(:action_error, :string, default: nil) # Single input row/card. Read-only: no volume, mute, or enable control — # unlike an output, a present capture card always gets a source (see @@ -742,6 +888,151 @@ defmodule UniversalProxyWeb.AudioLive do +
+ Pairing · {paired_label(@input)} +
+ +
+ + <%!-- Pairing token: read only on Show, so it is in the DOM only while + revealed; Copy is client-side (falls back to selecting the token + where the clipboard is blocked on the plain-HTTP device UI). --%> +
+ Pairing token + + {@pairing.token} + + — Unavailable + •••••••• +
+ + + +
+ + Paste into Music Assistant's Sendspin "Pairing token" option to pair this input. + + + + + + Paired with + No servers + <%= for {pairing, index} <- Enum.with_index(@input.pairings) do %> + 0}> + + {short_server_id(pairing.server_id)} + · {format_paired_at(pairing.paired_at)} + + + <% end %> + 1}> + 1}> + + +
+ {armed_sentence(@armed)} + + +
+ + +
+
assign(socket, :input_armed, nil) + _ -> socket + end + end + + # Only a named server can be unpaired one at a time; anything else arms nothing. + defp unpair_target(server_id) when is_binary(server_id) and server_id != "", + do: {:unpair, server_id} + + defp unpair_target(_server_id), do: nil + + defp armed_for({id, action}, id), do: action + defp armed_for(_armed, _id), do: nil + + defp run_input_action(key, {:unpair, server_id}) when is_binary(server_id), + do: input_facade().unpair(key, server_id) + + defp run_input_action(key, :unpair_all), do: input_facade().unpair(key, :all) + defp run_input_action(key, :regenerate), do: input_facade().regenerate_pairing_token(key) + defp run_input_action(_key, _action), do: {:error, :invalid} + + defp armed_sentence({:unpair, server_id}), + do: "Unpair #{short_server_id(server_id)}? It must pair again to stream this input." + + defp armed_sentence(:unpair_all), + do: "Unpair every server? Each must pair again to stream this input." + + defp armed_sentence(:regenerate), + do: "Regenerate the pairing token? The current token stops working; paired servers are kept." + + defp armed_sentence(_action), do: "" + + defp paired_label(%{pairings: [_ | _] = pairings}) do + count = length(pairings) + "paired with #{count} #{if count == 1, do: "server", else: "servers"}" + end + + defp paired_label(_input), do: "not paired" + + # A server's id is its public key; the first characters identify it. + defp short_server_id(server_id) when is_binary(server_id), + do: String.slice(server_id, 0, 10) <> "…" + + defp short_server_id(_server_id), do: "—" + + defp format_paired_at(%DateTime{} = at), do: Calendar.strftime(at, "%Y-%m-%d") + defp format_paired_at(_at), do: "—" + + defp input_token_reveal_ms, + do: Application.get_env(:universal_proxy, :input_token_reveal_ms, 60_000) + + # Only for an input this view still shows. + defp read_input_pairing_token(socket, id) do + with true <- Map.has_key?(socket.assigns.inputs, id), + {:ok, key} <- decode_key(id) do + input_facade().pairing_token(key) + else + _ -> {:error, :not_found} + end + end + + defp put_input_pairing_token(socket, id, value) do + update(socket, :input_pairing_tokens, &Map.put(&1, id, value)) + end + + # Test seam, like `:audio_facade` above. + defp input_facade, do: Application.get_env(:universal_proxy, :audio_input_facade, Input) + # Test seam, like `OverviewLive`'s `:storage_facade`: the app-tree # `Audio.Server` runs with no outputs in test. defp audio_facade, do: Application.get_env(:universal_proxy, :audio_facade, Audio) diff --git a/test/support/sendspin_pairing_server.ex b/test/support/sendspin_pairing_server.ex index cf30bf33..8836ff72 100644 --- a/test/support/sendspin_pairing_server.ex +++ b/test/support/sendspin_pairing_server.ex @@ -2,11 +2,11 @@ defmodule UniversalProxy.SendspinPairingServer do @moduledoc """ A scripted Music-Assistant-side pairing peer — CPace party A (initiator). - Written straight from the ground-truth spec - (`.claude/plans/usb-audio-input/research/aiosendspin-ground-truth.md` §3) - rather than by mirroring `UniversalProxy.Sendspin.Pairing`, so a - round-trip test exercises both halves of the exchange. It shares the - client's protocol primitives (`sid/2`, `derive_pin/4`, `mac_key/2`, …) + Written straight from the Sendspin 1.0.0-rc1 `pairing.md` (and + aiosendspin's server side) rather than by mirroring + `UniversalProxy.Sendspin.Pairing`, so a round-trip test exercises both + halves of the exchange. It shares the client's protocol primitives + (`sid/3`, `derive_pairing_code/3`, `mac_key/2`, …) because those are the one documented construction, not per-role logic. Like the client it is pure: `handle/3` takes a wire `type` plus a decoded @@ -16,7 +16,7 @@ defmodule UniversalProxy.SendspinPairingServer do alias UniversalProxy.Sendspin.CPace alias UniversalProxy.Sendspin.Pairing - @enforce_keys [:method, :handshake_hash, :pairing_index, :sid, :suite, :nonce_a, :pin_length] + @enforce_keys [:method, :handshake_hash, :pairing_index, :sid, :suite, :nonce_a] defstruct [ :method, :handshake_hash, @@ -24,36 +24,37 @@ defmodule UniversalProxy.SendspinPairingServer do :sid, :suite, :nonce_a, - :pin_length, :pin, :commit_b, + :nonce_b, :cpace, :peer_share, :isk, :mac_key, - :psk + :psk, + round: 1 ] @doc """ Build a server-side attempt. Options mirror the client's: `:handshake_hash`, `:pairing_index`, - `:suite`, `:method`, `:pin_length`, plus `:nonce_a` and `:pin` (the PIN an - operator typed; required up front for `:static_pin`, supplied later via - `submit_pin/2` for `:dynamic_pin`). + `:suite`, `:method` (`:dynamic_pairing_code` by default, + `:static_pairing_code` or `:pairing_psk`), plus `:nonce_a` and `:pin` (the + code an operator typed; required up front for `:static_pairing_code`, + supplied later via `submit_pin/2` for `:dynamic_pairing_code`). """ def start(opts) do hash = Keyword.fetch!(opts, :handshake_hash) index = Keyword.fetch!(opts, :pairing_index) %__MODULE__{ - method: Keyword.get(opts, :method, :dynamic_pin), + method: Keyword.get(opts, :method, :dynamic_pairing_code), handshake_hash: hash, pairing_index: index, - sid: Pairing.sid(hash, index), + sid: Pairing.sid(hash, index, 1), suite: Keyword.fetch!(opts, :suite), nonce_a: Keyword.get_lazy(opts, :nonce_a, &Pairing.generate_nonce/0), - pin_length: Keyword.get(opts, :pin_length, 6), pin: Keyword.get(opts, :pin) } end @@ -61,17 +62,22 @@ defmodule UniversalProxy.SendspinPairingServer do @doc "Feed the server one client message." def handle(server, type, payload) - def handle(%__MODULE__{method: :dynamic_pin} = server, "client/pair-init", payload) do + def handle(%__MODULE__{method: :dynamic_pairing_code} = server, "client/pair-init", payload) do {:ok, commit_b} = decode(payload["commit_B"], 32) server = %{server | commit_b: commit_b} {:send, [{"server/pair-init", %{"nonce_A" => b64(server.nonce_a)}}], server} end - def handle(%__MODULE__{method: :static_pin} = server, "client/pair-init", payload) do + def handle(%__MODULE__{method: :static_pairing_code} = server, "client/pair-init", payload) do refute_key(payload, "commit_B") pair_auth(server, server.pin) end + def handle(%__MODULE__{method: :pairing_psk} = server, "client/pair-init", payload) do + refute_key(payload, "commit_B") + {:ok, server} + end + def handle(%__MODULE__{} = server, "client/pair-auth", payload) do {:ok, yb} = decode(payload["pake_msg_2"], 32) {:ok, isk} = CPace.finish(server.cpace, yb, Pairing.client_ad()) @@ -82,24 +88,38 @@ defmodule UniversalProxy.SendspinPairingServer do {:send, [{"server/pair-confirm", %{"server_kc" => b64(tag)}}], server} end - # Verification order per ground truth §3: commitment, then the MCF tag, - # then that the PIN really was derived from both nonces. Only the last two - # are reported as pin_mismatch. + # Another round of the same attempt: same code, no nonce, its own sid. + def handle(%__MODULE__{method: :dynamic_pairing_code} = server, "client/pair-retry", _payload) do + round = server.round + 1 + sid = Pairing.sid(server.handshake_hash, server.pairing_index, round) + server = %{server | round: round, sid: sid, cpace: nil, isk: nil, mac_key: nil} + {:send, [{"server/pair-init", %{}}], server} + end + + # Verification order per `pairing.md`: the MCF tag, then the commitment + # opening (`wrapped_nonce_B`), then that the code really was derived from + # both nonces. def handle(%__MODULE__{} = server, "client/pair-confirm", payload) do {:ok, tb} = decode(payload["client_kc"], 64) - with :ok <- check_commit(server, payload), - :ok <- check_tag(server, tb), - :ok <- check_pin(server, payload) do + with :ok <- check_tag(server, tb), + {:ok, server} <- check_commit(server, payload), + :ok <- check_pin(server) do {:ok, server} end end + def handle(%__MODULE__{method: :pairing_psk} = server, "client/pair-finalize", payload) do + refute_key(payload, "wrapped_psk") + {:ok, psk} = decode(payload["long_term_psk"], 32) + {:send, [{"server/pair-finalize", %{}}], %{server | psk: psk}} + end + def handle(%__MODULE__{} = server, "client/pair-finalize", payload) do {:ok, wrapped} = decode(payload["wrapped_psk"], 48) wrap_key = Pairing.wrap_key(server.sid, server.isk) - case Pairing.unwrap_psk(server.suite, wrap_key, wrapped) do + case Pairing.unwrap(server.suite, wrap_key, wrapped) do {:ok, psk} -> {:send, [{"server/pair-finalize", %{}}], %{server | psk: psk}} @@ -109,18 +129,22 @@ defmodule UniversalProxy.SendspinPairingServer do end @doc """ - Supply the PIN the operator typed, producing `server/pair-auth`. + Supply the code the operator typed, producing `server/pair-auth`. - Dynamic-PIN only; the static flow takes its PIN in `start/1`. + Dynamic only; the static flow takes its code in `start/1`. """ - def submit_pin(%__MODULE__{method: :dynamic_pin} = server, pin), do: pair_auth(server, pin) + def submit_pin(%__MODULE__{method: :dynamic_pairing_code} = server, pin), + do: pair_auth(server, pin) - @doc "The PSK unwrapped from `client/pair-finalize`, once it has arrived." + @doc "The PSK received in `client/pair-finalize`, once it has arrived." def psk(%__MODULE__{psk: psk}), do: psk @doc "This attempt's `nonce_A`." def nonce_a(%__MODULE__{nonce_a: nonce_a}), do: nonce_a + @doc "This attempt's current round." + def round(%__MODULE__{round: round}), do: round + defp pair_auth(server, pin) do {:ok, cpace} = CPace.start(:a, prs: pin, ci: "", sid: server.sid, ad: Pairing.server_ad()) server = %{server | pin: pin, cpace: cpace} @@ -128,33 +152,33 @@ defmodule UniversalProxy.SendspinPairingServer do {:send, [{"server/pair-auth", %{"pake_msg_1" => b64(share)}}], server} end - defp check_commit(%__MODULE__{method: :static_pin}, payload) do - refute_key(payload, "nonce_B") - :ok - end - - defp check_commit(%__MODULE__{} = server, payload) do - {:ok, nonce_b} = decode(payload["nonce_B"], 32) - - if Pairing.commit_valid?(nonce_b, server.commit_b), - do: :ok, - else: {:error, :commit_mismatch} - end - defp check_tag(%__MODULE__{} = server, tb) do expected = Pairing.confirmation_tag(server.mac_key, server.peer_share, Pairing.client_ad()) - if :crypto.hash_equals(tb, expected), do: :ok, else: {:error, :pin_mismatch} + if :crypto.hash_equals(tb, expected), do: :ok, else: {:error, :pairing_code_mismatch} end - defp check_pin(%__MODULE__{method: :static_pin}, _payload), do: :ok + defp check_commit(%__MODULE__{method: :static_pairing_code} = server, payload) do + refute_key(payload, "wrapped_nonce_B") + {:ok, server} + end - defp check_pin(%__MODULE__{} = server, payload) do - {:ok, nonce_b} = decode(payload["nonce_B"], 32) + defp check_commit(%__MODULE__{} = server, payload) do + refute_key(payload, "nonce_B") + {:ok, wrapped} = decode(payload["wrapped_nonce_B"], 48) + key = Pairing.nonce_wrap_key(server.sid, server.isk) + + with {:ok, nonce_b} <- Pairing.unwrap(server.suite, key, wrapped) do + if Pairing.commit_valid?(nonce_b, server.commit_b), + do: {:ok, %{server | nonce_b: nonce_b}}, + else: {:error, :commit_mismatch} + end + end - derived = - Pairing.derive_pin(server.handshake_hash, server.nonce_a, nonce_b, server.pin_length) + defp check_pin(%__MODULE__{method: :static_pairing_code}), do: :ok - if derived == server.pin, do: :ok, else: {:error, :pin_mismatch} + defp check_pin(%__MODULE__{} = server) do + derived = Pairing.derive_pairing_code(server.handshake_hash, server.nonce_a, server.nonce_b) + if derived == server.pin, do: :ok, else: {:error, :code_not_bound} end defp refute_key(payload, key) do diff --git a/test/support/sendspin_source_peer.ex b/test/support/sendspin_source_peer.ex index 79c3f3b9..3c80ebf6 100644 --- a/test/support/sendspin_source_peer.ex +++ b/test/support/sendspin_source_peer.ex @@ -42,6 +42,19 @@ defmodule UniversalProxy.SendspinSourcePeer do @default_timeout 5_000 + # One fixed server identity by default, so a test can persist a pairing + # record bound to it (`default_server_id/0`) before the peer connects. + @default_server_keypair :crypto.generate_key(:ecdh, :x25519) + + @doc "The server static keypair `connect!/2` uses unless given `:server_keypair`." + def default_server_keypair, do: @default_server_keypair + + @doc "The `server_id` of `default_server_keypair/0`." + def default_server_id do + {pub, _priv} = @default_server_keypair + Base.url_encode64(pub, padding: false) + end + defstruct [ :conn, :ref, @@ -53,6 +66,7 @@ defmodule UniversalProxy.SendspinSourcePeer do :client_id, :psk, :psk_id, + :psk_category, :suite, :pairing, clock_offset_us: 0, @@ -68,16 +82,19 @@ defmodule UniversalProxy.SendspinSourcePeer do Options: * `:path` — defaults to `UniversalProxy.Audio.Input.Source.default_path/0`. - * `:server_keypair` — X25519 `{pub, priv}`; generated when absent. + * `:server_keypair` — X25519 `{pub, priv}`; `default_server_keypair/0` + when absent. * `:psk` — the PSK to hand the Noise session; defaults to the Sentinel PSK. * `:psk_id` — the id advertised in Noise message 1; defaults to the id of `:psk`. + * `:psk_category` — the category advertised beside it: `:sentinel` for + the Sentinel PSK and `:long_term` for anything else unless given. * `:clock_offset_us` — server clock minus client clock, in µs. """ def connect!(port, opts \\ []) do path = Keyword.get(opts, :path, UniversalProxy.Audio.Input.Source.default_path()) psk = Keyword.get(opts, :psk, Noise.sentinel_psk()) - server_keypair = Keyword.get_lazy(opts, :server_keypair, &Noise.generate_static_keypair/0) + server_keypair = Keyword.get(opts, :server_keypair, @default_server_keypair) {server_pub, _priv} = server_keypair {:ok, conn} = @@ -95,6 +112,7 @@ defmodule UniversalProxy.SendspinSourcePeer do server_id: Base.url_encode64(server_pub, padding: false), psk: psk, psk_id: Keyword.get_lazy(opts, :psk_id, fn -> Pairing.psk_id_for(psk) end), + psk_category: Keyword.get_lazy(opts, :psk_category, fn -> default_category(psk) end), clock_offset_us: Keyword.get(opts, :clock_offset_us, 0) } end @@ -131,7 +149,7 @@ defmodule UniversalProxy.SendspinSourcePeer do message_1 = noise - |> Decibel.handshake_encrypt(Jason.encode!(%{"psk_id" => peer.psk_id})) + |> Decibel.handshake_encrypt(Wire.encode_noise_msg1_payload(peer.psk_id, peer.psk_category)) |> IO.iodata_to_binary() peer = send_ws!(peer, {:text, Wire.encode_noise_handshake(message_1)}) @@ -151,14 +169,22 @@ defmodule UniversalProxy.SendspinSourcePeer do @doc """ Re-run the Noise handshake **in band**, as a server does after a successful - pairing (ground truth §4). + pairing. The two `noise/handshake` messages travel as ordinary encrypted JSON under the *old* session, the prologue is the previous handshake's hash, and `psk` - (the freshly paired long-term PSK) keys the new session. On return the peer - talks under the new session. + (by default the freshly paired long-term PSK; `:category` says which kind) + keys the new session. On return the peer talks under the new session; the + next message a server sends is `server/activate` (no hellos are replayed). + + `tolerate_time: true` discards `client/time` frames and audio chunks the + source sent before it received message 1 (a server must tolerate those); by + default any frame other than message 2 flunks. """ - def rehandshake!(peer, psk, timeout \\ @default_timeout) do + def rehandshake!(peer, psk, opts \\ []) do + category = Keyword.get(opts, :category, :long_term) + timeout = Keyword.get(opts, :timeout, @default_timeout) + {:ok, protocol} = Noise.protocol_name(peer.suite) {:ok, client_pub} = Base.url_decode64(peer.client_id, padding: false) psk_id = Pairing.psk_id_for(psk) @@ -173,7 +199,7 @@ defmodule UniversalProxy.SendspinSourcePeer do message_1 = noise - |> Decibel.handshake_encrypt(Jason.encode!(%{"psk_id" => psk_id})) + |> Decibel.handshake_encrypt(Wire.encode_noise_msg1_payload(psk_id, category)) |> IO.iodata_to_binary() peer = send_message!(peer, "noise/handshake", %{"data" => b64(message_1)}) @@ -186,17 +212,7 @@ defmodule UniversalProxy.SendspinSourcePeer do # message 2 — flunk on anything interleaved rather than tolerating it, which # is what makes a future regression that emits `client/time` one boundary # later (during re-handshake instead of pairing) fail loudly. - {payload, peer} = - case next!(peer, timeout) do - {{:json, "noise/handshake", payload}, peer} -> - {payload, peer} - - {other, _peer} -> - flunk( - "expected noise/handshake message 2 but received #{inspect(other)} mid-rehandshake; " <> - "a real server never interleaves frames with the in-band re-handshake" - ) - end + {payload, peer} = await_message_2!(peer, Keyword.get(opts, :tolerate_time, false), timeout) {:ok, message_2} = Base.url_decode64(payload["data"], padding: false) assert IO.iodata_to_binary(Decibel.handshake_decrypt(noise, message_2)) == "{}" @@ -208,10 +224,30 @@ defmodule UniversalProxy.SendspinSourcePeer do | noise: noise, handshake_hash: Decibel.handshake_hash(noise), psk: psk, - psk_id: psk_id + psk_id: psk_id, + psk_category: category } end + defp await_message_2!(peer, tolerate_time?, timeout) do + case next!(peer, timeout) do + {{:json, "noise/handshake", payload}, peer} -> + {payload, peer} + + {{:json, "client/time", _payload}, peer} when tolerate_time? -> + await_message_2!(peer, tolerate_time?, timeout) + + {{:audio, _timestamp_us, _payload}, peer} when tolerate_time? -> + await_message_2!(peer, tolerate_time?, timeout) + + {other, _peer} -> + flunk( + "expected noise/handshake message 2 but received #{inspect(other)} mid-rehandshake; " <> + "a real server never interleaves frames with the in-band re-handshake" + ) + end + end + @doc "Read the first (client/init) text frame without proceeding further." def expect_client_init!(peer, timeout \\ @default_timeout), do: expect_text!(peer, timeout) @@ -252,6 +288,32 @@ defmodule UniversalProxy.SendspinSourcePeer do send_message!(peer, "server/activate", payload) end + @doc """ + Assert the source sends nothing for `ms` (e.g. inside a re-handshake window). + Returns the peer unchanged. + """ + def refute_frames!(peer, ms) do + case peer.frames do + [] -> :ok + frames -> flunk("unexpected buffered frames: #{inspect(frames)}") + end + + case Mint.WebSocket.recv(peer.conn, 0, ms) do + {:error, _conn, reason, _} when reason == :timeout -> + peer + + {:error, _conn, %Mint.TransportError{reason: :timeout}, _} -> + peer + + {:ok, _conn, [{:data, _ref, data}]} -> + {:ok, _websocket, frames} = Mint.WebSocket.decode(peer.websocket, data) + flunk("expected silence but the source sent #{length(frames)} frame(s)") + + other -> + flunk("unexpected receive result: #{inspect(other)}") + end + end + @doc "Send `server/command` with a source `start`/`stop`." def command!(peer, command) when command in ["start", "stop"] do send_message!(peer, "server/command", %{"source" => %{"command" => command}}) @@ -280,11 +342,39 @@ defmodule UniversalProxy.SendspinSourcePeer do handshake_hash: peer.handshake_hash, pairing_index: index, suite: peer.suite, - pin_length: Keyword.get(opts, :pin_length, 6) + method: Keyword.get(opts, :method, :dynamic_pairing_code) ) - {:send, messages, pairing} = PairingServer.handle(pairing, "client/pair-init", payload) - {index, send_pairing!(%{peer | pairing: pairing}, messages)} + case PairingServer.handle(pairing, "client/pair-init", payload) do + {:send, messages, pairing} -> {index, send_pairing!(%{peer | pairing: pairing}, messages)} + {:ok, pairing} -> {index, %{peer | pairing: pairing}} + end + end + + @doc """ + Serve the rest of a Pairing PSK attempt opened by `await_pair_init!/3` + (with `method: :pairing_psk`): `client/pair-finalize {long_term_psk}` + follows `client/pair-init` immediately, and is answered with + `server/pair-finalize`. Strict. Returns `{psk, peer}`. + """ + def serve_pairing_psk_finalize!(peer, timeout \\ @default_timeout) do + {finalize, peer} = await_pairing_json!(peer, "client/pair-finalize", timeout) + + {:send, messages, pairing} = + PairingServer.handle(peer.pairing, "client/pair-finalize", finalize) + + peer = send_pairing!(%{peer | pairing: pairing}, messages) + {PairingServer.psk(pairing), peer} + end + + @doc """ + Wait for `client/pair-retry` (after a wrong code) and start the next round + with a nonce-less `server/pair-init`. Strict. + """ + def serve_pair_retry!(peer, timeout \\ @default_timeout) do + {payload, peer} = await_pairing_json!(peer, "client/pair-retry", timeout) + {:send, messages, pairing} = PairingServer.handle(peer.pairing, "client/pair-retry", payload) + send_pairing!(%{peer | pairing: pairing}, messages) end @doc """ @@ -565,6 +655,10 @@ defmodule UniversalProxy.SendspinSourcePeer do end end + defp default_category(psk) do + if psk == Noise.sentinel_psk(), do: :sentinel, else: :long_term + end + defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) diff --git a/test/universal_proxy/audio/input/server_test.exs b/test/universal_proxy/audio/input/server_test.exs index 08e89154..92fd15a4 100644 --- a/test/universal_proxy/audio/input/server_test.exs +++ b/test/universal_proxy/audio/input/server_test.exs @@ -78,6 +78,14 @@ defmodule UniversalProxy.Audio.Input.ServerTest do {:ok, state} end + def pairings_changed(pid), do: GenServer.cast(pid, :pairings_changed) + + @impl true + def handle_cast(:pairings_changed, state) do + record({:pairings_changed, state.key}) + {:noreply, state} + end + @impl true def handle_call({:emit, event}, _from, state) do send(state.owner, {:source_event, state.key, event}) @@ -296,7 +304,12 @@ defmodule UniversalProxy.Audio.Input.ServerTest do end test "never leaks pairing key material into the merged row", %{server: server, store: store} do - :ok = Store.save_config(store, @usb_key, %{psk: :crypto.strong_rand_bytes(32)}) + :ok = + Store.save_pairing(store, @usb_key, %{ + psk: :crypto.strong_rand_bytes(32), + server_id: Base.url_encode64(<<1::256>>, padding: false) + }) + {:ok, _keypair} = Store.ensure_client_keypair(store, @usb_key) input = add_usb_card!(server) @@ -304,6 +317,61 @@ defmodule UniversalProxy.Audio.Input.ServerTest do refute Map.has_key?(input, :psk) refute Map.has_key?(input, :psk_id) refute Map.has_key?(input, :client_keypair) + refute Map.has_key?(input, :pairing_psk) + + # Per record only the server's public key and when it paired. + assert input.paired + assert [%{server_id: _, paired_at: %DateTime{}} = summary] = input.pairings + assert Map.keys(summary) |> Enum.sort() == [:paired_at, :server_id] + end + + test "unpair/3 removes one record or all, tells the source and publishes the summary", %{ + server: server, + store: store + } do + a = Base.url_encode64(<<1::256>>, padding: false) + b = Base.url_encode64(<<2::256>>, padding: false) + assert Server.unpair(server, @usb_key, a) == {:error, :unavailable} + + :ok = Store.save_pairing(store, @usb_key, %{psk: <<11::256>>, server_id: a}) + :ok = Store.save_pairing(store, @usb_key, %{psk: <<12::256>>, server_id: b}) + add_usb_card!(server) + + assert :ok = Server.unpair(server, @usb_key, a) + assert_receive {:input_state, @usb_key, %{pairings: [%{server_id: ^b}]}} + Process.sleep(20) + assert {:pairings_changed, @usb_key} in SourceStubCalls.calls() + assert [%{pairings: [%{server_id: ^b}], paired: true}] = Server.list_inputs(server) + + assert :ok = Server.unpair(server, @usb_key, :all) + assert_receive {:input_state, @usb_key, %{pairings: []}} + assert [%{pairings: [], paired: false}] = Server.list_inputs(server) + assert {:ok, %{pairings: []}} = Store.get_config(store, @usb_key) + end + + test "regenerate_pairing_token/2 replaces the token and tells the source", %{ + server: server, + store: store + } do + add_usb_card!(server) + {:ok, before} = Server.pairing_token(server, @usb_key) + + assert :ok = Server.regenerate_pairing_token(server, @usb_key) + assert {:ok, after_token} = Server.pairing_token(server, @usb_key) + refute after_token == before + assert {:ok, ^after_token} = Store.pairing_token(store, @usb_key) + Process.sleep(20) + assert {:pairings_changed, @usb_key} in SourceStubCalls.calls() + end + + test "pairing_token/2 answers for a present input only", %{server: server, store: store} do + assert Server.pairing_token(server, @usb_key) == {:error, :unavailable} + + add_usb_card!(server) + + assert {:ok, "SP:0" <> _ = token} = Server.pairing_token(server, @usb_key) + assert {:ok, ^token} = Store.pairing_token(store, @usb_key) + refute Map.has_key?(hd(Server.list_inputs(server)), :pairing_psk) end end @@ -476,7 +544,12 @@ defmodule UniversalProxy.Audio.Input.ServerTest do :ok = SourceStub.emit(source, :connected) assert_receive {:input_state, @usb_key, %{connection: :connected, status: :waiting}} - :ok = SourceStub.emit(source, {:pairing_required, %{method: :dynamic_pin, pin_length: 6}}) + :ok = + SourceStub.emit( + source, + {:pairing_required, %{method: :dynamic_pairing_code, format: :digits}} + ) + assert_receive {:input_state, @usb_key, %{status: :pairing, pin: nil}} :ok = SourceStub.emit(source, :pairing_started) @@ -490,7 +563,12 @@ defmodule UniversalProxy.Audio.Input.ServerTest do # `:paired` lands after the Source has written the PSK, so the cached # row's `paired` flag has to be re-read, not left stale. - :ok = Store.save_pairing(store, @usb_key, %{psk: :crypto.strong_rand_bytes(32)}) + :ok = + Store.save_pairing(store, @usb_key, %{ + psk: :crypto.strong_rand_bytes(32), + server_id: Base.url_encode64(<<1::256>>, padding: false) + }) + :ok = SourceStub.emit(source, :paired) assert_receive {:input_state, @usb_key, %{status: :paired, pin: nil}} assert [%{paired: true}] = Server.list_inputs(server) @@ -520,7 +598,12 @@ defmodule UniversalProxy.Audio.Input.ServerTest do # The offer is held for operator consent: it renders as `:pairing` with no # PIN, which is exactly what the LiveView keys the "Allow pairing" button on. - :ok = SourceStub.emit(source, {:pairing_required, %{method: :dynamic_pin, pin_length: 6}}) + :ok = + SourceStub.emit( + source, + {:pairing_required, %{method: :dynamic_pairing_code, format: :digits}} + ) + assert_receive {:input_state, @usb_key, %{status: :pairing, pin: nil}} # The consent-wait timeout elapsed with no gesture: back to connected-idle. @@ -737,6 +820,9 @@ defmodule UniversalProxy.Audio.Input.ServerTest do assert Input.list_inputs() == [] assert Input.get_input(@usb_key) == :error assert Input.check_now() == :ok + assert Input.pairing_token(@usb_key) == {:error, :unavailable} + assert Input.unpair(@usb_key, :all) == {:error, :unavailable} + assert Input.regenerate_pairing_token(@usb_key) == {:error, :unavailable} end end end diff --git a/test/universal_proxy/audio/input/source_pairing_test.exs b/test/universal_proxy/audio/input/source_pairing_test.exs index f432b471..e4308dc5 100644 --- a/test/universal_proxy/audio/input/source_pairing_test.exs +++ b/test/universal_proxy/audio/input/source_pairing_test.exs @@ -11,10 +11,10 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do @table :audio_input_source_pairing_test @key {"USB Capture Card", 0x1D6B, 0x0105} - @pin_length 6 - # The `pairing` object a server sends alongside the `pairing` activity. - @pairing_params %{"method" => "dynamic_pin", "pin_length" => @pin_length} + # The `pairing` objects a server sends alongside the `pairing` activity. + @pairing_params %{"method" => "dynamic_pairing_code", "format" => "digits"} + @psk_pairing_params %{"method" => "pairing_psk"} setup do path = @@ -51,8 +51,7 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do {pid, port} end - # Connect and get as far as `client/hello`, asserting the trust level the - # handshake's PSK category implies. + # Connect and get as far as `client/hello`. defp connect_hello!(port, opts \\ []) do peer = port |> Peer.connect!(opts) |> Peer.handshake!() |> Peer.hello!() {hello, peer} = Peer.await_json!(peer, "client/hello") @@ -69,25 +68,42 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do assert_receive {:source_event, @key, {:pairing_required, _params}}, 2_000 assert_receive {:source_event, @key, :pairing_started}, 2_000 - Peer.await_pair_init!(peer, pin_length: @pin_length) + Peer.await_pair_init!(peer) end defp await_pin! do assert_receive {:source_event, @key, {:pairing_pin, pin}}, 2_000 - assert String.length(pin) == @pin_length - assert pin =~ ~r/\A[0-9]+\z/ + assert pin =~ ~r/\A[0-9]{6}\z/ pin end + # After the in-band re-handshake the server's next message is server/activate + # (no hellos are replayed); activate source@v1 and converge to :ready. + defp activate_ready!(peer, source) do + peer = Peer.activate!(peer) + assert_receive {:source_event, @key, :activated}, 2_000 + {initial, peer} = Peer.await_client_state!(peer, false) + assert initial == %{"available" => false, "source" => %{}} + {available, peer} = Peer.await_client_state!(peer, true) + assert available["available"] == true + assert Source.status(source) == :ready + peer + end + # The keypair record exists from the first `client/init`, so "not paired" # means the pairing fields are still blank rather than no record at all. defp refute_paired!(store) do assert {:ok, config} = Store.get_config(store, @key) - assert config.psk == nil - assert config.psk_id == nil - assert config.paired_at == nil + assert config.pairings == [] end + defp records(store) do + {:ok, config} = Store.get_config(store, @key) + config.pairings + end + + defp other_server_id, do: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + # A different PIN of the same length — what a mistyped digit produces. defp mistype(pin) do <> = pin @@ -107,9 +123,11 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do refute_receive {:source_event, @key, :pairing_started}, 300 # We neither open an attempt nor send `pair/abort`: the offer is held for - # the operator, and the `client/time` keepalive keeps the connection up. - # Serving a couple of exchanges pumps everything the source has sent — no - # `pair/abort` is among it. + # the operator — the server is told so with `client/pair-pending` — and the + # `client/time` keepalive keeps the connection up. + {pending, peer} = Peer.await_json!(peer, "client/pair-pending") + assert pending["pairing_index"] == 1 + assert pending["message"] =~ "Allow pairing" peer = Peer.serve_time!(peer, 2) refute Enum.any?(peer.messages, &match?({:json, "pair/abort", _}, &1)) @@ -133,7 +151,7 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do :ok = Source.allow_pairing(source) assert_receive {:source_event, @key, :pairing_started}, 2_000 - {index, _peer} = Peer.await_pair_init!(peer, pin_length: @pin_length) + {index, _peer} = Peer.await_pair_init!(peer) assert index == 1 end @@ -219,37 +237,38 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do refute_paired!(ctx.store) end - test "refuses to open an attempt while a long-term PSK already exists", ctx do + test "a consented code pairing adds a record beside another server's", ctx do {source, port} = start_source!(ctx) + other = other_server_id() + :ok = Store.save_pairing(ctx.store, @key, %{psk: Pairing.generate_psk(), server_id: other}) - psk = Pairing.generate_psk() - - :ok = - Store.save_pairing(ctx.store, @key, %{ - psk: psk, - psk_id: Pairing.psk_id_for(psk), - psk_category: :long_term, - server_id: "stored-server-id" - }) - - # An attacker connects with the published Sentinel PSK anyway (trust - # none) and, even with operator consent, must not be allowed to overwrite - # the stored PSK. - {hello, peer} = connect_hello!(port) - assert hello["trust_level"] == "none" + {_hello, peer} = connect_hello!(port) :ok = Source.allow_pairing(source) + {1, peer} = offer_pairing!(peer) + pin = await_pin!() + peer = peer |> Peer.submit_pin!(pin) |> Peer.serve_pair_auth!() + {psk, _peer} = Peer.serve_pair_finalize!(peer) + assert_receive {:source_event, @key, :paired}, 2_000 - peer = - Peer.activate!(peer, roles: [], activities: ["pairing"], pairing: @pairing_params) + ids = ctx.store |> records() |> Enum.map(& &1.server_id) |> Enum.sort() + assert ids == Enum.sort([other, Peer.default_server_id()]) + assert Enum.any?(records(ctx.store), &(&1.psk == psk)) + end - assert_receive {:source_event, @key, {:pairing_required, _params}}, 2_000 - assert_receive {:source_event, @key, {:pairing_failed, :already_paired}}, 2_000 - {reason, _peer} = Peer.await_pair_abort!(peer) - assert reason == "user_cancelled" - refute_received {:source_event, @key, :pairing_started} + test "re-pairing the same server by code replaces its record", ctx do + {source, port} = start_source!(ctx) + old = Pairing.generate_psk() + :ok = Store.save_pairing(ctx.store, @key, %{psk: old, server_id: Peer.default_server_id()}) + + {_hello, peer} = connect_hello!(port) + :ok = Source.allow_pairing(source) + {1, peer} = offer_pairing!(peer) + pin = await_pin!() + peer = peer |> Peer.submit_pin!(pin) |> Peer.serve_pair_auth!() + {psk, _peer} = Peer.serve_pair_finalize!(peer) + assert_receive {:source_event, @key, :paired}, 2_000 - # The stored PSK is untouched. - assert {:ok, %{psk: ^psk}} = Store.get_config(ctx.store, @key) + assert [%{psk: ^psk}] = records(ctx.store) end test "pairing attempts are capped at three per connection", ctx do @@ -257,17 +276,19 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do {_hello, peer} = connect_hello!(port) :ok = Source.allow_pairing(source) - # Three wrong-PIN attempts, indices 1..3. The window survives a failed + # Three failed attempts, indices 1..3. The window survives a failed # attempt, so one consent covers all three. peer = Enum.reduce(1..3, peer, fn expected_index, peer -> {index, peer} = offer_pairing!(peer) assert index == expected_index - pin = await_pin!() - peer = peer |> Peer.submit_pin!(mistype(pin)) |> Peer.serve_pair_auth!() - {"pin_mismatch", peer} = Peer.await_pair_abort!(peer) - assert_receive {:source_event, @key, {:pairing_failed, :pin_mismatch}}, 2_000 + _pin = await_pin!() + peer = Peer.abort_pairing!(peer, "user_cancelled") + + assert_receive {:source_event, @key, {:pairing_failed, {:peer_abort, :user_cancelled}}}, + 2_000 + peer end) @@ -285,14 +306,14 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do test "pairs, persists the PSK, re-handshakes at trust user and reaches :ready", ctx do {source, port} = start_source!(ctx) - {hello, peer} = connect_hello!(port) - assert hello["trust_level"] == "none" + {_hello, peer} = connect_hello!(port) + assert :sys.get_state(source).trust == :none :ok = Source.allow_pairing(source) {index, peer} = offer_pairing!(peer) assert index == 1 - # We derive and display the PIN; the operator types it into MA. + # We derive and display the code; the operator types it into MA. pin = await_pin!() peer = peer |> Peer.submit_pin!(pin) |> Peer.serve_pair_auth!() @@ -301,32 +322,19 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do assert_receive {:source_event, @key, :paired}, 2_000 # The server unwrapped exactly the PSK we minted, and it is persisted. - assert {:ok, config} = Store.get_config(ctx.store, @key) - assert config.psk == psk - assert config.psk_id == Pairing.psk_id_for(psk) - assert config.psk_category == :long_term - assert config.server_id == peer.server_id - assert %DateTime{} = config.paired_at + assert [record] = records(ctx.store) + assert record.psk == psk + assert record.psk_id == Pairing.psk_id_for(psk) + assert record.server_id == peer.server_id + assert %DateTime{} = record.paired_at assert Source.status(source) == :awaiting_rehandshake - # Ground truth §4: the server re-runs the handshake in band under the - # new PSK, then the connection replays hello/activate. + # The server re-runs the handshake in band under the new PSK; its next + # message is a fresh server/activate, now at trust user. peer = Peer.rehandshake!(peer, psk) - peer = Peer.hello!(peer) - - {hello, peer} = Peer.await_json!(peer, "client/hello") - assert hello["trust_level"] == "user" - - peer = Peer.activate!(peer) - assert_receive {:source_event, @key, :activated}, 2_000 - - {initial, peer} = Peer.await_client_state!(peer, false) - assert initial["available"] == false - - {available, _peer} = Peer.await_client_state!(peer, true) - assert available["available"] == true - assert Source.status(source) == :ready + assert %{trust: :user, psk_category: :long_term} = :sys.get_state(source) + _peer = activate_ready!(peer, source) end test "no client/time is emitted during the active pairing exchange", ctx do @@ -356,7 +364,7 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do {psk, peer} = Peer.serve_pair_finalize!(peer) assert_receive {:source_event, @key, :paired}, 2_000 - assert {:ok, %{psk: ^psk}} = Store.get_config(ctx.store, @key) + assert [%{psk: ^psk}] = records(ctx.store) assert Source.status(source) == :awaiting_rehandshake assert peer.time_exchanges == time_exchanges_before end @@ -387,11 +395,12 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do peer = Peer.rehandshake!(peer, psk) assert peer.time_exchanges == time_exchanges_before - # The connection is healthy afterward: it replays hello/activate at trust - # user. - peer = Peer.hello!(peer) - {hello, _peer} = Peer.await_json!(peer, "client/hello") - assert hello["trust_level"] == "user" + # Nor between message 2 and the server/activate that follows it: the + # client may send nothing but the handshake until that activation. + peer = Peer.refute_frames!(peer, 100) + + # The connection is healthy afterward, at trust user. + _peer = activate_ready!(peer, source) end test "client/time keepalive resumes after an aborted pairing attempt", ctx do @@ -418,7 +427,7 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do assert peer.time_exchanges >= time_exchanges_before + 2 end - test "a mistyped PIN aborts the attempt and the retry pairs at index 2", ctx do + test "a mistyped code retries in a second round of the same attempt", ctx do {source, port} = start_source!(ctx) {_hello, peer} = connect_hello!(port) @@ -426,31 +435,40 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do {1, peer} = offer_pairing!(peer) pin = await_pin!() - # The server's CPace share is built from the wrong PIN, so its own MCF - # tag fails our check and we abort before revealing nonce_B. + # The server's CPace share is built from the wrong code, so its own MCF + # tag fails our check: we ask for another round (strict — nothing else + # interleaves) rather than revealing nonce_B or aborting. peer = peer |> Peer.submit_pin!(mistype(pin)) |> Peer.serve_pair_auth!() + peer = Peer.serve_pair_retry!(peer) - {reason, peer} = Peer.await_pair_abort!(peer) - assert reason == "pin_mismatch" - - assert_receive {:source_event, @key, {:pairing_failed, :pin_mismatch}}, 2_000 - assert Source.status(source) == :pairing_required + # Same attempt, same code, shown again for the second round. + assert ^pin = await_pin!() + refute_received {:source_event, @key, {:pairing_failed, _}} refute_paired!(ctx.store) - # The server retries by granting the pairing activity again; the consent - # window survives the failure so the retry proceeds at index 2. - {index, peer} = offer_pairing!(peer) - assert index == 2 - - retry_pin = await_pin!() - assert retry_pin != pin - - peer = peer |> Peer.submit_pin!(retry_pin) |> Peer.serve_pair_auth!() + peer = peer |> Peer.submit_pin!(pin) |> Peer.serve_pair_auth!() {psk, _peer} = Peer.serve_pair_finalize!(peer) assert_receive {:source_event, @key, :paired}, 2_000 - assert {:ok, %{psk: ^psk}} = Store.get_config(ctx.store, @key) + assert [%{psk: ^psk}] = records(ctx.store) assert Source.status(source) == :awaiting_rehandshake + # A successful pairing consumes the consent window, round count and all. + assert :sys.get_state(source).pairing_window == nil + end + + test "an activation without pairing cancels a live attempt", ctx do + {source, port} = start_source!(ctx) + + {_hello, peer} = connect_hello!(port) + :ok = Source.allow_pairing(source) + {1, peer} = offer_pairing!(peer) + _pin = await_pin!() + + _peer = Peer.activate!(peer, roles: [], activities: []) + + assert_receive {:source_event, @key, {:pairing_failed, :cancelled}}, 2_000 + assert %{pairing: nil, fsm: :awaiting_activate} = :sys.get_state(source) + refute_received {:source_event, @key, :disconnected} end test "a pair/abort from the server ends the attempt without dropping the connection", @@ -552,16 +570,7 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do # The re-handshake completes at trust user and the source reaches :ready. peer = Peer.rehandshake!(peer, psk) - peer = Peer.hello!(peer) - {hello, peer} = Peer.await_json!(peer, "client/hello") - assert hello["trust_level"] == "user" - - peer = Peer.activate!(peer) - assert_receive {:source_event, @key, :activated}, 2_000 - {_initial, peer} = Peer.await_client_state!(peer, false) - {available, _peer} = Peer.await_client_state!(peer, true) - assert available["available"] == true - assert Source.status(source) == :ready + _peer = activate_ready!(peer, source) end test "a challenger during the consent-hold replaces the un-consented held offer", ctx do @@ -626,8 +635,8 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do assert Source.status(source) == :awaiting_activate end - test "a paired reconnect uses the stored PSK, asserts trust user and never pairs", ctx do - {_source, port} = start_source!(ctx) + test "a paired reconnect uses the stored PSK, lands at trust user and never pairs", ctx do + {source, port} = start_source!(ctx) psk = Pairing.generate_psk() :ok = @@ -635,11 +644,11 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do psk: psk, psk_id: Pairing.psk_id_for(psk), psk_category: :long_term, - server_id: "stored-server-id" + server_id: Peer.default_server_id() }) - {hello, peer} = connect_hello!(port, psk: psk) - assert hello["trust_level"] == "user" + {_hello, peer} = connect_hello!(port, psk: psk) + assert :sys.get_state(source).trust == :user _peer = Peer.activate!(peer) assert_receive {:source_event, @key, :activated}, 2_000 @@ -648,4 +657,161 @@ defmodule UniversalProxy.Audio.Input.SourcePairingTest do refute_received {:source_event, @key, :pairing_started} end end + + describe "Pairing PSK" do + # The operator pasted the input's pairing token into MA: no consent + # gesture, no code — the handshake under the pairing PSK authenticated both + # sides already. + defp pairing_psk!(ctx) do + {:ok, psk} = Store.ensure_pairing_psk(ctx.store, @key) + psk + end + + defp serve_pairing_psk!(peer) do + peer = + Peer.activate!(peer, roles: [], activities: ["pairing"], pairing: @psk_pairing_params) + + assert_receive {:source_event, @key, :pairing_started}, 2_000 + {index, peer} = Peer.await_pair_init!(peer, method: :pairing_psk) + {long_term, peer} = Peer.serve_pairing_psk_finalize!(peer) + assert_receive {:source_event, @key, :paired}, 2_000 + {index, long_term, peer} + end + + test "a pairing dial pairs without consent and re-handshakes to the new record", ctx do + {source, port} = start_source!(ctx) + pairing_psk = pairing_psk!(ctx) + + {_hello, peer} = connect_hello!(port, psk: pairing_psk, psk_category: :pairing) + assert %{trust: :none, psk_category: :pairing} = :sys.get_state(source) + + {1, long_term, peer} = serve_pairing_psk!(peer) + refute_received {:source_event, @key, {:pairing_required, _}} + + # A fresh long-term PSK — never the pairing PSK itself — is persisted, + # bound to this server, and the pairing PSK survives (it is long-lived). + assert long_term != pairing_psk + assert {:ok, config} = Store.get_config(ctx.store, @key) + assert [%{psk: ^long_term, server_id: server_id}] = config.pairings + assert server_id == peer.server_id + assert config.pairing_psk == pairing_psk + + peer = Peer.rehandshake!(peer, long_term) + assert %{trust: :user, psk_category: :long_term} = :sys.get_state(source) + _peer = activate_ready!(peer, source) + end + + test "a connected unpaired session re-handshakes to the pairing PSK, then pairs", ctx do + {source, port} = start_source!(ctx) + pairing_psk = pairing_psk!(ctx) + + {_hello, peer} = connect_hello!(port) + peer = Peer.activate!(peer, roles: [], activities: []) + assert :sys.get_state(source).psk_category == :sentinel + + peer = Peer.rehandshake!(peer, pairing_psk, category: :pairing, tolerate_time: true) + assert %{psk_category: :pairing, pairing_index: 0} = :sys.get_state(source) + + {1, long_term, peer} = serve_pairing_psk!(peer) + peer = Peer.rehandshake!(peer, long_term) + _peer = activate_ready!(peer, source) + end + + test "a token pairing replaces the same server's record and keeps the others", ctx do + {source, port} = start_source!(ctx) + other = other_server_id() + :ok = Store.save_pairing(ctx.store, @key, %{psk: Pairing.generate_psk(), server_id: other}) + + :ok = + Store.save_pairing(ctx.store, @key, %{ + psk: Pairing.generate_psk(), + server_id: Peer.default_server_id() + }) + + {_hello, peer} = connect_hello!(port, psk: pairing_psk!(ctx), psk_category: :pairing) + {1, long_term, _peer} = serve_pairing_psk!(peer) + + records = records(ctx.store) + assert length(records) == 2 + assert Enum.find(records, &(&1.server_id == Peer.default_server_id())).psk == long_term + assert Enum.any?(records, &(&1.server_id == other)) + assert Source.status(source) == :awaiting_rehandshake + end + + test "pairing_psk on a Sentinel session is method_not_supported", ctx do + {source, port} = start_source!(ctx) + {_hello, peer} = connect_hello!(port) + + peer = + Peer.activate!(peer, roles: [], activities: ["pairing"], pairing: @psk_pairing_params) + + {reason, _peer} = Peer.await_pair_abort!(peer) + assert reason == "method_not_supported" + + assert_receive {:source_event, @key, + {:pairing_failed, {:unsupported_method, :pairing_psk}}}, + 2_000 + + assert Source.status(source) == :pairing_required + refute_paired!(ctx.store) + end + + test "a code method on a pairing-PSK session is method_not_supported", ctx do + {_source, port} = start_source!(ctx) + {_hello, peer} = connect_hello!(port, psk: pairing_psk!(ctx), psk_category: :pairing) + + peer = Peer.activate!(peer, roles: [], activities: ["pairing"], pairing: @pairing_params) + + {reason, _peer} = Peer.await_pair_abort!(peer) + assert reason == "method_not_supported" + refute_paired!(ctx.store) + end + + test "an unknown method or the qr_code format is method_not_supported", ctx do + {_source, port} = start_source!(ctx) + {_hello, peer} = connect_hello!(port) + + for params <- [ + %{"method" => "dynamic_pin", "pin_length" => 6}, + %{"method" => "dynamic_pairing_code", "format" => "qr_code"} + ], + reduce: peer do + peer -> + peer = Peer.activate!(peer, roles: [], activities: ["pairing"], pairing: params) + {reason, peer} = Peer.await_pair_abort!(peer) + assert reason == "method_not_supported" + peer + end + end + + test "unpaired re-handshakes are capped per connection", ctx do + {source, port} = start_source!(ctx) + {_hello, peer} = connect_hello!(port) + peer = Peer.activate!(peer, roles: [], activities: []) + + sentinel = UniversalProxy.Sendspin.Noise.sentinel_psk() + pairing_psk = pairing_psk!(ctx) + + # Three token attempts that each switch to the pairing PSK and are + # cancelled back, plus the post-pairing re-key, stay within the cap. + peer = + Enum.reduce(1..8, peer, fn i, peer -> + {psk, category} = + if rem(i, 2) == 1, do: {pairing_psk, :pairing}, else: {sentinel, :sentinel} + + peer = Peer.rehandshake!(peer, psk, category: category, tolerate_time: true) + Peer.activate!(peer, roles: [], activities: []) + end) + + refute_received {:source_event, @key, {:error, _}} + + _peer = + Peer.send_message!(peer, "noise/handshake", %{ + "data" => Base.url_encode64(:crypto.strong_rand_bytes(48), padding: false) + }) + + assert_receive {:source_event, @key, {:error, {:unexpected_rehandshake, _, :none}}}, 2_000 + assert Source.status(source) == :listening + end + end end diff --git a/test/universal_proxy/audio/input/source_test.exs b/test/universal_proxy/audio/input/source_test.exs index 09147df8..863a7b2f 100644 --- a/test/universal_proxy/audio/input/source_test.exs +++ b/test/universal_proxy/audio/input/source_test.exs @@ -8,6 +8,7 @@ defmodule UniversalProxy.Audio.Input.SourceTest do alias UniversalProxy.Audio.Input.Source alias UniversalProxy.Audio.Input.Store alias UniversalProxy.Sendspin.Pairing + alias UniversalProxy.Sendspin.Wire alias UniversalProxy.SendspinSourcePeer, as: Peer @table :audio_input_source_test @@ -33,7 +34,7 @@ defmodule UniversalProxy.Audio.Input.SourceTest do {UniversalProxy.Audio.Input.Store, name: nil, table: @table, dets_path: path} ) - {:ok, store: store} + {:ok, store: store, dets_path: path} end # Emits `count` distinct 3,840-byte frames (20 ms of 48k/16/2 PCM) with a @@ -96,12 +97,7 @@ defmodule UniversalProxy.Audio.Input.SourceTest do psk = Pairing.generate_psk() :ok = - Store.save_pairing(ctx.store, @key, %{ - psk: psk, - psk_id: Pairing.psk_id_for(psk), - psk_category: :long_term, - server_id: "test-server-id" - }) + Store.save_pairing(ctx.store, @key, %{psk: psk, server_id: Peer.default_server_id()}) psk end @@ -265,28 +261,35 @@ defmodule UniversalProxy.Audio.Input.SourceTest do {hello, peer} = Peer.await_json!(peer, "client/hello") assert hello["name"] == "USB Capture" assert hello["supported_roles"] == ["source@v1"] - # Paired: the long-term PSK's category is `long_term` → trust `user`, the - # prerequisite for a source to activate at all. - assert hello["trust_level"] == "user" - # Formats live in client_stream/start, never in hello. + # rc1: no trust_level; supported_pair_methods is an object that always + # offers pairing_psk, beside the dynamic code shown on the Audio tab. + refute Map.has_key?(hello, "trust_level") + # Formats live in client-stream/start, never in hello. assert hello["source@v1_support"] == %{"features" => %{"line_sense" => false}} assert hello["unpaired_access"] == %{"enabled" => false} - assert [%{"method" => "dynamic_pin"} | _] = hello["supported_pair_methods"] + + assert hello["supported_pair_methods"] == %{ + "pairing_psk" => %{}, + "dynamic_pairing_code" => %{"out_channels" => ["display"], "formats" => ["digits"]} + } + assert hello["device_info"]["manufacturer"] == "Universal Proxy" peer = Peer.activate!(peer) assert_receive {:source_event, @key, :activated}, 2_000 + # Full state only: the activation's required update carries the source + # object, and so does every later one. {initial, peer} = Peer.await_client_state!(peer, false) - assert initial["available"] == false + assert initial == %{"available" => false, "source" => %{}} {available, peer} = Peer.await_client_state!(peer, true) - assert available["available"] == true + assert available == %{"available" => true, "source" => %{}} assert Source.status(source) == :ready # Nothing streams until the server asks (source@v1 defaults to stopped). peer = Peer.command!(peer, "start") - {stream, peer} = Peer.await_json!(peer, "client_stream/start") + {stream, peer} = Peer.await_json!(peer, "client-stream/start") assert stream["source"] == %{ "codec" => "pcm", @@ -308,11 +311,29 @@ defmodule UniversalProxy.Audio.Input.SourceTest do end describe "connection-level time sync" do - # The interop fix (PR #170): the client/time loop starts at connection - # setup, not at role activation, so an unpaired-and-idle MA connection stays + # The interop fix (PR #170): the client/time loop is connection-level, not + # tied to role activation, so an unpaired-and-idle MA connection stays # alive (Cowboy idle timeout) long enough to reach the pairing controls, and - # the filter is already converged when the role finally activates. - test "time-syncs in :awaiting_activate and activates already-converged", ctx do + # the filter is already converged when the role finally activates. rc1 lets + # it start only once the first server/activate has arrived. + test "sends nothing between client/hello and the first server/activate", ctx do + psk = pair!(ctx) + {_source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + + peer = Peer.connect!(port, psk: psk) + peer = Peer.handshake!(peer) + peer = Peer.hello!(peer) + {_hello, peer} = Peer.await_json!(peer, "client/hello") + + # 20 ms burst cadence: several ticks would have fired by now. + peer = Peer.refute_frames!(peer, 100) + + peer = Peer.activate!(peer, roles: [], activities: []) + {_time, _peer} = Peer.await_json!(peer, "client/time") + end + + test "time-syncs while idle-activated and activates already-converged", ctx do psk = pair!(ctx) # A slower burst than the default so serve_time!/3 answers each client/time # before the next tick fires: that keeps the exchange count exact for the @@ -324,6 +345,7 @@ defmodule UniversalProxy.Audio.Input.SourceTest do peer = Peer.handshake!(peer) peer = Peer.hello!(peer) {_hello, peer} = Peer.await_json!(peer, "client/hello") + peer = Peer.activate!(peer, roles: [], activities: []) # No role has activated, yet the source already sends client/time and # processes server/time: the periodic exchange keeps MA's connection alive @@ -364,17 +386,17 @@ defmodule UniversalProxy.Audio.Input.SourceTest do peer = connect_available!(port, psk: psk) peer = Peer.command!(peer, "start") - {_stream, peer} = Peer.await_json!(peer, "client_stream/start") + {_stream, peer} = Peer.await_json!(peer, "client-stream/start") assert_receive {:source_event, @key, :streaming}, 2_000 {_ts, _payload, peer} = Peer.await_audio!(peer) peer = Peer.command!(peer, "stop") - {_end, peer} = Peer.await_json!(peer, "client_stream/end") + {_end, peer} = Peer.await_json!(peer, "client-stream/end") assert_receive {:source_event, @key, :stopped}, 2_000 assert Source.status(source) == :ready peer = Peer.command!(peer, "start") - {_stream, peer} = Peer.await_json!(peer, "client_stream/start") + {_stream, peer} = Peer.await_json!(peer, "client-stream/start") assert_receive {:source_event, @key, :streaming}, 2_000 {_ts, payload, _peer} = Peer.await_audio!(peer) @@ -391,7 +413,7 @@ defmodule UniversalProxy.Audio.Input.SourceTest do peer = connect_available!(port, psk: psk) peer = Peer.command!(peer, "start") - {_stream, peer} = Peer.await_json!(peer, "client_stream/start") + {_stream, peer} = Peer.await_json!(peer, "client-stream/start") assert_receive {:source_event, @key, :streaming}, 2_000 {_ts, _payload, peer} = Peer.await_audio!(peer) @@ -403,11 +425,11 @@ defmodule UniversalProxy.Audio.Input.SourceTest do # let the restart open a second arecord while the first still holds the # ALSA device — the new capture is deferred until the old one's EXIT. peer = Peer.command!(peer, "stop") - {_end, peer} = Peer.await_json!(peer, "client_stream/end") + {_end, peer} = Peer.await_json!(peer, "client-stream/end") peer = Peer.command!(peer, "start") - {_stream, peer} = Peer.await_json!(peer, "client_stream/start") + {_stream, peer} = Peer.await_json!(peer, "client-stream/start") - # The restart's client_stream/start is only sent after the old capture's + # The restart's client-stream/start is only sent after the old capture's # EXIT is processed, so by the time it reaches the peer the old arecord is # already gone — proving the two never overlap. assert_received {:DOWN, ^mon, :process, ^cap1, _} @@ -450,7 +472,7 @@ defmodule UniversalProxy.Audio.Input.SourceTest do peer = connect_available!(port, psk: psk) peer = Peer.command!(peer, "start") - {_stream, peer} = Peer.await_json!(peer, "client_stream/start") + {_stream, peer} = Peer.await_json!(peer, "client-stream/start") assert_receive {:source_event, @key, :streaming}, 2_000 {_ts, _payload, peer} = Peer.await_audio!(peer) @@ -460,9 +482,9 @@ defmodule UniversalProxy.Audio.Input.SourceTest do # MA revokes source@v1 mid-stream (another source took the target, an # admin disabled it, …). We must stop capturing and sending audio, send - # client_stream/end, and hold the connection so MA can re-activate later. + # client-stream/end, and hold the connection so MA can re-activate later. peer = Peer.activate!(peer, roles: []) - {_end, peer} = Peer.await_json!(peer, "client_stream/end") + {_end, peer} = Peer.await_json!(peer, "client-stream/end") assert_receive {:source_event, @key, :stopped}, 2_000 # Capture is torn down (async), the connection stays up, and the FSM has @@ -485,7 +507,7 @@ defmodule UniversalProxy.Audio.Input.SourceTest do assert available["available"] == true peer = Peer.command!(peer, "start") - {_stream, peer} = Peer.await_json!(peer, "client_stream/start") + {_stream, peer} = Peer.await_json!(peer, "client-stream/start") assert_receive {:source_event, @key, :streaming}, 2_000 {_ts, payload, _peer} = Peer.await_audio!(peer) @@ -508,12 +530,11 @@ defmodule UniversalProxy.Audio.Input.SourceTest do Peer.activate!(peer, roles: [], activities: ["pairing"], - pairing: %{"method" => "dynamic_pin", "pin_length" => 6} + pairing: %{"method" => "dynamic_pairing_code", "format" => "digits"} ) assert_receive {:source_event, @key, {:pairing_required, params}}, 2_000 - assert params.method == :dynamic_pin - assert params.pin_length == 6 + assert params == %{method: :dynamic_pairing_code, format: :digits} refute_received {:source_event, @key, :activated} assert Source.status(source) == :pairing_required @@ -534,7 +555,7 @@ defmodule UniversalProxy.Audio.Input.SourceTest do # discovered source and sends an activate with neither a source@v1 role # nor a pairing activity. We must stay connected and idle (not close), so # MA keeps a stable connection and can later escalate to pairing. - peer = Peer.activate!(peer, roles: [], activities: ["playback"]) + peer = Peer.activate!(peer, roles: [], activities: []) refute_receive {:source_event, @key, {:error, _}}, 500 refute_received {:source_event, @key, :disconnected} @@ -546,7 +567,7 @@ defmodule UniversalProxy.Audio.Input.SourceTest do Peer.activate!(peer, roles: [], activities: ["pairing"], - pairing: %{"method" => "dynamic_pin", "pin_length" => 6} + pairing: %{"method" => "dynamic_pairing_code", "format" => "digits"} ) assert_receive {:source_event, @key, {:pairing_required, _params}}, 2_000 @@ -603,7 +624,7 @@ defmodule UniversalProxy.Audio.Input.SourceTest do peer = connect_available!(port, psk: psk) peer = Peer.command!(peer, "start") - {_stream, peer} = Peer.await_json!(peer, "client_stream/start") + {_stream, peer} = Peer.await_json!(peer, "client-stream/start") assert_receive {:source_event, @key, :streaming}, 2_000 # An attacker merely opens a socket (no handshake). The authenticated, @@ -621,26 +642,362 @@ defmodule UniversalProxy.Audio.Input.SourceTest do end end - describe "hostile peer" do - test "refuses to activate source@v1 at trust none and closes (B1)", ctx do + describe "keys and admissibility" do + test "an unpaired session asking for source@v1 gets goodbye pairing_required (B1)", ctx do {source, port} = start_source!(ctx) assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 - # Sentinel PSK ⇒ trust none. A spec-violating server that activates - # source@v1 anyway must be refused and the connection dropped, never - # opening capture. + # Sentinel PSK and no unpaired access: activating source@v1 would only be + # admissible with unpaired access, so we say so and close — never opening + # capture. peer = Peer.connect!(port) peer = Peer.handshake!(peer) peer = Peer.hello!(peer) {_hello, peer} = Peer.await_json!(peer, "client/hello") - _peer = Peer.activate!(peer) + peer = Peer.activate!(peer) - assert_receive {:source_event, @key, {:error, {:source_activated_untrusted, :none}}}, 2_000 + {goodbye, _peer} = Peer.await_json!(peer, "client/goodbye") + assert goodbye == %{"reason" => "pairing_required"} + assert_receive {:source_event, @key, {:error, {:goodbye, :pairing_required}}}, 2_000 assert_receive {:source_event, @key, :disconnected}, 2_000 refute_received {:source_event, @key, :activated} assert Source.status(source) == :listening end + test "a long-term session offered pairing gets goodbye unauthorized", ctx do + psk = pair!(ctx) + {_source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + + peer = Peer.connect!(port, psk: psk) + peer = Peer.handshake!(peer) + peer = Peer.hello!(peer) + {_hello, peer} = Peer.await_json!(peer, "client/hello") + + peer = + Peer.activate!(peer, + roles: [], + activities: ["pairing"], + pairing: %{"method" => "dynamic_pairing_code", "format" => "digits"} + ) + + {goodbye, _peer} = Peer.await_json!(peer, "client/goodbye") + assert goodbye == %{"reason" => "unauthorized"} + assert_receive {:source_event, @key, :disconnected}, 2_000 + end + + test "a psk_id we don't hold falls back to the Sentinel in the initial handshake", ctx do + {source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + + # The server references a long-term record we never had (or lost): we + # complete message 2 under the Sentinel instead of failing. + peer = + Peer.connect!(port, + psk: UniversalProxy.Sendspin.Noise.sentinel_psk(), + psk_id: Pairing.psk_id_for(Pairing.generate_psk()), + psk_category: :long_term + ) + + peer = Peer.handshake!(peer) + peer = Peer.hello!(peer) + {_hello, _peer} = Peer.await_json!(peer, "client/hello") + assert %{trust: :none, psk_category: :sentinel} = :sys.get_state(source) + end + + test "a PSK held only under another category is a miss, not a match", ctx do + {source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + {:ok, pairing_psk} = Store.ensure_pairing_psk(ctx.store, @key) + + # Our pairing PSK's id, declared as a long-term one. + peer = + Peer.connect!(port, + psk: UniversalProxy.Sendspin.Noise.sentinel_psk(), + psk_id: Pairing.psk_id_for(pairing_psk), + psk_category: :long_term + ) + + _peer = Peer.handshake!(peer) + assert %{trust: :none, psk_category: :sentinel} = :sys.get_state(source) + end + + test "the pairing PSK keys a session at its own category, still untrusted", ctx do + {source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + {:ok, pairing_psk} = Store.ensure_pairing_psk(ctx.store, @key) + + peer = Peer.connect!(port, psk: pairing_psk, psk_category: :pairing) + _peer = Peer.handshake!(peer) + assert %{trust: :none, psk_category: :pairing} = :sys.get_state(source) + end + + test "a long-term PSK presented by a different server fails the handshake", ctx do + psk = pair!(ctx) + {source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + + peer = + Peer.connect!(port, + psk: psk, + server_keypair: UniversalProxy.Sendspin.Noise.generate_static_keypair() + ) + + # A misbinding, not a miss: no Sentinel Fallback, no message 2. + assert catch_error(Peer.handshake!(peer, 1_000)) + assert_receive {:source_event, @key, {:error, :pairing_record_server_mismatch}}, 2_000 + assert Source.status(source) == :listening + end + + test "a message 1 payload without psk_category is a silent failure", ctx do + {source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + + peer = Peer.connect!(port) + {client_init, peer} = Peer.expect_client_init!(peer) + {:ok, %{client_id: client_id, suite: suite}} = Wire.decode_client_init(client_init) + server_init = Wire.encode_server_init(peer.server_id, 1) + peer = Peer.send_text!(peer, server_init) + + {:ok, protocol} = UniversalProxy.Sendspin.Noise.protocol_name(suite) + + noise = + Decibel.new(protocol, :ini, %{ + s: peer.server_keypair, + rs: Base.url_decode64!(client_id, padding: false), + psks: [UniversalProxy.Sendspin.Noise.sentinel_psk()], + prologue: client_init <> server_init + }) + + message_1 = + noise + |> Decibel.handshake_encrypt(Jason.encode!(%{"psk_id" => peer.psk_id})) + |> IO.iodata_to_binary() + + _peer = Peer.send_text!(peer, Wire.encode_noise_handshake(message_1)) + + assert_receive {:source_event, @key, {:error, :malformed_handshake_payload}}, 2_000 + assert_receive {:source_event, @key, :disconnected}, 2_000 + assert Source.status(source) == :listening + end + end + + describe "pairing records and revocation" do + defp other_server_id, do: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + + defp records(ctx) do + {:ok, config} = Store.get_config(ctx.store, @key) + config.pairings + end + + test "server/unpair in a paired session drops that record, says goodbye and closes", ctx do + psk = pair!(ctx) + other = other_server_id() + :ok = Store.save_pairing(ctx.store, @key, %{psk: Pairing.generate_psk(), server_id: other}) + {source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + + peer = connect_activated!(port, psk: psk) + assert_receive {:source_event, @key, :activated}, 2_000 + peer = Peer.send_message!(peer, "server/unpair") + + {goodbye, _peer} = Peer.await_json!(peer, "client/goodbye") + assert goodbye == %{"reason" => "unpaired"} + assert_receive {:source_event, @key, :unpaired}, 2_000 + assert_receive {:source_event, @key, :disconnected}, 2_000 + assert Source.status(source) == :listening + assert [%{server_id: ^other}] = records(ctx) + end + + test "server/unpair in an unpaired session is ignored", ctx do + {source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + + peer = port |> Peer.connect!() |> Peer.handshake!() |> Peer.hello!() + {_hello, peer} = Peer.await_json!(peer, "client/hello") + peer = Peer.activate!(peer, roles: [], activities: []) + _peer = Peer.send_message!(peer, "server/unpair") + + refute_receive {:source_event, @key, :disconnected}, 300 + assert Source.status(source) == :awaiting_activate + end + + test "pairings_changed/1 closes only a session whose record is gone", ctx do + psk = pair!(ctx) + other = other_server_id() + :ok = Store.save_pairing(ctx.store, @key, %{psk: Pairing.generate_psk(), server_id: other}) + {source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + + peer = connect_activated!(port, psk: psk) + assert_receive {:source_event, @key, :activated}, 2_000 + + :ok = Store.remove_pairing(ctx.store, @key, server_id: other) + :ok = Source.pairings_changed(source) + refute_receive {:source_event, @key, :disconnected}, 300 + + :ok = Store.remove_pairing(ctx.store, @key, server_id: Peer.default_server_id()) + :ok = Source.pairings_changed(source) + assert_receive {:source_event, @key, :disconnected}, 2_000 + assert Peer.closed?(peer, 2_000) + end + + test "regenerating the pairing PSK closes a session keyed by the old one", ctx do + {source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + {:ok, pairing_psk} = Store.ensure_pairing_psk(ctx.store, @key) + + _peer = port |> Peer.connect!(psk: pairing_psk, psk_category: :pairing) |> Peer.handshake!() + :ok = Store.regenerate_pairing_psk(ctx.store, @key) + :ok = Source.pairings_changed(source) + assert_receive {:source_event, @key, :disconnected}, 2_000 + + # The old token's PSK is no longer ours: a new handshake under it misses + # and falls back to the Sentinel. + peer = + port + |> Peer.connect!( + psk: UniversalProxy.Sendspin.Noise.sentinel_psk(), + psk_id: Pairing.psk_id_for(pairing_psk), + psk_category: :pairing + ) + + _peer = Peer.handshake!(peer) + assert %{psk_category: :sentinel} = :sys.get_state(source) + end + + test "a migrated pre-rc1 record still authenticates its server", ctx do + psk = Pairing.generate_psk() + {:ok, keypair} = Store.ensure_client_keypair(ctx.store, @key) + stop_supervised(UniversalProxy.Audio.Input.Store) + path = ctx.dets_path + {:ok, table} = :dets.open_file(:legacy_source_seed, file: to_charlist(path), type: :set) + + :ok = + :dets.insert( + table, + {@key, + %{ + friendly_name: "Old", + client_keypair: keypair, + psk: psk, + psk_id: "stale", + psk_category: :long_term, + server_id: Peer.default_server_id(), + paired_at: ~U[2026-08-17 00:00:00Z] + }} + ) + + :ok = :dets.close(table) + store = start_supervised!({Store, name: nil, table: @table, dets_path: path}, id: :reopened) + ctx = %{ctx | store: store} + + {_source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + _peer = connect_activated!(port, psk: psk) + assert_receive {:source_event, @key, :activated}, 2_000 + end + end + + describe "re-handshake on an active session" do + @describetag :python3 + + test "a key rotation keeps the stream and the clock filter", ctx do + arecord = write_fake_arecord!() + psk = pair!(ctx) + {source, port} = start_source!(ctx, capture_opts: [arecord_path: arecord]) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + + peer = connect_available!(port, psk: psk) + peer = Peer.command!(peer, "start") + {_stream, peer} = Peer.await_json!(peer, "client-stream/start") + assert_receive {:source_event, @key, :streaming}, 2_000 + {_ts, _payload, peer} = Peer.await_audio!(peer) + filter = :sys.get_state(source).filter + capture = :sys.get_state(source).capture + + peer = Peer.rehandshake!(peer, psk, tolerate_time: true) + # Nothing — not even captured audio — until the activation. + peer = Peer.refute_frames!(peer, 150) + peer = Peer.activate!(peer) + + {_ts, payload, peer} = Peer.await_audio!(peer) + assert byte_size(payload) == @frame_bytes + refute Enum.any?(peer.messages, &match?({:json, "client-stream/end", _}, &1)) + state = :sys.get_state(source) + assert state.fsm == :streaming + assert state.capture == capture + assert state.filter.count >= filter.count + refute_received {:source_event, @key, :stopped} + end + + test "a downgrade stops capture at once and ends the stream after the activation", ctx do + arecord = write_fake_arecord!() + psk = pair!(ctx) + {source, port} = start_source!(ctx, capture_opts: [arecord_path: arecord]) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + + peer = connect_available!(port, psk: psk) + peer = Peer.command!(peer, "start") + {_stream, peer} = Peer.await_json!(peer, "client-stream/start") + {_ts, _payload, peer} = Peer.await_audio!(peer) + + sentinel = UniversalProxy.Sendspin.Noise.sentinel_psk() + peer = Peer.rehandshake!(peer, sentinel, category: :sentinel, tolerate_time: true) + assert_receive {:source_event, @key, :stopped}, 2_000 + assert :sys.get_state(source).capture == nil + peer = Peer.refute_frames!(peer, 150) + + peer = Peer.activate!(peer, roles: [], activities: []) + {%{}, _peer} = Peer.await_json!(peer, "client-stream/end") + assert Source.status(source) == :awaiting_activate + assert :sys.get_state(source).trust == :none + end + end + + describe "re-handshake during a held offer" do + test "cancels the consent wait so nothing fires into the re-keyed session", ctx do + {source, port} = start_source!(ctx, pairing_window_ms: 100) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + {:ok, pairing_psk} = Store.ensure_pairing_psk(ctx.store, @key) + + peer = port |> Peer.connect!() |> Peer.handshake!() |> Peer.hello!() + {_hello, peer} = Peer.await_json!(peer, "client/hello") + + peer = + Peer.activate!(peer, + roles: [], + activities: ["pairing"], + pairing: %{"method" => "dynamic_pairing_code", "format" => "digits"} + ) + + {_pending, peer} = Peer.await_json!(peer, "client/pair-pending") + peer = Peer.rehandshake!(peer, pairing_psk, category: :pairing, tolerate_time: true) + + # Well past the 100 ms consent wait: no pair/abort (or anything) leaks. + peer = Peer.refute_frames!(peer, 300) + _peer = Peer.activate!(peer, roles: [], activities: []) + assert %{last_pairing_params: nil, consent_wait_timer: nil} = :sys.get_state(source) + end + end + + describe "activities" do + test "an unknown activity value is not an allowed set: goodbye unauthorized", ctx do + psk = pair!(ctx) + {_source, port} = start_source!(ctx) + assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 + + peer = port |> Peer.connect!(psk: psk) |> Peer.handshake!() |> Peer.hello!() + {_hello, peer} = Peer.await_json!(peer, "client/hello") + peer = Peer.activate!(peer, activities: ["playback", "dancing"]) + + {goodbye, _peer} = Peer.await_json!(peer, "client/goodbye") + assert goodbye == %{"reason" => "unauthorized"} + assert_receive {:source_event, @key, :disconnected}, 2_000 + end + end + + describe "hostile peer" do test "garbage ciphertext in transport mode drops the connection", ctx do {source, port} = start_source!(ctx) assert_receive {:source_event, @key, {:listener_bound, ^port}}, 2_000 @@ -701,7 +1058,7 @@ defmodule UniversalProxy.Audio.Input.SourceTest do peer = connect_available!(port, psk: psk) peer = Peer.command!(peer, "start") - {_stream, peer} = Peer.await_json!(peer, "client_stream/start") + {_stream, peer} = Peer.await_json!(peer, "client-stream/start") assert_receive {:source_event, @key, :streaming}, 2_000 {_ts, _payload, _peer} = Peer.await_audio!(peer) diff --git a/test/universal_proxy/audio/input/store_test.exs b/test/universal_proxy/audio/input/store_test.exs index 5acb949f..66a410f8 100644 --- a/test/universal_proxy/audio/input/store_test.exs +++ b/test/universal_proxy/audio/input/store_test.exs @@ -49,11 +49,9 @@ defmodule UniversalProxy.Audio.Input.StoreTest do assert cfg.friendly_name == "USB Capture Card" assert cfg.client_keypair == nil - assert cfg.psk == nil - assert cfg.psk_id == nil - assert cfg.psk_category == nil - assert cfg.server_id == nil - assert cfg.paired_at == nil + assert cfg.pairing_psk == nil + assert cfg.pairings == [] + refute Map.has_key?(cfg, :psk) end test "preserves a caller-supplied friendly_name", %{server: server} do @@ -74,14 +72,14 @@ defmodule UniversalProxy.Audio.Input.StoreTest do :ok = Store.save_config(server, @key, %{ friendly_name: "Kept Name", - psk_category: :long_term + pairing_psk: <<7::256>> }) :ok = Store.save_config(server, @key, %{friendly_name: "Renamed"}) {:ok, cfg} = Store.get_config(server, @key) assert cfg.friendly_name == "Renamed" - assert cfg.psk_category == :long_term + assert cfg.pairing_psk == <<7::256>> end test "explicit nil/false values are persisted rather than falling back to defaults", @@ -187,6 +185,55 @@ defmodule UniversalProxy.Audio.Input.StoreTest do end end + describe "ensure_pairing_psk/2 and pairing_token/2" do + test "mints a 32-byte pairing PSK once and keeps it", %{server: server} do + {:ok, psk} = Store.ensure_pairing_psk(server, @key) + assert byte_size(psk) == 32 + assert {:ok, ^psk} = Store.ensure_pairing_psk(server, @key) + assert {:ok, %{pairing_psk: ^psk}} = Store.get_config(server, @key) + end + + test "is stable across a GenServer restart", %{dets_path: path} do + pid = restart_store!(path, :first) + {:ok, psk} = Store.ensure_pairing_psk(pid, @key) + + reopened = restart_store!(path, :second) + assert {:ok, ^psk} = Store.ensure_pairing_psk(reopened, @key) + end + + test "is distinct per input", %{server: server} do + {:ok, a} = Store.ensure_pairing_psk(server, @key) + {:ok, b} = Store.ensure_pairing_psk(server, @usb_key) + assert a != b + end + + test "the token is SP:0 + base32(client key || pairing PSK)", %{server: server} do + {:ok, token} = Store.pairing_token(server, @key) + {:ok, {pub, _priv}} = Store.ensure_client_keypair(server, @key) + {:ok, pairing_psk} = Store.ensure_pairing_psk(server, @key) + + assert "SP:0" <> body = token + + assert {:ok, pub <> pairing_psk} == + body |> String.replace("9", "2") |> pad() |> Base.decode32() + + assert {:ok, ^token} = Store.pairing_token(server, @key) + end + + test "survives clear_pairing (it is a credential, not pairing state)", %{server: server} do + {:ok, psk} = Store.ensure_pairing_psk(server, @key) + :ok = Store.clear_pairing(server, @key) + assert {:ok, %{pairing_psk: ^psk}} = Store.get_config(server, @key) + end + + test "the table file is owner-only", %{dets_path: path} do + assert {:ok, %File.Stat{mode: mode}} = File.stat(path) + assert Bitwise.band(mode, 0o777) == 0o600 + end + + defp pad(body), do: body <> String.duplicate("=", rem(8 - rem(byte_size(body), 8), 8)) + end + describe "client_id/1" do test "is the url-safe, no-padding base64 encoding of the 32-byte pubkey" do pub = :crypto.strong_rand_bytes(32) @@ -202,93 +249,201 @@ defmodule UniversalProxy.Audio.Input.StoreTest do end end - describe "save_pairing/3 and clear_pairing/2" do - test "round-trips pairing state and defaults paired_at", %{server: server} do - :ok = - Store.save_pairing(server, @key, %{ - psk: :crypto.strong_rand_bytes(32), - psk_id: "some-psk-id", - psk_category: :long_term, - server_id: "some-server-id" - }) + describe "pairing records" do + defp server_id(n), do: Base.url_encode64(<>, padding: false) + defp pair!(server, n, at \\ nil) do + attrs = %{psk: <>, server_id: server_id(n)} + attrs = if at, do: Map.put(attrs, :paired_at, at), else: attrs + :ok = Store.save_pairing(server, @key, attrs) + end + + test "a pairing becomes a record with a derived psk_id and paired_at", %{server: server} do + pair!(server, 1) {:ok, cfg} = Store.get_config(server, @key) - assert byte_size(cfg.psk) == 32 - assert cfg.psk_id == "some-psk-id" - assert cfg.psk_category == :long_term - assert cfg.server_id == "some-server-id" - assert %DateTime{} = cfg.paired_at + + assert [record] = cfg.pairings + assert record.psk == <<1001::256>> + assert record.psk_id == UniversalProxy.Sendspin.Pairing.psk_id_for(<<1001::256>>) + assert record.server_id == server_id(1) + assert %DateTime{} = record.paired_at + assert record.last_used == record.paired_at end - test "accepts an explicit paired_at instead of defaulting", %{server: server} do - paired_at = ~U[2026-01-01 00:00:00Z] + test "accepts an explicit paired_at", %{server: server} do + pair!(server, 1, ~U[2026-01-01 00:00:00Z]) + {:ok, %{pairings: [record]}} = Store.get_config(server, @key) + assert record.paired_at == ~U[2026-01-01 00:00:00Z] + end + test "re-pairing the same server replaces its record; another server adds one", + %{server: server} do + pair!(server, 1) + pair!(server, 2) + :ok = Store.save_pairing(server, @key, %{psk: <<9::256>>, server_id: server_id(1)}) + + {:ok, cfg} = Store.get_config(server, @key) + assert length(cfg.pairings) == 2 + assert Enum.find(cfg.pairings, &(&1.server_id == server_id(1))).psk == <<9::256>> + end + + test "keeps at least five, then evicts the least recently used but never the protected one", + %{server: server} do + for n <- 1..8, do: pair!(server, n, DateTime.add(~U[2026-01-01 00:00:00Z], n, :day)) + + {:ok, cfg} = Store.get_config(server, @key) + assert length(cfg.pairings) == 8 + oldest = Enum.find(cfg.pairings, &(&1.server_id == server_id(1))) + + # A ninth server while the oldest record backs the open connection: the + # next-oldest goes instead, and the pairing still succeeds. :ok = - Store.save_pairing(server, @key, %{ - psk: <<0::256>>, - psk_id: "id", - psk_category: :long_term, - server_id: "server", - paired_at: paired_at - }) + Store.save_pairing(server, @key, %{psk: <<99::256>>, server_id: server_id(9)}, + protect: oldest.psk_id + ) + + {:ok, cfg} = Store.get_config(server, @key) + ids = Enum.map(cfg.pairings, & &1.server_id) + assert length(ids) == 8 + assert server_id(9) in ids + assert server_id(1) in ids + refute server_id(2) in ids + end + + test "touch_pairing/3 moves a record to the front of the LRU order", %{server: server} do + pair!(server, 1, ~U[2026-01-01 00:00:00Z]) + pair!(server, 2, ~U[2026-01-02 00:00:00Z]) + {:ok, cfg} = Store.get_config(server, @key) + assert [%{server_id: second} | _] = Store.pairings(cfg) + assert second == server_id(2) + first = Enum.find(cfg.pairings, &(&1.server_id == server_id(1))) + :ok = Store.touch_pairing(server, @key, first.psk_id) {:ok, cfg} = Store.get_config(server, @key) - assert cfg.paired_at == paired_at + assert [%{server_id: touched} | _] = Store.pairings(cfg) + assert touched == server_id(1) end - test "preserves friendly_name and client_keypair, round-trips through clear_pairing", + test "remove_pairing/3 by server_id or psk_id; clear_pairing/2 drops all, keeping identity", %{server: server} do :ok = Store.save_config(server, @key, %{friendly_name: "My Input"}) {:ok, keypair} = Store.ensure_client_keypair(server, @key) + {:ok, pairing_psk} = Store.ensure_pairing_psk(server, @key) + pair!(server, 1) + pair!(server, 2) + pair!(server, 3) - :ok = - Store.save_pairing(server, @key, %{ - psk: <<1::256>>, - psk_id: "id", - psk_category: :long_term, - server_id: "server" - }) - - {:ok, paired_cfg} = Store.get_config(server, @key) - assert paired_cfg.friendly_name == "My Input" - assert paired_cfg.client_keypair == keypair - assert paired_cfg.psk == <<1::256>> + :ok = Store.remove_pairing(server, @key, server_id: server_id(1)) + {:ok, cfg} = Store.get_config(server, @key) + record = Enum.find(cfg.pairings, &(&1.server_id == server_id(2))) + :ok = Store.remove_pairing(server, @key, psk_id: record.psk_id) + :ok = Store.remove_pairing(server, @key, psk_id: "not-there") + {:ok, cfg} = Store.get_config(server, @key) + assert Enum.map(cfg.pairings, & &1.server_id) == [server_id(3)] :ok = Store.clear_pairing(server, @key) + {:ok, cleared} = Store.get_config(server, @key) + assert cleared.pairings == [] + assert cleared.friendly_name == "My Input" + assert cleared.client_keypair == keypair + assert cleared.pairing_psk == pairing_psk + end + + test "regenerate_pairing_psk/2 mints a new pairing PSK and keeps the records", + %{server: server} do + {:ok, old} = Store.ensure_pairing_psk(server, @key) + {:ok, old_token} = Store.pairing_token(server, @key) + pair!(server, 1) - {:ok, cleared_cfg} = Store.get_config(server, @key) - assert cleared_cfg.friendly_name == "My Input" - assert cleared_cfg.client_keypair == keypair - assert cleared_cfg.psk == nil - assert cleared_cfg.psk_id == nil - assert cleared_cfg.psk_category == nil - assert cleared_cfg.server_id == nil - assert cleared_cfg.paired_at == nil + :ok = Store.regenerate_pairing_psk(server, @key) + {:ok, cfg} = Store.get_config(server, @key) + assert byte_size(cfg.pairing_psk) == 32 + refute cfg.pairing_psk == old + assert length(cfg.pairings) == 1 + refute Store.pairing_token(server, @key) == {:ok, old_token} end end - describe "persistence across restart" do - test "configs survive process restart", %{dets_path: path} do - pid = restart_store!(path, :first) + describe "pre-rc1 rows" do + # The shape the pre-rc1 source wrote: one flat pairing record. + defp legacy_row!(path, fields) do + stop_supervised(UniversalProxy.Audio.Input.Store) + {:ok, table} = :dets.open_file(:legacy_seed, file: to_charlist(path), type: :set) + + row = + Map.merge( + %{ + friendly_name: "Old Input", + client_keypair: Noise.generate_static_keypair(), + psk: nil, + psk_id: nil, + psk_category: nil, + server_id: nil, + paired_at: nil + }, + fields + ) + + :ok = :dets.insert(table, {@key, row}) + :ok = :dets.close(table) + start_supervised!({Store, name: nil, table: @table, dets_path: path}, id: :reopened) + end - :ok = - Store.save_pairing(pid, @key, %{ - psk: <<2::256>>, - psk_id: "restart-id", + test "a flat record becomes the first pairing, its psk_id recomputed", %{dets_path: path} do + psk = <<42::256>> + server_id = Base.url_encode64(<<5::256>>, padding: false) + + server = + legacy_row!(path, %{ + psk: psk, + psk_id: "a-stale-or-differently-derived-id", psk_category: :long_term, - server_id: "restart-server" + server_id: server_id, + paired_at: ~U[2026-08-17 00:00:00Z] }) - {:ok, %{paired_at: original_paired_at}} = Store.get_config(pid, @key) + {:ok, cfg} = Store.get_config(server, @key) + assert cfg.friendly_name == "Old Input" - reopened = restart_store!(path, :second) + assert [ + %{ + psk: ^psk, + server_id: ^server_id, + paired_at: ~U[2026-08-17 00:00:00Z] + } = record + ] = cfg.pairings - {:ok, cfg} = Store.get_config(reopened, @key) + assert record.psk_id == UniversalProxy.Sendspin.Pairing.psk_id_for(psk) + refute Map.has_key?(cfg, :psk) or Map.has_key?(cfg, :psk_category) - assert cfg.psk == <<2::256>> - assert cfg.psk_id == "restart-id" - assert cfg.server_id == "restart-server" - assert cfg.paired_at == original_paired_at + # The next write persists the new shape and keeps the record. + :ok = Store.save_config(server, @key, %{friendly_name: "Renamed"}) + {:ok, cfg} = Store.get_config(server, @key) + assert [%{psk: ^psk}] = cfg.pairings + assert [{@key, raw}] = :dets.lookup(@table, @key) + refute Map.has_key?(raw, :psk) + end + + test "a flat record without a usable server_id is dropped", %{dets_path: path} do + server = legacy_row!(path, %{psk: <<42::256>>, psk_id: "x", server_id: nil}) + assert {:ok, %{pairings: [], friendly_name: "Old Input"}} = Store.get_config(server, @key) + end + + test "an unpaired old row keeps its identity and gets no records", %{dets_path: path} do + server = legacy_row!(path, %{}) + assert {:ok, %{pairings: [], client_keypair: {_, _}}} = Store.get_config(server, @key) + end + end + + describe "persistence across restart" do + test "configs survive process restart", %{dets_path: path} do + pid = restart_store!(path, :first) + server_id = Base.url_encode64(<<3::256>>, padding: false) + :ok = Store.save_pairing(pid, @key, %{psk: <<2::256>>, server_id: server_id}) + {:ok, %{pairings: [original]}} = Store.get_config(pid, @key) + + reopened = restart_store!(path, :second) + assert {:ok, %{pairings: [^original]}} = Store.get_config(reopened, @key) end end end diff --git a/test/universal_proxy/sendspin/pairing_test.exs b/test/universal_proxy/sendspin/pairing_test.exs index 3282beea..96ca9450 100644 --- a/test/universal_proxy/sendspin/pairing_test.exs +++ b/test/universal_proxy/sendspin/pairing_test.exs @@ -8,16 +8,15 @@ defmodule UniversalProxy.Sendspin.PairingTest do @chacha "25519_ChaChaPoly_SHA256" @aesgcm "25519_AESGCM_SHA256" - # Known-answer vectors, computed by an independent pure-Python - # reimplementation of the ground-truth algorithms (§3) whose CPace core is - # itself pinned to the draft-irtf-cfrg-cpace-21 Appendix B.1 vectors and - # whose SENTINEL_PSK reproduces the published constant. The two CPace - # scalars are B.1's `ya`/`yb`, reused here under a Sendspin-shaped `sid`. + # Known-answer vectors, computed with aiosendspin 10.3.0's own Sendspin + # 1.0.0-rc1 implementation (`aiosendspin.noise.pairing` / `pairing_code`, + # cpace 0.1.0) from these fixed inputs. The two CPace scalars are + # draft-irtf-cfrg-cpace-21 Appendix B.1's `ya`/`yb`, reused under a + # Sendspin-shaped `sid`. @kat_hash Base.decode16!("000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F") @kat_nonce_a Base.decode16!("404142434445464748494A4B4C4D4E4F505152535455565758595A5B5C5D5E5F") @kat_nonce_b Base.decode16!("808182838485868788898A8B8C8D8E8F909192939495969798999A9B9C9D9E9F") @kat_index 1 - @kat_pin_length 6 @kat_scalar_a Base.decode16!("21B4F4BD9E64ED355C3EB676A28EBEDAF6D8F17BDC365995B319097153044080") @kat_scalar_b Base.decode16!("848B0779FF415F0AF4EA14DF9DD1D3C29AC41D836C7808896C4EBA19C51AC40A") @@ -25,29 +24,63 @@ defmodule UniversalProxy.Sendspin.PairingTest do @kat_sid Base.decode16!( "73656E647370696E2D706169722D70616B652D7631" <> "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F" <> - "00000001" + "00000001" <> "00000001" ) + @kat_sid_round_2 Base.decode16!( + "73656E647370696E2D706169722D70616B652D7631" <> + "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F" <> + "00000001" <> "00000002" + ) @kat_commit_b Base.decode16!("FC91579D2561D9CAA0C0B933CA3F976D6A860C6EE561FF24A1A790A649DF957C") - @kat_pin "789806" - @kat_ya Base.decode16!("9092E32299CF5A1F27E882737269A15E4F6263C56A930AE79C85C4D77487E721") - @kat_yb Base.decode16!("FE3226D416EBA5C959952B177B567918B92972D00C2339E0B18C594D91890115") + @kat_code "782070" + @kat_ya Base.decode16!("D07C3ABD8E3B123FFE0273D6A49FF9C80B2052289636E53DE696459DECBCC540") + @kat_yb Base.decode16!("217452BF1E317EE3D4EF572E88B57CF4E1CEE53E867F85FABDD5E00516CEFE58") @kat_isk Base.decode16!( - "B7C963C7022F3DC11F5E42E0A628C560177AF191EFCB084F23AC52C64253F9E2" <> - "82DE9C93268CD37976670E1F30593F2592259CD6C0760C620E9BBDD7389AFB8C" + "06FB8348EE58698E45605B069DB009692C26C27299777BAA22763F1BFC0854CE" <> + "593A0512023BADDBECC1BE77BE75B9BD24341ECF6D48C1773A3FFF540B16033E" ) - @kat_mac_key Base.decode16!( - "6C99DF53D0FA4212E9EC82D7B0113281FDC61FEA52DFFFF1C98E62900811E8E5" <> - "84FADE42F24562A7070C1E3AC2B10228851C8FD31FEAB437C69637D0AA20E150" - ) @kat_ta Base.decode16!( - "F9BDF3EDBB52BDFB096EEF689482CC33B7E9929049DA5436990721B681EDB16B" <> - "1F8AC07823B46C9FEBECEFDBAA7EAE08F731F9442D19A3EC99600E9638756BF8" + "1EC1A842B3CC2C45A3F3761E5DA696133B59F9293A9D0FCB2D8ECB48A3408F09" <> + "4973C4E74D43722FA4BE01F8F5EF5BEF1A0C7CB4F4700DAA662696E36C5E6D5D" ) @kat_tb Base.decode16!( - "1EACBF68420DB4BF259F958E33C30871168F68A6553892EE1BC2E64B580B9F5F" <> - "7BEDE56B021C08E3F97A79E246745B62290136318AB0ECE758F98A096D0C33F0" + "272811A52F08B2E78EB24EC1081E459B8F74C158EC47054E8DA87B0090E53ECA" <> + "FE9E3E2968967B31CD481E9CF95F65E7A791C800FE271F6FB952AFBA67FE1ED2" ) - @kat_wrap_key Base.decode16!("C244FD65FE33E5B5B4A7BDC9F6E483F384850EB2AAC94A0AF53CBCEEB13308B7") + @kat_nonce_wrap_key Base.decode16!( + "98043F92EC0DE3697F3B35E3C01C82C7AE2AD2AB0F2166C28FE016EC164CD3BA" + ) + @kat_psk_wrap_key Base.decode16!( + "6AF63807C0FE48CE0CCD4B63EAB8377DE128D0D5D3C2EAF1E59426528E818825" + ) + @kat_wrapped_nonce_chacha Base.decode16!( + "ECB2F4604CF99F1191E7D75F8B762DAD6E15D288E565D7B5BDF92E11ACC06FA3" <> + "FFD66FA4A421AC3CAC27FB8E3C50453F" + ) + @kat_wrapped_psk_chacha Base.decode16!( + "80FBE9F6785FA3120EF2C1019AB5C54313C83C08A0542E28209DFA68B4E4C7BC" <> + "60333BA984F0B2A1AE621517B5D883BD" + ) + @kat_wrapped_nonce_aesgcm Base.decode16!( + "619CA37970EC118BC89B4E79048F0B4D27A7628E64B259B0AB7280593B67A481" <> + "8F0A05FBF0CAAEAB3D181EF2A156EE54" + ) + # Round 2 runs a fresh CPace (same scalars here) under the round-2 sid. + @kat_ya_round_2 Base.decode16!( + "683D9EF8B3BC8C418AAB685912D697145EEC77A9F0EBD15BA2C55586A1E5580E" + ) + @kat_ta_round_2 Base.decode16!( + "EEA88FF484CA3ED27BCD86376B4B82D5BEAF36B0F529607CD0B6458637C24452" <> + "E4B759CECF76DCC941530B7D58FBEFC6897E5D3E982481FD8126D80A1E200986" + ) + @kat_tb_round_2 Base.decode16!( + "280C2697F4D7C6A78E0CD2BD3F634D13660EBF4B74557F2B03DBE71B6BE3F0CE" <> + "4F68C7282569C610612341D7D624B1A314E1A2978686BF41FF90D3153260B144" + ) + @kat_wrapped_nonce_round_2 Base.decode16!( + "DD5F8A3ED1A13724352E3C6E3EA5D8BE203919EEFAD6A035FADBA9D21F1C7F47" <> + "8729AED288566373E327333879B91B4B" + ) @kat_psk :binary.list_to_bin(Enum.to_list(0xA0..0xBF)) @kat_psk_id "UaXYTgo_O5NA363FD_WJGwAORfbAld1hDE9vHMOWFSg" @@ -59,24 +92,23 @@ defmodule UniversalProxy.Sendspin.PairingTest do [ handshake_hash: :crypto.strong_rand_bytes(32), pairing_index: 1, - suite: @chacha, - pin_length: 6 + suite: @chacha ], overrides ) end - # Drive a full attempt. `pin_fun` maps the PIN the client displayed onto - # the PIN the operator typed into the server, which is where a wrong-PIN + defp server_opts(client_opts), + do: Keyword.take(client_opts, [:handshake_hash, :pairing_index, :suite]) + + # Drive a full attempt. `pin_fun` maps the code the client displayed onto + # the code the operator typed into the server, which is where a wrong-code # test diverges. defp pair(opts, pin_fun \\ & &1) do client_opts = client_opts(opts) - server_opts = - Keyword.take(client_opts, [:handshake_hash, :pairing_index, :suite, :pin_length]) - {:ok, [{"client/pair-init", init}], client} = Pairing.start(client_opts) - server = Server.start(server_opts) + server = Server.start(server_opts(client_opts)) {:send, [{"server/pair-init", %{"nonce_A" => _} = server_init}], server} = Server.handle(server, "client/pair-init", init) @@ -94,34 +126,55 @@ defmodule UniversalProxy.Sendspin.PairingTest do %{client: client, server: server, pin: pin, server_confirm: confirm} end - describe "known-answer vectors (ground truth §3)" do - test "sid/2 concatenates label, handshake hash and big-endian counter" do - assert Pairing.sid(@kat_hash, @kat_index) == @kat_sid - assert byte_size(@kat_sid) == 21 + 32 + 4 + defp mistype(pin) do + <> = pin + other = if first == "9", do: "0", else: "9" + other <> rest + end + + describe "known-answer vectors (aiosendspin 10.3.0)" do + test "sid/3 concatenates label, handshake hash, big-endian index and round" do + assert Pairing.sid(@kat_hash, @kat_index, 1) == @kat_sid + assert Pairing.sid(@kat_hash, @kat_index, 2) == @kat_sid_round_2 + assert byte_size(@kat_sid) == 21 + 32 + 4 + 4 end - test "commit/1 and derive_pin/4" do + test "commit/1 and derive_pairing_code/3" do assert Pairing.commit(@kat_nonce_b) == @kat_commit_b assert Pairing.commit_valid?(@kat_nonce_b, @kat_commit_b) refute Pairing.commit_valid?(@kat_nonce_a, @kat_commit_b) - assert Pairing.derive_pin(@kat_hash, @kat_nonce_a, @kat_nonce_b, @kat_pin_length) == - @kat_pin + assert Pairing.derive_pairing_code(@kat_hash, @kat_nonce_a, @kat_nonce_b) == @kat_code end - test "derive_pin/4 zero-pads to the requested width" do - for length <- 4..12 do - pin = Pairing.derive_pin(@kat_hash, @kat_nonce_a, @kat_nonce_b, length) - assert String.length(pin) == length - assert pin =~ ~r/\A[0-9]+\z/ + test "derive_pairing_code/3 is always six zero-padded digits" do + for _ <- 1..50 do + code = + Pairing.derive_pairing_code( + :crypto.strong_rand_bytes(32), + :crypto.strong_rand_bytes(32), + :crypto.strong_rand_bytes(32) + ) + + assert code =~ ~r/\A[0-9]{6}\z/ end end - test "mac_key/2, confirmation_tag/3 and wrap_key/2" do - assert Pairing.mac_key(@kat_sid, @kat_isk) == @kat_mac_key - assert Pairing.confirmation_tag(@kat_mac_key, @kat_ya, "server") == @kat_ta - assert Pairing.confirmation_tag(@kat_mac_key, @kat_yb, "client") == @kat_tb - assert Pairing.wrap_key(@kat_sid, @kat_isk) == @kat_wrap_key + test "confirmation tags and both wrap keys" do + mac_key = Pairing.mac_key(@kat_sid, @kat_isk) + assert Pairing.confirmation_tag(mac_key, @kat_ya, "server") == @kat_ta + assert Pairing.confirmation_tag(mac_key, @kat_yb, "client") == @kat_tb + assert Pairing.wrap_key(@kat_sid, @kat_isk) == @kat_psk_wrap_key + assert Pairing.nonce_wrap_key(@kat_sid, @kat_isk) == @kat_nonce_wrap_key + end + + test "wrap/3 seals with the suite's AEAD under a zero nonce" do + assert Pairing.wrap(@chacha, @kat_nonce_wrap_key, @kat_nonce_b) == @kat_wrapped_nonce_chacha + assert Pairing.wrap(@aesgcm, @kat_nonce_wrap_key, @kat_nonce_b) == @kat_wrapped_nonce_aesgcm + assert Pairing.wrap(@chacha, @kat_psk_wrap_key, @kat_psk) == @kat_wrapped_psk_chacha + + assert Pairing.unwrap(@chacha, @kat_psk_wrap_key, @kat_wrapped_psk_chacha) == + {:ok, @kat_psk} end test "psk_id_for/1" do @@ -134,7 +187,6 @@ defmodule UniversalProxy.Sendspin.PairingTest do handshake_hash: @kat_hash, pairing_index: @kat_index, suite: @chacha, - pin_length: @kat_pin_length, nonce_b: @kat_nonce_b, cpace_scalar: @kat_scalar_b, psk: @kat_psk @@ -145,8 +197,8 @@ defmodule UniversalProxy.Sendspin.PairingTest do {:pin, pin, client} = Pairing.handle(client, "server/pair-init", %{"nonce_A" => b64(@kat_nonce_a)}) - assert pin == @kat_pin - assert Pairing.pin(client) == @kat_pin + assert pin == @kat_code + assert Pairing.pin(client) == @kat_code {:send, [{"client/pair-auth", auth}], client} = Pairing.handle(client, "server/pair-auth", %{"pake_msg_1" => b64(@kat_ya)}) @@ -156,11 +208,12 @@ defmodule UniversalProxy.Sendspin.PairingTest do {:send, [{"client/pair-confirm", confirm}, {"client/pair-finalize", finalize}], client} = Pairing.handle(client, "server/pair-confirm", %{"server_kc" => b64(@kat_ta)}) - assert confirm == %{"client_kc" => b64(@kat_tb), "nonce_B" => b64(@kat_nonce_b)} + assert confirm == %{ + "client_kc" => b64(@kat_tb), + "wrapped_nonce_B" => b64(@kat_wrapped_nonce_chacha) + } - wrapped = unb64(finalize["wrapped_psk"]) - assert byte_size(wrapped) == 48 - assert Pairing.unwrap_psk(@chacha, @kat_wrap_key, wrapped) == {:ok, @kat_psk} + assert finalize == %{"wrapped_psk" => b64(@kat_wrapped_psk_chacha)} assert {:paired, outcome, _client} = Pairing.handle(client, "server/pair-finalize", %{}) @@ -168,23 +221,66 @@ defmodule UniversalProxy.Sendspin.PairingTest do assert outcome == %{psk: @kat_psk, psk_id: @kat_psk_id, category: :long_term} end + test "a retry round re-emits the code and runs CPace under the round-2 sid" do + {:ok, _init, client} = + Pairing.start( + handshake_hash: @kat_hash, + pairing_index: @kat_index, + suite: @chacha, + nonce_b: @kat_nonce_b, + cpace_scalar: @kat_scalar_b, + psk: @kat_psk + ) + + {:pin, @kat_code, client} = + Pairing.handle(client, "server/pair-init", %{"nonce_A" => b64(@kat_nonce_a)}) + + {:send, _auth, client} = + Pairing.handle(client, "server/pair-auth", %{"pake_msg_1" => b64(@kat_ya)}) + + # A wrong server tag (the operator mistyped): ask for another round. + assert {:send, [{"client/pair-retry", %{}}], client} = + Pairing.handle(client, "server/pair-confirm", %{"server_kc" => b64(<<0::512>>)}) + + assert Pairing.round(client) == 2 + assert {:pin, @kat_code, client} = Pairing.handle(client, "server/pair-init", %{}) + + {:send, _auth, client} = + Pairing.handle(client, "server/pair-auth", %{"pake_msg_1" => b64(@kat_ya_round_2)}) + + {:send, [{"client/pair-confirm", confirm}, _finalize], _client} = + Pairing.handle(client, "server/pair-confirm", %{"server_kc" => b64(@kat_ta_round_2)}) + + assert confirm == %{ + "client_kc" => b64(@kat_tb_round_2), + "wrapped_nonce_B" => b64(@kat_wrapped_nonce_round_2) + } + end + test "the server's own CPace share matches the vector" do {:ok, cpace} = - CPace.start(:a, prs: @kat_pin, ci: "", sid: @kat_sid, ad: "server", scalar: @kat_scalar_a) + CPace.start(:a, + prs: @kat_code, + ci: "", + sid: @kat_sid, + ad: "server", + scalar: @kat_scalar_a + ) assert CPace.public_share(cpace) == @kat_ya assert CPace.finish(cpace, @kat_yb, "client") == {:ok, @kat_isk} end end - describe "dynamic-PIN round trip" do - test "the correct PIN pairs and both sides land on the same PSK" do + describe "dynamic pairing code round trip" do + test "the correct code pairs and both sides land on the same PSK" do %{client: client, server: server, server_confirm: confirm} = pair([]) assert {:send, [{"client/pair-confirm", client_confirm}, {"client/pair-finalize", finalize}], client} = Pairing.handle(client, "server/pair-confirm", confirm) + refute Map.has_key?(client_confirm, "nonce_B") assert {:ok, server} = Server.handle(server, "client/pair-confirm", client_confirm) assert {:send, [{"server/pair-finalize", %{}}], server} = @@ -199,7 +295,7 @@ defmodule UniversalProxy.Sendspin.PairingTest do assert Server.psk(server) == outcome.psk end - test "the AES-GCM suite wraps the PSK just as well" do + test "the AES-GCM suite wraps the PSK and nonce just as well" do %{client: client, server: server, server_confirm: confirm} = pair(suite: @aesgcm) {:send, [{"client/pair-confirm", client_confirm}, {"client/pair-finalize", finalize}], @@ -212,7 +308,7 @@ defmodule UniversalProxy.Sendspin.PairingTest do assert Server.psk(server) == outcome.psk end - test "every attempt derives a fresh PIN, PSK and commitment" do + test "every attempt derives a fresh code, PSK and commitment" do runs = for _ <- 1..5, do: pair([]) pins = Enum.map(runs, & &1.pin) @@ -220,19 +316,70 @@ defmodule UniversalProxy.Sendspin.PairingTest do assert Enum.all?(pins, &(String.length(&1) == 6)) end - test "a longer negotiated PIN still round-trips" do - %{client: client, server: server, pin: pin, server_confirm: confirm} = pair(pin_length: 10) - - assert String.length(pin) == 10 + test "a mistyped code retries in a second round and then pairs" do + %{client: client, server: server, pin: pin, server_confirm: confirm} = pair([], &mistype/1) - {:send, [{"client/pair-confirm", client_confirm}, _finalize], _client} = + {:send, [{"client/pair-retry", retry}], client} = Pairing.handle(client, "server/pair-confirm", confirm) - assert {:ok, _server} = Server.handle(server, "client/pair-confirm", client_confirm) + {:send, [{"server/pair-init", init}], server} = + Server.handle(server, "client/pair-retry", retry) + + refute Map.has_key?(init, "nonce_A") + assert {:pin, ^pin, client} = Pairing.handle(client, "server/pair-init", init) + + {:send, [{"server/pair-auth", auth}], server} = Server.submit_pin(server, pin) + + {:send, [{"client/pair-auth", client_auth}], client} = + Pairing.handle(client, "server/pair-auth", auth) + + {:send, [{"server/pair-confirm", confirm}], server} = + Server.handle(server, "client/pair-auth", client_auth) + + {:send, [{"client/pair-confirm", client_confirm}, {"client/pair-finalize", finalize}], + client} = Pairing.handle(client, "server/pair-confirm", confirm) + + {:ok, server} = Server.handle(server, "client/pair-confirm", client_confirm) + {:send, _, server} = Server.handle(server, "client/pair-finalize", finalize) + {:paired, outcome, _client} = Pairing.handle(client, "server/pair-finalize", %{}) + + assert Server.psk(server) == outcome.psk + assert Server.round(server) == 2 end end - describe "static-PIN round trip" do + describe "Pairing PSK" do + test "pair-init and the long-term PSK go out back to back, no PAKE" do + opts = client_opts(method: :pairing_psk, pairing_index: 2, psk: @kat_psk) + + assert {:ok, [{"client/pair-init", init}, {"client/pair-finalize", finalize}], client} = + Pairing.start(opts) + + assert init == %{"pairing_index" => 2} + assert finalize == %{"long_term_psk" => b64(@kat_psk)} + assert Pairing.stage(client) == :awaiting_pair_finalize + + server = Server.start(server_opts(opts) ++ [method: :pairing_psk]) + {:ok, server} = Server.handle(server, "client/pair-init", init) + + {:send, [{"server/pair-finalize", %{}}], server} = + Server.handle(server, "client/pair-finalize", finalize) + + assert {:paired, %{psk: @kat_psk, psk_id: @kat_psk_id}, _client} = + Pairing.handle(client, "server/pair-finalize", %{}) + + assert Server.psk(server) == @kat_psk + end + + test "a server reply other than server/pair-finalize is a protocol error" do + {:ok, _msgs, client} = Pairing.start(client_opts(method: :pairing_psk)) + + assert {:error, {:unexpected_message, "server/pair-auth", :awaiting_pair_finalize}, _} = + Pairing.handle(client, "server/pair-auth", %{"pake_msg_1" => b64(@kat_ya)}) + end + end + + describe "static pairing code round trip" do setup do opts = [handshake_hash: :crypto.strong_rand_bytes(32), pairing_index: 3, suite: @chacha] %{opts: opts} @@ -242,11 +389,11 @@ defmodule UniversalProxy.Sendspin.PairingTest do pin = "40028922" {:ok, [{"client/pair-init", init}], client} = - Pairing.start(opts ++ [method: :static_pin, pin: pin]) + Pairing.start(opts ++ [method: :static_pairing_code, pin: pin]) assert init == %{"pairing_index" => 3} - server = Server.start(opts ++ [method: :static_pin, pin: pin]) + server = Server.start(opts ++ [method: :static_pairing_code, pin: pin]) {:send, [{"server/pair-auth", auth}], server} = Server.handle(server, "client/pair-init", init) @@ -260,7 +407,7 @@ defmodule UniversalProxy.Sendspin.PairingTest do {:send, [{"client/pair-confirm", client_confirm}, {"client/pair-finalize", finalize}], client} = Pairing.handle(client, "server/pair-confirm", confirm) - refute Map.has_key?(client_confirm, "nonce_B") + refute Map.has_key?(client_confirm, "wrapped_nonce_B") {:ok, server} = Server.handle(server, "client/pair-confirm", client_confirm) {:send, _, server} = Server.handle(server, "client/pair-finalize", finalize) @@ -269,11 +416,11 @@ defmodule UniversalProxy.Sendspin.PairingTest do assert Server.psk(server) == outcome.psk end - test "a client PIN the operator did not type fails the server's tag check", %{opts: opts} do + test "a client code the operator did not type aborts without a retry", %{opts: opts} do {:ok, [{"client/pair-init", init}], client} = - Pairing.start(opts ++ [method: :static_pin, pin: "40028922"]) + Pairing.start(opts ++ [method: :static_pairing_code, pin: "40028922"]) - server = Server.start(opts ++ [method: :static_pin, pin: "40028923"]) + server = Server.start(opts ++ [method: :static_pairing_code, pin: "40028923"]) {:send, [{"server/pair-auth", auth}], server} = Server.handle(server, "client/pair-init", init) @@ -286,56 +433,58 @@ defmodule UniversalProxy.Sendspin.PairingTest do # Client rejects the server's Ta first, but even if it were tricked # into replying the server would reject Tb. - assert {:abort, :pin_mismatch, _msgs, _client} = + assert {:abort, :pairing_code_mismatch, _msgs, _client} = Pairing.handle(client, "server/pair-confirm", confirm) forged = %{"client_kc" => b64(:crypto.strong_rand_bytes(64))} - assert Server.handle(server, "client/pair-confirm", forged) == {:error, :pin_mismatch} + + assert Server.handle(server, "client/pair-confirm", forged) == + {:error, :pairing_code_mismatch} end - test "start/1 refuses a PIN that is not eight digits", %{opts: opts} do + test "start/1 refuses a code that is not eight digits", %{opts: opts} do for pin <- ["1234567", "123456789", "abcdefgh", "1234 678", nil] do - assert {:error, {:invalid_static_pin, ^pin}} = - Pairing.start(opts ++ [method: :static_pin, pin: pin]) + assert {:error, :invalid_static_pairing_code} = + Pairing.start(opts ++ [method: :static_pairing_code, pin: pin]) end - assert Pairing.valid_static_pin?("40028922") - refute Pairing.valid_static_pin?("4002892") + assert Pairing.valid_static_pairing_code?("40028922") + refute Pairing.valid_static_pairing_code?("4002892") end end - describe "wrong PIN" do - test "a mistyped operator PIN fails the client's MCF check and aborts" do - %{client: client, server_confirm: confirm} = - pair([], fn pin -> - <> = pin - other = if first == "9", do: "0", else: "9" - other <> rest - end) + describe "round limit" do + test "a mistyped code aborts with pairing_code_mismatch once max_rounds is spent" do + %{client: client, server_confirm: confirm} = pair([max_rounds: 1], &mistype/1) - assert {:abort, :pin_mismatch, [{"pair/abort", payload}], client} = + assert {:abort, :pairing_code_mismatch, [{"pair/abort", payload}], client} = Pairing.handle(client, "server/pair-confirm", confirm) - assert payload == %{"reason" => "pin_mismatch"} - assert Pairing.stage(client) == {:failed, :pin_mismatch} + assert payload == %{"reason" => "pairing_code_mismatch"} + assert Pairing.stage(client) == {:failed, :pairing_code_mismatch} end - test "the server rejects a client tag from a different PIN" do - %{server: server} = - pair([], fn pin -> - <> = pin - other = if first == "9", do: "0", else: "9" - other <> rest - end) + test "the server rejects a client tag from a different code" do + %{server: server} = pair([], &mistype/1) confirm = %{ "client_kc" => b64(:crypto.strong_rand_bytes(64)), - "nonce_B" => b64(:crypto.strong_rand_bytes(32)) + "wrapped_nonce_B" => b64(:crypto.strong_rand_bytes(48)) } - # Commit is checked before the tag, so a random nonce_B trips that - # first — as the ground truth's verification order requires. - assert Server.handle(server, "client/pair-confirm", confirm) == {:error, :commit_mismatch} + # The MCF tag is the first check (`pairing.md` "Server verification"). + assert Server.handle(server, "client/pair-confirm", confirm) == + {:error, :pairing_code_mismatch} + end + + test "a retry round's server/pair-init must not carry a nonce" do + %{client: client, server_confirm: confirm} = pair([], &mistype/1) + + {:send, [{"client/pair-retry", _}], client} = + Pairing.handle(client, "server/pair-confirm", confirm) + + assert {:error, {:unexpected_field, "nonce_A"}, _client} = + Pairing.handle(client, "server/pair-init", %{"nonce_A" => b64(@kat_nonce_a)}) end end @@ -343,13 +492,10 @@ defmodule UniversalProxy.Sendspin.PairingTest do test "a tampered commit_B is caught on reveal" do client_opts = client_opts([]) - server_opts = - Keyword.take(client_opts, [:handshake_hash, :pairing_index, :suite, :pin_length]) - {:ok, [{"client/pair-init", init}], client} = Pairing.start(client_opts) tampered = %{init | "commit_B" => b64(Pairing.commit(Pairing.generate_nonce()))} - server = Server.start(server_opts) + server = Server.start(server_opts(client_opts)) {:send, [{"server/pair-init", server_init}], server} = Server.handle(server, "client/pair-init", tampered) @@ -366,41 +512,58 @@ defmodule UniversalProxy.Sendspin.PairingTest do {:send, [{"client/pair-confirm", client_confirm}, _finalize], _client} = Pairing.handle(client, "server/pair-confirm", confirm) - # The PAKE itself still succeeds — the PIN matched — so the mismatch - # only surfaces when nonce_B is revealed. + # The PAKE itself still succeeds — the code matched — so the mismatch + # only surfaces when nonce_B is unwrapped and checked. assert Server.handle(server, "client/pair-confirm", client_confirm) == {:error, :commit_mismatch} end - test "a flipped bit in the server tag is rejected" do - %{client: client, server_confirm: confirm} = pair([]) + test "a tampered wrapped_nonce_B fails to unwrap" do + %{client: client, server: server, server_confirm: confirm} = pair([]) + + {:send, [{"client/pair-confirm", client_confirm}, _finalize], _client} = + Pairing.handle(client, "server/pair-confirm", confirm) + + <> = unb64(client_confirm["wrapped_nonce_B"]) + + tampered = %{ + client_confirm + | "wrapped_nonce_B" => b64(<>) + } + + assert Server.handle(server, "client/pair-confirm", tampered) == {:error, :unwrap_failed} + end + + test "a flipped bit in the server tag is a failed round" do + %{client: client, server_confirm: confirm} = pair(max_rounds: 1) <> = unb64(confirm["server_kc"]) flipped = %{confirm | "server_kc" => b64(<>)} - assert {:abort, :pin_mismatch, _msgs, _client} = + assert {:abort, :pairing_code_mismatch, _msgs, _client} = Pairing.handle(client, "server/pair-confirm", flipped) end test "a server that reflects our own share back is rejected" do - %{client: client} = pair([]) + %{client: client} = pair(max_rounds: 1) # Ya replaced by Yb: the tag the attacker can compute is Tb, not Ta. reflected = Pairing.confirmation_tag(:crypto.strong_rand_bytes(64), <<0::256>>, "server") - assert {:abort, :pin_mismatch, _msgs, _client} = + assert {:abort, :pairing_code_mismatch, _msgs, _client} = Pairing.handle(client, "server/pair-confirm", %{"server_kc" => b64(reflected)}) end test "a tampered wrapped_psk fails to unwrap" do %{client: client, server: server, server_confirm: confirm} = pair([]) - {:send, [_confirm, {"client/pair-finalize", finalize}], _client} = - Pairing.handle(client, "server/pair-confirm", confirm) + {:send, [{"client/pair-confirm", client_confirm}, {"client/pair-finalize", finalize}], + _client} = Pairing.handle(client, "server/pair-confirm", confirm) + + {:ok, server} = Server.handle(server, "client/pair-confirm", client_confirm) <> = unb64(finalize["wrapped_psk"]) tampered = %{finalize | "wrapped_psk" => b64(<>)} - assert Server.handle(server, "client/pair-finalize", tampered) == - {:error, :psk_unwrap_failed} + assert Server.handle(server, "client/pair-finalize", tampered) == {:error, :unwrap_failed} end end @@ -409,14 +572,14 @@ defmodule UniversalProxy.Sendspin.PairingTest do %{client: client, server_confirm: confirm} = pair_with_server_override(handshake_hash: :crypto.strong_rand_bytes(32)) - assert {:abort, :pin_mismatch, _msgs, _client} = + assert {:abort, :pairing_code_mismatch, _msgs, _client} = Pairing.handle(client, "server/pair-confirm", confirm) end test "a replayed attempt counter breaks the tags too" do %{client: client, server_confirm: confirm} = pair_with_server_override(pairing_index: 2) - assert {:abort, :pin_mismatch, _msgs, _client} = + assert {:abort, :pairing_code_mismatch, _msgs, _client} = Pairing.handle(client, "server/pair-confirm", confirm) end end @@ -447,6 +610,7 @@ defmodule UniversalProxy.Sendspin.PairingTest do assert payload == %{"reason" => "attempt_timeout"} assert Pairing.stage(client) == {:failed, :attempt_timeout} assert Pairing.attempt_timeout_ms() == 120_000 + assert Pairing.round_limit() == 20 end test "an out-of-order message is a protocol error" do @@ -487,7 +651,7 @@ defmodule UniversalProxy.Sendspin.PairingTest do end describe "start/1 option validation" do - test "rejects a bad handshake hash, counter, suite, PSK, nonce or PIN length" do + test "rejects a bad handshake hash, counter, suite, PSK, nonce or round budget" do assert {:error, {:invalid_handshake_hash, nil}} = Pairing.start(pairing_index: 1, suite: @chacha) @@ -499,7 +663,8 @@ defmodule UniversalProxy.Sendspin.PairingTest do assert {:error, {:invalid_psk, 8}} = Pairing.start(client_opts(psk: <<0::64>>)) assert {:error, {:invalid_nonce, 8}} = Pairing.start(client_opts(nonce_b: <<0::64>>)) - assert {:error, {:invalid_pin_length, 3}} = Pairing.start(client_opts(pin_length: 3)) + assert {:error, {:invalid_max_rounds, 0}} = Pairing.start(client_opts(max_rounds: 0)) + assert {:error, {:invalid_max_rounds, 21}} = Pairing.start(client_opts(max_rounds: 21)) assert {:error, {:unsupported_method, :gesture}} = Pairing.start(client_opts(method: :gesture)) @@ -507,7 +672,7 @@ defmodule UniversalProxy.Sendspin.PairingTest do end describe "inspect redaction" do - test "a mid-attempt struct leaks neither the CPace scalar nor nonce_b" do + test "a mid-attempt struct leaks neither the CPace scalar nor nonce_b nor the PSK" do # Drive to `:awaiting_pair_auth`, where the live `%CPace{}` (with its # secret scalar) and `nonce_b` (still secret — revealed only later in # `client/pair-confirm`) both sit in the struct. @@ -516,9 +681,9 @@ defmodule UniversalProxy.Sendspin.PairingTest do client_opts( handshake_hash: @kat_hash, pairing_index: @kat_index, - pin_length: @kat_pin_length, nonce_b: @kat_nonce_b, - cpace_scalar: @kat_scalar_b + cpace_scalar: @kat_scalar_b, + psk: @kat_psk ) ) @@ -531,23 +696,25 @@ defmodule UniversalProxy.Sendspin.PairingTest do rendered = inspect(client) - for secret <- [@kat_scalar_b, @kat_nonce_b] do + for secret <- [@kat_scalar_b, @kat_nonce_b, @kat_psk] do refute rendered =~ inspect(secret) refute rendered =~ Base.encode16(secret) refute rendered =~ Base.encode16(secret, case: :lower) refute rendered =~ b64(secret) end + + refute rendered =~ @kat_code end end # Same drive as `pair/2` but with the server deliberately bound to a # different sid input than the client. defp pair_with_server_override(override) do - client_opts = client_opts() + client_opts = client_opts(max_rounds: 1) server_opts = client_opts - |> Keyword.take([:handshake_hash, :pairing_index, :suite, :pin_length]) + |> server_opts() |> Keyword.merge(override) {:ok, [{"client/pair-init", init}], client} = Pairing.start(client_opts) diff --git a/test/universal_proxy/sendspin/pairing_token_test.exs b/test/universal_proxy/sendspin/pairing_token_test.exs new file mode 100644 index 00000000..7a589fef --- /dev/null +++ b/test/universal_proxy/sendspin/pairing_token_test.exs @@ -0,0 +1,24 @@ +defmodule UniversalProxy.Sendspin.PairingTokenTest do + use ExUnit.Case, async: true + + alias UniversalProxy.Sendspin.PairingToken + + # `pairing.md` "Pairing PSK Flow": the version-0 reference vector for + # client_key = 0x00..0x1f and pairing_psk = 0xe0..0xff. + @spec_vector "SP:0AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO74HR6LZ7J5PW674PT6X37T6757Y" + + test "encodes the spec's version-0 reference vector" do + client_key = :binary.list_to_bin(Enum.to_list(0x00..0x1F)) + pairing_psk = :binary.list_to_bin(Enum.to_list(0xE0..0xFF)) + + assert PairingToken.encode_psk_token(client_key, pairing_psk) == @spec_vector + end + + test "stays inside the QR alphanumeric set, with no padding and no 2s" do + token = + PairingToken.encode_psk_token(:crypto.strong_rand_bytes(32), :crypto.strong_rand_bytes(32)) + + assert token =~ ~r/\ASP:0[A-Z3-9]+\z/ + assert byte_size(token) == 4 + 103 + end +end diff --git a/test/universal_proxy/sendspin/wire_test.exs b/test/universal_proxy/sendspin/wire_test.exs index 10b9618a..6a4e348f 100644 --- a/test/universal_proxy/sendspin/wire_test.exs +++ b/test/universal_proxy/sendspin/wire_test.exs @@ -201,17 +201,23 @@ defmodule UniversalProxy.Sendspin.WireTest do end end - describe "decode_frame/2 — fragmentation (types 2/3)" do - test "reassembles a message split across fragment-more + fragment-end" do + describe "decode_frame/2 — fragmentation (type 1)" do + # `[1][flags][orig_type][data]` first, `[1][flags][data]` after; flags + # bit 1 = first, bit 0 = last (`messaging.md` "Fragmentation"). + @first 0b10 + @last 0b01 + @middle 0b00 + + test "reassembles a message split across a first and a last fragment" do body = Wire.encode_client_time(999) plaintext = Wire.wrap_json(body) <> = plaintext {first, rest} = String.split_at(data, div(byte_size(data), 2)) assert {:pending, r1} = - Wire.decode_frame(<<2, orig_type, first::binary>>, Reassembler.new()) + Wire.decode_frame(<<1, @first, orig_type, first::binary>>, Reassembler.new()) - assert {:complete, {:json, ^body}, r2} = Wire.decode_frame(<<3, rest::binary>>, r1) + assert {:complete, {:json, ^body}, r2} = Wire.decode_frame(<<1, @last, rest::binary>>, r1) assert r2 == Reassembler.new() end @@ -220,49 +226,82 @@ defmodule UniversalProxy.Sendspin.WireTest do <> = plaintext <> = rest - {:pending, r1} = Wire.decode_frame(<<2, orig_type, a>>, Reassembler.new()) - {:pending, r2} = Wire.decode_frame(<<2, b, c>>, r1) - {:pending, r3} = Wire.decode_frame(<<2, d, e, f>>, r2) + {:pending, r1} = Wire.decode_frame(<<1, @first, orig_type, a>>, Reassembler.new()) + {:pending, r2} = Wire.decode_frame(<<1, @middle, b, c>>, r1) + {:pending, r3} = Wire.decode_frame(<<1, @middle, d, e, f>>, r2) assert {:complete, {:audio, 55, <<1, 2, 3, 4, 5, 6>>}, _} = - Wire.decode_frame(<<3, tail::binary>>, r3) + Wire.decode_frame(<<1, @last, tail::binary>>, r3) end - test "a fragment-end with no fragment-more in flight is a protocol error" do - assert {:error, :fragment_end_without_start} = - Wire.decode_frame(<<3, "data">>, Reassembler.new()) + test "a single fragment flagged both first and last dispatches at once" do + body = Wire.encode_client_time(1) + + assert {:complete, {:json, ^body}, _} = + Wire.decode_frame(<<1, 0b11, 0, body::binary>>, Reassembler.new()) + end + + test "a non-first fragment with none in flight is a protocol error" do + assert {:error, :fragment_without_start} = + Wire.decode_frame(<<1, @last, "data">>, Reassembler.new()) + + assert {:error, :fragment_without_start} = + Wire.decode_frame(<<1, @middle, "data">>, Reassembler.new()) + end + + test "a first fragment while one is in flight is a protocol error" do + {:pending, r1} = Wire.decode_frame(<<1, @first, 0, "abc">>, Reassembler.new()) + assert {:error, :fragment_restarted} = Wire.decode_frame(<<1, @first, 0, "x">>, r1) end - test "a fragment-more with no orig_type byte is a protocol error" do - assert {:error, :fragment_missing_orig_type} = Wire.decode_frame(<<2>>, Reassembler.new()) + test "a first fragment with no orig_type byte is a protocol error" do + assert {:error, :fragment_missing_orig_type} = + Wire.decode_frame(<<1, @first>>, Reassembler.new()) + + assert {:error, :fragment_missing_flags} = Wire.decode_frame(<<1>>, Reassembler.new()) + end + + test "an orig_type of 1 is a protocol error" do + assert {:error, :invalid_orig_type} = + Wire.decode_frame(<<1, @first, 1, "x">>, Reassembler.new()) end - test "orig_type of a fragment type (2 or 3) is a protocol error" do - assert {:error, :invalid_orig_type} = Wire.decode_frame(<<2, 2, "x">>, Reassembler.new()) - assert {:error, :invalid_orig_type} = Wire.decode_frame(<<2, 3, "x">>, Reassembler.new()) + test "a nonzero reserved flag bit is a protocol error" do + for flags <- [0b100, 0b1000_0010, 0xFF] do + assert {:error, :fragment_reserved_flags} = + Wire.decode_frame(<<1, flags, 0, "x">>, Reassembler.new()) + end end test "a non-fragment frame while a fragmented message is in flight is a protocol error" do - {:pending, r1} = Wire.decode_frame(<<2, 0, "abc">>, Reassembler.new()) + {:pending, r1} = Wire.decode_frame(<<1, @first, 0, "abc">>, Reassembler.new()) assert {:error, :fragment_interrupted} = Wire.decode_frame(<<0, "oops">>, r1) end + + test "the retired legacy IDs 2 and 3 are just unimplemented binary types now" do + assert {:complete, {:binary, 2, <<0, "abc">>}, _} = + Wire.decode_frame(<<2, 0, "abc">>, Reassembler.new()) + + assert {:complete, {:binary, 3, "abc"}, _} = + Wire.decode_frame(<<3, "abc">>, Reassembler.new()) + end end describe "decode_frame/2 — reassembly bounds (W4)" do test "the running size on the reassembler matches the actual buffer length at every step" do - {:pending, r1} = Wire.decode_frame(<<2, 0, "abc">>, Reassembler.new()) + {:pending, r1} = Wire.decode_frame(<<1, 0b10, 0, "abc">>, Reassembler.new()) assert r1.size == 3 assert IO.iodata_length(r1.buffer) == 3 - {:pending, r2} = Wire.decode_frame(<<2, "de">>, r1) + {:pending, r2} = Wire.decode_frame(<<1, 0, "de">>, r1) assert r2.size == 5 assert IO.iodata_length(r2.buffer) == 5 - {:pending, r3} = Wire.decode_frame(<<2, "fghij">>, r2) + {:pending, r3} = Wire.decode_frame(<<1, 0, "fghij">>, r2) assert r3.size == 10 assert IO.iodata_length(r3.buffer) == 10 - assert {:complete, {:json, "abcdefghij"}, r4} = Wire.decode_frame(<<3>>, r3) + assert {:complete, {:json, "abcdefghij"}, r4} = Wire.decode_frame(<<1, 0b01>>, r3) assert r4 == Reassembler.new() end @@ -270,13 +309,13 @@ defmodule UniversalProxy.Sendspin.WireTest do chunk = :binary.copy(<<0>>, 65_000) # 17 chunks of 65,000 bytes = 1,105,000 bytes, past the 1 MB cap — - # each fed as its own fragment-more frame, well under the fragment - # COUNT cap, so this exercises the byte cap specifically. - {:pending, r1} = Wire.decode_frame(<<2, 0, chunk::binary>>, Reassembler.new()) + # each fed as its own fragment, well under the fragment COUNT cap, so + # this exercises the byte cap specifically. + {:pending, r1} = Wire.decode_frame(<<1, 0b10, 0, chunk::binary>>, Reassembler.new()) result = Enum.reduce_while(2..17, {:pending, r1}, fn _i, {:pending, r} -> - case Wire.decode_frame(<<2, chunk::binary>>, r) do + case Wire.decode_frame(<<1, 0, chunk::binary>>, r) do {:pending, _} = pending -> {:cont, pending} {:error, _} = error -> {:halt, error} end @@ -286,11 +325,11 @@ defmodule UniversalProxy.Sendspin.WireTest do end test "the fragment COUNT cap trips independent of size on tiny fragments" do - {:pending, r1} = Wire.decode_frame(<<2, 0, 1>>, Reassembler.new()) + {:pending, r1} = Wire.decode_frame(<<1, 0b10, 0, 1>>, Reassembler.new()) result = Enum.reduce_while(2..5_000, {:pending, r1}, fn _i, {:pending, r} -> - case Wire.decode_frame(<<2, 1>>, r) do + case Wire.decode_frame(<<1, 0, 1>>, r) do {:pending, _} = pending -> {:cont, pending} {:error, _} = error -> {:halt, error} end @@ -311,10 +350,9 @@ defmodule UniversalProxy.Sendspin.WireTest do @base %{ name: "USB Capture", device_info: nil, - trust_level: :none, supported_roles: ["source@v1"], source_v1_support: %{features: %{line_sense: nil}}, - supported_pair_methods: [%{method: :pairing_psk}], + supported_pair_methods: %{pairing_psk: %{}}, unpaired_access: %{enabled: false} } @@ -324,7 +362,7 @@ defmodule UniversalProxy.Sendspin.WireTest do assert decoded == @base end - test "round-trips with device_info and a paired trust level" do + test "round-trips with device_info" do fields = %{ @base | device_info: %{ @@ -332,28 +370,102 @@ defmodule UniversalProxy.Sendspin.WireTest do manufacturer: "bbangert", software_version: "1.2.3", mac_address: "aa:bb:cc:dd:ee:ff" - }, - trust_level: :user + } } text = Wire.encode_client_hello(fields) assert {:ok, ^fields} = Wire.decode_client_hello(text) end - test "round-trips line_sense features and a fuller pair-method descriptor" do + test "sends no trust_level, and supported_pair_methods as an object keyed by method" do + fields = %{ + @base + | supported_pair_methods: %{ + pairing_psk: %{}, + dynamic_pairing_code: %{out_channels: ["display"], formats: ["digits"]} + } + } + + {:ok, "client/hello", payload} = + fields |> Wire.encode_client_hello() |> Wire.decode_envelope() + + refute Map.has_key?(payload, "trust_level") + + assert payload["supported_pair_methods"] == %{ + "pairing_psk" => %{}, + "dynamic_pairing_code" => %{"out_channels" => ["display"], "formats" => ["digits"]} + } + end + + test "golden payload: the source's rc1 client/hello" do + fields = %{ + name: "Line In", + device_info: nil, + supported_roles: ["source@v1"], + source_v1_support: %{features: %{line_sense: false}}, + supported_pair_methods: %{ + pairing_psk: %{}, + dynamic_pairing_code: %{out_channels: ["display"], formats: ["digits"]} + }, + unpaired_access: %{enabled: false} + } + + assert Jason.decode!(Wire.encode_client_hello(fields)) == %{ + "type" => "client/hello", + "payload" => %{ + "name" => "Line In", + "supported_roles" => ["source@v1"], + "source@v1_support" => %{"features" => %{"line_sense" => false}}, + "supported_pair_methods" => %{ + "pairing_psk" => %{}, + "dynamic_pairing_code" => %{ + "out_channels" => ["display"], + "formats" => ["digits"] + } + }, + "unpaired_access" => %{"enabled" => false} + } + } + end + + test "round-trips line_sense features and fuller pair-method descriptors" do fields = %{ @base | source_v1_support: %{features: %{line_sense: true}}, - supported_pair_methods: [ - %{method: :dynamic_pin, out_channels: ["display"], min_pin_length: 6}, - %{method: :static_pin, locations: ["device"]} - ] + supported_pair_methods: %{ + pairing_psk: %{locations: ["operator"]}, + static_pairing_code: %{locations: ["device"]} + } } text = Wire.encode_client_hello(fields) assert {:ok, ^fields} = Wire.decode_client_hello(text) end + test "an unrecognized pair method key is ignored, not an error or a new atom" do + payload = + build_client_hello_payload(@base) + |> put_in(["supported_pair_methods", "future_method"], %{"x" => 1}) + + text = Jason.encode!(%{"type" => "client/hello", "payload" => payload}) + + assert {:ok, %{supported_pair_methods: %{pairing_psk: %{}} = methods}} = + Wire.decode_client_hello(text) + + assert map_size(methods) == 1 + end + + test "the pre-rc1 list shape is rejected" do + payload = + build_client_hello_payload(@base) + |> Map.put("supported_pair_methods", [%{"method" => "pairing_psk"}]) + + text = Jason.encode!(%{"type" => "client/hello", "payload" => payload}) + + assert {:error, {:invalid_field, "supported_pair_methods"}} = + Wire.decode_client_hello(text) + end + test "a server MUST NOT see the support object without the role listed, and vice versa" do # Not a Wire-enforced invariant (that's the FSM's job) — just proving # both are independently encodable so the FSM can enforce it. @@ -362,13 +474,6 @@ defmodule UniversalProxy.Sendspin.WireTest do assert {:ok, %{source_v1_support: nil}} = Wire.decode_client_hello(text) end - test "invalid trust_level is rejected without creating an atom from it" do - payload = build_client_hello_payload(@base) |> Map.put("trust_level", "root") - text = Jason.encode!(%{"type" => "client/hello", "payload" => payload}) - - assert {:error, {:invalid_field, {"trust_level", "root"}}} = Wire.decode_client_hello(text) - end - defp build_client_hello_payload(fields) do {:ok, "client/hello", payload} = fields |> Wire.encode_client_hello() |> Wire.decode_envelope() @@ -377,6 +482,46 @@ defmodule UniversalProxy.Sendspin.WireTest do end end + describe "client/goodbye" do + test "golden payloads for the reasons the source sends" do + for reason <- [:pairing_required, :unauthorized, :shutdown] do + assert Jason.decode!(Wire.encode_client_goodbye(reason)) == %{ + "type" => "client/goodbye", + "payload" => %{"reason" => Atom.to_string(reason)} + } + end + end + end + + describe "noise/handshake message 1 payload" do + test "decodes psk_id with each psk_category code" do + for {code, category} <- [{"lt", :long_term}, {"pr", :pairing}, {"sn", :sentinel}] do + payload = Jason.encode!(%{"psk_id" => "abc", "psk_category" => code}) + + assert {:ok, %{psk_id: "abc", psk_category: ^category}} = + Wire.decode_noise_msg1_payload(payload) + + assert Jason.decode!(Wire.encode_noise_msg1_payload("abc", category)) == %{ + "psk_id" => "abc", + "psk_category" => code + } + end + end + + test "a missing or unknown category, missing psk_id or non-JSON is malformed" do + for payload <- [ + ~s({"psk_id":"abc"}), + ~s({"psk_id":"abc","psk_category":"xx"}), + ~s({"psk_category":"lt"}), + ~s({"psk_id":1,"psk_category":"lt"}), + "not json", + "" + ] do + assert {:error, :malformed_handshake_payload} = Wire.decode_noise_msg1_payload(payload) + end + end + end + describe "server/activate" do test "round-trips activities-only (no pairing, active_roles omitted)" do fields = %{activities: [:playback], active_roles: nil, pairing: nil} @@ -384,19 +529,56 @@ defmodule UniversalProxy.Sendspin.WireTest do assert {:ok, ^fields} = Wire.decode_server_activate(text) end - test "extracts active_roles and the pairing method" do - fields = %{ - activities: [:pairing], - active_roles: [], - pairing: %{method: :dynamic_pin, pin_length: 6, languages: ["en"]} - } + test "extracts active_roles and the rc1 pairing method and format" do + for pairing <- [ + %{method: :dynamic_pairing_code, format: :digits}, + %{method: :pairing_psk, format: nil}, + %{method: :static_pairing_code, format: nil} + ] do + fields = %{activities: [:pairing], active_roles: [], pairing: pairing} + text = Wire.encode_server_activate(fields) + assert {:ok, ^fields} = Wire.decode_server_activate(text) + end + end - text = Wire.encode_server_activate(fields) - assert {:ok, ^fields} = Wire.decode_server_activate(text) + test "an unknown method or format decodes to {:unknown, string} rather than erroring" do + text = + Jason.encode!(%{ + "type" => "server/activate", + "payload" => %{ + "activities" => ["pairing"], + "active_roles" => [], + "pairing" => %{"method" => "dynamic_pin", "format" => "hologram"} + } + }) + + assert {:ok, + %{pairing: %{method: {:unknown, "dynamic_pin"}, format: {:unknown, "hologram"}}}} = + Wire.decode_server_activate(text) + end + + test "an unknown activity decodes to {:unknown, string}, not an error or a new atom" do + text = + Jason.encode!(%{ + "type" => "server/activate", + "payload" => %{"activities" => ["playback", "management"], "active_roles" => []} + }) + + assert {:ok, %{activities: [:playback, {:unknown, "management"}]}} = + Wire.decode_server_activate(text) + end + + test "server/unpair is recognized" do + assert {:ok, {:server_unpair, %{}}} = Wire.decode_message(Wire.encode_server_unpair()) + + assert Jason.decode!(Wire.encode_server_unpair()) == %{ + "type" => "server/unpair", + "payload" => %{} + } end test "multiple activities round-trip in order" do - fields = %{activities: [:playback, :management], active_roles: ["source@v1"], pairing: nil} + fields = %{activities: [:playback, :pairing], active_roles: ["source@v1"], pairing: nil} text = Wire.encode_server_activate(fields) assert {:ok, ^fields} = Wire.decode_server_activate(text) end @@ -417,6 +599,13 @@ defmodule UniversalProxy.Sendspin.WireTest do end describe "client/state (source object)" do + test "golden payload: an active source always carries the full source object" do + assert Jason.decode!(Wire.encode_client_state(false, %{signal: nil})) == %{ + "type" => "client/state", + "payload" => %{"available" => false, "source" => %{}} + } + end + test "round-trips with no source object" do text = Wire.encode_client_state(true, nil) assert {:ok, %{available: true, source: nil}} = Wire.decode_client_state(text) @@ -451,11 +640,28 @@ defmodule UniversalProxy.Sendspin.WireTest do end end - describe "client_stream/start and client_stream/end" do + describe "client-stream/start and client-stream/end" do test "round-trips a pcm stream with no codec_header" do fields = %{codec: :pcm, channels: 2, sample_rate: 48_000, bit_depth: 16, codec_header: nil} text = Wire.encode_client_stream_start(fields) assert {:ok, ^fields} = Wire.decode_client_stream_start(text) + + assert Jason.decode!(text) == %{ + "type" => "client-stream/start", + "payload" => %{ + "source" => %{ + "codec" => "pcm", + "channels" => 2, + "sample_rate" => 48_000, + "bit_depth" => 16 + } + } + } + end + + test "the pre-rc1 underscore name is no longer recognized" do + text = Wire.encode_message("client_stream/start", %{"source" => %{}}) + assert {:unknown, "client_stream/start", _} = Wire.decode_message(text) end test "round-trips a flac stream whose codec_header is standard (padded) Base64" do @@ -475,21 +681,21 @@ defmodule UniversalProxy.Sendspin.WireTest do # codec_header is standard Base64 (padded), not base64url — assert the # wire text actually uses that alphabet/padding convention. - {:ok, "client_stream/start", %{"source" => %{"codec_header" => wire_header}}} = + {:ok, "client-stream/start", %{"source" => %{"codec_header" => wire_header}}} = Wire.decode_envelope(text) assert wire_header == Base.encode64(header) end - test "client_stream/end has no payload fields" do + test "client-stream/end has no payload fields" do text = Wire.encode_client_stream_end() assert {:ok, %{}} = Wire.decode_client_stream_end(text) - assert {:ok, "client_stream/end", %{}} = Wire.decode_envelope(text) + assert {:ok, "client-stream/end", %{}} = Wire.decode_envelope(text) end end describe "pairing messages" do - test "client/pair-init round-trips with and without commit_B (dynamic vs static PIN)" do + test "client/pair-init round-trips with and without commit_B (dynamic code vs the others)" do commit_b = :crypto.strong_rand_bytes(32) text_static = Wire.encode_client_pair_init(1) @@ -501,10 +707,31 @@ defmodule UniversalProxy.Sendspin.WireTest do Wire.decode_client_pair_init(text_dynamic) end - test "server/pair-init round-trips the 32-byte nonce_A" do + test "server/pair-init round-trips the 32-byte nonce_A, and omits it on a retry round" do nonce_a = :crypto.strong_rand_bytes(32) text = Wire.encode_server_pair_init(nonce_a) assert {:ok, %{nonce_a: ^nonce_a}} = Wire.decode_server_pair_init(text) + + retry = Wire.encode_server_pair_init(nil) + assert {:ok, "server/pair-init", %{}} = Wire.decode_envelope(retry) + assert {:ok, %{nonce_a: nil}} = Wire.decode_server_pair_init(retry) + end + + test "client/pair-pending and client/pair-retry golden payloads" do + assert Jason.decode!(Wire.encode_client_pair_pending(2, "Press the button")) == %{ + "type" => "client/pair-pending", + "payload" => %{"pairing_index" => 2, "message" => "Press the button"} + } + + assert Jason.decode!(Wire.encode_client_pair_pending(1)) == %{ + "type" => "client/pair-pending", + "payload" => %{"pairing_index" => 1} + } + + assert Jason.decode!(Wire.encode_client_pair_retry()) == %{ + "type" => "client/pair-retry", + "payload" => %{} + } end test "pair/abort round-trips every documented reason" do @@ -512,8 +739,7 @@ defmodule UniversalProxy.Sendspin.WireTest do :attempt_timeout, :concurrent_attempt, :method_not_supported, - :pin_length_unacceptable, - :pin_mismatch, + :pairing_code_mismatch, :user_cancelled ] do text = Wire.encode_pair_abort(reason) @@ -546,17 +772,22 @@ defmodule UniversalProxy.Sendspin.WireTest do ta |> Wire.encode_server_pair_confirm() |> Wire.decode_server_pair_confirm() end - test "client/pair-confirm round-trips with and without nonce_B (static vs dynamic PIN)" do + test "client/pair-confirm round-trips with and without wrapped_nonce_B (static vs dynamic)" do tb = :crypto.strong_rand_bytes(64) - nonce_b = :crypto.strong_rand_bytes(32) + wrapped = :crypto.strong_rand_bytes(48) text_static = Wire.encode_client_pair_confirm(tb) - assert {:ok, %{client_kc: ^tb, nonce_b: nil}} = Wire.decode_client_pair_confirm(text_static) - text_dynamic = Wire.encode_client_pair_confirm(tb, nonce_b) + assert {:ok, %{client_kc: ^tb, wrapped_nonce_b: nil}} = + Wire.decode_client_pair_confirm(text_static) - assert {:ok, %{client_kc: ^tb, nonce_b: ^nonce_b}} = + text_dynamic = Wire.encode_client_pair_confirm(tb, wrapped) + + assert {:ok, %{client_kc: ^tb, wrapped_nonce_b: ^wrapped}} = Wire.decode_client_pair_confirm(text_dynamic) + + {:ok, "client/pair-confirm", payload} = Wire.decode_envelope(text_dynamic) + assert Map.keys(payload) |> Enum.sort() == ["client_kc", "wrapped_nonce_B"] end test "client/pair-finalize round-trips long_term_psk (Pairing PSK flow)" do @@ -565,7 +796,7 @@ defmodule UniversalProxy.Sendspin.WireTest do assert {:ok, %{long_term_psk: ^psk}} = Wire.decode_client_pair_finalize(text) end - test "client/pair-finalize round-trips wrapped_psk (PIN flows)" do + test "client/pair-finalize round-trips wrapped_psk (pairing-code flows)" do wrapped = :crypto.strong_rand_bytes(48) assert {:ok, text} = Wire.encode_client_pair_finalize(%{wrapped_psk: wrapped}) assert {:ok, %{wrapped_psk: ^wrapped}} = Wire.decode_client_pair_finalize(text) @@ -619,7 +850,7 @@ defmodule UniversalProxy.Sendspin.WireTest do test "a real Pairing.abort/2 pair/abort round-trips through Wire's typed codec" do state = %{ - method: :dynamic_pin, + method: :dynamic_pairing_code, stage: :awaiting_server_init, handshake_hash: :crypto.strong_rand_bytes(32), pairing_index: 0, @@ -630,11 +861,11 @@ defmodule UniversalProxy.Sendspin.WireTest do state = struct(Pairing, state) - {:abort, :pin_mismatch, [{"pair/abort", payload}], _state} = - Pairing.abort(state, :pin_mismatch) + {:abort, :pairing_code_mismatch, [{"pair/abort", payload}], _state} = + Pairing.abort(state, :pairing_code_mismatch) text = Wire.encode_message("pair/abort", payload) - assert {:ok, %{reason: :pin_mismatch}} = Wire.decode_pair_abort(text) + assert {:ok, %{reason: :pairing_code_mismatch}} = Wire.decode_pair_abort(text) end test "decode_envelope's raw payload is exactly what Pairing.handle/3 expects" do @@ -646,14 +877,13 @@ defmodule UniversalProxy.Sendspin.WireTest do {:ok, "server/pair-init", payload} = Wire.decode_envelope(text) state = %{ - method: :dynamic_pin, + method: :dynamic_pairing_code, stage: :awaiting_server_init, handshake_hash: :crypto.strong_rand_bytes(32), pairing_index: 0, - sid: Pairing.sid(:crypto.strong_rand_bytes(32), 0), + sid: Pairing.sid(:crypto.strong_rand_bytes(32), 0, 1), suite: "25519_ChaChaPoly_SHA256", psk: :crypto.strong_rand_bytes(32), - pin_length: 6, nonce_b: :crypto.strong_rand_bytes(32) } diff --git a/test/universal_proxy_web/live/audio_live_test.exs b/test/universal_proxy_web/live/audio_live_test.exs index eca9b686..5d30ce23 100644 --- a/test/universal_proxy_web/live/audio_live_test.exs +++ b/test/universal_proxy_web/live/audio_live_test.exs @@ -966,6 +966,311 @@ defmodule UniversalProxyWeb.AudioLiveTest do end end + describe "input pairing token disclosure" do + @input_token "SP:0" <> String.duplicate("INPUTTOKEN", 10) + + defmodule InputPairingTokenFacade do + @moduledoc false + # Stands in for `UniversalProxy.Audio.Input` (AudioLive's + # `:audio_input_facade` seam); the result is set per test. + def pairing_token(key) do + send(Application.get_env(:universal_proxy, :input_token_test_pid), {:input_read, key}) + Application.get_env(:universal_proxy, :input_token_result) + end + + def unpair(key, which) do + send(Application.get_env(:universal_proxy, :input_token_test_pid), {:unpair, key, which}) + Application.get_env(:universal_proxy, :input_action_result, :ok) + end + + def regenerate_pairing_token(key) do + send(Application.get_env(:universal_proxy, :input_token_test_pid), {:regenerate, key}) + Application.get_env(:universal_proxy, :input_action_result, :ok) + end + end + + setup do + Application.put_env(:universal_proxy, :audio_input_facade, InputPairingTokenFacade) + Application.put_env(:universal_proxy, :input_token_test_pid, self()) + Application.put_env(:universal_proxy, :input_token_result, {:ok, @input_token}) + + on_exit(fn -> + Application.delete_env(:universal_proxy, :audio_input_facade) + Application.delete_env(:universal_proxy, :input_token_test_pid) + Application.delete_env(:universal_proxy, :input_token_result) + Application.delete_env(:universal_proxy, :input_action_result) + end) + end + + # Mount with one input and open its "Pairing" disclosure. + defp mount_with_input(conn, input \\ sample_input()) do + {:ok, view, _html} = live(conn, "/audio") + Phoenix.PubSub.broadcast(@pubsub, "sendspin:input_added", {:sendspin_input_added, input}) + id = encode(@input_key) + html = render_hook(view, "toggle_input_more", %{"id" => id}) + {view, id, html} + end + + @server_a "AAAAAAAAAAserverA" <> String.duplicate("a", 26) + @server_b "BBBBBBBBBBserverB" <> String.duplicate("b", 26) + + defp paired_input do + sample_input(%{ + paired: true, + pairings: [ + %{server_id: @server_a, paired_at: ~U[2026-11-01 10:00:00Z]}, + %{server_id: @server_b, paired_at: ~U[2026-10-01 10:00:00Z]} + ] + }) + end + + test "the disclosure starts closed and closing it drops a revealed token", %{conn: conn} do + {:ok, view, _html} = live(conn, "/audio") + + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:input_added", + {:sendspin_input_added, sample_input()} + ) + + html = render(view) + assert html =~ "Pairing · not paired" + refute has_element?(view, "button[phx-click=show_input_pairing_token]") + + id = encode(@input_key) + render_hook(view, "toggle_input_more", %{"id" => id}) + view |> element("button[phx-click=show_input_pairing_token]") |> render_click() + assert render(view) =~ @input_token + + html = render_hook(view, "toggle_input_more", %{"id" => id}) + refute html =~ @input_token + html = render_hook(view, "toggle_input_more", %{"id" => id}) + refute html =~ @input_token + end + + test "a revealed token hides itself after the reveal window", %{conn: conn} do + Application.put_env(:universal_proxy, :input_token_reveal_ms, 50) + on_exit(fn -> Application.delete_env(:universal_proxy, :input_token_reveal_ms) end) + {view, id, _html} = mount_with_input(conn) + + view |> element("button[phx-click=show_input_pairing_token]") |> render_click() + assert has_element?(view, "#input-pairing-token-#{id}") + Process.sleep(120) + refute render(view) =~ @input_token + refute has_element?(view, "#input-pairing-token-#{id}") + end + + test "lists paired servers by short id and date", %{conn: conn} do + {view, _id, html} = mount_with_input(conn, paired_input()) + + assert html =~ "Pairing · paired with 2 servers" + assert html =~ String.slice(@server_a, 0, 10) <> "…" + assert html =~ "2026-11-01" + assert has_element?(view, "button[phx-click=arm_input_action][phx-value-action=unpair_all]") + end + + test "Unpair is armed by the first click and runs on Confirm", %{conn: conn} do + {view, id, _html} = mount_with_input(conn, paired_input()) + + view + |> element(~s(button[phx-value-action=unpair][phx-value-server="#{@server_b}"])) + |> render_click() + + refute_received {:unpair, _, _} + assert has_element?(view, "#input-armed-#{id}", "Unpair " <> String.slice(@server_b, 0, 10)) + + view |> element("button[phx-click=confirm_input_action]") |> render_click() + assert_received {:unpair, @input_key, @server_b} + refute has_element?(view, "#input-armed-#{id}") + end + + test "Cancel disarms without acting; Unpair all unpairs everything", %{conn: conn} do + {view, _id, _html} = mount_with_input(conn, paired_input()) + + view |> element("button[phx-value-action=unpair_all]") |> render_click() + view |> element("button[phx-click=disarm_input_action]") |> render_click() + refute_received {:unpair, _, _} + + view |> element("button[phx-value-action=unpair_all]") |> render_click() + view |> element("button[phx-click=confirm_input_action]") |> render_click() + assert_received {:unpair, @input_key, :all} + end + + test "Regenerate asks first, then mints a new token and hides the old one", %{conn: conn} do + {view, id, _html} = mount_with_input(conn) + view |> element("button[phx-click=show_input_pairing_token]") |> render_click() + + view |> element("#regenerate-input-token-#{id}") |> render_click() + assert has_element?(view, "#input-armed-#{id}", "Regenerate the pairing token?") + refute_received {:regenerate, _} + + html = view |> element("button[phx-click=confirm_input_action]") |> render_click() + assert_received {:regenerate, @input_key} + refute html =~ @input_token + end + + test "a failed Unpair keeps the prompt armed and shows an error; a retry can succeed", + %{conn: conn} do + Application.put_env(:universal_proxy, :input_action_result, {:error, :unavailable}) + {view, id, _html} = mount_with_input(conn, paired_input()) + + view |> element("button[phx-value-action=unpair_all]") |> render_click() + view |> element("button[phx-click=confirm_input_action]") |> render_click() + + assert_received {:unpair, @input_key, :all} + assert has_element?(view, "#input-armed-#{id}") + assert has_element?(view, "#input-action-error-#{id}") + + Application.put_env(:universal_proxy, :input_action_result, :ok) + view |> element("button[phx-click=confirm_input_action]") |> render_click() + refute has_element?(view, "#input-armed-#{id}") + refute has_element?(view, "#input-action-error-#{id}") + end + + test "a failed Unpair of one server keeps the prompt armed and shows an error", + %{conn: conn} do + Application.put_env(:universal_proxy, :input_action_result, {:error, :unavailable}) + {view, id, _html} = mount_with_input(conn, paired_input()) + + view + |> element(~s(button[phx-value-action=unpair][phx-value-server="#{@server_a}"])) + |> render_click() + + view |> element("button[phx-click=confirm_input_action]") |> render_click() + assert_received {:unpair, @input_key, @server_a} + assert has_element?(view, "#input-armed-#{id}", "Unpair " <> String.slice(@server_a, 0, 10)) + assert has_element?(view, "#input-action-error-#{id}") + + view |> element("button[phx-click=disarm_input_action]") |> render_click() + refute has_element?(view, "#input-armed-#{id}") + end + + test "a failed Regenerate keeps the prompt and the displayed token", %{conn: conn} do + Application.put_env(:universal_proxy, :input_action_result, {:error, :unavailable}) + {view, id, _html} = mount_with_input(conn) + view |> element("button[phx-click=show_input_pairing_token]") |> render_click() + + view |> element("#regenerate-input-token-#{id}") |> render_click() + html = view |> element("button[phx-click=confirm_input_action]") |> render_click() + + assert_received {:regenerate, @input_key} + assert html =~ @input_token + assert has_element?(view, "#input-armed-#{id}", "Regenerate the pairing token?") + assert has_element?(view, "#input-action-error-#{id}") + end + + test "a pairings update from input_state re-renders the list", %{conn: conn} do + {view, _id, _html} = mount_with_input(conn, paired_input()) + + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:input_state", + {:input_state, @input_key, + %{ + status: :waiting, + connection: :disconnected, + pin: nil, + port: 9_928, + last_error: nil, + pairings: [%{server_id: @server_a, paired_at: ~U[2026-11-01 10:00:00Z]}] + }} + ) + + html = render(view) + assert html =~ "paired with 1 server" + refute html =~ String.slice(@server_b, 0, 10) + end + + test "an unpair without a server arms nothing and doesn't crash", %{conn: conn} do + {view, id, _html} = mount_with_input(conn) + render_hook(view, "arm_input_action", %{"id" => id, "action" => "unpair"}) + render_hook(view, "confirm_input_action", %{"id" => id}) + assert render(view) =~ "Pairing token" + refute_received {:unpair, _, _} + end + + test "actions for an unknown input do nothing", %{conn: conn} do + {view, _id, _html} = mount_with_input(conn) + render_hook(view, "arm_input_action", %{"id" => "garbage", "action" => "unpair_all"}) + render_hook(view, "confirm_input_action", %{"id" => "garbage"}) + refute_received {:unpair, _, _} + end + + test "is not in the rendered HTML until Show, and gone again after Hide", %{conn: conn} do + {view, id, html} = mount_with_input(conn) + + assert html =~ "Pairing token" + assert html =~ "to pair this input" + refute html =~ @input_token + refute has_element?(view, "#input-pairing-token-#{id}") + # Nothing is read until the operator asks. + refute_received {:input_read, _} + + html = view |> element("button[phx-click=show_input_pairing_token]") |> render_click() + assert_received {:input_read, @input_key} + assert html =~ @input_token + assert has_element?(view, "#input-pairing-token-#{id}", @input_token) + + html = view |> element("button[phx-click=hide_input_pairing_token]") |> render_click() + refute html =~ @input_token + refute has_element?(view, "#input-pairing-token-#{id}") + end + + test "Copy is offered only while revealed, client-side with the select fallback", + %{conn: conn} do + {view, id, _html} = mount_with_input(conn) + copy = "#copy-input-pairing-token-#{id}" + + refute has_element?(view, copy) + view |> element("button[phx-click=show_input_pairing_token]") |> render_click() + + assert has_element?( + view, + ~s(#{copy}[phx-hook="CopyToClipboard"][data-clipboard="#{@input_token}"]) <> + ~s([data-select-target="input-pairing-token-#{id}"]) + ) + + view |> element("button[phx-click=hide_input_pairing_token]") |> render_click() + refute has_element?(view, copy) + end + + test "a store failure shows Unavailable and no token", %{conn: conn} do + Application.put_env(:universal_proxy, :input_token_result, {:error, :unavailable}) + {view, id, _html} = mount_with_input(conn) + + html = view |> element("button[phx-click=show_input_pairing_token]") |> render_click() + assert html =~ "Unavailable" + refute has_element?(view, "#copy-input-pairing-token-#{id}") + end + + test "is dropped when the input goes away, and ignores events for unknown inputs", + %{conn: conn} do + {view, id, _html} = mount_with_input(conn) + render_hook(view, "show_input_pairing_token", %{"id" => id}) + assert render(view) =~ @input_token + assert_received {:input_read, @input_key} + + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:input_removed", + {:sendspin_input_removed, %{key: @input_key}} + ) + + refute render(view) =~ @input_token + + render_hook(view, "show_input_pairing_token", %{"id" => id}) + render_hook(view, "show_input_pairing_token", %{"id" => "garbage"}) + refute_received {:input_read, _} + refute render(view) =~ @input_token + end + + test "an output's token controls never read an input's token", %{conn: conn} do + {view, _id, _html} = mount_with_input(conn) + refute has_element?(view, "button[phx-click=show_pairing_token]") + refute_received {:input_read, _} + end + end + describe "Home Assistant pairing toggle" do @usb_key {"USB Audio", 0x1D6B, 0x0104}