├── .formatter.exs ├── .travis.yml ├── .gitignore ├── test ├── test_helper.exs ├── credential_test.exs ├── web_authn_ex_test.exs ├── authenticator_test.exs ├── support │ └── fake_authenticator.exs ├── auth_attestation_response_test.exs └── auth_assertion_response_test.exs ├── lib ├── web_authn_ex │ ├── attestation_statement │ │ ├── none.ex │ │ └── fido_u2f.ex │ ├── cbor.ex │ ├── cbor │ │ ├── types.ex │ │ ├── decoder.ex │ │ └── encoder.ex │ ├── public_key_u2f.ex │ ├── bits.ex │ ├── client_data.ex │ ├── attestation_statement.ex │ ├── ec2_key.ex │ ├── credential.ex │ ├── auth_data.ex │ ├── authenticator_response.ex │ ├── auth_attestation_response.ex │ └── auth_assertion_response.ex └── web_authn_ex.ex ├── LICENSE ├── config └── config.exs ├── mix.exs ├── mix.lock └── README.md /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: '1.7.3' 3 | otp_release: '21.1.1' 4 | script: 5 | - mix credo --strict 6 | - mix test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build/ 2 | /cover/ 3 | /deps/ 4 | /doc/ 5 | /.fetch 6 | erl_crash.dump 7 | *.ez 8 | web_authn_ex-*.tar 9 | .elixir_ls/ 10 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | {:ok, files} = File.ls("./test/support") 4 | 5 | Enum.each(files, fn file -> 6 | Code.require_file("support/#{file}", __DIR__) 7 | end) 8 | -------------------------------------------------------------------------------- /lib/web_authn_ex/attestation_statement/none.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.AttestationStatement.None do 2 | @moduledoc """ 3 | Verifies None attestation statement 4 | """ 5 | alias __MODULE__ 6 | @enforce_keys [:statement] 7 | defstruct [:statement] 8 | def new(statement), do: {:ok, %None{statement: statement}} 9 | 10 | def valid?(_, _, _) do 11 | true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/web_authn_ex/cbor.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.Cbor do 2 | @moduledoc """ 3 | Implementation of CBOR (rfc7049) encoder and decoder. 4 | """ 5 | alias WebAuthnEx.Cbor.{Decoder, Encoder} 6 | 7 | def encode(value) do 8 | Encoder.encode(value) 9 | end 10 | 11 | def decode!(value) do 12 | {:ok, result} = decode(value) 13 | 14 | result 15 | end 16 | 17 | def decode(value) do 18 | Decoder.decode(value) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/web_authn_ex/cbor/types.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.Cbor.Types do 2 | @moduledoc """ 3 | Types for CBOR objects. 4 | """ 5 | @unsigned_integer <<0b000::3>> 6 | @negative_integer <<0b001::3>> 7 | @byte_string <<0b010::3>> 8 | @string <<0b011::3>> 9 | @map <<0b101::3>> 10 | @array <<0b100::3>> 11 | 12 | def unsigned_integer, do: @unsigned_integer 13 | def negative_integer, do: @negative_integer 14 | def byte_string, do: @byte_string 15 | def string, do: @string 16 | def map, do: @map 17 | def array, do: @array 18 | end 19 | -------------------------------------------------------------------------------- /test/credential_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CredentialTest do 2 | use ExUnit.Case 3 | doctest WebAuthnEx.Credential 4 | alias WebAuthnEx.Credential 5 | 6 | def raw_attested_credential_data(options \\ %{}) do 7 | options = 8 | options 9 | |> Map.put(:aaguid, 16 |> :crypto.strong_rand_bytes()) 10 | |> Map.put(:id, 16 |> :crypto.strong_rand_bytes()) 11 | |> Map.put(:public_key, options.public_key || FakeAuthenticator.fake_cose_credential_key()) 12 | 13 | options.aaguid <> 14 | <> <> options.id <> options.public_key 15 | end 16 | 17 | test "#valid? returns false if public key is missing" do 18 | raw_data = raw_attested_credential_data(%{public_key: ""}) 19 | refute Credential.valid?(raw_data) 20 | refute Credential.new(raw_data).public_key 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/web_authn_ex/public_key_u2f.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.PublicKeyU2f do 2 | @moduledoc """ 3 | Validates PublicKeyU2f 4 | """ 5 | alias WebauthnEx.EC2Key 6 | alias __MODULE__ 7 | @coordinate_length 32 8 | 9 | defstruct [:data] 10 | 11 | def new(data) do 12 | %PublicKeyU2f{ 13 | data: data 14 | } 15 | end 16 | 17 | def valid?(%PublicKeyU2f{} = public_key) do 18 | byte_size(public_key.data) >= @coordinate_length * 2 && 19 | byte_size(cose_key(public_key).x_coordinate) == @coordinate_length && 20 | byte_size(cose_key(public_key).y_coordinate) == @coordinate_length && 21 | cose_key(public_key).algorithm == -7 22 | end 23 | 24 | def cose_key(%PublicKeyU2f{} = public_key) do 25 | EC2Key.from_cbor(public_key.data) 26 | end 27 | 28 | def to_binary(key) do 29 | <<4>> <> key.x_coordinate <> key.y_coordinate 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/web_authn_ex.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx do 2 | @moduledoc """ 3 | WebAuthnEx implement https://www.w3.org/TR/webauthn/ for Elixir. 4 | """ 5 | # credo:disable-for-next-line 6 | @cred_param_ES256 %{type: "public-key", alg: -7} 7 | @user_id "1" 8 | @user_name "web-user" 9 | 10 | def credential_creation_options(rp_name, rp_id \\ nil) do 11 | rp = 12 | %{name: rp_name, rp_id: rp_id} 13 | |> Enum.filter(fn {_, v} -> v != nil end) 14 | |> Enum.into(%{}) 15 | 16 | %{ 17 | challenge: challenge(), 18 | # credo:disable-for-next-line 19 | pubKeyCredParams: [@cred_param_ES256], 20 | rp: rp, 21 | user: %{name: @user_name, displayName: @user_name, id: @user_id} 22 | } 23 | end 24 | 25 | def credential_request_options do 26 | %{ 27 | challenge: challenge(), 28 | allowCredentials: [] 29 | } 30 | end 31 | 32 | defp challenge do 33 | :crypto.strong_rand_bytes(32) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/web_authn_ex/bits.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.Bits do 2 | @moduledoc """ 3 | Extract bits from binary to list and vice versa 4 | """ 5 | # Credits: https://minhajuddin.com/2016/11/01/how-to-extract-bits-from-a-binary-in-elixir/ 6 | # this is the public api which allows you to pass any binary representation 7 | def extract(str) when is_binary(str) do 8 | str 9 | |> extract([]) 10 | |> Enum.reverse() 11 | end 12 | 13 | def insert(bits) when is_list(bits) do 14 | bits 15 | |> Enum.reverse() 16 | |> Enum.into(<<>>, fn bit -> <> end) 17 | end 18 | 19 | # this function does the heavy lifting by matching the input binary to 20 | # a single bit and sends the rest of the bits recursively back to itself 21 | defp extract(<>, acc) when is_bitstring(bits) do 22 | extract(bits, [b | acc]) 23 | end 24 | 25 | # this is the terminal condition when we don't have anything more to extract 26 | defp extract(<<>>, acc), do: acc |> Enum.reverse() 27 | end 28 | -------------------------------------------------------------------------------- /lib/web_authn_ex/client_data.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.ClientData do 2 | @moduledoc """ 3 | WebAuthnEx.ClientData decodes client_data_json to ClientData struct 4 | """ 5 | 6 | alias __MODULE__ 7 | defstruct type: nil, challenge: nil, origin: nil, hash: nil 8 | 9 | def new(client_data_json) do 10 | client_data_json 11 | |> Jason.decode!() 12 | |> extract_data(client_data_json) 13 | end 14 | 15 | def extract_data(json, client_data_json) do 16 | cond do 17 | Map.has_key?(json, "type") == false -> 18 | {:error, "Type is missing"} 19 | 20 | Map.has_key?(json, "challenge") == false -> 21 | {:error, "Challenge is missing"} 22 | 23 | Map.has_key?(json, "origin") == false -> 24 | {:error, "Origin is missing"} 25 | 26 | true -> 27 | {:ok, 28 | %ClientData{ 29 | type: json["type"], 30 | challenge: json["challenge"], 31 | origin: json["origin"], 32 | hash: :crypto.hash(:sha256, client_data_json) 33 | }} 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Sander Groen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/web_authn_ex/attestation_statement.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.AttestationStatement do 2 | @moduledoc """ 3 | Verifies attestation statement of various types. 4 | """ 5 | alias WebAuthnEx.AttestationStatement.{FidoU2f, None} 6 | 7 | def from("fido-u2f", statement) do 8 | FidoU2f.new(statement) 9 | end 10 | 11 | def from("none", statement) do 12 | None.new(statement) 13 | end 14 | 15 | # def from("packed", statement) do 16 | # WebAuthnEx.AttestationStatement.Packed.new(statement) 17 | # end 18 | 19 | # def from("android-safetynet", statement) do 20 | # WebAuthnEx.AttestationStatement.AndroidSafetynet.new(statement) 21 | # end 22 | 23 | def from(format, _statement) do 24 | {:error, "Unsupported attestation format '#{format}'"} 25 | end 26 | 27 | def valid?("none", authenticator_data, client_data_hash, attestation_statement) do 28 | None.valid?( 29 | authenticator_data, 30 | client_data_hash, 31 | attestation_statement 32 | ) 33 | end 34 | 35 | def valid?("fido-u2f", authenticator_data, client_data_hash, attestation_statement) do 36 | FidoU2f.valid?( 37 | authenticator_data, 38 | client_data_hash, 39 | attestation_statement 40 | ) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/web_authn_ex/ec2_key.ex: -------------------------------------------------------------------------------- 1 | defmodule WebauthnEx.EC2Key do 2 | @moduledoc """ 3 | Creates EC2Key 4 | """ 5 | @alg_label 3 6 | @crv_label -1 7 | @x_label -2 8 | @y_label -3 9 | @kty_ec2 2 10 | @kty_label 1 11 | 12 | alias __MODULE__ 13 | defstruct [:algorithm, :curve, :x_coordinate, :y_coordinate] 14 | 15 | def new(algorithm, curve, x_coordinate, y_coordinate) do 16 | %EC2Key{ 17 | algorithm: algorithm, 18 | curve: curve, 19 | x_coordinate: x_coordinate, 20 | y_coordinate: y_coordinate 21 | } 22 | end 23 | 24 | def from_cbor(cbor) do 25 | from_map(WebAuthnEx.Cbor.decode!(cbor)) 26 | end 27 | 28 | def from_map(map) do 29 | %{ 30 | @alg_label => algoritm, 31 | @crv_label => curve, 32 | @x_label => x_coordinate, 33 | @y_label => y_coordinate 34 | } = map 35 | 36 | new(algoritm, curve, x_coordinate, y_coordinate) 37 | # case enforce_type(map) do 38 | # :ok -> new(algoritm, curve, x_coordinate, y_coordinate) 39 | # :error -> :error 40 | # end 41 | end 42 | 43 | def enforce_type(map) do 44 | %{@kty_label => label} = map 45 | 46 | case label do 47 | @kty_ec2 -> :ok 48 | _ -> :error 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :web_authn_ex, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:web_authn_ex, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env()}.exs" 31 | -------------------------------------------------------------------------------- /test/web_authn_ex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnExTest do 2 | use ExUnit.Case 3 | 4 | doctest WebAuthnEx 5 | @credential_options WebAuthnEx.credential_creation_options("web-server", "example.com") 6 | @credential_request_options WebAuthnEx.credential_request_options() 7 | 8 | test "credential_options has a 32 byte length challenge" do 9 | assert byte_size(@credential_options.challenge) == 32 10 | end 11 | 12 | test "credential_options has public key params" do 13 | assert Enum.at(@credential_options.pubKeyCredParams, 0)[:type] == "public-key" 14 | assert Enum.at(@credential_options.pubKeyCredParams, 0)[:alg] == -7 15 | end 16 | 17 | test "credential_options has relying party info" do 18 | assert @credential_options[:rp][:name] == "web-server" 19 | end 20 | 21 | test "credential_options has user info" do 22 | user_info = @credential_options[:user] 23 | assert user_info[:name] == "web-user" 24 | assert user_info[:displayName] == "web-user" 25 | assert user_info[:id] == "1" 26 | end 27 | 28 | test "request_options has a 32 byte length challenge" do 29 | assert byte_size(@credential_request_options[:challenge]) == 32 30 | end 31 | 32 | test "request_options has allowCredentials param with an empty array" do 33 | assert @credential_request_options.allowCredentials == [] 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.MixProject do 2 | use Mix.Project 3 | @version "0.1.1" 4 | 5 | @description """ 6 | Implementation of a WebAuthn Relying Party in Elixir. 7 | """ 8 | 9 | def project do 10 | [ 11 | app: :web_authn_ex, 12 | version: @version, 13 | elixir: "~> 1.7", 14 | start_permanent: Mix.env() == :prod, 15 | deps: deps(), 16 | description: @description, 17 | package: package(), 18 | docs: docs() 19 | ] 20 | end 21 | 22 | # Run "mix help compile.app" to learn about applications. 23 | def application do 24 | [ 25 | extra_applications: [:logger, :crypto, :public_key] 26 | ] 27 | end 28 | 29 | # Run "mix help deps" to learn about dependencies. 30 | defp deps do 31 | [ 32 | {:jason, "~> 1.0"}, 33 | {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, 34 | {:ex_doc, ">= 0.0.0", only: :dev} 35 | ] 36 | end 37 | 38 | defp package do 39 | [ 40 | maintainers: ["Sander Groen"], 41 | licenses: ["MIT"], 42 | links: %{"GitHub" => "https://github.com/sandergroen/web_authn_ex"} 43 | ] 44 | end 45 | 46 | defp docs do 47 | [ 48 | main: "readme", 49 | name: "WebAuthnEx", 50 | source_ref: "v#{@version}", 51 | canonical: "https://hexdocs.pm/web_auth_ex", 52 | source_url: "https://github.com/sandergroen/web_authn_ex", 53 | extras: [ 54 | "README.md" 55 | ] 56 | ] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 3 | "credo": {:hex, :credo, "1.0.2", "88bc918f215168bf6ce7070610a6173c45c82f32baa08bdfc80bf58df2d103b6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm"}, 5 | "ex_doc": {:hex, :ex_doc, "0.19.3", "3c7b0f02851f5fc13b040e8e925051452e41248f685e40250d7e40b07b9f8c10", [:mix], [{:earmark, "~> 1.2", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 7 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 10 | } 11 | -------------------------------------------------------------------------------- /lib/web_authn_ex/credential.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.Credential do 2 | @moduledoc """ 3 | WebAuthnEx.Credential creates public key from binary data 4 | """ 5 | alias WebAuthnEx.PublicKeyU2f 6 | @aaguid_length 16 7 | @id_length 2 8 | 9 | alias __MODULE__ 10 | defstruct [:id, :public_key] 11 | 12 | def new(auth_data) do 13 | %Credential{ 14 | id: id(auth_data), 15 | public_key: credential(auth_data) 16 | } 17 | end 18 | 19 | def credential(auth_data) do 20 | if id(auth_data) do 21 | public_key = 22 | auth_data 23 | |> public_key() 24 | |> PublicKeyU2f.cose_key() 25 | |> PublicKeyU2f.to_binary() 26 | 27 | {{:ECPoint, public_key}, {:namedCurve, :prime256v1}} 28 | end 29 | end 30 | 31 | def public_key(auth_data) do 32 | auth_data 33 | |> data_at(public_key_position(auth_data), public_key_length(auth_data)) 34 | |> PublicKeyU2f.new() 35 | end 36 | 37 | def public_key_position(auth_data) do 38 | id_position() + id_length(auth_data) 39 | end 40 | 41 | def public_key_length(auth_data) do 42 | byte_size(auth_data) + @aaguid_length + @id_length + id_length(auth_data) 43 | end 44 | 45 | def id(auth_data) do 46 | if valid?(auth_data) do 47 | auth_data |> :binary.part(id_position(), id_length(auth_data)) 48 | end 49 | end 50 | 51 | def id_position do 52 | @aaguid_length + @id_length 53 | end 54 | 55 | def id_length(auth_data) do 56 | <> = auth_data |> :binary.part(@aaguid_length, @id_length) 57 | number 58 | end 59 | 60 | def valid?(data) do 61 | byte_size(data) >= @aaguid_length + @id_length && 62 | data 63 | |> public_key() 64 | |> WebAuthnEx.PublicKeyU2f.valid?() 65 | end 66 | 67 | defp data_at(data, pos, length) do 68 | data 69 | |> :binary.bin_to_list() 70 | |> Enum.slice(pos..(pos + length - 1)) 71 | |> :binary.list_to_bin() 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/web_authn_ex/attestation_statement/fido_u2f.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.AttestationStatement.FidoU2f do 2 | @moduledoc """ 3 | Verifies FidoU2F attestation statement 4 | """ 5 | alias __MODULE__ 6 | @enforce_keys [:statement] 7 | defstruct [:statement] 8 | def new(statement), do: {:ok, %FidoU2f{statement: statement}} 9 | @valid_attestation_certificate_count 1 10 | 11 | def valid?(authenticator_data, client_data_hash, %FidoU2f{} = fido2) do 12 | valid_format?(fido2) && valid_certificate_public_key?(fido2) && 13 | valid_signature?(authenticator_data, client_data_hash, fido2) 14 | end 15 | 16 | def valid_certificate_public_key?(fido2) do 17 | case fido2 |> certificate() |> public_key() do 18 | {:ok, _} -> 19 | true 20 | 21 | {:error, _} -> 22 | false 23 | end 24 | end 25 | 26 | def certificate(fido2) do 27 | :public_key.pkix_decode_cert(Enum.at(fido2.statement["x5c"], 0), :otp) 28 | end 29 | 30 | def public_key(certificate) do 31 | public_key = certificate |> elem(1) |> elem(7) |> elem(2) 32 | 33 | case public_key do 34 | {:ECPoint, _} -> 35 | {:ok, {public_key, {:namedCurve, :prime256v1}}} 36 | 37 | _ -> 38 | {:error, "no matching key"} 39 | end 40 | end 41 | 42 | def valid_format?(fido2) do 43 | !!(fido2.statement["x5c"] && fido2.statement["sig"]) && 44 | length(fido2.statement["x5c"]) == @valid_attestation_certificate_count 45 | end 46 | 47 | def signature(fido2) do 48 | fido2.statement["sig"] 49 | end 50 | 51 | def valid_signature?(authenticator_data, client_data_hash, fido2) do 52 | signature = signature(fido2) 53 | {:ok, public_key} = fido2 |> certificate() |> public_key() 54 | verification_data = verification_data(authenticator_data, client_data_hash) 55 | :public_key.verify(verification_data, :sha256, signature, public_key) 56 | end 57 | 58 | def verification_data(authenticator_data, client_data_hash) do 59 | {{:ECPoint, public_key}, {:namedCurve, :prime256v1}} = 60 | authenticator_data.credential.public_key 61 | 62 | <<0>> <> 63 | authenticator_data.rp_id_hash <> 64 | client_data_hash <> authenticator_data.credential.id <> public_key 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/web_authn_ex/auth_data.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.AuthData do 2 | @moduledoc """ 3 | Validates authenticator data 4 | """ 5 | alias WebAuthnEx.{Bits, Credential} 6 | alias __MODULE__ 7 | 8 | @rp_id_hash_position 0 9 | @rp_id_hash_length 32 10 | @flags_length 1 11 | @sign_count_length 4 12 | @sign_count_position 33 13 | @user_present_flag_position 0 14 | @user_verified_flag_position 2 15 | @attested_credential_data_included_flag_position 6 16 | 17 | defstruct [:credential, :rp_id_hash, :sign_count, :flags] 18 | 19 | def new(auth_data) do 20 | %AuthData{ 21 | credential: credential(auth_data), 22 | rp_id_hash: rp_id_hash(auth_data), 23 | sign_count: sign_count(auth_data), 24 | flags: flags(auth_data) 25 | } 26 | end 27 | 28 | def valid?(%AuthData{} = auth_data, data) do 29 | length = byte_size(data) - base_length() 30 | credential_data = data |> :binary.part(base_length(), length) 31 | 32 | case attested_credential_data?(auth_data) do 33 | true -> byte_size(data) > base_length() && Credential.valid?(credential_data) 34 | false -> byte_size(data) == base_length() 35 | end 36 | end 37 | 38 | def user_flagged?(%AuthData{} = auth_data) do 39 | user_present?(auth_data) || user_verified?(auth_data) 40 | end 41 | 42 | def user_present?(%AuthData{} = auth_data) do 43 | Enum.at(auth_data.flags, @user_present_flag_position) == 1 44 | end 45 | 46 | def user_verified?(%AuthData{} = auth_data) do 47 | Enum.at(auth_data.flags, @user_verified_flag_position) == 1 48 | end 49 | 50 | def attested_credential_data?(%AuthData{} = auth_data) do 51 | Enum.at(auth_data.flags, @attested_credential_data_included_flag_position) == 1 52 | end 53 | 54 | def credential(auth_data) do 55 | length = byte_size(auth_data) - base_length() 56 | 57 | auth_data 58 | |> :binary.part(base_length(), length) 59 | |> Credential.new() 60 | end 61 | 62 | defp rp_id_hash(auth_data) do 63 | auth_data 64 | |> :binary.part(@rp_id_hash_position, @rp_id_hash_length) 65 | end 66 | 67 | defp base_length do 68 | @rp_id_hash_length + @flags_length + @sign_count_length 69 | end 70 | 71 | defp sign_count(data) do 72 | <> = 73 | data |> :binary.part(@sign_count_position, @sign_count_length) 74 | 75 | number 76 | end 77 | 78 | defp flags(data) do 79 | data 80 | |> :binary.part(@rp_id_hash_length, @flags_length) 81 | |> Bits.extract() 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/web_authn_ex/authenticator_response.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.AuthenticatorResponse do 2 | @moduledoc """ 3 | Validates authenticator 4 | """ 5 | alias __MODULE__ 6 | alias WebAuthnEx.{AuthData, ClientData} 7 | @enforce_keys [:client_data] 8 | defstruct [:client_data] 9 | def new(client_data), do: {:ok, %AuthenticatorResponse{client_data: client_data}} 10 | 11 | def valid?(original_challenge, original_origin, attestation, rp_id, client_data_json) do 12 | authenticator_data = authenticator_data(attestation) 13 | {:ok, client_data} = ClientData.new(client_data_json) 14 | 15 | auth_data = 16 | case is_binary(attestation) do 17 | true -> attestation 18 | false -> attestation["authData"] 19 | end 20 | 21 | with true <- valid_type?(client_data_json, client_data.type), 22 | true <- valid_challenge?(original_challenge, client_data), 23 | true <- valid_origin?(original_origin, client_data), 24 | true <- valid_rp_id?(original_origin, authenticator_data, rp_id), 25 | true <- AuthData.valid?(authenticator_data, auth_data), 26 | true <- AuthData.user_flagged?(authenticator_data) do 27 | true 28 | else 29 | false -> false 30 | end 31 | end 32 | 33 | def valid_challenge?(original_challenge, client_data) do 34 | Base.url_decode64!(client_data.challenge, padding: false) == original_challenge 35 | end 36 | 37 | def valid_origin?(original_origin, client_data) do 38 | client_data.origin == original_origin 39 | end 40 | 41 | def authenticator_data(authenticator_data) when is_binary(authenticator_data) do 42 | AuthData.new(authenticator_data) 43 | end 44 | 45 | def authenticator_data(%{"authData" => auth_data} = authenticator_data) 46 | when is_map(authenticator_data) do 47 | auth_data |> AuthData.new() 48 | end 49 | 50 | def valid_rp_id?(original_origin, authenticator_data, nil) do 51 | case rp_id_from_origin(original_origin) do 52 | {:ok, host} -> 53 | :crypto.hash(:sha256, host) == authenticator_data.rp_id_hash 54 | 55 | {:error, nil} -> 56 | false 57 | end 58 | end 59 | 60 | def valid_rp_id?(_, authenticator_data, rp_id) do 61 | :crypto.hash(:sha256, rp_id) == authenticator_data.rp_id_hash 62 | end 63 | 64 | def valid_type?(client_data_json, type) do 65 | {:ok, client_data} = ClientData.new(client_data_json) 66 | client_data.type == type 67 | end 68 | 69 | def rp_id_from_origin(origin) when origin == nil do 70 | {:error, nil} 71 | end 72 | 73 | def rp_id_from_origin(origin) do 74 | case URI.parse(origin) do 75 | %URI{host: nil} -> {:error, nil} 76 | %URI{host: host} -> {:ok, host} 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/web_authn_ex/cbor/decoder.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.Cbor.Decoder do 2 | @moduledoc """ 3 | Decodes CBOR objects. 4 | """ 5 | 6 | alias WebAuthnEx.Cbor.Types 7 | 8 | @unsigned_integer Types.unsigned_integer() 9 | @negative_integer Types.negative_integer() 10 | @string Types.string() 11 | @array Types.array() 12 | @byte_string Types.byte_string() 13 | @map Types.map() 14 | 15 | def decode(value) do 16 | {value, rest} = read(value) 17 | 18 | if rest == <<>> do 19 | {:ok, value} 20 | else 21 | {:error, :invalid_trailing_data} 22 | end 23 | end 24 | 25 | defp read(<<@unsigned_integer, bits::bits>>), do: read_unsigned_integer(bits) 26 | defp read(<<@negative_integer, bits::bits>>), do: read_negative_integer(bits) 27 | defp read(<<@string, bits::bits>>), do: read_binary(bits) 28 | defp read(<<@byte_string, bits::bits>>), do: read_binary(bits) 29 | defp read(<<@array, bits::bits>>), do: read_array(bits) 30 | defp read(<<@map, bits::bits>>), do: read_map(bits) 31 | 32 | defp read_binary(value) do 33 | {length, rest} = read_unsigned_integer(value) 34 | <> = rest 35 | {value, rest} 36 | end 37 | 38 | def read_map(value) do 39 | {size, rest} = read_unsigned_integer(value) 40 | 41 | if size == 0 do 42 | {%{}, rest} 43 | else 44 | {map, rest} = 45 | Enum.reduce(1..size, {%{}, rest}, fn _, acc -> 46 | {key, rest} = read(elem(acc, 1)) 47 | {value, rest} = read(rest) 48 | {Map.put(elem(acc, 0), key, value), rest} 49 | end) 50 | 51 | {map, rest} 52 | end 53 | end 54 | 55 | defp read_array(value) do 56 | {size, rest} = read_unsigned_integer(value) 57 | 58 | if size == 0 do 59 | {[], rest} 60 | else 61 | {values, rest} = 62 | Enum.reduce(1..size, {[], rest}, fn _, {acc, rest} -> 63 | {value, rest} = read(rest) 64 | {[value | acc], rest} 65 | end) 66 | 67 | {values |> Enum.reverse(), rest} 68 | end 69 | end 70 | 71 | defp read_unsigned_integer(<<27::size(5), value::size(64), rest::bits>>), do: {value, rest} 72 | defp read_unsigned_integer(<<26::size(5), value::size(32), rest::bits>>), do: {value, rest} 73 | defp read_unsigned_integer(<<25::size(5), value::size(16), rest::bits>>), do: {value, rest} 74 | defp read_unsigned_integer(<<24::size(5), value::size(8), rest::bits>>), do: {value, rest} 75 | defp read_unsigned_integer(<<(<>)::bitstring, rest::bits>>), do: {value, rest} 76 | 77 | defp read_negative_integer(value) do 78 | {unsigned, rest} = read_unsigned_integer(value) 79 | {unsiged_to_negative(unsigned), rest} 80 | end 81 | 82 | defp unsiged_to_negative(unsined_value), do: (unsined_value + 1) * -1 83 | end 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebAuthnEx 2 | 3 | WebAuthn library for Elixir inspired by [https://github.com/cedarcode/webauthn-ruby](https://github.com/cedarcode/webauthn-ruby) 4 | 5 | [![Build Status](https://travis-ci.org/sandergroen/web_authn_ex.svg?branch=master)](https://travis-ci.org/sandergroen/web_authn_ex) 6 | 7 | # What is WebAuthn? 8 | 9 | - [WebAuthn article with Google IO 2018 talk](https://developers.google.com/web/updates/2018/05/webauthn) 10 | - [Web Authentication API draft article by Mozilla](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) 11 | - [WebAuthn W3C Candidate Recommendation](https://www.w3.org/TR/webauthn/) 12 | - [WebAuthn W3C Editor's Draft](https://w3c.github.io/webauthn/) 13 | 14 | ## Prerequisites 15 | 16 | This package will help your Elixir server act as a conforming [_Relying-Party_](https://www.w3.org/TR/webauthn/#relying-party), in WebAuthn terminology. But for the [_Registration_](https://www.w3.org/TR/webauthn/#registration) and [_Authentication_](https://www.w3.org/TR/webauthn/#authentication) ceremonies to work, you will also need 17 | 18 | ### A conforming User Agent 19 | 20 | Currently supporting [Web Authentication API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API): 21 | - [Mozilla Firefox](https://www.mozilla.org/firefox/) 60+ 22 | - [Google Chrome](https://www.google.com/chrome/) 67+ 23 | - [Google Chrome for Android](https://play.google.com/store/apps/details?id=com.android.chrome) 70+ 24 | 25 | ### A conforming Authenticator 26 | 27 | * Roaming authenticators 28 | * [Security Key by Yubico](https://www.yubico.com/product/security-key-by-yubico/) 29 | * [YubiKey 5 Series](https://www.yubico.com/products/yubikey-5-overview/) key 30 | * Platform authenticators 31 | * Android's Fingerprint Scanner 32 | * MacBook [Touch ID](https://en.wikipedia.org/wiki/Touch_ID) 33 | 34 | NOTE: Firefox states ([Firefox 60 release notes](https://www.mozilla.org/en-US/firefox/60.0/releasenotes/)) they only support USB FIDO2 or FIDO U2F enabled devices in their current implementation (version 60). 35 | It's up to the gem's user to verify user agent compatibility if any other device wants to be used as the authenticator component. 36 | 37 | ## Installation 38 | 39 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 40 | by adding `web_authn_ex` to your list of dependencies in `mix.exs`: 41 | 42 | ```elixir 43 | def deps do 44 | [ 45 | {:web_authn_ex, "~> 0.1.0"} 46 | ] 47 | end 48 | ``` 49 | 50 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 51 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 52 | be found at [https://hexdocs.pm/web_authn_ex](https://hexdocs.pm/web_authn_ex). 53 | 54 | # Usage 55 | 56 | NOTE: You can find a working example on how to use this package in a Phoenix app in [https://github.com/sandergroen/webauthn_phoenix_demo](https://github.com/sandergroen/webauthn_phoenix_demo) 57 | 58 | # Contributing 59 | 60 | Bug reports and pull requests are welcome on GitHub at [https://github.com/sandergroen/web_authn_ex](https://github.com/sandergroen/web_authn_ex). 61 | 62 | -------------------------------------------------------------------------------- /lib/web_authn_ex/cbor/encoder.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.Cbor.Encoder do 2 | @moduledoc """ 3 | Encodes CBOR objects. 4 | """ 5 | alias WebAuthnEx.Cbor.Types 6 | 7 | def encode(value) do 8 | case value do 9 | value when is_integer(value) and value >= 0 -> 10 | concat(Types.unsigned_integer(), encode_unsigned_int(value)) 11 | 12 | value when is_integer(value) -> 13 | concat(Types.negative_integer(), encode_negative_int(value)) 14 | 15 | value when is_atom(value) -> 16 | concat(Types.string(), encode_string(value)) 17 | 18 | value when is_binary(value) -> 19 | concat(Types.byte_string(), encode_byte_string(value)) 20 | 21 | value when is_list(value) -> 22 | concat(Types.array(), encode_array(value)) 23 | 24 | value when is_map(value) -> 25 | concat(Types.map(), encode_map(value)) 26 | end 27 | end 28 | 29 | def concat(left, right) do 30 | <> 31 | end 32 | 33 | def encode_byte_string(value) do 34 | length = encode_unsigned_int(byte_size(value)) 35 | 36 | concat(length, value) 37 | end 38 | 39 | def encode_array(value) do 40 | length = encode_unsigned_int(length(value)) 41 | values = value |> Enum.map(&encode/1) |> Enum.join() 42 | 43 | concat(length, values) 44 | end 45 | 46 | def encode_map(value) do 47 | length = encode_unsigned_int(map_size(value)) 48 | 49 | values = 50 | value 51 | |> Map.keys() 52 | |> Enum.map(fn key -> 53 | concat(encode(key), encode(value[key])) 54 | end) 55 | |> Enum.reduce(<<>>, &concat/2) 56 | 57 | concat(length, values) 58 | end 59 | 60 | def encode_string(value) do 61 | string = to_string(value) 62 | length = encode_unsigned_int(String.length(string)) 63 | concat(length, string) 64 | end 65 | 66 | def encode_unsigned_int(value) do 67 | case value do 68 | value when value in 0..23 -> 69 | <> 70 | 71 | value when value in 24..0x0FF -> 72 | <<24::size(5), value>> 73 | 74 | value when value in 0x100..0x0FFFF -> 75 | <<25::size(5), value::size(16)>> 76 | 77 | value when value in 0x10000..0x0FFFFFFFF -> 78 | <<26::size(5), value::size(32)>> 79 | 80 | value when value in 0x100000000..0x0FFFFFFFFFFFFFFFF -> 81 | <<27::size(5), value::size(64)>> 82 | end 83 | end 84 | 85 | def encode_negative_int(value) do 86 | unsigned_value = value * -1 - 1 87 | 88 | case unsigned_value do 89 | unsigned_value when unsigned_value in 0..23 -> 90 | <> 91 | 92 | unsigned_value when unsigned_value in 24..0x0FF -> 93 | <<56::size(5), unsigned_value>> 94 | 95 | unsigned_value when unsigned_value in 0x100..0x0FFFF -> 96 | <<57::size(5), unsigned_value::size(16)>> 97 | 98 | unsigned_value when unsigned_value in 0x10000..0x0FFFFFFFF -> 99 | <<58::size(5), unsigned_value::size(32)>> 100 | 101 | unsigned_value when unsigned_value in 0x100000000..0x0FFFFFFFFFFFFFFFF -> 102 | <<59::size(5), unsigned_value::size(64)>> 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/authenticator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AuthenticatorTest do 2 | use ExUnit.Case 3 | doctest WebAuthnEx.AuthData 4 | alias WebAuthnEx.AuthData 5 | 6 | @rp_id "localhost" 7 | @sign_count 42 8 | @user_present true 9 | @user_verified false 10 | @authenticator FakeAuthenticator.create(%{ 11 | challenge: FakeAuthenticator.fake_challenge(), 12 | rp_id: @rp_id, 13 | sign_count: @sign_count, 14 | context: %{ 15 | user_present: @user_present, 16 | user_verified: @user_verified, 17 | attested_credential_data_present: true 18 | } 19 | }) 20 | @authenticator_data AuthData.new(@authenticator.authenticator_data) 21 | 22 | test "#rp_id_hash" do 23 | assert @authenticator_data.rp_id_hash == FakeAuthenticator.rp_id_hash(@rp_id) 24 | end 25 | 26 | test "#sign_count" do 27 | assert @authenticator_data.sign_count == 42 28 | end 29 | 30 | test "#user_present? when UP flag is set" do 31 | assert AuthData.user_present?(@authenticator_data) 32 | end 33 | 34 | test "when UP flag is not set" do 35 | refute false 36 | |> authenticator(@user_verified) 37 | |> authenticator_data() 38 | |> AuthData.user_present?() 39 | end 40 | 41 | test "#user_verified? when UV flag is set" do 42 | assert @user_present 43 | |> authenticator(true) 44 | |> authenticator_data() 45 | |> AuthData.user_verified?() 46 | end 47 | 48 | test "#user_verified? when UV flag not is set" do 49 | refute @user_present 50 | |> authenticator(false) 51 | |> authenticator_data() 52 | |> AuthData.user_verified?() 53 | end 54 | 55 | test "#user_flagged? when both UP and UV flag are set" do 56 | assert true 57 | |> authenticator(true) 58 | |> authenticator_data() 59 | |> AuthData.user_flagged?() 60 | end 61 | 62 | test "#user_flagged? when only UP is set" do 63 | assert true 64 | |> authenticator(false) 65 | |> authenticator_data() 66 | |> AuthData.user_flagged?() 67 | end 68 | 69 | test "#user_flagged? when only UV flag is set" do 70 | assert false 71 | |> authenticator(true) 72 | |> authenticator_data() 73 | |> AuthData.user_flagged?() 74 | end 75 | 76 | test "#user_flagged? when both UP and UV flag are not set" do 77 | refute false 78 | |> authenticator(false) 79 | |> authenticator_data() 80 | |> AuthData.user_flagged?() 81 | end 82 | 83 | defp authenticator(user_present, user_verified) do 84 | FakeAuthenticator.create(%{ 85 | challenge: FakeAuthenticator.fake_challenge(), 86 | rp_id: @rp_id, 87 | sign_count: @sign_count, 88 | context: %{ 89 | user_present: user_present, 90 | user_verified: user_verified, 91 | attested_credential_data_present: true 92 | } 93 | }) 94 | end 95 | 96 | defp authenticator_data(authenticator) do 97 | authenticator.authenticator_data 98 | |> AuthData.new() 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/web_authn_ex/auth_attestation_response.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.AuthAttestationResponse do 2 | @moduledoc """ 3 | Validates attestation 4 | """ 5 | alias __MODULE__ 6 | alias WebAuthnEx.{AttestationStatement, AuthenticatorResponse, ClientData} 7 | 8 | @enforce_keys [ 9 | :original_challenge, 10 | :original_origin, 11 | :attestation, 12 | :attestation_statement, 13 | :credential, 14 | :client_data_json, 15 | :rp_id 16 | ] 17 | defstruct [ 18 | :original_challenge, 19 | :original_origin, 20 | :attestation, 21 | :attestation_statement, 22 | :credential, 23 | :client_data_json, 24 | :rp_id, 25 | :valid_authenticator, 26 | :valid_attestation_statement, 27 | :client_data 28 | ] 29 | 30 | def new(original_challenge, original_origin, attestation_object, client_data_json, rp_id \\ nil) do 31 | attestation = attestation(attestation_object) 32 | attestation_statement = attestation_statement(attestation) 33 | client_data = ClientData.new(client_data_json) 34 | 35 | case client_data do 36 | {:ok, client_data} -> 37 | %AuthAttestationResponse{ 38 | original_challenge: original_challenge, 39 | original_origin: original_origin, 40 | attestation: attestation, 41 | attestation_statement: attestation_statement, 42 | credential: credential(attestation), 43 | client_data_json: client_data_json, 44 | rp_id: rp_id, 45 | client_data: client_data 46 | } 47 | |> valid?() 48 | |> result() 49 | 50 | {:error, reason} -> 51 | {:error, reason} 52 | end 53 | end 54 | 55 | def result( 56 | %AuthAttestationResponse{ 57 | valid_authenticator: valid_authenticator, 58 | valid_attestation_statement: valid_attestation_statement 59 | } = auth_attestation_response 60 | ) do 61 | cond do 62 | valid_authenticator == false -> 63 | {:error, "Validation of authenticator failed!"} 64 | 65 | valid_attestation_statement == false -> 66 | {:error, "Validation of attestation statement failed!"} 67 | 68 | true -> 69 | {:ok, auth_attestation_response} 70 | end 71 | end 72 | 73 | def valid?(%AuthAttestationResponse{} = auth_attestation_response) do 74 | auth_attestation_response 75 | |> valid_authenticator_response?() 76 | |> valid_attestation_statement?() 77 | end 78 | 79 | def valid_authenticator_response?(%AuthAttestationResponse{} = auth_attestation_response) do 80 | case AuthenticatorResponse.valid?( 81 | auth_attestation_response.original_challenge, 82 | auth_attestation_response.original_origin, 83 | auth_attestation_response.attestation, 84 | auth_attestation_response.rp_id, 85 | auth_attestation_response.client_data_json 86 | ) do 87 | true -> 88 | %AuthAttestationResponse{auth_attestation_response | valid_authenticator: true} 89 | 90 | false -> 91 | %AuthAttestationResponse{auth_attestation_response | valid_authenticator: false} 92 | end 93 | end 94 | 95 | def valid_attestation_statement?(%AuthAttestationResponse{} = auth_attestation_response) do 96 | case AttestationStatement.valid?( 97 | auth_attestation_response.attestation["fmt"], 98 | authenticator_data(auth_attestation_response.attestation), 99 | auth_attestation_response.client_data.hash, 100 | auth_attestation_response.attestation_statement 101 | ) do 102 | true -> 103 | %AuthAttestationResponse{auth_attestation_response | valid_attestation_statement: true} 104 | 105 | false -> 106 | %AuthAttestationResponse{auth_attestation_response | valid_attestation_statement: false} 107 | end 108 | end 109 | 110 | def attestation_statement(attestation) do 111 | {:ok, statement} = AttestationStatement.from(attestation["fmt"], attestation["attStmt"]) 112 | statement 113 | end 114 | 115 | def authenticator_data(attestation) do 116 | AuthenticatorResponse.authenticator_data(attestation) 117 | end 118 | 119 | defp credential(attestation) do 120 | authenticator_data(attestation).credential 121 | end 122 | 123 | defp attestation(attestation_object) do 124 | WebAuthnEx.Cbor.decode!(attestation_object) 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/web_authn_ex/auth_assertion_response.ex: -------------------------------------------------------------------------------- 1 | defmodule WebAuthnEx.AuthAssertionResponse do 2 | @moduledoc """ 3 | Validates assertion 4 | """ 5 | alias WebAuthnEx.AuthenticatorResponse 6 | alias __MODULE__ 7 | @enforce_keys [:credential_id, :auth_data_bytes, :signature] 8 | defstruct [ 9 | :credential_id, 10 | :auth_data_bytes, 11 | :signature, 12 | :allowed_credentials, 13 | :valid_authenticator, 14 | :valid_assertion_statement, 15 | :valid_credential, 16 | :valid_signature 17 | ] 18 | 19 | def new( 20 | credential_id, 21 | auth_data_bytes, 22 | signature, 23 | challenge, 24 | original_origin, 25 | allowed_credentials, 26 | rp_id, 27 | client_data_json 28 | ) do 29 | %AuthAssertionResponse{ 30 | credential_id: credential_id, 31 | auth_data_bytes: auth_data_bytes, 32 | signature: signature, 33 | allowed_credentials: allowed_credentials, 34 | valid_authenticator: nil, 35 | valid_assertion_statement: nil, 36 | valid_credential: nil, 37 | valid_signature: nil 38 | } 39 | |> valid?(challenge, original_origin, rp_id, client_data_json) 40 | |> result() 41 | end 42 | 43 | def result( 44 | %AuthAssertionResponse{ 45 | valid_authenticator: valid_authenticator, 46 | valid_assertion_statement: valid_assertion_statement, 47 | valid_credential: valid_credential, 48 | valid_signature: valid_signature 49 | } = auth_assertion_response 50 | ) do 51 | cond do 52 | valid_authenticator == false -> 53 | {:error, "Validation of authenticator failed!"} 54 | 55 | valid_assertion_statement == false -> 56 | {:error, "Validation of assertion statement failed!"} 57 | 58 | valid_credential == false -> 59 | {:error, "Validation of credential failed!"} 60 | 61 | valid_signature == false -> 62 | {:error, "Validation of signature failed!"} 63 | 64 | true -> 65 | {:ok, auth_assertion_response} 66 | end 67 | end 68 | 69 | def valid?( 70 | %AuthAssertionResponse{} = auth_assertion_response, 71 | original_challenge, 72 | original_origin, 73 | rp_id, 74 | client_data_json 75 | ) do 76 | auth_assertion_response 77 | |> valid_authenticator_response?( 78 | original_challenge, 79 | original_origin, 80 | auth_assertion_response.auth_data_bytes, 81 | rp_id, 82 | client_data_json 83 | ) 84 | |> valid_credential?() 85 | |> valid_signature?( 86 | credential_public_key( 87 | auth_assertion_response.allowed_credentials, 88 | auth_assertion_response.credential_id 89 | ), 90 | auth_assertion_response.signature, 91 | client_data_json, 92 | auth_assertion_response.auth_data_bytes 93 | ) 94 | end 95 | 96 | def valid_authenticator_response?( 97 | auth_assertion_response, 98 | original_challenge, 99 | original_origin, 100 | auth_data_bytes, 101 | rp_id, 102 | client_data_json 103 | ) do 104 | case AuthenticatorResponse.valid?( 105 | original_challenge, 106 | original_origin, 107 | auth_data_bytes, 108 | rp_id, 109 | client_data_json 110 | ) do 111 | true -> 112 | %AuthAssertionResponse{auth_assertion_response | valid_authenticator: true} 113 | 114 | false -> 115 | %AuthAssertionResponse{auth_assertion_response | valid_authenticator: false} 116 | end 117 | end 118 | 119 | def valid_credential?(%AuthAssertionResponse{} = auth_assertion_response) do 120 | credential_valid = 121 | auth_assertion_response.allowed_credentials 122 | |> Enum.map(fn c -> c[:id] end) 123 | |> Enum.member?(auth_assertion_response.credential_id) 124 | 125 | case credential_valid do 126 | true -> 127 | %AuthAssertionResponse{auth_assertion_response | valid_credential: true} 128 | 129 | false -> 130 | %AuthAssertionResponse{auth_assertion_response | valid_credential: false} 131 | end 132 | end 133 | 134 | def valid_signature?( 135 | auth_assertion_response, 136 | public_key_bytes, 137 | signature, 138 | client_data_json, 139 | authenticator_data_bytes 140 | ) do 141 | if auth_assertion_response.valid_credential do 142 | client_data_hash = :crypto.hash(:sha256, client_data_json) 143 | public_key = {{:ECPoint, public_key_bytes}, {:namedCurve, :prime256v1}} 144 | 145 | signature_valid = 146 | :public_key.verify( 147 | authenticator_data_bytes <> client_data_hash, 148 | :sha256, 149 | signature, 150 | public_key 151 | ) 152 | 153 | case signature_valid do 154 | true -> 155 | %AuthAssertionResponse{auth_assertion_response | valid_signature: true} 156 | 157 | false -> 158 | %AuthAssertionResponse{auth_assertion_response | valid_signature: false} 159 | end 160 | else 161 | %AuthAssertionResponse{auth_assertion_response | valid_signature: false} 162 | end 163 | end 164 | 165 | def credential_public_key(allowed_credentials, credential_id) do 166 | matched_credential = Enum.find(allowed_credentials, fn x -> x[:id] == credential_id end) 167 | 168 | matched_credential[:public_key] 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /test/support/fake_authenticator.exs: -------------------------------------------------------------------------------- 1 | defmodule FakeAuthenticator do 2 | alias __MODULE__ 3 | @enforce_keys [:challenge, :rp_id, :sign_count, :context, :type] 4 | defstruct [:challenge, :rp_id, :sign_count, :context, :type] 5 | 6 | def get(options \\ %{context: %{}}) do 7 | context = 8 | %{attested_credential_data_present: false} 9 | |> Map.merge(options.context) 10 | 11 | %{challenge: fake_challenge(), rp_id: "localhost", sign_count: 0} 12 | |> Map.merge(options) 13 | |> Map.put(:context, context) 14 | |> new("webauthn.get") 15 | |> signature() 16 | end 17 | 18 | def get_wrong_type(options \\ %{context: %{}}) do 19 | context = 20 | %{attested_credential_data_present: false} 21 | |> Map.merge(options.context) 22 | 23 | %{challenge: fake_challenge(), rp_id: "localhost", sign_count: 0} 24 | |> Map.merge(options) 25 | |> Map.put(:context, context) 26 | |> new("webauthn.create") 27 | |> signature() 28 | end 29 | 30 | def create(options \\ %{context: %{}}) do 31 | context = 32 | %{attested_credential_data_present: true} 33 | |> Map.merge(options.context) 34 | 35 | %{challenge: fake_challenge(), rp_id: "localhost", sign_count: 0} 36 | |> Map.merge(options) 37 | |> Map.put(:context, context) 38 | |> new("webauthn.create") 39 | |> attestation_object() 40 | end 41 | 42 | defp new(options, type) do 43 | %FakeAuthenticator{ 44 | challenge: options.challenge, 45 | rp_id: options.rp_id, 46 | sign_count: options.sign_count, 47 | context: options.context, 48 | type: type 49 | } 50 | |> raw_flags() 51 | |> credential_key() 52 | |> credential_id() 53 | |> authenticator_data() 54 | |> origin() 55 | |> client_data_json() 56 | end 57 | 58 | def authenticator_data(%FakeAuthenticator{} = authenticator) do 59 | authenticator 60 | |> Map.put( 61 | :authenticator_data, 62 | rp_id_hash(authenticator.rp_id) <> 63 | authenticator.flags <> 64 | raw_sign_count(authenticator.sign_count) <> attested_credential_data(authenticator) 65 | ) 66 | end 67 | 68 | def client_data_json(%FakeAuthenticator{} = authenticator) do 69 | authenticator 70 | |> Map.put( 71 | :client_data_json, 72 | %{ 73 | challenge: encode(authenticator.challenge), 74 | origin: authenticator.origin, 75 | type: authenticator.type 76 | } 77 | |> Jason.encode!() 78 | ) 79 | end 80 | 81 | def public_key(credential_key) do 82 | {public_key, _} = credential_key 83 | public_key 84 | end 85 | 86 | def credential_key(%FakeAuthenticator{} = authenticator) do 87 | authenticator 88 | |> Map.put(:credential_key, :crypto.generate_key(:ecdh, :prime256v1)) 89 | end 90 | 91 | def raw_flags(%FakeAuthenticator{} = authenticator) do 92 | authenticator 93 | |> Map.put( 94 | :flags, 95 | [ 96 | bit(:user_present, authenticator.context), 97 | 0, 98 | bit(:user_verified, authenticator.context), 99 | 0, 100 | 0, 101 | 0, 102 | bit(:attested_credential_data_present, authenticator.context), 103 | 0 104 | ] 105 | |> WebAuthnEx.Bits.insert() 106 | ) 107 | end 108 | 109 | def raw_sign_count(sign_count) do 110 | <> 111 | end 112 | 113 | def credential_id(%FakeAuthenticator{} = authenticator) do 114 | authenticator 115 | |> Map.put(:credential_id, 16 |> :crypto.strong_rand_bytes()) 116 | end 117 | 118 | def rp_id_hash(rp_id) do 119 | :crypto.hash(:sha256, rp_id) 120 | end 121 | 122 | def origin(%FakeAuthenticator{} = authenticator) do 123 | authenticator 124 | |> Map.put(:origin, authenticator.context[:origin] || fake_origin()) 125 | end 126 | 127 | def bit(flag, context) do 128 | case context[flag] do 129 | nil -> 1 130 | true -> 1 131 | _ -> 0 132 | end 133 | end 134 | 135 | defp attestation_object(%FakeAuthenticator{} = authenticator) do 136 | authenticator 137 | |> Map.put( 138 | :attestation_object, 139 | WebAuthnEx.Cbor.encode(%{ 140 | "fmt" => "none", 141 | "attStmt" => %{}, 142 | "authData" => authenticator.authenticator_data 143 | }) 144 | ) 145 | end 146 | 147 | defp attested_credential_data(%FakeAuthenticator{} = authenticator) do 148 | case authenticator.type do 149 | "webauthn.create" -> 150 | aaguid() <> 151 | <> <> 152 | authenticator.credential_id <> cose_credential_public_key(authenticator) 153 | 154 | _ -> 155 | <<"">> 156 | end 157 | end 158 | 159 | defp cose_credential_public_key(%FakeAuthenticator{} = authenticator) do 160 | {public_key, _} = authenticator.credential_key 161 | 162 | x_coordinate = 163 | public_key |> :binary.bin_to_list() |> Enum.slice(1..32) |> :binary.list_to_bin() 164 | 165 | y_coordinate = 166 | public_key |> :binary.bin_to_list() |> Enum.slice(33..64) |> :binary.list_to_bin() 167 | 168 | fake_cose_credential_key(%{ 169 | algorithm: nil, 170 | x_coordinate: x_coordinate, 171 | y_coordinate: y_coordinate 172 | }) 173 | end 174 | 175 | defp aaguid do 176 | 16 |> :crypto.strong_rand_bytes() 177 | end 178 | 179 | def fake_origin do 180 | "http://localhost" 181 | end 182 | 183 | def fake_challenge do 184 | 32 |> :crypto.strong_rand_bytes() 185 | end 186 | 187 | defp encode(value) do 188 | value |> Base.url_encode64(padding: false) 189 | end 190 | 191 | defp signature(%FakeAuthenticator{} = authenticator) do 192 | params = :crypto.ec_curve(:prime256v1) 193 | {_, private_key} = authenticator.credential_key 194 | 195 | hash = 196 | authenticator.authenticator_data <> :crypto.hash(:sha256, authenticator.client_data_json) 197 | 198 | authenticator 199 | |> Map.put(:signature, :crypto.sign(:ecdsa, :sha256, hash, [private_key, params])) 200 | end 201 | 202 | def fake_cose_credential_key(options \\ %{algorithm: nil, x_coordinate: nil, y_coordinate: nil}) do 203 | kty_label = 1 204 | alg_label = 3 205 | crv_label = -1 206 | x_label = -2 207 | y_label = -3 208 | 209 | kty_ec2 = 2 210 | alg_es256 = -7 211 | crv_p256 = 1 212 | 213 | WebAuthnEx.Cbor.encode(%{ 214 | kty_label => kty_ec2, 215 | alg_label => options.algorithm || alg_es256, 216 | crv_label => crv_p256, 217 | x_label => options.x_coordinate || :crypto.strong_rand_bytes(32), 218 | y_label => options.y_coordinate || :crypto.strong_rand_bytes(32) 219 | }) 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /test/auth_attestation_response_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AuthAttestationResponseTest do 2 | use ExUnit.Case 3 | doctest WebAuthnEx.AuthAttestationResponse 4 | alias WebAuthnEx.AuthAttestationResponse 5 | 6 | test "is valid if everything's in place" do 7 | original_challenge = FakeAuthenticator.fake_challenge() 8 | origin = FakeAuthenticator.fake_origin() 9 | 10 | authenticator = 11 | FakeAuthenticator.create(%{challenge: original_challenge, context: %{origin: origin}}) 12 | 13 | {:ok, auth_attestation_response} = 14 | AuthAttestationResponse.new( 15 | original_challenge, 16 | origin, 17 | authenticator.attestation_object, 18 | authenticator.client_data_json, 19 | authenticator.rp_id 20 | ) 21 | 22 | credential = auth_attestation_response.credential 23 | assert is_binary(credential.id) 24 | 25 | {{:ECPoint, public_key}, {:namedCurve, :prime256v1}} = credential.public_key 26 | assert is_binary(public_key) 27 | end 28 | 29 | test "can validate fido-u2f attestation" do 30 | original_origin = "http://localhost:3000" 31 | 32 | {:ok, original_challenge} = 33 | "11CzaFXezx7YszNaYE3pag==" 34 | |> Base.decode64() 35 | 36 | {:ok, attestation_object} = 37 | "o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEYwRAIgekOQZSd0/dNZZ3iBBaKWUVaYx49+w37LunPGKthcYG8CICFt3JdafYmqC3oAHDeFkLYM0eQjWPjZkh7WBqvRCcR9Y3g1Y4FZAsIwggK+MIIBpqADAgECAgR0hv3CMA0GCSqGSIb3DQEBCwUAMC4xLDAqBgNVBAMTI1l1YmljbyBVMkYgUm9vdCBDQSBTZXJpYWwgNDU3MjAwNjMxMCAXDTE0MDgwMTAwMDAwMFoYDzIwNTAwOTA0MDAwMDAwWjBvMQswCQYDVQQGEwJTRTESMBAGA1UECgwJWXViaWNvIEFCMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMSgwJgYDVQQDDB9ZdWJpY28gVTJGIEVFIFNlcmlhbCAxOTU1MDAzODQyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElV3zrfckfTF17/2cxPMaToeOuuGBCVZhUPs4iy5fZSe/V0CapYGlDQrFLxhEXAoTVIoTU8ik5ZpwTlI7wE3r7aNsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBSAwIQYLKwYBBAGC5RwBAQQEEgQQ+KAR84wKTRWABhcRH57cfTAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQAxXEiA5ppSfjhmib1p/Qqob0nrnk6FRUFVb6rQCzoAih3cAflsdvZoNhqR4jLIEKecYwdMm256RusdtdhcREifhop2Q9IqXIYuwD8D5YSL44B9es1V+OGuHuITrHOrSyDj+9UmjLB7h4AnHR9L4OXdrHNNOliXvU1zun81fqIIyZ2KTSkC5gl6AFxNyQTcChgSDgr30Az8lpoohuWxsWHz7cvGd6Z41/tTA5zNoYa+NLpTMZUjQ51/2Upw8jBiG5PEzkJo0xdNlDvGrj/JN8LeQ9a0TiEVPfhQkl+VkGIuvEbg6xjGQfD+fm8qCamykHcZ9i5hNaGQMqITwJi3KDzuaGF1dGhEYXRhWMRJlg3liA6MaHQ0Fw9kdmBbj+SuuaKGMseZXPO6gx2XY0EAAAAAAAAAAAAAAAAAAAAAAAAAAABA2Nc6mqO+eIH0eIAhy1xfIJcjHtlOAsRLHxf4u5apXnhI6j8oGbmF87Qz6L8AvGjlHQLjGhAXjLpBFb2aeVowSqUBAgMmIAEhWCBsj3dTr9jqWWOwuDzWAQOqqugB1YGYKpE/YqHfRB3GrCJYIPiyHJ4rYZRaqfJQKAInKzINuxkQARzVdNcChyszi/Pr" 38 | |> Base.decode64() 39 | 40 | {:ok, client_data_json} = 41 | "eyJjaGFsbGVuZ2UiOiIxMUN6YUZYZXp4N1lzek5hWUUzcGFnIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDozMDAwIiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9" 42 | |> Base.decode64() 43 | 44 | assert {:ok, auth_attestation_response} = 45 | AuthAttestationResponse.new( 46 | original_challenge, 47 | original_origin, 48 | attestation_object, 49 | client_data_json, 50 | "localhost" 51 | ) 52 | end 53 | 54 | test "origin validation matches the default one" do 55 | original_challenge = FakeAuthenticator.fake_challenge() 56 | original_origin = FakeAuthenticator.fake_origin() 57 | 58 | authenticator = 59 | %{challenge: original_challenge, context: %{origin: original_origin}} 60 | |> FakeAuthenticator.create() 61 | 62 | origin = "http://localhost" 63 | 64 | assert {:ok, auth_attestation_response} = 65 | AuthAttestationResponse.new( 66 | original_challenge, 67 | origin, 68 | authenticator.attestation_object, 69 | authenticator.client_data_json, 70 | "localhost" 71 | ) 72 | end 73 | 74 | test "origin validation doesn't match the default one" do 75 | original_challenge = FakeAuthenticator.fake_challenge() 76 | original_origin = FakeAuthenticator.fake_origin() 77 | 78 | authenticator = 79 | %{challenge: original_challenge, context: %{origin: original_origin}} 80 | |> FakeAuthenticator.create() 81 | 82 | origin = "http://invalid" 83 | 84 | assert {:error, "Validation of authenticator failed!"} = 85 | AuthAttestationResponse.new( 86 | original_challenge, 87 | origin, 88 | authenticator.attestation_object, 89 | authenticator.client_data_json, 90 | "localhost" 91 | ) 92 | end 93 | 94 | test "rp_id validation matches the default one" do 95 | original_challenge = FakeAuthenticator.fake_challenge() 96 | original_origin = FakeAuthenticator.fake_origin() 97 | rp_id = "localhost" 98 | 99 | authenticator = 100 | %{challenge: original_challenge, rp_id: rp_id, context: %{origin: original_origin}} 101 | |> FakeAuthenticator.create() 102 | 103 | assert {:ok, auth_attestation_response} = 104 | AuthAttestationResponse.new( 105 | original_challenge, 106 | original_origin, 107 | authenticator.attestation_object, 108 | authenticator.client_data_json, 109 | "localhost" 110 | ) 111 | end 112 | 113 | test "rp_id validation doesn't match the default one" do 114 | original_challenge = FakeAuthenticator.fake_challenge() 115 | original_origin = FakeAuthenticator.fake_origin() 116 | rp_id = "invalid" 117 | 118 | authenticator = 119 | %{challenge: original_challenge, rp_id: rp_id, context: %{origin: original_origin}} 120 | |> FakeAuthenticator.create() 121 | 122 | assert {:error, "Validation of authenticator failed!"} = 123 | AuthAttestationResponse.new( 124 | original_challenge, 125 | original_origin, 126 | authenticator.attestation_object, 127 | authenticator.client_data_json, 128 | "localhost" 129 | ) 130 | end 131 | 132 | test "rp_id validation matches one explicitly given" do 133 | original_challenge = FakeAuthenticator.fake_challenge() 134 | original_origin = FakeAuthenticator.fake_origin() 135 | rp_id = "custom" 136 | 137 | authenticator = 138 | %{challenge: original_challenge, rp_id: rp_id, context: %{origin: original_origin}} 139 | |> FakeAuthenticator.create() 140 | 141 | assert {:ok, auth_attestation_response} = 142 | AuthAttestationResponse.new( 143 | original_challenge, 144 | original_origin, 145 | authenticator.attestation_object, 146 | authenticator.client_data_json, 147 | "custom" 148 | ) 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /test/auth_assertion_response_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AuthAssertionResponseTest do 2 | use ExUnit.Case 3 | doctest WebAuthnEx.AuthAssertionResponse 4 | @original_challenge FakeAuthenticator.fake_challenge() 5 | @original_origin FakeAuthenticator.fake_origin() 6 | @authenticator FakeAuthenticator.get(%{ 7 | challenge: @original_challenge, 8 | context: %{origin: @original_origin} 9 | }) 10 | @credential_key @authenticator.credential_key 11 | @credential_id @authenticator.credential_id 12 | @allowed_credentials [ 13 | %{id: @credential_id, public_key: FakeAuthenticator.public_key(@credential_key)} 14 | ] 15 | @authenticator_data @authenticator.authenticator_data 16 | 17 | test "is valid if everything's in place" do 18 | assert WebAuthnEx.AuthAssertionResponse.new( 19 | @credential_id, 20 | @authenticator_data, 21 | @authenticator.signature, 22 | @original_challenge, 23 | @original_origin, 24 | @allowed_credentials, 25 | @authenticator.rp_id, 26 | @authenticator.client_data_json 27 | ) 28 | end 29 | 30 | test "is valid with more than one allowed credential" do 31 | {public_key, _} = :crypto.generate_key(:ecdh, :prime256v1) 32 | 33 | allowed_credentials = [ 34 | %{ 35 | id: 16 |> :crypto.strong_rand_bytes(), 36 | public_key: public_key 37 | } 38 | | @allowed_credentials 39 | ] 40 | 41 | assert WebAuthnEx.AuthAssertionResponse.new( 42 | @credential_id, 43 | @authenticator_data, 44 | @authenticator.signature, 45 | @original_challenge, 46 | @original_origin, 47 | allowed_credentials, 48 | @authenticator.rp_id, 49 | @authenticator.client_data_json 50 | ) 51 | end 52 | 53 | test "is invalid if signature was signed with a different key" do 54 | public_key = 55 | FakeAuthenticator.get().credential_key 56 | |> FakeAuthenticator.public_key() 57 | 58 | allowed_credentials = [ 59 | %{ 60 | id: @credential_id, 61 | public_key: public_key 62 | } 63 | ] 64 | 65 | assert {:error, "Validation of signature failed!"} = 66 | WebAuthnEx.AuthAssertionResponse.new( 67 | @credential_id, 68 | @authenticator_data, 69 | @authenticator.signature, 70 | @original_challenge, 71 | @original_origin, 72 | allowed_credentials, 73 | @authenticator.rp_id, 74 | @authenticator.client_data_json 75 | ) 76 | end 77 | 78 | test "is invalid if credential id is not among the allowed ones" do 79 | allowed_credentials = [ 80 | %{ 81 | id: 16 |> :crypto.strong_rand_bytes(), 82 | public_key: FakeAuthenticator.public_key(@credential_key) 83 | } 84 | ] 85 | 86 | assert {:error, "Validation of credential failed!"} = 87 | WebAuthnEx.AuthAssertionResponse.new( 88 | @credential_id, 89 | @authenticator_data, 90 | @authenticator.signature, 91 | @original_challenge, 92 | @original_origin, 93 | allowed_credentials, 94 | @authenticator.rp_id, 95 | @authenticator.client_data_json 96 | ) 97 | end 98 | 99 | test "type validation is invalid if type is create instead of get" do 100 | authenticator = 101 | FakeAuthenticator.get_wrong_type(%{ 102 | challenge: @original_challenge, 103 | context: %{origin: @original_origin} 104 | }) 105 | 106 | assert {:error, "Validation of authenticator failed!"} = 107 | WebAuthnEx.AuthAssertionResponse.new( 108 | @credential_id, 109 | authenticator.authenticator_data, 110 | authenticator.signature, 111 | @original_challenge, 112 | @original_origin, 113 | @allowed_credentials, 114 | authenticator.rp_id, 115 | authenticator.client_data_json 116 | ) 117 | end 118 | 119 | test "user present validation is invalid if user flags are off" do 120 | authenticator = 121 | FakeAuthenticator.get(%{ 122 | challenge: @original_challenge, 123 | context: %{ 124 | origin: @original_origin, 125 | user_present: false, 126 | user_verified: false 127 | } 128 | }) 129 | 130 | assert {:error, "Validation of authenticator failed!"} = 131 | WebAuthnEx.AuthAssertionResponse.new( 132 | @credential_id, 133 | authenticator.authenticator_data, 134 | authenticator.signature, 135 | @original_challenge, 136 | @original_origin, 137 | @allowed_credentials, 138 | authenticator.rp_id, 139 | authenticator.client_data_json 140 | ) 141 | end 142 | 143 | test "challenge validation is invalid if challenge doesn't match" do 144 | assert {:error, "Validation of authenticator failed!"} = 145 | WebAuthnEx.AuthAssertionResponse.new( 146 | @credential_id, 147 | @authenticator.authenticator_data, 148 | @authenticator.signature, 149 | FakeAuthenticator.fake_challenge(), 150 | @original_origin, 151 | @allowed_credentials, 152 | @authenticator.rp_id, 153 | @authenticator.client_data_json 154 | ) 155 | end 156 | 157 | test "origin validation is invalid if origin doesn't match" do 158 | assert {:error, "Validation of authenticator failed!"} = 159 | WebAuthnEx.AuthAssertionResponse.new( 160 | @credential_id, 161 | @authenticator.authenticator_data, 162 | @authenticator.signature, 163 | @original_challenge, 164 | "http://different-origin", 165 | @allowed_credentials, 166 | @authenticator.rp_id, 167 | @authenticator.client_data_json 168 | ) 169 | end 170 | 171 | test "rp_id validation is invalid if rp_id_hash doesn't match" do 172 | authenticator = 173 | FakeAuthenticator.get(%{ 174 | challenge: @original_challenge, 175 | rp_id: "different-rp_id", 176 | context: %{ 177 | origin: @original_origin 178 | } 179 | }) 180 | 181 | assert {:error, "Validation of signature failed!"} = 182 | WebAuthnEx.AuthAssertionResponse.new( 183 | @credential_id, 184 | authenticator.authenticator_data, 185 | authenticator.signature, 186 | @original_challenge, 187 | @original_origin, 188 | @allowed_credentials, 189 | authenticator.rp_id, 190 | authenticator.client_data_json 191 | ) 192 | end 193 | 194 | test "when rp_id is explicitly given is valid if correct rp_id is given" do 195 | authenticator = 196 | FakeAuthenticator.get(%{ 197 | challenge: @original_challenge, 198 | rp_id: "different-rp_id", 199 | context: %{ 200 | origin: @original_origin 201 | } 202 | }) 203 | 204 | allowed_credentials = [ 205 | %{ 206 | id: authenticator.credential_id, 207 | public_key: FakeAuthenticator.public_key(authenticator.credential_key) 208 | } 209 | ] 210 | 211 | assert WebAuthnEx.AuthAssertionResponse.new( 212 | authenticator.credential_id, 213 | authenticator.authenticator_data, 214 | authenticator.signature, 215 | @original_challenge, 216 | @original_origin, 217 | allowed_credentials, 218 | "different-rp_id", 219 | authenticator.client_data_json 220 | ) 221 | end 222 | end 223 | --------------------------------------------------------------------------------