#!/usr/bin/env perl

##
## Author......: Robert Guetzkow
## License.....: MIT
##

use strict;
use warnings;

use Crypt::PBKDF2;
use Crypt::Mode::CBC;
use Crypt::Mode::ECB;

# Details of the protocol design can be found in ISO 22510:2019 and
# application notes published by the KNX Association.

# ETS 5 allows a maximum of 20 characters in a password.
# The salt is used as Secure Session Identifier, which is 2 Bytes long.
sub module_constraints { [[0, 20], [2, 2], [-1, -1], [-1, -1], [-1, -1]] }

sub device_authentication_code
{
  my $password = shift;

  my $pbkdf2 = Crypt::PBKDF2->new
  (
    hasher     => Crypt::PBKDF2->hasher_from_algorithm ("HMACSHA2", 256),
    iterations => 65536,
    output_len => 16
  );

  my $device_authentication_code = $pbkdf2->PBKDF2 ("device-authentication-code.1.secure.ip.knx.org",
                                                    $password);

  return $device_authentication_code;
}

sub block_formatting
{
  # Simplified block formatting function, where payload is always empty
  my $b0 = shift;
  my $associated_data = shift;
  my $associated_data_length = pack ("s>", length ($associated_data));
  my $blocks_unpadded = $associated_data_length . $associated_data;
  my $pad_len = int ((length ($blocks_unpadded) + 16 - 1) / 16) * 16;
  my $blocks_padded = $blocks_unpadded . "\0" x ($pad_len - length ($blocks_unpadded));

  return $b0 . $blocks_padded;
}

sub encrypt
{
  # Simplified encryption that only performs steps required for the MAC, not full CCM
  my $blocks = shift;
  my $nonce = shift;
  my $key = shift;
  my $iv = "\0" x 16;

  my $aes_cbc = Crypt::Mode::CBC->new ("AES", 0);
  my $ciphertext = $aes_cbc->encrypt ($blocks, $key, $iv);
  my $y_n = substr ($ciphertext, length ($ciphertext) - 16, 16);

  my $aes_ecb = Crypt::Mode::ECB->new ("AES", 0);
  my $s_0 = $aes_ecb->encrypt ($nonce, $key);

  return $y_n ^ $s_0;
}

sub generate_session_response_mac
{
  my $secure_session_identifier = shift;
  my $public_value_xor          = shift;
  my $key                       = shift;

  # Constants used for the cryptography in Session_Response frames
  my $knx_ip_header = pack ("H*", "061009520038");
  my $b0            = pack ("H*", "00000000000000000000000000000000");
  my $nonce         = pack ("H*", "0000000000000000000000000000ff00");

  my $associated_data = $knx_ip_header . $secure_session_identifier . $public_value_xor;

  my $blocks = block_formatting ($b0, $associated_data);

  return encrypt ($blocks, $nonce, $key);
}

sub module_generate_hash
{
  my $word = shift;

  # Parameters that would be found in the Session_Request and Session_Response frames
  my $secure_session_identifier = shift;
  my $public_value_xor          = shift // random_bytes (32);

  my $device_authentication_code = device_authentication_code ($word);

  my $mac = generate_session_response_mac ($secure_session_identifier,
                                           $public_value_xor,
                                           $device_authentication_code);

  my $hash = sprintf ("\$knx-ip-secure-device-authentication-code\$*%s*%s*%s",
                      unpack ("H*", $secure_session_identifier),
                      unpack ("H*", $public_value_xor),
                      unpack ("H*", $mac));

  return $hash;
}

sub module_verify_hash
{
  my $line = shift;

  my ($hash, $word) = split (':', $line);

  return unless defined $hash;
  return unless defined $word;

  my @data = split ('\*', $hash);

  return unless scalar (@data) == 4;

  my $signature = shift @data;

  return unless ($signature eq "\$knx-ip-secure-device-authentication-code\$");

  my $secure_session_identifier = pack ("H*", shift @data); #  2 Bytes expected (using the "salt" for this purpose)
  my $public_value_xor          = pack ("H*", shift @data); # 32 Bytes expected (xor of client's and server's public value)
  my $mac                       = pack ("H*", shift @data); # 16 Bytes expected

  return unless (length ($secure_session_identifier) == 2);
  return unless (length ($public_value_xor) == 32);
  return unless (length ($mac) == 16);

  my $word_packed = pack_if_HEX_notation ($word);

  my $new_hash = module_generate_hash ($word_packed,
                                       $secure_session_identifier,
                                       $public_value_xor);

  return ($new_hash, $word);
}

1;