Skip to content

Commit

Permalink
Implement S2 command class (#900)
Browse files Browse the repository at this point in the history
* S2 KEX Set/Get/Report/Fail
* S2 Message Encapsulation
* S2 Network Key Get/Report/Verify
* S2 Nonce Get/Report
* S2 Public Key Report
* S2 Transfer End
  • Loading branch information
bjyoungblood authored May 16, 2024
1 parent 2fdeb44 commit 4075175
Show file tree
Hide file tree
Showing 27 changed files with 1,284 additions and 63 deletions.
27 changes: 24 additions & 3 deletions lib/grizzly/commands/table.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
171 changes: 171 additions & 0 deletions lib/grizzly/zwave/command_classes/security_2.ex
Original file line number Diff line number Diff line change
@@ -1,13 +1,184 @@
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)

<<sender_node_id::16, destination_tag::16, aad.home_id::32, aad.message_length::16,
aad.sequence_number::8, 0::6, bool_to_bit(aad.encrypted_extensions?)::1,
bool_to_bit(extensions?)::1, aad.extensions::binary>>
end

def encode(
%__MODULE__{sender_node_id: sender_node_id, destination_tag: destination_tag} = aad
) do
extensions? = if(aad.extensions != <<>>, do: true, else: false)

<<sender_node_id::8, destination_tag::8, aad.home_id::32, aad.message_length::16,
aad.sequence_number::8, 0::6, bool_to_bit(aad.encrypted_extensions?)::1,
bool_to_bit(extensions?)::1, aad.extensions::binary>>
end
end

@type kex_scheme :: :kex_scheme_1
@type ecdh_profile :: :curve_25519

@behaviour Grizzly.ZWave.CommandClass

@impl Grizzly.ZWave.CommandClass
def byte(), do: 0x9F

@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 = <<constant_nk::binary-size(15), 0x01>>
ccm_key = aes_cmac_calculate(network_key, t0)

# pstring first half
t1 = <<ccm_key::binary-size(16), constant_nk::binary-size(15), 2::size(8)>>
pstring1 = aes_cmac_calculate(network_key, t1)

# pstring second half
t2 = <<pstring1::binary-size(16), constant_nk::binary-size(15), 3::size(8)>>
pstring2 = aes_cmac_calculate(network_key, t2)

# MPAN key
t3 = <<pstring2::binary-size(16), constant_nk::binary-size(15), 4::size(8)>>
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 <<int::16 <- public_key>>, 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
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand All @@ -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),
Expand Down
41 changes: 41 additions & 0 deletions lib/grizzly/zwave/commands/s2_kex_fail.ex
Original file line number Diff line number Diff line change
@@ -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)

<<Security.failed_type_to_byte(kex_fail_type)::8>>
end

@impl Grizzly.ZWave.Command
def decode_params(<<kex_fail_type::8>>) do
kex_fail_type = Security.failed_type_from_byte(kex_fail_type)

{:ok, [kex_fail_type: kex_fail_type]}
end
end
30 changes: 30 additions & 0 deletions lib/grizzly/zwave/commands/s2_kex_get.ex
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 4075175

Please sign in to comment.