diff --git a/assets/js/app.js b/assets/js/app.js index 4178a8d..ea4fb0e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -21,6 +21,42 @@ function applyTheme() { applyThemeIcon() } +// Write `text` to the clipboard; resolves true only once the write is +// confirmed. Browsers expose `navigator.clipboard` only on secure +// origins, and the device UI is plain HTTP, so fall back to +// `execCommand("copy")` on a temporary textarea. Call it synchronously +// from the click handler: the fallback needs the user gesture. +function copyText(text) { + if (window.isSecureContext && navigator.clipboard) { + return navigator.clipboard.writeText(text).then(() => true, err => { + console.warn("Clipboard copy failed:", err) + return false + }) + } + + const previousFocus = document.activeElement + const textarea = document.createElement("textarea") + textarea.value = text + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.top = "0" + textarea.style.left = "-9999px" + textarea.style.opacity = "0" + document.body.appendChild(textarea) + textarea.select() + + let ok = false + try { + ok = document.execCommand("copy") + } catch (err) { + console.warn("Clipboard copy failed:", err) + } + + textarea.remove() + if (previousFocus && typeof previousFocus.focus === "function") previousFocus.focus() + return Promise.resolve(ok) +} + const Hooks = { ThemeToggle: { mounted() { @@ -51,19 +87,44 @@ const Hooks = { // Copy the element's `data-clipboard` attr to the system clipboard on // click. Used by the Audio tab's per-card "more" disclosure for the - // client_id. Briefly mutates the button's text to give the user a - // visible "Copied" cue without round-tripping through the server. + // client_id and the revealed pairing token. Briefly mutates the + // button's text to "Copied" only once the write is confirmed; on + // failure it shows a failure cue and, when `data-select-target` names + // an element id, selects that element's text for a manual Ctrl/Cmd+C. CopyToClipboard: { mounted() { this.el.addEventListener("click", () => { const text = this.el.dataset.clipboard - if (!text || !navigator.clipboard) return - navigator.clipboard.writeText(text).then(() => { - const original = this.el.textContent - this.el.textContent = "Copied" - setTimeout(() => { this.el.textContent = original }, 1200) - }).catch(err => console.warn("Clipboard copy failed:", err)) + if (!text) return + copyText(text).then(ok => { + if (ok) { + this.flash("Copied", 1200) + } else { + this.selectTarget() + this.flash("Copy failed — select manually", 2500) + } + }) }) + }, + destroyed() { if (this.timer) clearTimeout(this.timer) }, + flash(label, ms) { + if (this.timer) clearTimeout(this.timer) + else this.original = this.el.textContent + this.el.textContent = label + this.timer = setTimeout(() => { + this.el.textContent = this.original + this.timer = null + }, ms) + }, + selectTarget() { + const id = this.el.dataset.selectTarget + const target = id && document.getElementById(id) + if (!target) return + const range = document.createRange() + range.selectNodeContents(target) + const selection = window.getSelection() + selection.removeAllRanges() + selection.addRange(range) } }, diff --git a/c_src/sendspin_player/README.md b/c_src/sendspin_player/README.md index eded48d..92ba95c 100644 --- a/c_src/sendspin_player/README.md +++ b/c_src/sendspin_player/README.md @@ -69,6 +69,8 @@ Differences: keeps the library's identity keypair and pairing state under `--state-dir`, and unpaired access is enabled (see *Identity and admission* below) +- The Pairing PSK token is written to `/pairing_token` (never + to stdout/stderr) for the web UI to show on demand ## sendspin-cpp pin @@ -157,7 +159,7 @@ The flags must match what `UniversalProxy.Audio.Player` passes — see the | Flag | Required | Description | | -------------------------- | -------- | -------------------------------------------- | | `--name STR` | yes | Friendly display name | -| `--state-dir PATH` | yes | Per-output directory for the persisted identity keypair and pairing state, created `0700`. The Sendspin `client_id` is derived from the keypair and reported on `listening` | +| `--state-dir PATH` | yes | Per-output directory for the persisted identity keypair and pairing state, created `0700`. The Sendspin `client_id` is derived from the keypair and reported on `listening`; the pairing token is written to `pairing_token` in it | | `--server URL` | no | Outbound Sendspin server WebSocket URL | | `--product STR` | no | Product id in `device_info` (node name, e.g. `universal-proxy-07507f`); vendor is fixed to `Universal Proxy`. Default `sendspin_player` | | `--mac STR` | no | MAC for `device_info`, lowercase colon-separated. Pre-normalized by the Elixir side; empty/absent = library auto-detects from the active interface | @@ -186,6 +188,7 @@ changes, and on errors. {"event":"volume","value":80} {"event":"mute","value":false} {"event":"error","kind":"alsa_configure","msg":"..."} +{"event":"error","kind":"pairing_token","msg":"..."} // non-fatal; token itself never on stdout {"event":"shutdown"} ``` @@ -239,7 +242,20 @@ player-only client that offers unpaired access without any setup step. The library always advertises the `pairing_psk` method in `client/hello`. It backs it with a Pairing PSK it generates on first start and stores under the `pairing_psk` key, so `--state-dir` holds pairing secrets (the -PSK and any pairing records) as well as the identity key. We do not offer +PSK and any pairing records) as well as the identity key. + +After a successful `start()` the binary writes `client.pairing_token()` +(the 107-character `SP:0…` string, RFC 4648 base32 with `2` mapped to +`9`) to `/pairing_token` through the same atomic `0600` write +path, rewriting it on every start so it follows the stored PSK. A server +that offers a "Pairing token" option (Music Assistant 2.10.7+) takes this +string to pair over the PSK instead of relying on unpaired access. The +token is a long-lived pairing secret, so it is never printed: the BEAM +side reads the file only when a user asks to see or copy it. If the token +is unavailable or the write fails, the binary emits a non-fatal +`pairing_token` error event (without the token) and keeps running. + +We do not offer the pairing-code methods (dynamic or static code); those would need UI to show the code (`on_display_pairing_code`) and to confirm the pairing window. diff --git a/c_src/sendspin_player/src/file_persistence.cpp b/c_src/sendspin_player/src/file_persistence.cpp index b511bd5..0ae4c48 100644 --- a/c_src/sendspin_player/src/file_persistence.cpp +++ b/c_src/sendspin_player/src/file_persistence.cpp @@ -159,6 +159,10 @@ bool FilePersistence::save_blob(const std::string& key, const uint8_t* data, siz return !keypair_save_failed_; } +bool FilePersistence::write_file(const std::string& name, const std::string& data) { + return write_blob(name, reinterpret_cast(data.data()), data.size()); +} + bool FilePersistence::write_blob(const std::string& key, const uint8_t* data, size_t len) { if (!valid_key(key) || dir_fd_ < 0) { std::fprintf(stderr, "sendspin_player: refusing persistence key '%s'\n", key.c_str()); diff --git a/c_src/sendspin_player/src/file_persistence.h b/c_src/sendspin_player/src/file_persistence.h index 095b5f2..fe13e86 100644 --- a/c_src/sendspin_player/src/file_persistence.h +++ b/c_src/sendspin_player/src/file_persistence.h @@ -26,6 +26,11 @@ // of the wrong length as absent, so this stays a pure byte store and never // interprets what it holds. // +// Besides the library blobs, main.cpp stores the pairing token (the +// "SP:..." string a Sendspin server is given to pair via the Pairing PSK) +// as `pairing_token` through `write_file()`, so the BEAM side can show it +// to the user on demand without it ever crossing stdout. +// // The output delay key is deliberately not stored. The BEAM side already // owns that value (persisted from the `static_delay` event and passed back // as `--initial-static-delay-ms`), and a stored blob would take precedence @@ -55,6 +60,12 @@ class FilePersistence : public sendspin::SendspinPersistenceProvider { /// reported here rather than silently replaced. bool keypair_save_failed() const { return keypair_save_failed_; } + /// Write a file of our own (not a library blob) into the state dir with + /// the same atomic, 0600, fsynced path. `name` must not be one of the + /// library's `persistence_keys` (all at most 12 characters), or the + /// library would read it back as its own state. + bool write_file(const std::string& name, const std::string& data); + private: /// Library keys are short lowercase identifiers (`keypair`, `rec_0`, /// …). Anything else is refused rather than turned into a path. diff --git a/c_src/sendspin_player/src/main.cpp b/c_src/sendspin_player/src/main.cpp index 246edf7..1898c3a 100644 --- a/c_src/sendspin_player/src/main.cpp +++ b/c_src/sendspin_player/src/main.cpp @@ -15,6 +15,8 @@ // non-default WebSocket port so multiple players can coexist // • Persists the library's identity and pairing state under --state-dir // (file_persistence.h) and reports the derived client_id on stdout +// • Writes the Pairing PSK token to /pairing_token (0600) for +// the BEAM side to show on demand; it never goes to stdout or stderr // // Licensed under the Apache License, Version 2.0. @@ -39,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -798,8 +801,9 @@ int main(int argc, char* argv[]) { // Admit servers that have not paired with us. Music Assistant approves // a player-only client that offers this without any setup step, which // keeps outputs zero-config. The library always offers `pairing_psk` - // (its generated PSK is persisted like the keypair), but we have no UI - // for the pairing-code methods or for handing out the PSK token. The + // (its generated PSK is persisted like the keypair); its token is + // written to the state dir below for the web UI to hand out. There is + // no UI for the pairing-code methods. The // library never persists this flag, so it is set on every launch, // before start(), so the first client/hello carries it. client.set_unpaired_access_enabled(true); @@ -842,6 +846,21 @@ int main(int argc, char* argv[]) { return 1; } + // The token is a long-lived pairing secret: it goes only to a 0600 file + // in the 0700 state dir, never to stdout/stderr. Rewritten on every + // start so it follows the stored PSK. Failure is not fatal: unpaired + // access still works, the UI just has no token to show. + { + std::optional token = client.pairing_token(); + if (!token) { + emit_json("{\"event\":\"error\",\"kind\":\"pairing_token\"," + "\"msg\":\"pairing token unavailable\"}"); + } else if (!persistence.write_file("pairing_token", *token)) { + emit_json("{\"event\":\"error\",\"kind\":\"pairing_token\"," + "\"msg\":\"failed to write the pairing token\"}"); + } + } + { auto v = static_cast(opts.initial_volume); audio_sink.set_volume(v); diff --git a/lib/universal_proxy/audio.ex b/lib/universal_proxy/audio.ex index 5ecfbe1..5b12784 100644 --- a/lib/universal_proxy/audio.ex +++ b/lib/universal_proxy/audio.ex @@ -13,7 +13,7 @@ defmodule UniversalProxy.Audio do `{card_name, nil, nil}` (e.g. `{"bcm2835 Headphones", nil, nil}`). """ - alias UniversalProxy.Audio.{Server, Store} + alias UniversalProxy.Audio.{PairingToken, Server, Store} @typedoc """ Composite identifier for an audio output: @@ -55,4 +55,24 @@ defmodule UniversalProxy.Audio do def set_enabled({_, _, _} = key, enabled?) when is_boolean(enabled?) do Server.set_enabled(key, enabled?) end + + @doc """ + Read an output's Sendspin pairing token (the `"SP:..."` string a + Sendspin server such as Music Assistant takes to pair with the output). + + Read from the player's state dir on every call and never cached or + broadcast: it is a long-lived pairing secret. `{:error, :not_ready}` + until the output's player has started once; `{:error, :unavailable}` + when the output isn't present, the audio subsystem is down, or the + file is unreadable or malformed. + """ + @spec pairing_token(key()) :: {:ok, String.t()} | {:error, :not_ready | :unavailable} + def pairing_token({_, _, _} = key) do + case Server.state_dir(key) do + {:ok, dir} -> PairingToken.read(dir) + :error -> {:error, :unavailable} + end + catch + :exit, _ -> {:error, :unavailable} + end end diff --git a/lib/universal_proxy/audio/pairing_token.ex b/lib/universal_proxy/audio/pairing_token.ex new file mode 100644 index 0000000..1aa0733 --- /dev/null +++ b/lib/universal_proxy/audio/pairing_token.ex @@ -0,0 +1,70 @@ +defmodule UniversalProxy.Audio.PairingToken do + @moduledoc """ + Reads a player's Sendspin pairing token from its state dir. + + `sendspin_player` writes the token (`"SP:"` followed by RFC 4648 base32, + 107 characters for the current version) to `pairing_token` in its + `--state-dir` on every start. It is a long-lived pairing secret: a + server holding it can pair with the output over the Pairing PSK. It is + read only on demand, never cached here, and never included in a return + value other than `{:ok, token}`, so it cannot leak through a logged + error tuple. + """ + + @filename "pairing_token" + + # The spec caps a token at 127 bytes. Reads stop just past that (room for + # a trailing newline plus one byte), so a huge file is never loaded; a + # read that fills the whole buffer means the file is oversized and is + # rejected before trimming, so trailing content past the limit cannot + # hide behind whitespace. + @max_bytes 127 + @read_bytes @max_bytes + 2 + + @type error :: :not_ready | :unavailable + + @doc "The token file's name inside a player's state dir." + @spec filename() :: String.t() + def filename, do: @filename + + @doc """ + Read and validate the token in `state_dir`. + + `{:error, :not_ready}` when the file does not exist yet (the player has + never started); `{:error, :unavailable}` when it cannot be read or does + not hold a well-formed token. + """ + @spec read(Path.t()) :: {:ok, String.t()} | {:error, error()} + def read(state_dir) when is_binary(state_dir) do + path = Path.join(state_dir, @filename) + + case File.open(path, [:read, :binary], &IO.binread(&1, @read_bytes)) do + {:ok, data} when byte_size(data) == @read_bytes -> {:error, :unavailable} + {:ok, data} when is_binary(data) -> validate(data) + {:ok, _eof_or_error} -> {:error, :unavailable} + {:error, :enoent} -> {:error, :not_ready} + {:error, _reason} -> {:error, :unavailable} + end + end + + @doc """ + Validate raw file contents as a token: surrounding whitespace is + trimmed, and the result must start with `"SP:"`, be at most 127 bytes, + and hold only printable ASCII. + """ + @spec validate(binary()) :: {:ok, String.t()} | {:error, :unavailable} + def validate(data) when is_binary(data) do + token = String.trim(data) + + if byte_size(token) in 4..@max_bytes//1 and match?("SP:" <> _, token) and + printable_ascii?(token) do + {:ok, token} + else + {:error, :unavailable} + end + end + + defp printable_ascii?(<<>>), do: true + defp printable_ascii?(<>) when c in 0x21..0x7E, do: printable_ascii?(rest) + defp printable_ascii?(_), do: false +end diff --git a/lib/universal_proxy/audio/player.ex b/lib/universal_proxy/audio/player.ex index 6c1e666..38d4956 100644 --- a/lib/universal_proxy/audio/player.ex +++ b/lib/universal_proxy/audio/player.ex @@ -48,6 +48,7 @@ defmodule UniversalProxy.Audio.Player do {"event":"mute","value":false} {"event":"static_delay","value":200} {"event":"error","kind":"alsa_configure","msg":"..."} + {"event":"error","kind":"pairing_token","msg":"..."} {"event":"shutdown"} Commands (BEAM → binary, written to stdin): @@ -56,6 +57,11 @@ defmodule UniversalProxy.Audio.Player do {"cmd":"set_muted","value":true} {"cmd":"shutdown"} + The binary also writes its Sendspin pairing token to `pairing_token` + in the state dir (see `state_dir/1`); that secret never appears on + stdout, so it never reaches PubSub or the logs. + `UniversalProxy.Audio.pairing_token/1` reads it on demand. + Each event is broadcast on PubSub topic `"sendspin:state"` as `{:sendspin_state, key, event_map}` so `LiveView` (Phase 4) and the `Audio.Server` cache can stay in sync without polling. @@ -219,7 +225,7 @@ defmodule UniversalProxy.Audio.Player do mdns_module = Keyword.get(opts, :mdns_module, MdnsLite) device_name_fun = Keyword.get(opts, :device_name_fun, &default_device_name/0) mac_address_fun = Keyword.get(opts, :mac_address_fun, &default_mac_address/0) - state_dir = Keyword.get(opts, :state_dir) || default_state_dir(config) + state_dir = Keyword.get(opts, :state_dir) || state_dir(config) state = %__MODULE__{ key: key, @@ -1023,14 +1029,20 @@ defmodule UniversalProxy.Audio.Player do defp binary_or_nil(v) when is_binary(v) and v != "", do: v defp binary_or_nil(_), do: nil - # One directory per output for the binary's keypair and pairing state, - # on the writable data partition on Nerves and under `_build/` on the - # host, like `Audio.Store`'s DETS file. Named by the Store's per-output - # `client_id`, which is generated once and never changes, so the - # Sendspin identity derived from the keypair is stable across respawns - # and reboots. That value is always generated hex today; anything else - # (a corrupted DETS row) is hashed so it can never escape the base dir. - defp default_state_dir(cfg) do + @doc """ + The `--state-dir` a player for this output config uses (unless started + with an explicit `:state_dir`). + + One directory per output for the binary's keypair and pairing state, + on the writable data partition on Nerves and under `_build/` on the + host, like `Audio.Store`'s DETS file. Named by the Store's per-output + `client_id`, which is generated once and never changes, so the + Sendspin identity derived from the keypair is stable across respawns + and reboots. That value is always generated hex today; anything else + (a corrupted DETS row) is hashed so it can never escape the base dir. + """ + @spec state_dir(%{required(:client_id) => String.t(), optional(any()) => any()}) :: String.t() + def state_dir(cfg) do base = if File.dir?("/data") do "/data/sendspin_players" diff --git a/lib/universal_proxy/audio/server.ex b/lib/universal_proxy/audio/server.ex index fae2f38..49f2989 100644 --- a/lib/universal_proxy/audio/server.ex +++ b/lib/universal_proxy/audio/server.ex @@ -137,6 +137,17 @@ defmodule UniversalProxy.Audio.Server do GenServer.call(server, {:set_enabled, key, enabled?}) end + @doc """ + The `--state-dir` of a currently present output's player (see + `UniversalProxy.Audio.Player.state_dir/1`). Returns `:error` if the + output isn't tracked. The dir is returned whether or not the player is + running: what it holds persists across respawns. + """ + @spec state_dir(GenServer.server(), key()) :: {:ok, String.t()} | :error + def state_dir(server \\ __MODULE__, {slot_sub, _vid, _pid} = key) when is_binary(slot_sub) do + GenServer.call(server, {:state_dir, key}) + end + @doc """ Force a hotplug poll synchronously. Tests use this to avoid waiting on the periodic timer. @@ -283,6 +294,16 @@ defmodule UniversalProxy.Audio.Server do end end + def handle_call({:state_dir, key}, _from, state) do + case Map.fetch(state.outputs, key) do + {:ok, %{client_id: id} = output} when is_binary(id) -> + {:reply, {:ok, Player.state_dir(output)}, state} + + _ -> + {:reply, :error, state} + end + end + def handle_call({:update_config, key, params}, _from, state) do with {:ok, existing} <- Map.fetch(state.outputs, key), update = sanitize_update(params), diff --git a/lib/universal_proxy_web/live/audio_live.ex b/lib/universal_proxy_web/live/audio_live.ex index 0ac4cdc..5b57d3b 100644 --- a/lib/universal_proxy_web/live/audio_live.ex +++ b/lib/universal_proxy_web/live/audio_live.ex @@ -46,6 +46,17 @@ defmodule UniversalProxyWeb.AudioLive do use UniversalProxyWeb, :live_view + defmodule RevealedToken do + @moduledoc false + # A pairing token the user chose to reveal. Wrapped so a LiveView + # crash report (which inspects the socket assigns) cannot print it. + @derive {Inspect, except: [:token]} + @enforce_keys [:token] + defstruct [:token] + + @type t :: %__MODULE__{token: String.t()} + end + require Logger import UniversalProxyWeb.Components.UI @@ -117,6 +128,11 @@ defmodule UniversalProxyWeb.AudioLive do # index + client_id). Tracking as a MapSet keyed on the same # encoded ids the cards use. |> assign(:more_open, MapSet.new()) + # Per-card Sendspin pairing token disclosure, keyed by the encoded + # card id: a `RevealedToken` while shown, or the reason it could not + # be read. Absent means hidden. The token is read on demand only and + # dropped again on Hide / disclosure close / output removal. + |> assign(:pairing_tokens, %{}) # Audio inputs (capture cards), keyed by the same base64-encoded # `{slot_sub, vid, pid}` id the outputs map above uses. |> assign(:inputs, build_inputs_map(inputs))} @@ -220,13 +236,32 @@ defmodule UniversalProxyWeb.AudioLive do def handle_event("toggle_more", %{"id" => id}, socket) do {:noreply, - update(socket, :more_open, fn open -> + socket + |> update(:more_open, fn open -> if MapSet.member?(open, id) do MapSet.delete(open, id) else MapSet.put(open, id) end - end)} + end) + |> update(:pairing_tokens, &Map.delete(&1, id))} + end + + def handle_event("show_pairing_token", %{"id" => id}, socket) do + case read_pairing_token(socket, id) do + {:ok, token} -> + {:noreply, put_pairing_token(socket, id, %RevealedToken{token: token})} + + {:error, reason} when reason in [:not_ready, :unavailable] -> + {:noreply, put_pairing_token(socket, id, reason)} + + {:error, :not_found} -> + {:noreply, socket} + end + end + + def handle_event("hide_pairing_token", %{"id" => id}, socket) do + {:noreply, update(socket, :pairing_tokens, &Map.delete(&1, id))} end # Operator consent gesture for pairing a capture card. Opens the source's @@ -268,6 +303,7 @@ defmodule UniversalProxyWeb.AudioLive do socket |> update(:outputs, &Map.delete(&1, id)) |> update(:more_open, &MapSet.delete(&1, id)) + |> update(:pairing_tokens, &Map.delete(&1, id)) |> maybe_start_reconnecting(key, removed) |> assign(:bt_meta, build_bt_meta()) @@ -359,6 +395,7 @@ defmodule UniversalProxyWeb.AudioLive do hci={get_in(@bt_meta, [output.bt_mac, :hci])} battery={get_in(@bt_meta, [output.bt_mac, :battery])} more_open?={MapSet.member?(@more_open, id)} + pairing={Map.get(@pairing_tokens, id)} /> <.reconnecting_card :for={{mac, info} <- active_reconnecting(@reconnecting, @outputs)} name={info.name} mac={mac} /> @@ -581,6 +618,7 @@ defmodule UniversalProxyWeb.AudioLive do attr(:hci, :string, default: nil) attr(:battery, :integer, default: nil) attr(:more_open?, :boolean, required: true) + attr(:pairing, :any, default: nil) # Single player card. Layout is a vertical flex with three logical # blocks: @@ -734,7 +772,8 @@ defmodule UniversalProxyWeb.AudioLive do - <%!-- Disclosure: card index + client id (mono) + copy --%> + <%!-- Disclosure: card index, client id and pairing token, each + with a copy action --%>
Pairing token + + {@pairing.token} + + + — {pairing_token_hint(@pairing)} + + •••••••• +
+ + + <%!-- Copy is offered only while revealed, so the token is in the + DOM only after Show; the hook copies client-side and falls + back to selecting the token where the clipboard is blocked + (non-secure origins). --%> + +
+ + Paste into Music Assistant's Sendspin "Pairing token" option to pair this output. +
<%!-- Last-error footnote (only when present) --%> @@ -1179,6 +1268,28 @@ defmodule UniversalProxyWeb.AudioLive do |> Enum.sort_by(fn {_mac, %{name: name}} -> name end) end + defp pairing_token_hint(:not_ready), do: "Available once the output has started" + defp pairing_token_hint(:unavailable), do: "Unavailable" + + # Only for a card this view still shows: a stale event for a removed + # output reads nothing. + defp read_pairing_token(socket, id) do + with {:ok, _output} <- fetch_output(socket, id), + {:ok, key} <- decode_key(id) do + audio_facade().pairing_token(key) + else + _ -> {:error, :not_found} + end + end + + defp put_pairing_token(socket, id, value) do + update(socket, :pairing_tokens, &Map.put(&1, id, value)) + end + + # 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) + defp fetch_output(socket, id) do case Map.fetch(socket.assigns.outputs, id) do {:ok, _} = ok -> ok diff --git a/test/sendspin_player_contract_test.exs b/test/sendspin_player_contract_test.exs index 4c94478..20b16f1 100644 --- a/test/sendspin_player_contract_test.exs +++ b/test/sendspin_player_contract_test.exs @@ -27,6 +27,11 @@ defmodule SendspinPlayerContractTest do # the upstream default (8928). @ws_port 18_928 + # pairing.md "Pairing Token", version 0: "SP:" + version + RFC 4648 + # base32 of key || PSK (64 bytes → 103 chars unpadded), with `2` mapped + # to `9`. + @pairing_token_re ~r/\ASP:0[A-Z3-79]{103}\z/ + # chmod 000 does not stop root from reading a file. @running_as_root (case System.cmd("id", ["-u"]) do {"0\n", 0} -> true @@ -168,6 +173,65 @@ defmodule SendspinPlayerContractTest do assert assert_listening_event(again)["client_id"] == first["client_id"] end + test "writes the pairing token privately under --state-dir", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "pairing_token") + assert %File.Stat{mode: mode} = File.stat!(path) + assert Bitwise.band(mode, 0o777) == 0o600 + + token = File.read!(path) + assert byte_size(token) == 107 + assert token =~ @pairing_token_re + assert UniversalProxy.Audio.PairingToken.read(tmp_dir) == {:ok, token} + + # Our own file, so the temp file of its atomic write must be gone. + refute File.exists?(Path.join(tmp_dir, ".pairing_token.tmp")) + end + + test "keeps the same pairing token across a restart", %{port: port, tmp_dir: tmp_dir} do + token = File.read!(Path.join(tmp_dir, "pairing_token")) + send_command(port, ~s({"cmd":"shutdown"})) + assert wait_for_exit(port) == 0 + + again = spawn_player(state_dir: tmp_dir, ws_port: @ws_port + 7) + + os_pid = + case Port.info(again, :os_pid) do + {:os_pid, pid} -> pid + _ -> nil + end + + on_exit(fn -> shutdown_os_process(os_pid) end) + + assert_started_event(again) + assert_volume_event(again) + assert File.read!(Path.join(tmp_dir, "pairing_token")) == token + end + + test "never prints the pairing token on stdout or stderr", %{tmp_dir: tmp_dir} do + # Verbose library logging on, stderr merged into the captured stream, + # across a full start → listening → shutdown cycle. + dir = Path.join(tmp_dir, "quiet") + + port = + spawn_player( + state_dir: dir, + ws_port: @ws_port + 8, + log_level: "verbose", + port_opts: [:stderr_to_stdout] + ) + + output = collect_until(port, ~s("event":"listening"), "") + send_command(port, ~s({"cmd":"shutdown"})) + {output, 0} = collect_until_exit(port, output) + + assert output =~ ~s("event":"listening") + token = File.read!(Path.join(dir, "pairing_token")) + assert token =~ @pairing_token_re + refute String.contains?(output, token) + # Nor the bare body, in case something logs it without the prefix. + refute String.contains?(output, binary_part(token, 4, 103)) + end + test "exits before `listening` when the keypair cannot be persisted", %{tmp_dir: tmp_dir} do # The state dir itself initialises fine and holds no keypair; a # directory at the provider's temp path then makes the library's first @@ -301,6 +365,8 @@ defmodule SendspinPlayerContractTest do defp spawn_player(opts) do ws_port = Keyword.get(opts, :ws_port, @ws_port) extra = Keyword.get(opts, :extra, []) + log_level = Keyword.get(opts, :log_level, "error") + port_opts = Keyword.get(opts, :port_opts, []) state_dir_args = case Keyword.fetch!(opts, :state_dir) do @@ -314,19 +380,20 @@ defmodule SendspinPlayerContractTest do :binary, :exit_status, {:line, 1024}, - args: - [ - "--name", - "contract-test", - "--mdns-port", - Integer.to_string(ws_port), - "--alsa-device", - "default", - "--initial-volume", - "42", - "--log-level", - "error" - ] ++ state_dir_args ++ extra + {:args, + [ + "--name", + "contract-test", + "--mdns-port", + Integer.to_string(ws_port), + "--alsa-device", + "default", + "--initial-volume", + "42", + "--log-level", + log_level + ] ++ state_dir_args ++ extra} + | port_opts ] ) end @@ -360,6 +427,30 @@ defmodule SendspinPlayerContractTest do end end + # Raw captured output (lines and partial lines alike) until `marker` + # shows up. + defp collect_until(port, marker, acc) do + if String.contains?(acc, marker) do + acc + else + receive do + {^port, {:data, {_eol_or_noeol, chunk}}} -> + collect_until(port, marker, acc <> chunk <> "\n") + after + 5_000 -> flunk("#{inspect(marker)} not seen within 5s") + end + end + end + + defp collect_until_exit(port, acc) do + receive do + {^port, {:data, {_eol_or_noeol, chunk}}} -> collect_until_exit(port, acc <> chunk <> "\n") + {^port, {:exit_status, status}} -> {acc, status} + after + 5_000 -> flunk("binary did not exit within 5s of shutdown command") + end + end + defp send_command(port, json) when is_binary(json) do Port.command(port, json <> "\n") end diff --git a/test/support/fake_sendspin_player.py b/test/support/fake_sendspin_player.py index 37dc43e..2399594 100755 --- a/test/support/fake_sendspin_player.py +++ b/test/support/fake_sendspin_player.py @@ -9,6 +9,7 @@ startup → {"event":"started","version":...,"port":...,"name":...,"alsa_device":...} {"event":"volume","value":} {"event":"static_delay","value":} + (writes /pairing_token, 0600; never printed) {"event":"listening","port":...,"client_id":...} stdin commands → stdout events {"cmd":"set_volume","value":N} → {"event":"volume","value":N} @@ -36,6 +37,26 @@ # shape. Tests assert on this constant. FAKE_CLIENT_ID = "ZmFrZS1zZW5kc3Bpbi1wbGF5ZXItaWQtMzJieXRlcyE" +# Same shape as the real binary's token ("SP:" + version "0" + 103 base32 +# chars, 2 mapped to 9). Like the real one it is only ever written to +# /pairing_token, never to stdout/stderr. +FAKE_PAIRING_TOKEN = "SP:0" + ("FAKETOKEN" * 12)[:103] + + +def write_pairing_token(state_dir): + """Mirror main.cpp: atomic 0600 write into a 0700 dir; a failure is a + non-fatal `pairing_token` error event that never carries the token.""" + try: + os.makedirs(state_dir, mode=0o700, exist_ok=True) + tmp = os.path.join(state_dir, ".pairing_token.tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + f.write(FAKE_PAIRING_TOKEN) + os.replace(tmp, os.path.join(state_dir, "pairing_token")) + except OSError: + emit({"event": "error", "kind": "pairing_token", + "msg": "failed to write the pairing token"}) + def emit(event): sys.stdout.write(json.dumps(event) + "\n") @@ -51,8 +72,8 @@ def main(): # is a fatal error. Marking them required here means a regression # that drops either arg from `Audio.Player.build_cli_args/1` # surfaces as a non-zero exit from the fake, which Player.init - # reports as `{:binary_exited, _}` and tests fail. The fake never - # writes to the state dir. + # reports as `{:binary_exited, _}` and tests fail. The only thing the + # fake writes to the state dir is the pairing token file. p.add_argument("--name", required=True) p.add_argument("--state-dir", required=True) p.add_argument("--product", default="sendspin_player") @@ -84,6 +105,10 @@ def main(): "alsa_device": args.alsa_device, }) + # Like the real binary, write the pairing token after start() and + # before the initial `volume` event. + write_pairing_token(args.state_dir) + # Real binary emits a separate `volume` event after `started` to # publish the initial volume (main.cpp:642-649). emit({"event": "volume", "value": args.initial_volume}) diff --git a/test/universal_proxy/audio/pairing_token_test.exs b/test/universal_proxy/audio/pairing_token_test.exs new file mode 100644 index 0000000..566d2d6 --- /dev/null +++ b/test/universal_proxy/audio/pairing_token_test.exs @@ -0,0 +1,72 @@ +defmodule UniversalProxy.Audio.PairingTokenTest do + use ExUnit.Case, async: true + + alias UniversalProxy.Audio + alias UniversalProxy.Audio.PairingToken + + @moduletag :tmp_dir + + # Shape of a real token: "SP:" + version "0" + 103 base32 chars. + @token "SP:0" <> String.duplicate("ABCDEFGH34567", 7) <> "ABCDEFGHIJKL" + + defp write!(dir, data), do: File.write!(Path.join(dir, PairingToken.filename()), data) + + test "a missing file is not ready", %{tmp_dir: dir} do + assert PairingToken.read(dir) == {:error, :not_ready} + assert PairingToken.read(Path.join(dir, "absent")) == {:error, :not_ready} + end + + test "returns a valid token, trimmed", %{tmp_dir: dir} do + assert byte_size(@token) == 107 + write!(dir, @token) + assert PairingToken.read(dir) == {:ok, @token} + + write!(dir, @token <> "\n") + assert PairingToken.read(dir) == {:ok, @token} + end + + test "rejects malformed contents", %{tmp_dir: dir} do + for bad <- [ + "", + "\n", + "SP:", + "XX:0ABC", + "sp:0ABC", + "SP:0AB C", + "SP:0ABé", + "SP:0AB\0", + <<"SP:0", 0xFF>>, + "SP:" <> String.duplicate("A", 125) + ] do + write!(dir, bad) + assert PairingToken.read(dir) == {:error, :unavailable}, "accepted #{inspect(bad)}" + end + end + + test "accepts the 127-byte maximum", %{tmp_dir: dir} do + token = "SP:" <> String.duplicate("A", 124) + write!(dir, token) + assert PairingToken.read(dir) == {:ok, token} + + write!(dir, token <> "\n") + assert PairingToken.read(dir) == {:ok, token} + end + + test "rejects a file whose content runs past the read limit", %{tmp_dir: dir} do + token = "SP:" <> String.duplicate("A", 124) + write!(dir, token <> "\n\n" <> "garbage") + assert PairingToken.read(dir) == {:error, :unavailable} + + write!(dir, @token <> String.duplicate(" ", 22) <> "garbage") + assert PairingToken.read(dir) == {:error, :unavailable} + end + + test "an unreadable token path is unavailable", %{tmp_dir: dir} do + File.mkdir_p!(Path.join(dir, PairingToken.filename())) + assert PairingToken.read(dir) == {:error, :unavailable} + end + + test "Audio.pairing_token/1 is unavailable for an unknown output" do + assert Audio.pairing_token({"no such output", nil, nil}) == {:error, :unavailable} + end +end diff --git a/test/universal_proxy/audio/player_test.exs b/test/universal_proxy/audio/player_test.exs index 2fbda9c..c11b143 100644 --- a/test/universal_proxy/audio/player_test.exs +++ b/test/universal_proxy/audio/player_test.exs @@ -226,6 +226,17 @@ defmodule UniversalProxy.Audio.PlayerTest do refute "--client-id" in Player.__cli_args__(state) end + @tag :tmp_dir + test "the pairing token lands in the state dir, never on PubSub", %{tmp_dir: tmp_dir} do + _pid = start_player!(state_dir: tmp_dir) + assert_receive {:sendspin_state, @key, %{event: "listening"}}, 2_000 + + assert {:ok, "SP:0" <> _ = token} = UniversalProxy.Audio.PairingToken.read(tmp_dir) + + {:messages, messages} = Process.info(self(), :messages) + refute inspect(messages, limit: :infinity, printable_limit: :infinity) =~ token + end + test "hashes a Store client_id that is not a safe directory name" do # Only `generate_client_id/0` writes the field today; this guards a # corrupted DETS row from turning into a path outside the base dir. diff --git a/test/universal_proxy/audio/server_test.exs b/test/universal_proxy/audio/server_test.exs index 3df1ca0..f473e54 100644 --- a/test/universal_proxy/audio/server_test.exs +++ b/test/universal_proxy/audio/server_test.exs @@ -625,6 +625,29 @@ defmodule UniversalProxy.Audio.ServerTest do end end + describe "state_dir/2" do + test "returns the player's state dir for a present output only", %{server: server} do + EnumerateStub.set(%{ + @hp_key => %{ + card_index: 0, + alsa_device: "plughw:0,0", + card_name: "bcm2835 Headphones", + usb_port: nil + } + }) + + :ok = Server.check_now(server) + assert_receive {:sendspin_output_added, _} + + {:ok, output} = Server.get_output(server, @hp_key) + assert {:ok, dir} = Server.state_dir(server, @hp_key) + assert dir == UniversalProxy.Audio.Player.state_dir(output) + assert Path.basename(dir) == output.client_id + + assert :error = Server.state_dir(server, {"nope", nil, nil}) + end + end + describe "update_config/3" do setup %{server: server} do EnumerateStub.set(%{ diff --git a/test/universal_proxy_web/live/audio_live_test.exs b/test/universal_proxy_web/live/audio_live_test.exs index a853078..2a507a1 100644 --- a/test/universal_proxy_web/live/audio_live_test.exs +++ b/test/universal_proxy_web/live/audio_live_test.exs @@ -815,4 +815,120 @@ defmodule UniversalProxyWeb.AudioLiveTest do refute html =~ "Reconnecting" assert html =~ "No audio outputs detected" end + + describe "pairing token disclosure" do + @token "SP:0" <> String.duplicate("PAIRTOKEN", 12) + + defmodule PairingTokenFacade do + @moduledoc false + # Stands in for `UniversalProxy.Audio` (see AudioLive's + # `:audio_facade` seam); the result is set per test. + def pairing_token(key) do + send(Application.get_env(:universal_proxy, :pairing_token_test_pid), {:read, key}) + Application.get_env(:universal_proxy, :pairing_token_result) + end + end + + setup do + Application.put_env(:universal_proxy, :audio_facade, PairingTokenFacade) + Application.put_env(:universal_proxy, :pairing_token_test_pid, self()) + Application.put_env(:universal_proxy, :pairing_token_result, {:ok, @token}) + + on_exit(fn -> + Application.delete_env(:universal_proxy, :audio_facade) + Application.delete_env(:universal_proxy, :pairing_token_test_pid) + Application.delete_env(:universal_proxy, :pairing_token_result) + end) + end + + defp open_disclosure(conn) do + {:ok, view, _html} = live(conn, "/audio") + + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:output_added", + {:sendspin_output_added, sample_output()} + ) + + id = encode(@hp_key) + html = render_hook(view, "toggle_more", %{"id" => id}) + {view, id, html} + end + + test "is hidden until Show, and hidden again after Hide", %{conn: conn} do + {view, id, html} = open_disclosure(conn) + + assert html =~ "Pairing token" + assert html =~ "Pairing token" option" + refute html =~ @token + # Nothing is read until the user asks. + refute_received {:read, _} + + html = view |> element("button[phx-click=show_pairing_token]") |> render_click() + assert_received {:read, @hp_key} + assert html =~ @token + + html = view |> element("button[phx-click=hide_pairing_token]") |> render_click() + refute html =~ @token + + # Closing the disclosure drops a revealed token too. + render_hook(view, "show_pairing_token", %{"id" => id}) + assert render(view) =~ @token + render_hook(view, "toggle_more", %{"id" => id}) + html = render_hook(view, "toggle_more", %{"id" => id}) + refute html =~ @token + end + + test "Copy is offered only while the token is revealed", %{conn: conn} do + {view, id, html} = open_disclosure(conn) + copy = "#copy-pairing-token-#{id}" + + refute html =~ @token + refute has_element?(view, copy) + + view |> element("button[phx-click=show_pairing_token]") |> render_click() + + assert has_element?(view, "#pairing-token-#{id}", @token) + + assert has_element?( + view, + ~s(#{copy}[phx-hook="CopyToClipboard"][data-clipboard="#{@token}"]) <> + ~s([data-select-target="pairing-token-#{id}"]) + ) + + html = view |> element("button[phx-click=hide_pairing_token]") |> render_click() + refute html =~ @token + refute has_element?(view, copy) + refute has_element?(view, "#pairing-token-#{id}") + end + + test "shows a hint instead when the token is not ready", %{conn: conn} do + Application.put_env(:universal_proxy, :pairing_token_result, {:error, :not_ready}) + {view, id, _html} = open_disclosure(conn) + + html = view |> element("button[phx-click=show_pairing_token]") |> render_click() + assert html =~ "Available once the output has started" + refute has_element?(view, "#copy-pairing-token-#{id}") + end + + test "ignores events for an output that is gone", %{conn: conn} do + {view, id, _html} = open_disclosure(conn) + render_hook(view, "show_pairing_token", %{"id" => id}) + assert render(view) =~ @token + + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:output_removed", + {:sendspin_output_removed, %{key: @hp_key}} + ) + + refute render(view) =~ @token + assert_received {:read, @hp_key} + + render_hook(view, "show_pairing_token", %{"id" => id}) + render_hook(view, "show_pairing_token", %{"id" => "garbage"}) + refute_received {:read, _} + refute render(view) =~ @token + end + end end