anfly0 / exBankID

exBankID is a simple stateless API-client for the Swedish BankID API
MIT License
17 stars 4 forks source link

Client-side validation of personal numbers #10

Open anfly0 opened 4 years ago

anfly0 commented 4 years ago

Add personal number validation to the new methods in ExBankID.Auth.Payload and ExBankID.Sign.Payload.

kwando commented 4 years ago

Here is some code for validating and calculating the checksum of Swedish personal numbers using Luhns algorithm.

There are 2 versions available, one recursive and one based on Enum.

defmodule LuhnChecker do
  def valid?(number) when is_integer(number) and number >= 0 do
    digits = Enum.reverse(Integer.digits(number))
    rem(sum_digits(digits, 1), 10) === 0
  end

  def checksum(number) when is_integer(number) and number >= 0 do
    calculate_checksum(Integer.digits(number))
  end

  # alternate solution to calculating the checksum
  defp checksum1(digits) do
    sum =
      digits
      |> Enum.zip(Stream.cycle([2, 1]))
      |> Enum.flat_map(fn {digit, factor} -> (digit * factor) |> Integer.digits() end)
      |> Enum.sum()

    ceil(sum / 10) * 10 - sum
  end

  defp calculate_checksum(digits) do
    sum = sum_digits(digits, 2)
    ceil(sum / 10) * 10 - sum
  end

  defp sum_digits([], _), do: 0

  defp sum_digits([digit | digits], factor) do
    sum_digits(
      digits,
      next_factor(factor)
    ) + checksum_digit(digit * factor)
  end

  defp checksum_digit(digit) when digit <= 9, do: digit
  defp checksum_digit(digit), do: digit - 9

  defp next_factor(1), do: 2
  defp next_factor(2), do: 1
end
anfly0 commented 4 years ago

First of all, thank you, @kwando, for taking the time. This looks very promising, and I would be happy to merge some version of this. If you want and have the time, could you wrap this up in a PR? Just decide on what version you think is the cleanest and add some basic tests.

If you would like to take a crack at the rest of this issue, you're of course more than welcome to do so.

carlgleisner commented 1 year ago

There is actually already a HEX package for validating strings of numbers based on Luhn's algorithm: https://hex.pm/packages/luhn.

I threw together the following example that validates:

  1. The century and
  2. The Luhn checksum
def check_personal_number(personal_number)
    when is_binary(personal_number) do
  with true <- String.length(personal_number) == 12,
       true <- check_personal_number_century(personal_number),
       true <- personal_number |> String.slice(2, 10) |> Luhn.valid?() do
    {:ok, personal_number}
  else
    false -> {:error, "Invalid personal number: #{personal_number}"}
  end
end

def check_personal_number(nil) do
  {:ok, nil}
end

defp check_personal_number_century("19" <> _), do: true
defp check_personal_number_century("20" <> _), do: true
defp check_personal_number_century(_), do: false

Then of course, one could validate that the month and day are reasonable numbers. My mind goes to NimbleParsec and this Elixir Forum topic. Now, needless to say, there is the Gregorian calendar to observe in this regard. But, I guess that a rudimentary check is better than nothing to begin with?

For testing there's the Swedish Tax Authority's published personal numbers reserved for testing. I guess one would want tests that cover the cases. With the current ones all Luhn validations will be performed on mere zeros.

carlgleisner commented 1 year ago

First stab. No previous experience in writing parsers so take it for what it is.

defmodule MyParser do
  import NimbleParsec

  century =
    choice([string("19"), string("20")])

  year =
    integer(2)

  month =
    ~w(01 02 03 04 05 06 07 08 09 10 11 12)
    |> Enum.map(&string/1)
    |> choice()

  day =
    (~w(01 02 03 04 05 06 07 08 09) ++ Enum.map(10..31, &to_string/1))
    |> Enum.map(&string/1)
    |> choice()

  defparsec :personal_number, century |> concat(year) |> concat(month) |> concat(day)
end