diff --git a/lib/nerves_hub/device_link/connections.ex b/lib/nerves_hub/device_link/connections.ex new file mode 100644 index 000000000..19f0f5bfb --- /dev/null +++ b/lib/nerves_hub/device_link/connections.ex @@ -0,0 +1,182 @@ +defmodule NervesHub.DeviceLink.Connections do + @moduledoc """ + Functions for connecting devices, including the reporting of connection availability. + """ + + alias NervesHub.Devices + alias NervesHub.Devices.Connections, as: DeviceConnections + alias NervesHub.Devices.DeviceConnection + alias NervesHub.Devices.Device + alias NervesHub.Products + alias NervesHub.Tracker + + alias Plug.Crypto + + # Default 90 seconds max age for the signature + @default_max_hmac_age 90 + + @type auth() :: {:ssl_certs, any()} | {:shared_secrets, list()} + @type connection_id() :: binary() + + @doc """ + Find and connect a device. Devices can also be created, if the strategy supports it. + """ + @spec connect_device(auth()) :: {:ok, {connection_id(), Device.t()}} | {:error, :invalid_auth} + def connect_device(auth) + + def connect_device({:ssl_certs, ssl_cert}) do + X509.Certificate.from_der!(ssl_cert) + |> Devices.get_device_by_x509() + |> case do + {:ok, device} -> + {:ok, on_connect(device)} + + error -> + :telemetry.execute([:nerves_hub, :devices, :invalid_auth], %{count: 1}, %{ + auth: :cert, + reason: error + }) + + {:error, :invalid_auth} + end + end + + def connect_device({:shared_secrets, x_headers}) do + headers = Map.new(x_headers) + + with :ok <- check_shared_secret_enabled(), + {:ok, key, salt, verification_opts} <- decode_from_headers(headers), + {:ok, auth} <- get_shared_secret_auth(key), + {:ok, signature} <- Map.fetch(headers, "x-nh-signature"), + {:ok, identifier} <- Crypto.verify(auth.secret, salt, signature, verification_opts), + {:ok, device} <- get_or_maybe_create_device(auth, identifier) do + {:ok, on_connect(device)} + else + error -> + :telemetry.execute([:nerves_hub, :devices, :invalid_auth], %{count: 1}, %{ + auth: :shared_secrets, + reason: error, + product_key: Map.get(headers, "x-nh-key", "*empty*") + }) + + {:error, :invalid_auth} + end + end + + @doc """ + Mark a device as disconnected. + """ + @spec disconnect_device(any(), Device.t(), connection_id()) :: :ok + def disconnect_device(reason, device, reference_id) + + def disconnect_device({:error, {:shutdown, :disconnected}}, device, reference_id) do + :telemetry.execute([:nerves_hub, :devices, :duplicate_connection], %{count: 1}, %{ + ref_id: reference_id, + device: device + }) + + disconnect_device(:ok, device, reference_id) + end + + def disconnect_device(_, device, reference_id) do + :telemetry.execute([:nerves_hub, :devices, :disconnect], %{count: 1}, %{ + ref_id: reference_id, + identifier: device.identifier + }) + + _ = DeviceConnections.device_disconnected(reference_id) + + Tracker.offline(device) + end + + @doc """ + Mark a device as still connected and alive. + """ + @spec device_heartbeat(connection_id()) :: :ok + def device_heartbeat(reference_id) do + DeviceConnections.device_heartbeat(reference_id) + end + + defp on_connect(%Device{status: :registered} = device) do + Devices.set_as_provisioned!(device) + |> on_connect() + end + + defp on_connect(device) do + # disconnect devices using the same identifier + Phoenix.Channel.Server.broadcast_from!( + NervesHub.PubSub, + self(), + "device_socket:#{device.id}", + "disconnect", + %{} + ) + + {:ok, %DeviceConnection{id: connection_id}} = DeviceConnections.device_connected(device.id) + + :telemetry.execute([:nerves_hub, :devices, :connect], %{count: 1}, %{ + ref_id: connection_id, + identifier: device.identifier, + firmware_uuid: get_in(device, [Access.key(:firmware_metadata), Access.key(:uuid)]) + }) + + Tracker.online(device) + + {connection_id, device} + end + + defp decode_from_headers(%{"x-nh-alg" => "NH1-HMAC-" <> alg} = headers) do + with [digest_str, iter_str, klen_str] <- String.split(alg, "-"), + digest <- String.to_existing_atom(String.downcase(digest_str)), + {iterations, ""} <- Integer.parse(iter_str), + {key_length, ""} <- Integer.parse(klen_str), + {signed_at, ""} <- Integer.parse(headers["x-nh-time"]), + {:ok, key} <- Map.fetch(headers, "x-nh-key") do + expected_salt = """ + NH1:device-socket:shared-secret:connect + + x-nh-alg=NH1-HMAC-#{alg} + x-nh-key=#{key} + x-nh-time=#{signed_at} + """ + + opts = [ + key_length: key_length, + key_iterations: iterations, + key_digest: digest, + signed_at: signed_at, + max_age: max_hmac_age() + ] + + {:ok, key, expected_salt, opts} + end + end + + defp decode_from_headers(_headers), do: {:error, :headers_decode_failed} + + defp get_shared_secret_auth("nhp_" <> _ = key), do: Products.get_shared_secret_auth(key) + defp get_shared_secret_auth(key), do: Devices.get_shared_secret_auth(key) + + defp get_or_maybe_create_device(%Products.SharedSecretAuth{} = auth, identifier) do + # TODO: Support JITP profile here to decide if enabled or what tags to use + Devices.get_or_create_device(auth, identifier) + end + + defp get_or_maybe_create_device(%{device: %{identifier: identifier} = device}, identifier), + do: {:ok, device} + + defp get_or_maybe_create_device(_auth, _identifier), do: {:error, :bad_identifier} + + defp max_hmac_age() do + Application.get_env(:nerves_hub, __MODULE__, []) + |> Keyword.get(:max_age, @default_max_hmac_age) + end + + defp check_shared_secret_enabled() do + if Products.shared_secrets_enabled?() do + :ok + else + {:error, :shared_secrets_not_enabled} + end + end +end diff --git a/lib/nerves_hub/products.ex b/lib/nerves_hub/products.ex index d830f0d97..e5e48c8c2 100644 --- a/lib/nerves_hub/products.ex +++ b/lib/nerves_hub/products.ex @@ -313,4 +313,11 @@ defmodule NervesHub.Products do :nope end) end + + def shared_secrets_enabled?() do + # NervesHubWeb.DeviceSocket should really be :device_auth or :product_auth + Application.get_env(:nerves_hub, NervesHubWeb.DeviceSocket, []) + |> Keyword.get(:shared_secrets, []) + |> Keyword.get(:enabled, false) + end end diff --git a/lib/nerves_hub_web/channels/device_socket.ex b/lib/nerves_hub_web/channels/device_socket.ex index 0b0e8ca5a..5c8d93df0 100644 --- a/lib/nerves_hub_web/channels/device_socket.ex +++ b/lib/nerves_hub_web/channels/device_socket.ex @@ -4,34 +4,19 @@ defmodule NervesHubWeb.DeviceSocket do require Logger - alias NervesHub.Devices - alias NervesHub.Devices.Connections - alias NervesHub.Devices.DeviceConnection - alias NervesHub.Products - alias NervesHub.Tracker - - alias Plug.Crypto + alias NervesHub.DeviceLink.Connections channel("console", NervesHubWeb.ConsoleChannel) channel("device", NervesHubWeb.DeviceChannel) channel("extensions", NervesHubWeb.ExtensionsChannel) - # Default 90 seconds max age for the signature - @default_max_hmac_age 90 - defoverridable init: 1, handle_in: 2, terminate: 2 - @impl Phoenix.Socket.Transport - def init(state_tuple) do - {:ok, {state, socket}} = super(state_tuple) - socket = on_connect(socket) - {:ok, {state, socket}} - end - @impl Phoenix.Socket.Transport @decorate with_span("Channels.DeviceSocket.terminate") def terminate(reason, {_channels_info, socket} = state) do - on_disconnect(reason, socket) + %{assigns: %{device: device, reference_id: reference_id}} = socket + Connections.disconnect_device(reason, device, reference_id) super(reason, state) end @@ -71,50 +56,33 @@ defmodule NervesHubWeb.DeviceSocket do # Used by Devices connecting with SSL certificates @impl Phoenix.Socket - @decorate with_span("Channels.DeviceSocket.connect") + @decorate with_span("Channels.DeviceSocket.connect#ssl_cert") def connect(_params, socket, %{peer_data: %{ssl_cert: ssl_cert}}) when not is_nil(ssl_cert) do - X509.Certificate.from_der!(ssl_cert) - |> Devices.get_device_by_x509() - |> case do - {:ok, device} -> - socket_and_assigns(socket, device) + case Connections.connect_device({:ssl_certs, ssl_cert}) do + {:ok, ref_and_device} -> + socket_and_assigns(socket, ref_and_device) error -> - :telemetry.execute([:nerves_hub, :devices, :invalid_auth], %{count: 1}, %{ - auth: :cert, - reason: error - }) - - {:error, :invalid_auth} + error end end # Used by Devices connecting with HMAC Shared Secrets - @decorate with_span("Channels.DeviceSocket.connect") + @impl Phoenix.Socket + @decorate with_span("Channels.DeviceSocket.connect#shared_secrets") def connect(_params, socket, %{x_headers: x_headers}) when is_list(x_headers) and length(x_headers) > 0 do - headers = Map.new(x_headers) + case Connections.connect_device({:shared_secrets, x_headers}) do + {:ok, ref_and_device} -> + socket_and_assigns(socket, ref_and_device) - with :ok <- check_shared_secret_enabled(), - {:ok, key, salt, verification_opts} <- decode_from_headers(headers), - {:ok, auth} <- get_shared_secret_auth(key), - {:ok, signature} <- Map.fetch(headers, "x-nh-signature"), - {:ok, identifier} <- Crypto.verify(auth.secret, salt, signature, verification_opts), - {:ok, device} <- get_or_maybe_create_device(auth, identifier) do - socket_and_assigns(socket, device) - else error -> - :telemetry.execute([:nerves_hub, :devices, :invalid_auth], %{count: 1}, %{ - auth: :shared_secrets, - reason: error, - product_key: Map.get(headers, "x-nh-key", "*empty*") - }) - - {:error, :invalid_auth} + error end end + @impl Phoenix.Socket def connect(_params, _socket, _connect_info) do {:error, :no_auth} end @@ -133,150 +101,16 @@ defmodule NervesHubWeb.DeviceSocket do ] end - defp decode_from_headers(%{"x-nh-alg" => "NH1-HMAC-" <> alg} = headers) do - with [digest_str, iter_str, klen_str] <- String.split(alg, "-"), - digest <- String.to_existing_atom(String.downcase(digest_str)), - {iterations, ""} <- Integer.parse(iter_str), - {key_length, ""} <- Integer.parse(klen_str), - {signed_at, ""} <- Integer.parse(headers["x-nh-time"]), - {:ok, key} <- Map.fetch(headers, "x-nh-key") do - expected_salt = """ - NH1:device-socket:shared-secret:connect - - x-nh-alg=NH1-HMAC-#{alg} - x-nh-key=#{key} - x-nh-time=#{signed_at} - """ - - opts = [ - key_length: key_length, - key_iterations: iterations, - key_digest: digest, - signed_at: signed_at, - max_age: max_hmac_age() - ] - - {:ok, key, expected_salt, opts} - end - end - - defp decode_from_headers(_headers), do: {:error, :headers_decode_failed} - - defp get_shared_secret_auth("nhp_" <> _ = key), do: Products.get_shared_secret_auth(key) - defp get_shared_secret_auth(key), do: Devices.get_shared_secret_auth(key) - - defp get_or_maybe_create_device(%Products.SharedSecretAuth{} = auth, identifier) do - # TODO: Support JITP profile here to decide if enabled or what tags to use - Devices.get_or_create_device(auth, identifier) - end - - defp get_or_maybe_create_device(%{device: %{identifier: identifier} = device}, identifier), - do: {:ok, device} - - defp get_or_maybe_create_device(_auth, _identifier), do: {:error, :bad_identifier} - - defp max_hmac_age() do - Application.get_env(:nerves_hub, __MODULE__, []) - |> Keyword.get(:max_age, @default_max_hmac_age) - end - - defp check_shared_secret_enabled() do - if shared_secrets_enabled?() do - :ok - else - {:error, :shared_secrets_not_enabled} - end - end - - defp socket_and_assigns(socket, device) do - # disconnect devices using the same identifier - _ = socket.endpoint.broadcast_from(self(), "device_socket:#{device.id}", "disconnect", %{}) - + defp socket_and_assigns(socket, {ref_id, device}) do socket = socket |> assign(:device, device) + |> assign(:reference_id, ref_id) {:ok, socket} end - @decorate with_span("Channels.DeviceSocket.on_connect#registered") - defp on_connect(%{assigns: %{device: %{status: :registered} = device}} = socket) do - socket - |> assign(device: Devices.set_as_provisioned!(device)) - |> on_connect() - end - - @decorate with_span("Channels.DeviceSocket.on_connect#provisioned") - defp on_connect(%{assigns: %{device: device}} = socket) do - # Report connection and use connection id as reference - {:ok, %DeviceConnection{id: connection_id}} = - Connections.device_connected(device.id) - - :telemetry.execute([:nerves_hub, :devices, :connect], %{count: 1}, %{ - ref_id: connection_id, - identifier: socket.assigns.device.identifier, - firmware_uuid: - get_in(socket.assigns.device, [Access.key(:firmware_metadata), Access.key(:uuid)]) - }) - - Tracker.online(device) - - socket - |> assign(:device, device) - |> assign(:reference_id, connection_id) - end - - @decorate with_span("Channels.DeviceSocket.on_disconnect") - defp on_disconnect(exit_reason, socket) - - defp on_disconnect({:error, reason}, %{ - assigns: %{ - device: device, - reference_id: reference_id - } - }) do - if reason == {:shutdown, :disconnected} do - :telemetry.execute([:nerves_hub, :devices, :duplicate_connection], %{count: 1}, %{ - ref_id: reference_id, - device: device - }) - end - - shutdown(device, reference_id) - - :ok - end - - defp on_disconnect(_, %{ - assigns: %{ - device: device, - reference_id: reference_id - } - }) do - shutdown(device, reference_id) - end - - @decorate with_span("Channels.DeviceSocket.shutdown") - defp shutdown(device, reference_id) do - :telemetry.execute([:nerves_hub, :devices, :disconnect], %{count: 1}, %{ - ref_id: reference_id, - identifier: device.identifier - }) - - :ok = Connections.device_disconnected(reference_id) - - Tracker.offline(device) - - :ok - end - defp last_seen_update_interval() do Application.get_env(:nerves_hub, :device_last_seen_update_interval_minutes) end - - def shared_secrets_enabled?() do - Application.get_env(:nerves_hub, __MODULE__, []) - |> Keyword.get(:shared_secrets, []) - |> Keyword.get(:enabled, false) - end end diff --git a/lib/nerves_hub_web/live/product/settings.ex b/lib/nerves_hub_web/live/product/settings.ex index 7be6f909f..6890c401f 100644 --- a/lib/nerves_hub_web/live/product/settings.ex +++ b/lib/nerves_hub_web/live/product/settings.ex @@ -3,7 +3,6 @@ defmodule NervesHubWeb.Live.Product.Settings do alias NervesHub.Extensions alias NervesHub.Products - alias NervesHubWeb.DeviceSocket def mount(_params, _session, socket) do product = Products.load_shared_secret_auth(socket.assigns.product) @@ -14,7 +13,7 @@ defmodule NervesHubWeb.Live.Product.Settings do |> sidebar_tab(:settings) |> assign(:product, product) |> assign(:shared_secrets, product.shared_secret_auths) - |> assign(:shared_auth_enabled, DeviceSocket.shared_secrets_enabled?()) + |> assign(:shared_auth_enabled, Products.shared_secrets_enabled?()) |> assign(:form, to_form(Ecto.Changeset.change(product))) |> assign(:available_extensions, extensions())