diff --git a/lib/grizzly/commands/table.ex b/lib/grizzly/commands/table.ex index 4158a207..72ffdc1d 100644 --- a/lib/grizzly/commands/table.ex +++ b/lib/grizzly/commands/table.ex @@ -122,9 +122,30 @@ defmodule Grizzly.Commands.Table do {:s0_message_encapsulation, {Commands.S0MessageEncapsulation, handler: AckResponse}}, # S2 - {:security_2_commands_supported_get, - {Commands.Security2CommandsSupportedGet, - handler: {WaitReport, complete_report: :security_2_commands_supported_report}}}, + {:s2_kex_get, + {Commands.S2KexGet, + handler: {WaitReport, complete_report: :s2_kex_report, supports_supervision?: false}}}, + {:s2_kex_set, + {Commands.S2KexSet, + handler: {WaitReport, complete_report: :s2_kex_report, supports_supervision?: false}}}, + {:s2_kex_fail, {Commands.S2KexFail, handler: {AckResponse, supports_supervision?: false}}}, + {:s2_kex_report, + {Commands.S2KexReport, handler: {AckResponse, supports_supervision?: false}}}, + {:s2_public_key_report, + {Commands.S2PublicKeyReport, handler: {AckResponse, supports_supervision?: false}}}, + {:s2_network_key_get, + {Commands.S2NetworkKeyGet, + handler: + {WaitReport, complete_report: :s2_network_key_report, supports_supervision?: false}}}, + {:s2_network_key_report, + {Commands.S2NetworkKeyReport, handler: {AckResponse, supports_supervision?: false}}}, + {:s2_network_key_verify, + {Commands.S2NetworkKeyVerify, handler: {AckResponse, supports_supervision?: false}}}, + {:s2_transfer_end, + {Commands.S2TransferEnd, handler: {AckResponse, supports_supervision?: false}}}, + {:s2_commands_supported_get, + {Commands.S2CommandsSupportedGet, + handler: {WaitReport, complete_report: :s2_commands_supported_report}}}, # DSKs {:node_add_keys_set, diff --git a/lib/grizzly/zwave/command_classes/security_2.ex b/lib/grizzly/zwave/command_classes/security_2.ex index 94f741dc..bebc558b 100644 --- a/lib/grizzly/zwave/command_classes/security_2.ex +++ b/lib/grizzly/zwave/command_classes/security_2.ex @@ -1,8 +1,77 @@ defmodule Grizzly.ZWave.CommandClasses.Security2 do @moduledoc """ Security 2 (S2) Command Class + + ### Definitions + + - **CKDF** - CMAC-based Key Derivation Function + - **MEI** - Mutual Entropy Input + - **SPAN** - Singlecast Pre-Agreed Nonce + - **MPAN** - Multicast Pre-Agreed Nonce + - **MGRP** - Multicast Group + - **SOS** - Singlecast Out-of-Sync + - **MOS** - Multicast Out-of-Sync """ + defmodule AAD do + @moduledoc """ + S2 AAD (Additional Authenticated Data) structure + """ + import Grizzly.ZWave.Encoding, only: [bool_to_bit: 1] + + @type t :: %__MODULE__{ + sender_node_id: non_neg_integer(), + destination_tag: non_neg_integer(), + home_id: non_neg_integer(), + message_length: non_neg_integer(), + sequence_number: non_neg_integer(), + encrypted_extensions?: boolean(), + extensions: binary() + } + + defstruct sender_node_id: nil, + destination_tag: nil, + home_id: nil, + message_length: nil, + sequence_number: nil, + encrypted_extensions?: false, + extensions: <<>> + + @doc """ + Create a new S2 AAD struct. + """ + def new(opts) do + struct(__MODULE__, opts) + end + + @doc """ + Encodes the AAD into a binary. + """ + def encode( + %__MODULE__{sender_node_id: sender_node_id, destination_tag: destination_tag} = aad + ) + when sender_node_id > 255 or destination_tag > 255 do + extensions? = if(aad.extensions != <<>>, do: true, else: false) + + <> + end + + def encode( + %__MODULE__{sender_node_id: sender_node_id, destination_tag: destination_tag} = aad + ) do + extensions? = if(aad.extensions != <<>>, do: true, else: false) + + <> + end + end + + @type kex_scheme :: :kex_scheme_1 + @type ecdh_profile :: :curve_25519 + @behaviour Grizzly.ZWave.CommandClass @impl Grizzly.ZWave.CommandClass @@ -10,4 +79,106 @@ defmodule Grizzly.ZWave.CommandClasses.Security2 do @impl Grizzly.ZWave.CommandClass def name(), do: :security_2 + + # Key derivation functions + + @doc """ + Expands a network key into a CCM key for encryption and authorization, a + personalization string, and an MPAN key using the CKDF-Expand algorithm as + described in https://datatracker.ietf.org/doc/html/draft-moskowitz-hip-dex-02#section-6.3. + """ + def generic_expand(network_key, constant_nk) do + # ccm_key + t0 = <> + ccm_key = aes_cmac_calculate(network_key, t0) + + # pstring first half + t1 = <> + pstring1 = aes_cmac_calculate(network_key, t1) + + # pstring second half + t2 = <> + pstring2 = aes_cmac_calculate(network_key, t2) + + # MPAN key + t3 = <> + mpan_key = aes_cmac_calculate(network_key, t3) + + {ccm_key, pstring1 <> pstring2, mpan_key} + end + + @doc """ + Expands a network key into a CCM key for encryption and authorization, a + personalization string, and an MPAN key using the CKDF-Expand algorithm as + described in https://datatracker.ietf.org/doc/html/draft-moskowitz-hip-dex-02#section-6.3. + """ + def network_key_expand(network_key) do + generic_expand(network_key, :binary.copy(<<0x55>>, 16)) + end + + @doc """ + Expands a temporary network key into a CCM key for encryption and authorization, a + personalization string, and an MPAN key using the CKDF-Expand algorithm as + described in https://datatracker.ietf.org/doc/html/draft-moskowitz-hip-dex-02#section-6.3. + """ + def temp_key_expand(prk) do + generic_expand(prk, :binary.copy(<<0x88>>, 16)) + end + + @spec temp_key_extract(<<_::256>>, <<_::256>>, <<_::256>>) :: <<_::128>> + def temp_key_extract(ecdh_shared_secret, sender_pubkey, receiver_pubkey) do + constant_prk = :binary.copy(<<0x33>>, 16) + + aes_cmac_calculate(constant_prk, ecdh_shared_secret <> sender_pubkey <> receiver_pubkey) + |> binary_slice(0..15) + end + + @doc """ + Mix and expand the sender and receiver entropy inputs into a nonce using CKDF-MEI. + """ + @spec ckdf_mei_expand(<<_::128>>, <<_::128>>) :: <<_::256>> + def ckdf_mei_expand(sender_entropy_input, receiver_entropy_input) do + # Extract nonce PRK + constant_nonce = :binary.copy(<<0x26>>, 16) + nonce_prk = aes_cmac_calculate(constant_nonce, sender_entropy_input <> receiver_entropy_input) + + # Expand nonce PRK + const_entropy_input = :binary.copy(<<0x88>>, 15) + + t0 = const_entropy_input <> <<0x00>> + t1 = aes_cmac_calculate(nonce_prk, t0 <> const_entropy_input <> <<0x01>>) + t2 = aes_cmac_calculate(nonce_prk, t1 <> const_entropy_input <> <<0x02>>) + + t1 <> t2 + end + + @doc """ + Encode the ECDH public key into a DSK string. + """ + def ecdh_public_key_to_dsk_string(public_key) do + for <>, into: [] do + Integer.to_string(int) |> String.pad_leading(5, "0") + end + |> Enum.join("-") + end + + @doc """ + Computes an ECDH public key for the given private key. + """ + def ecdh_public_key(private_key) do + {pub_key, _} = :crypto.generate_key(:ecdh, :x25519, private_key) + pub_key + end + + @doc """ + Computes the shared secret using the ECDH algorithm with the local node's + private key and the remote node's public key (as reported by S2 Public Key Report). + """ + def ecdh_shared_secret(private_key, remote_public_key) do + :crypto.compute_key(:ecdh, remote_public_key, private_key, :x25519) + end + + defp aes_cmac_calculate(key, message) do + :crypto.mac(:cmac, :aes_128_cbc, key, message) + end end diff --git a/lib/grizzly/zwave/commands/security_2_commands_supported_get.ex b/lib/grizzly/zwave/commands/s2_commands_supported_get.ex similarity index 86% rename from lib/grizzly/zwave/commands/security_2_commands_supported_get.ex rename to lib/grizzly/zwave/commands/s2_commands_supported_get.ex index 67d7d479..3feae765 100644 --- a/lib/grizzly/zwave/commands/security_2_commands_supported_get.ex +++ b/lib/grizzly/zwave/commands/s2_commands_supported_get.ex @@ -1,4 +1,4 @@ -defmodule Grizzly.ZWave.Commands.Security2CommandsSupportedGet do +defmodule Grizzly.ZWave.Commands.S2CommandsSupportedGet do @moduledoc """ Query the commands supported by a node when using S2. """ @@ -12,7 +12,7 @@ defmodule Grizzly.ZWave.Commands.Security2CommandsSupportedGet do @spec new([]) :: {:ok, Command.t()} def new(params \\ []) do command = %Command{ - name: :security_2_commands_supported_get, + name: :s2_commands_supported_get, command_byte: 0x0D, command_class: Security2, params: params, diff --git a/lib/grizzly/zwave/commands/security_2_commands_supported_report.ex b/lib/grizzly/zwave/commands/s2_commands_supported_report.ex similarity index 90% rename from lib/grizzly/zwave/commands/security_2_commands_supported_report.ex rename to lib/grizzly/zwave/commands/s2_commands_supported_report.ex index f9a86712..cf9a0604 100644 --- a/lib/grizzly/zwave/commands/security_2_commands_supported_report.ex +++ b/lib/grizzly/zwave/commands/s2_commands_supported_report.ex @@ -1,4 +1,4 @@ -defmodule Grizzly.ZWave.Commands.Security2CommandsSupportedReport do +defmodule Grizzly.ZWave.Commands.S2CommandsSupportedReport do @moduledoc """ Lists commands supported by a node when using S2. """ @@ -15,7 +15,7 @@ defmodule Grizzly.ZWave.Commands.Security2CommandsSupportedReport do @spec new([param()]) :: {:ok, Command.t()} def new(params) do command = %Command{ - name: :security_2_commands_supported_report, + name: :s2_commands_supported_report, command_byte: 0x0E, command_class: Security2, params: put_defaults(params), diff --git a/lib/grizzly/zwave/commands/s2_kex_fail.ex b/lib/grizzly/zwave/commands/s2_kex_fail.ex new file mode 100644 index 00000000..941481fd --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_kex_fail.ex @@ -0,0 +1,41 @@ +defmodule Grizzly.ZWave.Commands.S2KexFail do + @moduledoc """ + This command is used to advertise an error condition to the other party of an + S2 bootstrapping process. + """ + @behaviour Grizzly.ZWave.Command + + alias Grizzly.ZWave.Command + alias Grizzly.ZWave.CommandClasses.Security2 + alias Grizzly.ZWave.Security + + @type param :: {:kex_fail_type, Security.key_exchange_fail_type()} + + @impl Grizzly.ZWave.Command + @spec new([param()]) :: {:ok, Command.t()} + def new(params \\ []) do + command = %Command{ + name: :s2_kex_fail, + command_byte: 0x07, + command_class: Security2, + params: params, + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + def encode_params(command) do + kex_fail_type = Command.param!(command, :kex_fail_type) + + <> + end + + @impl Grizzly.ZWave.Command + def decode_params(<>) do + kex_fail_type = Security.failed_type_from_byte(kex_fail_type) + + {:ok, [kex_fail_type: kex_fail_type]} + end +end diff --git a/lib/grizzly/zwave/commands/s2_kex_get.ex b/lib/grizzly/zwave/commands/s2_kex_get.ex new file mode 100644 index 00000000..20df2d8c --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_kex_get.ex @@ -0,0 +1,30 @@ +defmodule Grizzly.ZWave.Commands.S2KexGet do + @moduledoc """ + This command is used by an including node to query the joining node for + supported KEX Schemes and ECDH profiles as well as which network keys the + joining node intends to request. + """ + @behaviour Grizzly.ZWave.Command + + alias Grizzly.ZWave.Command + alias Grizzly.ZWave.CommandClasses.Security2 + + @impl Grizzly.ZWave.Command + def new(_params \\ []) do + command = %Command{ + name: :s2_kex_get, + command_byte: 0x04, + command_class: Security2, + params: [], + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + def encode_params(_command), do: <<>> + + @impl Grizzly.ZWave.Command + def decode_params(_binary), do: {:ok, []} +end diff --git a/lib/grizzly/zwave/commands/s2_kex_report.ex b/lib/grizzly/zwave/commands/s2_kex_report.ex new file mode 100644 index 00000000..b2d05fd2 --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_kex_report.ex @@ -0,0 +1,122 @@ +defmodule Grizzly.ZWave.Commands.S2KexReport do + @moduledoc """ + This command is used by a joining node to advertise the network keys which it + intends to request from the including node. The including node subsequently + grants keys which may be exchanged once a temporary secure channel has been + established. + + After establishment of the temporary secure channel, the including node uses + this command to confirm the set of keys that the joining node intends to + request. + """ + @behaviour Grizzly.ZWave.Command + + import Bitwise + import Grizzly.ZWave.Encoding + + alias Grizzly.ZWave.Command + alias Grizzly.ZWave.CommandClasses.Security2 + alias Grizzly.ZWave.Security + + @default_kex_schemes [:kex_scheme_1] + @default_ecdh_profiles [:curve_25519] + + @type param :: + {:request_csa, boolean()} + | {:echo, boolean()} + | {:nls_support, boolean()} + | {:supported_kex_schemes, [Security2.kex_scheme()]} + | {:supported_ecdh_profiles, [Security2.ecdh_profile()]} + | {:requested_keys, [Security.key()]} + + @impl Grizzly.ZWave.Command + @spec new([param()]) :: {:ok, Command.t()} + def new(params \\ []) do + command = %Command{ + name: :s2_kex_report, + command_byte: 0x05, + command_class: Security2, + params: params, + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + def encode_params(command) do + request_csa = Command.param(command, :request_csa, false) + echo = Command.param(command, :echo, false) + nls_support = Command.param(command, :nls_support, false) + supported_kex_schemes = Command.param(command, :supported_kex_schemes, @default_kex_schemes) + + supported_ecdh_profiles = + Command.param(command, :supported_ecdh_profiles, @default_ecdh_profiles) + + requested_keys = Command.param!(command, :requested_keys) + + <<0::5, bool_to_bit(nls_support)::1, bool_to_bit(request_csa)::1, bool_to_bit(echo)::1, + encode_supported_kex_schemes(supported_kex_schemes)::8, + encode_supported_ecdh_profiles(supported_ecdh_profiles)::8, + Security.keys_to_byte(requested_keys)::8>> + end + + @impl Grizzly.ZWave.Command + def decode_params( + <<_reserved::5, nls_support::1, request_csa::1, echo::1, supported_kex_schemes::8, + supported_ecdh_profiles::8, requested_keys::8>> + ) do + request_csa = bit_to_bool(request_csa) + echo = bit_to_bool(echo) + nls_support = bit_to_bool(nls_support) + supported_kex_schemes = decode_supported_kex_schemes(supported_kex_schemes) + supported_ecdh_profiles = decode_supported_ecdh_profiles(supported_ecdh_profiles) + requested_keys = Security.byte_to_keys(requested_keys) + + {:ok, + [ + request_csa: request_csa, + echo: echo, + nls_support: nls_support, + supported_kex_schemes: supported_kex_schemes, + supported_ecdh_profiles: supported_ecdh_profiles, + requested_keys: requested_keys + ]} + end + + @kex_scheme_1_mask 0b00000010 + + defp encode_supported_kex_schemes(schemes) do + if :kex_scheme_1 in schemes do + @kex_scheme_1_mask + else + 0 + end + end + + defp decode_supported_kex_schemes(byte) do + if band(byte, @kex_scheme_1_mask) == @kex_scheme_1_mask do + {:ok, [:kex_scheme_1]} + else + {:ok, []} + end + end + + @curve_25519_mask 0b00000001 + + defp encode_supported_ecdh_profiles(profiles) do + if :curve_25519 in profiles do + @curve_25519_mask + else + 0 + end + end + + defp decode_supported_ecdh_profiles(byte) do + if band(byte, @curve_25519_mask) == @curve_25519_mask do + {:ok, [:curve_25519]} + else + {:ok, []} + end + end +end diff --git a/lib/grizzly/zwave/commands/s2_kex_set.ex b/lib/grizzly/zwave/commands/s2_kex_set.ex new file mode 100644 index 00000000..3a9a6d93 --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_kex_set.ex @@ -0,0 +1,111 @@ +defmodule Grizzly.ZWave.Commands.S2KexSet do + @moduledoc """ + During initial key exchange this command is used by an including node to grant + network keys to a joining node. The joining node subsequently requests the + granted keys once a temporary secure channel has been established. + + After establishment of the temporary secure channel, the joining node issues + this command to the including node to securely state its intention to request + the keys that were granted previously. + """ + @behaviour Grizzly.ZWave.Command + + import Bitwise + import Grizzly.ZWave.Encoding + + alias Grizzly.ZWave.Command + alias Grizzly.ZWave.CommandClasses.Security2 + alias Grizzly.ZWave.Security + + @type param :: + {:request_csa, boolean()} + | {:echo, boolean()} + | {:supported_kex_schemes, [Security2.kex_scheme()]} + | {:supported_ecdh_profiles, [Security2.ecdh_profile()]} + | {:granted_keys, [Security.key()]} + + @impl Grizzly.ZWave.Command + @spec new([param()]) :: {:ok, Command.t()} + def new(params \\ []) do + command = %Command{ + name: :s2_kex_set, + command_byte: 0x06, + command_class: Security2, + params: params, + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + def encode_params(command) do + request_csa = Command.param(command, :request_csa, false) + echo = Command.param(command, :echo, false) + supported_kex_schemes = Command.param(command, :supported_kex_schemes, [:kex_scheme_1]) + supported_ecdh_profiles = Command.param(command, :supported_ecdh_profiles, [:curve_25519]) + granted_keys = Command.param!(command, :granted_keys) + + <<0::6, bool_to_bit(request_csa)::1, bool_to_bit(echo)::1, + encode_supported_kex_schemes(supported_kex_schemes)::8, + encode_supported_ecdh_profiles(supported_ecdh_profiles)::8, + Security.keys_to_byte(granted_keys)::8>> + end + + @impl Grizzly.ZWave.Command + def decode_params( + <<_reserved::6, request_csa::1, echo::1, supported_kex_schemes::8, + supported_ecdh_profiles::8, granted_keys::8>> + ) do + request_csa = bit_to_bool(request_csa) + echo = bit_to_bool(echo) + supported_kex_schemes = decode_supported_kex_schemes(supported_kex_schemes) + supported_ecdh_profiles = decode_supported_ecdh_profiles(supported_ecdh_profiles) + granted_keys = Security.byte_to_keys(granted_keys) + + {:ok, + [ + {:request_csa, request_csa}, + {:echo, echo}, + {:supported_kex_schemes, supported_kex_schemes}, + {:supported_ecdh_profiles, supported_ecdh_profiles}, + {:granted_keys, granted_keys} + ]} + end + + @kex_scheme_1_mask 0b00000010 + + defp encode_supported_kex_schemes(schemes) do + if :kex_scheme_1 in schemes do + @kex_scheme_1_mask + else + 0 + end + end + + defp decode_supported_kex_schemes(byte) do + if band(byte, @kex_scheme_1_mask) == @kex_scheme_1_mask do + {:ok, [:kex_scheme_1]} + else + {:ok, []} + end + end + + @curve_25519_mask 0b00000001 + + defp encode_supported_ecdh_profiles(profiles) do + if :curve_25519 in profiles do + @curve_25519_mask + else + 0 + end + end + + defp decode_supported_ecdh_profiles(byte) do + if band(byte, @curve_25519_mask) == @curve_25519_mask do + {:ok, [:curve_25519]} + else + {:ok, []} + end + end +end diff --git a/lib/grizzly/zwave/commands/s2_message_encapsulation.ex b/lib/grizzly/zwave/commands/s2_message_encapsulation.ex new file mode 100644 index 00000000..67e4b2cb --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_message_encapsulation.ex @@ -0,0 +1,161 @@ +defmodule Grizzly.ZWave.Commands.S2MessageEncapsulation do + @moduledoc """ + Encapsulates a message for transmission using S2. + + ## Params + + * `:seq_number` - must carry an increment of the value carried in the previous + outgoing message. + * `:extensions` - a list of extensions (SPAN, MGRP, MOS) to include with the command. + * `:encrypted_extensions?` - when set, indicates that the command includes encrypted + extensions (MPAN). Unlike `extensions?`, this param is required when encoding as + it is impossible to determine if the encrypted payload contains extensions at that + point. + * `:encrypted_payload` - includes the encrypted extensions (if any), encrypted command + payload, and auth tag + + + ## Notes + + * Although this module's functions support encoding/decoding the MPAN extension, + MPAN is an encrypted extension and must be included in the encrypted payload. + As such, it will be ignored if provided in the `:extensions` param. + """ + + @behaviour Grizzly.ZWave.Command + + import Grizzly.ZWave.Encoding + alias Grizzly.ZWave.{Command, DecodeError} + alias Grizzly.ZWave.CommandClasses.Security2 + + @type extension_type :: :span | :mpan | :mgrp | :mos + + @type extension :: + {:span, <<_::128>>} + | {:mgrp, group_id :: byte()} + | {:mos, boolean()} + + @type param :: + {:seq_number, byte()} + | {:extensions, list()} + | {:encrypted_extensions?, boolean()} + | {:encrypted_payload, binary()} + + @impl Grizzly.ZWave.Command + @spec new([param()]) :: {:ok, Command.t()} + def new(params) do + # MPAN is an encrypted extension, so reject it if it was included + params = Keyword.replace(params, :extensions, reject_mpan(params[:extensions] || [])) + + command = %Command{ + name: :s2_message_encapsulation, + command_byte: 0x03, + command_class: Security2, + params: params, + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + @spec encode_params(Command.t()) :: binary() + def encode_params(cmd) do + seq_number = Command.param!(cmd, :seq_number) + extensions = Command.param(cmd, :extensions, []) + encrypted_extensions? = Command.param(cmd, :encrypted_extensions?, false) + encrypted_payload = Command.param!(cmd, :encrypted_payload) + + ext = encode_extensions(extensions) + extensions? = ext != <<>> + + <> + end + + @impl Grizzly.ZWave.Command + @spec decode_params(binary()) :: {:ok, [param()]} | {:error, DecodeError.t()} + def decode_params(<>) do + {extensions, encrypted_payload} = + if(ext? == 1, + do: decode_extensions(rest), + else: {[], rest} + ) + + {:ok, + [ + seq_number: seq_number, + extensions: extensions, + encrypted_extensions?: bit_to_bool(encrypted_ext?), + encrypted_payload: encrypted_payload + ]} + end + + def encode_extensions(extensions) do + extensions = + extensions + |> reject_mpan() + |> Enum.reject(fn {t, v} -> t == :mos and v == false end) + + encoded_extensions = + for {type, value} <- extensions, into: [] do + data = encode_extension_data(type, value) + # we'll clear the more_to_follow bit on the last extension later + length = byte_size(data) + 2 + critical? = if(type == :mos, do: 0, else: 1) + type = encode_extension_type(type) + <> + end + + encoded_extensions + |> List.update_at(-1, fn <> -> + <> + end) + |> Enum.join() + end + + def decode_extensions( + <>, + extensions \\ [] + ) do + type = decode_extension_type(type) + extensions = [{type, decode_extension_data(type, extension_data)} | extensions] + + if more_to_follow? == 1 do + decode_extensions(rest, extensions) + else + {extensions, rest} + end + end + + defp encode_extension_data(:span, span), do: span + defp encode_extension_data(:mos, true), do: <<>> + defp encode_extension_data(:mgrp, group_id), do: <> + + defp encode_extension_data(:mpan, value) do + group_id = Keyword.fetch!(value, :group_id) + mpan_state = Keyword.fetch!(value, :mpan_state) + <> + end + + defp decode_extension_data(:span, span), do: span + defp decode_extension_data(:mgrp, <>), do: group_id + defp decode_extension_data(:mos, <<>>), do: true + + defp decode_extension_data(:mpan, <>), + do: [group_id: group_id, mpan_state: mpan_state] + + defp encode_extension_type(:span), do: 1 + defp encode_extension_type(:mpan), do: 2 + defp encode_extension_type(:mgrp), do: 3 + defp encode_extension_type(:mos), do: 4 + + defp decode_extension_type(1), do: :span + defp decode_extension_type(2), do: :mpan + defp decode_extension_type(3), do: :mgrp + defp decode_extension_type(4), do: :mos + + @spec reject_mpan([extension()]) :: [extension()] + defp reject_mpan(exts), do: Enum.reject(exts, fn {type, _} -> type == :mpan end) +end diff --git a/lib/grizzly/zwave/commands/s2_message_encapsulation/extensions.ex b/lib/grizzly/zwave/commands/s2_message_encapsulation/extensions.ex new file mode 100644 index 00000000..2b466d45 --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_message_encapsulation/extensions.ex @@ -0,0 +1,76 @@ +defmodule Grizzly.ZWave.Commands.S2MessageEncapsulation.Extensions do + @moduledoc """ + Functions for working with S2 Message Encapsulation extensions. + """ + + import Grizzly.ZWave.Encoding + + @type extension_type :: :span | :mpan | :mgrp | :mos + + @type t :: [extension()] + + @type mpan :: %{group_id: 0..255, inner_mpan_state: <<_::128>>} + + @type extension :: + {:span, senders_entropy_input :: binary()} + | {:mpan, mpan()} + | {:mgrp, group_id :: byte()} + | {:mos, true} + + @spec from_binary(binary()) :: + {:ok, {[extension()], remainder :: binary()}} + | {:error, :unsupported_critical_extension} + def from_binary(binary) do + {extensions, remainder} = split_extensions(binary) + + if Enum.any?(extensions, &(&1.type == :unsupported && &1.critical?)) do + {:error, :unsupported_critical_extension} + else + extensions = Enum.map(extensions, &parse_extension/1) + {:ok, {extensions, remainder}} + end + end + + # Splits extensions into a list of binaries but doesn't decode them yet. + @spec split_extensions(binary(), [map()]) :: {[map()], binary()} + defp split_extensions( + <>, + acc \\ [] + ) do + ext = %{critical?: bit_to_bool(critical?), type: decode_type(type), data: data} + + if bit_to_bool(more_to_follow?) do + split_extensions(rest, [ext | acc]) + else + {[ext | acc], rest} + end + end + + # defp encode_type(:span), do: 0x01 + # defp encode_type(:mpan), do: 0x02 + # defp encode_type(:mgrp), do: 0x03 + # defp encode_type(:mos), do: 0x04 + + defp decode_type(0x01), do: :span + defp decode_type(0x02), do: :mpan + defp decode_type(0x03), do: :mgrp + defp decode_type(0x04), do: :mos + defp decode_type(_), do: :unsupported + + defp parse_extension(%{type: :span, data: <>}) do + {:span, senders_entropy_input} + end + + defp parse_extension(%{type: :mpan, data: <>}) do + {:mpan, %{group_id: group_id, inner_mpan_state: inner_mpan_state}} + end + + defp parse_extension(%{type: :mgrp, data: <>}) do + {:mgrp, group_id} + end + + defp parse_extension(%{type: :mos, data: <<>>}) do + {:mos, true} + end +end diff --git a/lib/grizzly/zwave/commands/s2_network_key_get.ex b/lib/grizzly/zwave/commands/s2_network_key_get.ex new file mode 100644 index 00000000..cce1454d --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_network_key_get.ex @@ -0,0 +1,42 @@ +defmodule Grizzly.ZWave.Commands.S2NetworkKeyGet do + @moduledoc """ + This command is used by a joining node to request one key from the including + node. One instance of this command MUST be sent for each key that was granted + by the including node. + """ + @behaviour Grizzly.ZWave.Command + + alias Grizzly.ZWave.Command + alias Grizzly.ZWave.CommandClasses.Security2 + alias Grizzly.ZWave.Security + + @type param :: {:requested_key, Security.key()} + + @impl Grizzly.ZWave.Command + @spec new([param()]) :: {:ok, Command.t()} + def new(params \\ []) do + command = %Command{ + name: :s2_network_key_get, + command_byte: 0x09, + command_class: Security2, + params: params, + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + def encode_params(command) do + requested_key = Command.param!(command, :requested_key) + + <> + end + + @impl Grizzly.ZWave.Command + def decode_params(<>) do + requested_key = Security.key_from_byte(requested_key) + + {:ok, [requested_key: requested_key]} + end +end diff --git a/lib/grizzly/zwave/commands/s2_network_key_report.ex b/lib/grizzly/zwave/commands/s2_network_key_report.ex new file mode 100644 index 00000000..663da080 --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_network_key_report.ex @@ -0,0 +1,39 @@ +defmodule Grizzly.ZWave.Commands.S2NetworkKeyReport do + @moduledoc """ + This command is used by an including node to transfer one key to the joining + node. + """ + @behaviour Grizzly.ZWave.Command + + alias Grizzly.ZWave.Command + alias Grizzly.ZWave.CommandClasses.Security2 + alias Grizzly.ZWave.Security + + @type param :: {:granted_key, Security.key(), network_key: <<_::128>>} + + @impl Grizzly.ZWave.Command + @spec new([param()]) :: {:ok, Command.t()} + def new(params \\ []) do + command = %Command{ + name: :s2_network_key_report, + command_byte: 0x0A, + command_class: Security2, + params: params, + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + def encode_params(command) do + granted_key = Command.param!(command, :granted_key) + <> + end + + @impl Grizzly.ZWave.Command + def decode_params(<>) do + granted_key = Security.key_from_byte(granted_key) + {:ok, [granted_key: granted_key]} + end +end diff --git a/lib/grizzly/zwave/commands/s2_network_key_verify.ex b/lib/grizzly/zwave/commands/s2_network_key_verify.ex new file mode 100644 index 00000000..05bfdb22 --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_network_key_verify.ex @@ -0,0 +1,30 @@ +defmodule Grizzly.ZWave.Commands.S2NetworkKeyVerify do + @moduledoc """ + This command is used by a joining node to verify a newly exchanged key with + the including node. + """ + @behaviour Grizzly.ZWave.Command + + alias Grizzly.ZWave.Command + alias Grizzly.ZWave.CommandClasses.Security2 + + @impl Grizzly.ZWave.Command + @spec new(keyword()) :: {:ok, Command.t()} + def new(_params) do + command = %Command{ + name: :s2_network_key_verify, + command_byte: 0x0B, + command_class: Security2, + params: [], + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + def encode_params(_command), do: <<>> + + @impl Grizzly.ZWave.Command + def decode_params(_binary), do: {:ok, []} +end diff --git a/lib/grizzly/zwave/commands/s2_nonce_get.ex b/lib/grizzly/zwave/commands/s2_nonce_get.ex new file mode 100644 index 00000000..01ffc63e --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_nonce_get.ex @@ -0,0 +1,43 @@ +defmodule Grizzly.ZWave.Commands.S2NonceGet do + @moduledoc """ + What does this command do?? + + ## Params + + * `:sequence_number` - must carry an increment of the value carried in the previous + outgoing message. + """ + + @behaviour Grizzly.ZWave.Command + + alias Grizzly.ZWave.{Command, DecodeError} + alias Grizzly.ZWave.CommandClasses.Security2 + + @type param :: {:sequence_number, any()} + + @impl Grizzly.ZWave.Command + @spec new([param()]) :: {:ok, Command.t()} + def new(params) do + command = %Command{ + name: :s2_nonce_get, + command_byte: 0x01, + command_class: Security2, + params: params, + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + @spec encode_params(Command.t()) :: binary() + def encode_params(_command) do + <<>> + end + + @impl Grizzly.ZWave.Command + @spec decode_params(binary()) :: {:ok, [param()]} | {:error, DecodeError.t()} + def decode_params(_binary) do + {:ok, []} + end +end diff --git a/lib/grizzly/zwave/commands/s2_nonce_report.ex b/lib/grizzly/zwave/commands/s2_nonce_report.ex new file mode 100644 index 00000000..d35cfc45 --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_nonce_report.ex @@ -0,0 +1,75 @@ +defmodule Grizzly.ZWave.Commands.S2NonceReport do + @moduledoc """ + What does this command do?? + + ## Params + + * `:seq_number` - must carry an increment of the value carried in the previous + outgoing message. + * `:mpan_out_of_sync?` - when set by a sending node, indicates that the sender + does not have MPAN state for the Multicast group used in the most recently + received singlecast follow-up S2 Encap command sent by the destination of + this command. + * `:span_out_of_sync?` - when set by a sending node, indicates that the sender + does not have a SPAn established for for the receiving node or was unable to + decrypt the most recently received singlecast S2 Encap command sent by the + destination of this command. + * `:receivers_entropy_input` - when present, carries the Receiver's Entropy + Input in preparation for new S2 transmissions based on the SPAN. Optional + unless `span_out_of_sync` is set. + """ + + @behaviour Grizzly.ZWave.Command + + import Grizzly.ZWave.Encoding + alias Grizzly.ZWave.{Command, DecodeError} + alias Grizzly.ZWave.CommandClasses.Security2 + + @type param :: + {:seq_number, byte()} + | {:mpan_out_of_sync?, boolean()} + | {:span_out_of_sync?, boolean()} + | {:receivers_entropy_input, <<_::128>>} + + @impl Grizzly.ZWave.Command + @spec new([param()]) :: {:ok, Command.t()} + def new(params) do + command = %Command{ + name: :s2_nonce_report, + command_byte: 0x02, + command_class: Security2, + params: params, + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + @spec encode_params(Command.t()) :: binary() + def encode_params(command) do + seq_number = Command.param!(command, :seq_number) + mpan_out_of_sync? = Command.param!(command, :mpan_out_of_sync?) + span_out_of_sync? = Command.param!(command, :span_out_of_sync?) + receivers_entropy_input = Command.param(command, :receivers_entropy_input, <<>>) || <<>> + + <> + end + + @impl Grizzly.ZWave.Command + @spec decode_params(binary()) :: {:ok, [param()]} | {:error, DecodeError.t()} + def decode_params( + <> + ) do + {:ok, + [ + seq_number: seq_number, + mpan_out_of_sync?: bit_to_bool(mpan_out_of_sync?), + span_out_of_sync?: bit_to_bool(span_out_of_sync?), + receivers_entropy_input: + if(receivers_entropy_input == <<>>, do: nil, else: receivers_entropy_input) + ]} + end +end diff --git a/lib/grizzly/zwave/commands/s2_public_key_report.ex b/lib/grizzly/zwave/commands/s2_public_key_report.ex new file mode 100644 index 00000000..3a8d0dde --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_public_key_report.ex @@ -0,0 +1,43 @@ +defmodule Grizzly.ZWave.Commands.S2PublicKeyReport do + @moduledoc """ + This command is used by both the including and the joining node to establish + the Elliptic Curve Shared Secret. This is needed to establish the temporary + secure channel that enables transfer of all other keys. + """ + @behaviour Grizzly.ZWave.Command + + import Grizzly.ZWave.Encoding + alias Grizzly.ZWave.Command + alias Grizzly.ZWave.CommandClasses.Security2 + + @type param :: {:ecdh_public_key, binary()} | {:including_node, boolean()} + + @impl Grizzly.ZWave.Command + @spec new([param()]) :: {:ok, Command.t()} + def new(params \\ []) do + command = %Command{ + name: :s2_public_key_report, + command_byte: 0x08, + command_class: Security2, + params: params, + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + def encode_params(command) do + including_node = Command.param!(command, :including_node) + ecdh_public_key = Command.param!(command, :ecdh_public_key) + + <<0::7, bool_to_bit(including_node)::1>> <> ecdh_public_key + end + + @impl Grizzly.ZWave.Command + def decode_params(<<_reserved::7, including_node::1, ecdh_public_key::binary>>) do + including_node = bit_to_bool(including_node) + + {:ok, [including_node: including_node, ecdh_public_key: ecdh_public_key]} + end +end diff --git a/lib/grizzly/zwave/commands/s2_transfer_end.ex b/lib/grizzly/zwave/commands/s2_transfer_end.ex new file mode 100644 index 00000000..4c793b17 --- /dev/null +++ b/lib/grizzly/zwave/commands/s2_transfer_end.ex @@ -0,0 +1,51 @@ +defmodule Grizzly.ZWave.Commands.S2TransferEnd do + @moduledoc """ + This command is used by the including node to complete the verification of + each individual key exchange while the joining node uses this command to + complete the S2 bootstrapping process after all granted keys have been + successfully exchanged. + + The joining node MUST send this command after all granted keys have been + verified. + + This command MUST be ignored if Learn mode and Add Node mode are both + disabled. + """ + @behaviour Grizzly.ZWave.Command + + import Grizzly.ZWave.Encoding + alias Grizzly.ZWave.Command + alias Grizzly.ZWave.CommandClasses.Security2 + + @type param :: {:key_verified, boolean(), key_request_complete: boolean()} + + @impl Grizzly.ZWave.Command + @spec new([param()]) :: {:ok, Command.t()} + def new(params \\ []) do + command = %Command{ + name: :s2_transfer_end, + command_byte: 0x0C, + command_class: Security2, + params: params, + impl: __MODULE__ + } + + {:ok, command} + end + + @impl Grizzly.ZWave.Command + def encode_params(command) do + key_verified = Command.param!(command, :key_verified) + key_request_complete = Command.param!(command, :key_request_complete) + + <<0::6, bool_to_bit(key_verified)::1, bool_to_bit(key_request_complete)::1>> + end + + @impl Grizzly.ZWave.Command + def decode_params(<<_reserved::6, key_verified::1, key_request_complete::1>>) do + key_verified = bit_to_bool(key_verified) + key_request_complete = bit_to_bool(key_request_complete) + + {:ok, [key_verified: key_verified, key_request_complete: key_request_complete]} + end +end diff --git a/lib/grizzly/zwave/decoder.ex b/lib/grizzly/zwave/decoder.ex index 130feede..df632140 100644 --- a/lib/grizzly/zwave/decoder.ex +++ b/lib/grizzly/zwave/decoder.ex @@ -195,7 +195,7 @@ defmodule Grizzly.ZWave.Decoder do {0x67, 0x09, Commands.S2ResynchronizationEvent}, {0x67, 0x0E, Commands.ZWaveLongRangeChannelReport}, - # Security + # S0 {0x98, 0x02, Commands.S0CommandsSupportedGet}, {0x98, 0x03, Commands.S0CommandsSupportedReport}, {0x98, 0x04, Commands.S0SecuritySchemeGet}, @@ -207,9 +207,21 @@ defmodule Grizzly.ZWave.Decoder do {0x98, 0x80, Commands.S0NonceReport}, {0x98, 0x81, Commands.S0MessageEncapsulation}, - # Security2 - {0x9F, 0x0D, Commands.Security2CommandsSupportedGet}, - {0x9F, 0x0E, Commands.Security2CommandsSupportedReport}, + # S2 + {0x9F, 0x01, Commands.S2NonceGet}, + {0x9F, 0x02, Commands.S2NonceReport}, + {0x9F, 0x03, Commands.S2MessageEncapsulation}, + {0x9F, 0x04, Commands.S2KexGet}, + {0x9F, 0x05, Commands.S2KexReport}, + {0x9F, 0x06, Commands.S2KexSet}, + {0x9F, 0x07, Commands.S2KexFail}, + {0x9F, 0x08, Commands.S2PublicKeyReport}, + {0x9F, 0x09, Commands.S2NetworkKeyGet}, + {0x9F, 0x0A, Commands.S2NetworkKeyReport}, + {0x9F, 0x0B, Commands.S2NetworkKeyVerify}, + {0x9F, 0x0C, Commands.S2TransferEnd}, + {0x9F, 0x0D, Commands.S2CommandsSupportedGet}, + {0x9F, 0x0E, Commands.S2CommandsSupportedReport}, # Window Covering {0x6A, 0x01, Commands.WindowCoveringSupportedGet}, diff --git a/lib/grizzly/zwave/security.ex b/lib/grizzly/zwave/security.ex index cda5878c..27b151f0 100644 --- a/lib/grizzly/zwave/security.ex +++ b/lib/grizzly/zwave/security.ex @@ -4,7 +4,8 @@ defmodule Grizzly.ZWave.Security do """ import Bitwise - @type key :: :s2_unauthenticated | :s2_authenticated | :s2_access_control | :s0 + @type s2_key :: :s2_unauthenticated | :s2_authenticated | :s2_access_control + @type key :: s2_key() | :s0 @type key_byte :: 0x01 | 0x02 | 0x04 | 0x80 @@ -50,7 +51,7 @@ defmodule Grizzly.ZWave.Security do @spec keys_to_byte([key]) :: byte def keys_to_byte(keys) do - Enum.reduce(keys, 0, fn key, byte -> byte ||| key_byte(key) end) + Enum.reduce(keys, 0, fn key, byte -> byte ||| key_to_byte(key) end) end @doc """ @@ -94,11 +95,20 @@ defmodule Grizzly.ZWave.Security do so this function does not support encoding to that key. """ - @spec key_byte(key) :: key_byte() - def key_byte(:s0), do: 0x80 - def key_byte(:s2_access_control), do: 0x04 - def key_byte(:s2_authenticated), do: 0x02 - def key_byte(:s2_unauthenticated), do: 0x01 + @spec key_to_byte(key()) :: key_byte() + def key_to_byte(:s0), do: 0x80 + def key_to_byte(:s2_access_control), do: 0x04 + def key_to_byte(:s2_authenticated), do: 0x02 + def key_to_byte(:s2_unauthenticated), do: 0x01 + + @doc """ + Get the key represented by the given byte. + """ + @spec key_from_byte(key_byte()) :: key() + def key_from_byte(0x80), do: :s0 + def key_from_byte(0x04), do: :s2_access_control + def key_from_byte(0x02), do: :s2_authenticated + def key_from_byte(0x01), do: :s2_unauthenticated @doc """ Gets the highest security level key from a key list diff --git a/mix.exs b/mix.exs index 22a71b50..e473015e 100644 --- a/mix.exs +++ b/mix.exs @@ -33,6 +33,7 @@ defmodule Grizzly.MixProject do [ {:cerlc, "~> 0.2.0"}, {:circular_buffer, "~> 0.4"}, + {:ctr_drbg, "~> 0.1"}, {:dialyxir, "~> 1.4.0", only: [:test, :dev], runtime: false}, {:mimic, "~> 1.7", only: :test}, {:muontrap, "~> 1.0 or ~> 0.4"}, diff --git a/mix.lock b/mix.lock index 8eaeb2f3..23f9f07d 100644 --- a/mix.lock +++ b/mix.lock @@ -5,6 +5,7 @@ "circular_buffer": {:hex, :circular_buffer, "0.4.1", "477f370fd8cfe1787b0a1bade6208bbd274b34f1610e41f1180ba756a7679839", [:mix], [], "hexpm", "633ef2e059dde0d7b89bbab13b1da9d04c6685e80e68fbdf41282d4fae746b72"}, "credo": {:hex, :credo, "1.7.6", "b8f14011a5443f2839b04def0b252300842ce7388f3af177157c86da18dfbeea", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "146f347fb9f8cbc5f7e39e3f22f70acbef51d441baa6d10169dd604bfbc55296"}, "credo_binary_patterns": {:hex, :credo_binary_patterns, "0.2.3", "0dabadbe3cfd8db14b69ff346c112bfadde9bf65dc7aea19c39743c8d2ed07fa", [:mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "3c333a564ed3e27f5c9f69985a921b88ef90f131bf722d085957cc4b25b7a085"}, + "ctr_drbg": {:hex, :ctr_drbg, "0.1.0", "3ed43d241991d5bdf02e96f447e7ba8f0979dc9521dbb943dd75143da9eb21a6", [:mix], [], "hexpm", "aa7b0f56f672307d3253d10d0f0ad516ff91282d04ab4e817edc2ef98a1b3376"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, "elixir_make": {:hex, :elixir_make, "0.8.3", "d38d7ee1578d722d89b4d452a3e36bcfdc644c618f0d063b874661876e708683", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "5c99a18571a756d4af7a4d89ca75c28ac899e6103af6f223982f09ce44942cc9"}, diff --git a/test/grizzly/zwave/command_classes/security_2_test.exs b/test/grizzly/zwave/command_classes/security_2_test.exs new file mode 100644 index 00000000..311bca15 --- /dev/null +++ b/test/grizzly/zwave/command_classes/security_2_test.exs @@ -0,0 +1,78 @@ +defmodule Grizzly.ZWave.CommandClasses.Security2Test do + use ExUnit.Case, async: true + + alias Grizzly.ZWave.CommandClasses.Security2 + + describe "key derivation" do + test "network_key_expand/1" do + network_key = + <<0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, + 0x4E, 0x4F>> + + expected_ccm_key = + <<0xAF, 0x7F, 0x0A, 0x1E, 0xFC, 0xFE, 0x8F, 0x4B, 0x1F, 0xD2, 0x84, 0x5D, 0xD2, 0x85, + 0x6F, 0x7D>> + + expected_pstring = + <<0xCC, 0xF8, 0xB4, 0x2A, 0x4F, 0x70, 0x21, 0x76, 0xAC, 0x2B, 0x91, 0x94, 0xDB, 0xBD, + 0xB8, 0x2C, 0x47, 0x43, 0x02, 0xFF, 0x12, 0xE1, 0xE8, 0x26, 0x64, 0x22, 0xF3, 0xAC, + 0x44, 0x89, 0x4B, 0x87>> + + expected_mpan_key = + <<0xB8, 0x9D, 0xB1, 0x54, 0x3F, 0xD2, 0x82, 0x0E, 0xA9, 0x79, 0xC9, 0x6A, 0x30, 0x5A, + 0x23, 0x0B>> + + {ccm_key, pstring, mpan_key} = Security2.network_key_expand(network_key) + + assert expected_ccm_key == ccm_key + assert expected_pstring == pstring + assert expected_mpan_key == mpan_key + end + + test "temp_key_extract/2" do + ecdh_shared_secret = + <<0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, + 0x46, 0x47, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x40, 0x41, 0x42, 0x43, + 0x44, 0x45, 0x46, 0x47>> + + sender_pubkey = + <<0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, + 0x4E, 0x4F, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x48, 0x49, 0x4A, 0x4B, + 0x4C, 0x4D, 0x4E, 0x4F>> + + receiver_pubkey = + <<0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, + 0x4E, 0x4F, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x48, 0x49, 0x4A, 0x4B, + 0x4C, 0x4D, 0x4E, 0x4F>> + + expected_prk = + <<0x74, 0x77, 0x8F, 0x89, 0xD4, 0xD8, 0x8C, 0x6E, 0x46, 0x59, 0x63, 0x4A, 0x88, 0xEA, + 0x98, 0x7A>> + + assert expected_prk == + Security2.temp_key_extract(ecdh_shared_secret, sender_pubkey, receiver_pubkey) + end + + test "temp_key_expand/1" do + prk = + <<0x74, 0x77, 0x8F, 0x89, 0xD4, 0xD8, 0x8C, 0x6E, 0x46, 0x59, 0x63, 0x4A, 0x88, 0xEA, + 0x98, 0x7A>> + + expected_ccm_key = + <<0x47, 0xDA, 0x9D, 0x15, 0x39, 0xEC, 0x73, 0xE5, 0xD2, 0xA0, 0xF7, 0x37, 0xDB, 0xF2, + 0x9B, 0x33>> + + expected_pstring = + <<0x94, 0xA5, 0x3E, 0xB9, 0xA1, 0x2D, 0x7C, 0xD9, 0x49, 0xEA, 0x70, 0xFF, 0xB8, 0xA6, + 0xE7, 0x1E, 0xA6, 0xF7, 0x9C, 0xA7, 0xA0, 0x14, 0x5A, 0x08, 0xBA, 0xD5, 0x82, 0xAE, + 0xB0, 0x9B, 0xD2, 0x8B>> + + expected_mpan_key = + <<0xC2, 0x8E, 0x57, 0x5F, 0x24, 0x6F, 0x59, 0xA4, 0xA5, 0x85, 0x0F, 0x20, 0x66, 0xF1, + 0x06, 0x2B>> + + assert {^expected_ccm_key, ^expected_pstring, ^expected_mpan_key} = + Security2.temp_key_expand(prk) + end + end +end diff --git a/test/grizzly/zwave/commands/s2_commands_supported_get_test.exs b/test/grizzly/zwave/commands/s2_commands_supported_get_test.exs new file mode 100644 index 00000000..2a3a9e92 --- /dev/null +++ b/test/grizzly/zwave/commands/s2_commands_supported_get_test.exs @@ -0,0 +1,18 @@ +defmodule Grizzly.ZWave.Commands.S2CommandsSupportedGetTest do + use ExUnit.Case, async: true + + alias Grizzly.ZWave.Commands.S2CommandsSupportedGet + + test "creates the command and validates params" do + assert {:ok, _} = S2CommandsSupportedGet.new() + end + + test "encodes params correctly" do + assert {:ok, cmd} = S2CommandsSupportedGet.new() + assert <<>> = S2CommandsSupportedGet.encode_params(cmd) + end + + test "decodes params correctly" do + assert {:ok, []} = S2CommandsSupportedGet.decode_params(<<>>) + end +end diff --git a/test/grizzly/zwave/commands/s2_commands_supported_report_test.exs b/test/grizzly/zwave/commands/s2_commands_supported_report_test.exs new file mode 100644 index 00000000..a2121149 --- /dev/null +++ b/test/grizzly/zwave/commands/s2_commands_supported_report_test.exs @@ -0,0 +1,27 @@ +defmodule Grizzly.ZWave.Commands.S2CommandsSupportedReportTest do + use ExUnit.Case, async: true + + alias Grizzly.ZWave.Commands.S2CommandsSupportedReport + + test "creates the command and validates params" do + {:ok, _} = S2CommandsSupportedReport.new(command_classes: [:user_code, :association]) + end + + test "encodes params correctly" do + {:ok, cmd} = S2CommandsSupportedReport.new(command_classes: [:user_code, :association]) + bin = S2CommandsSupportedReport.encode_params(cmd) + assert <<0x63, 0x85>> = bin + + {:ok, cmd} = S2CommandsSupportedReport.new(command_classes: [:user_code]) + bin = S2CommandsSupportedReport.encode_params(cmd) + assert <<0x63>> = bin + end + + test "decodes params correctly" do + {:ok, params} = S2CommandsSupportedReport.decode_params(<<0x63, 0x85>>) + assert [:user_code, :association] = params[:command_classes] + + {:ok, params} = S2CommandsSupportedReport.decode_params(<<0x63>>) + assert [:user_code] = params[:command_classes] + end +end diff --git a/test/grizzly/zwave/commands/s2_message_encapsulation_test.exs b/test/grizzly/zwave/commands/s2_message_encapsulation_test.exs new file mode 100644 index 00000000..9b3e4626 --- /dev/null +++ b/test/grizzly/zwave/commands/s2_message_encapsulation_test.exs @@ -0,0 +1,63 @@ +defmodule Grizzly.ZWave.Commands.S2MessageEncapsulationTest do + use ExUnit.Case, async: true + + alias Grizzly.ZWave.Commands.S2MessageEncapsulation + + test "encodes params correctly" do + {:ok, cmd} = + S2MessageEncapsulation.new( + seq_number: 0xAB, + extensions: [ + span: <<1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16>>, + mpan: [ + group_id: 20, + mpan_state: <<17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32>> + ], + mgrp: 20, + mos: true + ], + encrypted_extensions?: true, + encrypted_payload: <<0xDE, 0xAD, 0xBE, 0xEF>> + ) + + binary = S2MessageEncapsulation.encode_params(cmd) + + assert binary == + <<0xAB, 0b00000011, 18, 0b11000001, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 3, 0b11000011, 20, 2, 0b000000100, 0xDE, 0xAD, 0xBE, 0xEF>> + + {:ok, cmd} = + S2MessageEncapsulation.new( + seq_number: 0xEE, + extensions: [mos: false], + encrypted_payload: <<0xDE, 0xAD, 0xBE, 0xEF>> + ) + + binary = S2MessageEncapsulation.encode_params(cmd) + + assert binary == <<0xEE, 0, 0xDE, 0xAD, 0xBE, 0xEF>> + end + + test "decodes params correctly" do + {:ok, params} = + S2MessageEncapsulation.decode_params( + <<0xAB, 0b00000011, 18, 0b11000001, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 3, 0b11000011, 20, 2, 0b000000100, 0xDE, 0xAD, 0xBE, 0xEF>> + ) + + assert params[:seq_number] == 0xAB + assert params[:extensions][:span] == <<1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16>> + assert params[:extensions][:mgrp] == 20 + assert params[:extensions][:mos] == true + assert params[:encrypted_extensions?] == true + assert params[:encrypted_payload] == <<0xDE, 0xAD, 0xBE, 0xEF>> + + {:ok, params} = + S2MessageEncapsulation.decode_params(<<0xEE, 0, 0xDE, 0xAD, 0xBE, 0xEF>>) + + assert params[:seq_number] == 0xEE + assert params[:extensions] == [] + assert params[:encrypted_extensions?] == false + assert params[:encrypted_payload] == <<0xDE, 0xAD, 0xBE, 0xEF>> + end +end diff --git a/test/grizzly/zwave/commands/security_2_commands_supported_get_test.exs b/test/grizzly/zwave/commands/security_2_commands_supported_get_test.exs deleted file mode 100644 index 46d02e14..00000000 --- a/test/grizzly/zwave/commands/security_2_commands_supported_get_test.exs +++ /dev/null @@ -1,18 +0,0 @@ -defmodule Grizzly.ZWave.Commands.Security2CommandsSupportedGetTest do - use ExUnit.Case, async: true - - alias Grizzly.ZWave.Commands.Security2CommandsSupportedGet - - test "creates the command and validates params" do - assert {:ok, _} = Security2CommandsSupportedGet.new() - end - - test "encodes params correctly" do - assert {:ok, cmd} = Security2CommandsSupportedGet.new() - assert <<>> = Security2CommandsSupportedGet.encode_params(cmd) - end - - test "decodes params correctly" do - assert {:ok, []} = Security2CommandsSupportedGet.decode_params(<<>>) - end -end diff --git a/test/grizzly/zwave/commands/security_2_commands_supported_report_test.exs b/test/grizzly/zwave/commands/security_2_commands_supported_report_test.exs deleted file mode 100644 index dbeff265..00000000 --- a/test/grizzly/zwave/commands/security_2_commands_supported_report_test.exs +++ /dev/null @@ -1,27 +0,0 @@ -defmodule Grizzly.ZWave.Commands.Security2CommandsSupportedReportTest do - use ExUnit.Case, async: true - - alias Grizzly.ZWave.Commands.Security2CommandsSupportedReport - - test "creates the command and validates params" do - {:ok, _} = Security2CommandsSupportedReport.new(command_classes: [:user_code, :association]) - end - - test "encodes params correctly" do - {:ok, cmd} = Security2CommandsSupportedReport.new(command_classes: [:user_code, :association]) - bin = Security2CommandsSupportedReport.encode_params(cmd) - assert <<0x63, 0x85>> = bin - - {:ok, cmd} = Security2CommandsSupportedReport.new(command_classes: [:user_code]) - bin = Security2CommandsSupportedReport.encode_params(cmd) - assert <<0x63>> = bin - end - - test "decodes params correctly" do - {:ok, params} = Security2CommandsSupportedReport.decode_params(<<0x63, 0x85>>) - assert [:user_code, :association] = params[:command_classes] - - {:ok, params} = Security2CommandsSupportedReport.decode_params(<<0x63>>) - assert [:user_code] = params[:command_classes] - end -end