Skip to content

Commit

Permalink
building upon @jjcarstens psk magic
Browse files Browse the repository at this point in the history
this is based upon 0d44fc2 with the addition of Product Auth Tokens (needs some naming love)
  • Loading branch information
joshk committed Dec 10, 2023
1 parent eac1083 commit c986666
Show file tree
Hide file tree
Showing 10 changed files with 237 additions and 7 deletions.
37 changes: 35 additions & 2 deletions lib/nerves_hub/devices.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ defmodule NervesHub.Devices do
alias NervesHub.Firmwares
alias NervesHub.Firmwares.Firmware
alias NervesHub.Firmwares.FirmwareMetadata
alias NervesHub.Products
alias NervesHub.Products.Product
alias NervesHub.Repo
alias NervesHub.TaskSupervisor, as: Tasks
Expand All @@ -27,8 +28,24 @@ defmodule NervesHub.Devices do

@min_fwup_delta_updatable_version ">=1.6.0"

def get_device(device_id), do: Repo.get(Device, device_id)
def get_device!(device_id), do: Repo.get!(Device, device_id)
def get_device!(device_id) do
Repo.get!(Device, device_id)
end

def get_device(device_id) when is_integer(device_id) do
Repo.get(Device, device_id)
end

def get_device(product_id: product_id, identifier: identifier) do
Device
|> where([d], d.product_id == ^product_id)
|> where([d], d.identifier == ^identifier)
|> Repo.one()
|> case do
nil -> {:error, :not_found}
device -> {:ok, device}
end
end

def get_devices_by_org_id(org_id) do
query =
Expand Down Expand Up @@ -223,6 +240,22 @@ defmodule NervesHub.Devices do
end
end

def get_or_create_device(token_auth: token_auth, identifier: identifier) do
with {:error, :not_found} <-
get_device(product_id: token_auth.product_id, identifier: identifier),
{:ok, product} <-
Products.get_product(token_auth.product_id) do
create_device(%{
org_id: product.org_id,
product_id: product.id,
identifier: identifier
})
else
result ->
result
end
end

def get_device_by(filters) do
Repo.get_by(Device, filters)
|> case do
Expand Down
21 changes: 20 additions & 1 deletion lib/nerves_hub/products.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule NervesHub.Products do

alias Ecto.Multi
alias NervesHub.{Certificate, Repo}
alias NervesHub.Products.Product
alias NervesHub.Products.{Product, TokenAuth}
alias NervesHub.Accounts.{User, Org, OrgUser}

alias NimbleCSV.RFC4180, as: CSV
Expand Down Expand Up @@ -54,6 +54,16 @@ defmodule NervesHub.Products do
|> Repo.get!(id)
end

def get_product(id) do
Product
|> Repo.exclude_deleted()
|> Repo.get(id)
|> case do
nil -> {:error, :not_found}
product -> {:ok, product}
end
end

def get_product_by_org_id_and_name(org_id, name) do
Product
|> Repo.exclude_deleted()
Expand Down Expand Up @@ -130,6 +140,15 @@ defmodule NervesHub.Products do
Product.changeset(product, %{})
end

def get_token_auth(access_id: access_id) do
TokenAuth
|> join(:inner, [ta], p in assoc(ta, :product))
|> where([ta], ta.access_id == ^access_id)
|> where([ta], is_nil(ta.deactivated_at))
|> where([p], is_nil(p.deleted_at))
|> Repo.one()
end

def devices_csv(%Product{} = product) do
product = Repo.preload(product, [:org, devices: :device_certificates])
data = Enum.map(product.devices, &device_csv_line(&1, product))
Expand Down
2 changes: 2 additions & 0 deletions lib/nerves_hub/products/product.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule NervesHub.Products.Product do
alias NervesHub.Devices.CACertificate
alias NervesHub.Devices.Device
alias NervesHub.Firmwares.Firmware
alias NervesHub.Products.TokenAuth
alias NervesHub.Repo

@required_params [:name, :org_id]
Expand All @@ -19,6 +20,7 @@ defmodule NervesHub.Products.Product do
has_many(:firmwares, Firmware)
has_many(:jitp, CACertificate.JITP)
has_many(:archives, Archive)
has_many(:auth_tokens, TokenAuth)

belongs_to(:org, Org, where: [deleted_at: nil])

Expand Down
38 changes: 38 additions & 0 deletions lib/nerves_hub/products/token_auth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
defmodule NervesHub.Products.TokenAuth do
use Ecto.Schema

import Ecto.Changeset

alias NervesHub.Products.Product

@type t :: %__MODULE__{}

@access_id_prefix "nhp"

schema "product_auth_tokens" do
belongs_to(:product, Product)

field(:access_id, :string)
field(:secret, :string)

field(:deactivated_at, :utc_datetime)

timestamps()
end

def create_changeset(%Product{} = product) do
change(%__MODULE__{}, product_id: product.id)
|> put_change(:access_id, "#{@access_id_prefix}_#{generate_token()}")
|> put_change(:secret, generate_token())
|> validate_required([:product_id, :access_id, :secret])
|> validate_format(:access_id, ~r/^nhp_[a-zA-Z0-9\-\/\+]{43}$/)
|> validate_format(:secret, ~r/^[a-zA-Z0-9\-\/\+]{43}$/)
|> foreign_key_constraint(:product_id)
|> unique_constraint(:access_id)
|> unique_constraint(:secret)
end

defp generate_token() do
:crypto.strong_rand_bytes(32) |> Base.encode64(padding: false)
end
end
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule NervesHubWeb.DeviceSocket do
defmodule NervesHubWeb.DeviceSocketCertAuth do
use Phoenix.Socket

alias NervesHub.Devices
Expand Down
110 changes: 110 additions & 0 deletions lib/nerves_hub_web/channels/device_socket_token_auth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
defmodule NervesHubWeb.DeviceSocketTokenAuth do
use Phoenix.Socket

alias NervesHub.Devices

alias Plug.Crypto

channel("console", NervesHubWeb.ConsoleChannel)
channel("device", NervesHubWeb.DeviceChannel)

@salt_headers [
"x-nh-key-digest",
"x-nh-key-iterations",
"x-nh-key-length",
"x-nh-access-id",
"x-nh-time"
]

# Default 15 min max age for the signature
@default_max_age 900

def connect(_params, socket, %{x_headers: headers}) do
parsed_data = parse_headers(headers)
verify_opts = verify_options(parsed_data)
salt = expected_salt(headers)

with {:ok, access_id} <- Keyword.fetch(parsed_data, :access_id),
{:ok, token_auth} <- get_product_token_auth(access_id),
{:ok, signature} <- Keyword.fetch(parsed_data, :signature),
{:ok, identifier} <- Crypto.verify(token_auth.secret, salt, signature, verify_opts),
{:ok, device} <-
Devices.get_or_create_device(token_auth: token_auth, identifier: identifier) do
socket =
socket
|> assign(:device, device)
|> assign(:reference_id, generate_reference_id())

{:ok, socket}
else
_ -> {:error, :invalud_auth}
end
end

def connect(_params, _socket, _connect_info), do: :error

def id(%{assigns: %{device: device}}), do: "device_socket:#{device.id}"
def id(_socket), do: nil

defp parse_headers(headers) do
for {k, v} <- headers do
case String.downcase(k) do
"x-nh-time" ->
{:signed_at, String.to_integer(v)}

"x-nh-key-length" ->
{:key_length, String.to_integer(v)}

"x-nh-key-iterations" ->
{:key_iterations, String.to_integer(v)}

"x-nh-key-digest" ->
"NH1-HMAC-" <> digest_str = v
{:key_digest, String.to_existing_atom(String.downcase(digest_str))}

"x-nh-access-id" ->
{:access_id, v}

"x-nh-signature" ->
{:signature, v}

_ ->
# TODO: What should we do with this unknown?
nil
end
end
end

defp verify_options(parsed_data) do
Keyword.take(parsed_data, [:key_digest, :key_iterations, :key_length, :signed_at])
# TODO: Make max_age configurable?
|> Keyword.put(:max_age, @default_max_age)
end

defp expected_salt(headers) do
salt_headers =
Enum.filter(headers, &(elem(&1, 0) in @salt_headers))
|> Enum.sort_by(fn {k, _} -> k end)
|> Enum.map_join("\n", fn {k, v} -> "#{k}=#{v}" end)

"""
NH1:device:token:connect
#{salt_headers}
"""
end

defp get_product_token_auth(access_id) do
case NervesHub.Products.get_token_auth(access_id: access_id) do
nil -> {:error, :unknown_access_id}
token_auth -> {:ok, token_auth}
end
end

defp generate_reference_id() do
Base.encode32(:crypto.strong_rand_bytes(2), padding: false)
end

def handle_error(conn, :invalud_auth),
do: Plug.Conn.send_resp(conn, 403, "Invalid authorization")
end
2 changes: 1 addition & 1 deletion lib/nerves_hub_web/device_endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule NervesHubWeb.DeviceEndpoint do

socket(
"/socket",
NervesHubWeb.DeviceSocket,
NervesHubWeb.DeviceSocketCertAuth,
websocket: [
connect_info: [:peer_data, :x_headers]
]
Expand Down
7 changes: 7 additions & 0 deletions lib/nerves_hub_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ defmodule NervesHubWeb.Endpoint do
websocket: [connect_info: [session: @session_options]]
)

socket("/device-socket", NervesHubWeb.DeviceSocketTokenAuth,
websocket: [
connect_info: [:x_headers],
error_handler: {NervesHubWeb.DeviceSocketTokenAuth, :handle_error, []}
]
)

# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phoenix.digest
Expand Down
19 changes: 19 additions & 0 deletions priv/repo/migrations/20231209055552_create_product_auth_tokens.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule NervesHub.Repo.Migrations.CreateProductAuthTokens do
use Ecto.Migration

def change do
create table(:product_auth_tokens) do
add(:product_id, references(:products), null: false)

add(:access_id, :string, null: false)
add(:secret, :string, null: false)

add(:deactivated_at, :utc_datetime)

timestamps()
end

create index(:product_auth_tokens, [:access_id], unique: true)
create index(:product_auth_tokens, [:secret], unique: true)
end
end
6 changes: 4 additions & 2 deletions test/nerves_hub_web/channels/device_channel_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule NervesHubWeb.DeviceChannelTest do
use DefaultMocks

alias NervesHub.Fixtures
alias NervesHubWeb.DeviceChannel
alias NervesHubWeb.DeviceChannelCertAuth
alias NervesHubWeb.DeviceSocket

Check warning on line 7 in test/nerves_hub_web/channels/device_channel_test.exs

View workflow job for this annotation

GitHub Actions / compile-and-test

unused alias DeviceSocket

test "basic connection to the channel" do

Check failure on line 9 in test/nerves_hub_web/channels/device_channel_test.exs

View workflow job for this annotation

GitHub Actions / compile-and-test

test basic connection to the channel (NervesHubWeb.DeviceChannelTest)
Expand All @@ -16,7 +16,9 @@ defmodule NervesHubWeb.DeviceChannelTest do
%{db_cert: certificate, cert: _cert} = Fixtures.device_certificate_fixture(device)

{:ok, socket} =
connect(DeviceSocket, %{}, connect_info: %{peer_data: %{ssl_cert: certificate.der}})
connect(DeviceChannelCertAuth, %{},
connect_info: %{peer_data: %{ssl_cert: certificate.der}}
)

# Joins the channel
assert {:ok, %{}, socket} = subscribe_and_join(socket, DeviceChannel, "device")
Expand Down

0 comments on commit c986666

Please sign in to comment.